feat(seed): sieben Vorlagen, Beispielbestand und Demo-Kunden (#33)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #33.
This commit is contained in:
2026-09-26 03:26:58 +00:00
parent ae290345b7
commit 9bd198a531
33 changed files with 3400 additions and 119 deletions
+330
View File
@@ -0,0 +1,330 @@
import { randomBytes } from "node:crypto";
import { verschluesseln } from "@/lib/geheimnisse";
import { istTagCode } from "@/lib/tagcode";
import { AD_ZUORDNUNG } from "@/lib/verzeichnis";
import { STANDARD_FILTER } from "@/server/ldap/zuordnung";
import { GeraeteTyp, Herkunft } from "../../src/generated/prisma/enums.js";
import type { PrismaClient } from "../../src/generated/prisma/client.js";
/**
* Beispielbestand: Abteilungen, Personen, Geraete, Tags (#017).
*
* Damit ist die Anwendung nach dem Seed nicht nur bedienbar, sondern vorfuehrbar:
* ein Lauf laesst sich starten, ein Etikett drucken, ein QR-Code scannen. Alles
* gehoert der eigenen Organisation; im Modus DIENSTLEISTUNG kommen die
* Demo-Kunden dazu (siehe `dienstleistung.ts`).
*
* **Idempotent ueber sprechende Schluessel**, nicht ueber Kennungen: Abteilungen
* ueber `(kundeId, name)`, Personen ueber den UPN, Geraete ueber den `tagCode`.
* Gibt es die Zeile schon, bleibt sie, wie sie ist — wer im Betrieb etwas
* geaendert hat, bekommt es nicht beim naechsten Seed-Lauf zurueckgesetzt.
*/
export type AbteilungsVorgabe = {
readonly name: string;
readonly zielOu: string;
readonly standardGruppen: readonly string[];
};
/**
* Nur die Buchhaltung ist im Auftrag ausbuchstabiert (OU und Gruppen); Vertrieb
* und IT bekommen dieselbe Form. Eine Abteilung ohne Ziel-OU waere im
* Onboarding-Lauf ein leeres `-Path` im `New-ADUser`-Befehl — genau die Stelle,
* an der man den Wert nicht selbst zusammenraten will.
*/
export const ABTEILUNGEN: readonly AbteilungsVorgabe[] = [
{
name: "Buchhaltung",
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local",
standardGruppen: ["G-Buchhaltung", "G-Alle"],
},
{
name: "Vertrieb",
zielOu: "OU=Vertrieb,OU=Benutzer,DC=firma,DC=local",
standardGruppen: ["G-Vertrieb", "G-Alle"],
},
{
name: "IT",
zielOu: "OU=IT,OU=Benutzer,DC=firma,DC=local",
standardGruppen: ["G-IT", "G-Alle"],
},
];
export type PersonenVorgabe = {
readonly vorname: string;
readonly nachname: string;
readonly sam: string;
readonly upn: string;
readonly abteilung: string;
readonly titel: string;
};
/**
* Fuenf Personen, von Hand angelegt (`Herkunft.MANUELL`) — so, wie sie in einem
* Haus ohne Verzeichnis entstehen.
*
* Maria Huber steht so im Auftrag und taucht in den Abnahmekriterien wieder auf.
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei; **gespeichert wird
* die Zuordnung nicht**: `vorgesetzterExterneId` zeigt auf die `externeId`
* derselben Verzeichnisquelle, und eine von Hand angelegte Person hat beides
* nicht. Im Onboarding-Lauf wird der Vorgesetzte ohnehin beim Start gewaehlt.
*/
export const PERSONEN: readonly PersonenVorgabe[] = [
{
vorname: "Maria",
nachname: "Huber",
sam: "mhuber",
upn: "m.huber@firma.example",
abteilung: "Buchhaltung",
titel: "Sachbearbeiterin Rechnungsprüfung",
},
{
vorname: "Petra",
nachname: "Klein",
sam: "pklein",
upn: "p.klein@firma.example",
abteilung: "Buchhaltung",
titel: "Leiterin Buchhaltung",
},
{
vorname: "Thomas",
nachname: "Berger",
sam: "tberger",
upn: "t.berger@firma.example",
abteilung: "Vertrieb",
titel: "Vertriebsleiter",
},
{
vorname: "Sabine",
nachname: "Fischer",
sam: "sfischer",
upn: "s.fischer@firma.example",
abteilung: "Vertrieb",
titel: "Vertriebsinnendienst",
},
{
vorname: "Lukas",
nachname: "Mayr",
sam: "lmayr",
upn: "l.mayr@firma.example",
abteilung: "IT",
titel: "Systemadministrator",
},
];
/** Die beiden Geraete aus dem Auftrag, samt der Tag-Codes von den Aufklebern. */
export const PC_TAG = "LZ-7K3F9Q";
export const NB_TAG = "LZ-M2XH4T";
/**
* Fuenf vorgedruckte, noch nicht zugeordnete Tags.
*
* Feste Codes und keine gezogenen: ein Seed, der jedes Mal fuenf neue Codes
* zieht, ist nicht idempotent — beim dritten Lauf laegen fuenfzehn Aufkleber im
* Stapel. Die Codes stehen im Alphabet ohne I, O, 0 und 1, damit sie sich von
* einem Etikett ablesen lassen.
*/
export const OFFENE_TAGS: readonly string[] = [
"LZ-4B7NCD",
"LZ-9TQK2M",
"LZ-R5HXJW",
"LZ-P3VZ8L",
"LZ-K6DYFN",
];
export type StammdatenErgebnis = {
readonly abteilungen: number;
readonly personen: number;
readonly geraete: number;
readonly offeneTags: number;
};
/**
* Legt einen Tag an, wenn es ihn noch nicht gibt.
*
* `TagCodeVergabe` und `Geraet` werden gemeinsam geschrieben: ein Geraet mit
* einem Code, der nicht im Vergabebuch steht, waere nach dem Loeschen wieder
* frei — und ein alter Aufkleber im Regal zeigte auf ein fremdes Objekt.
*
* Gefragt werden deshalb **beide Buecher**, so wie es `freienTagCodeZiehen` im
* Geraetedienst tut: ein Code, der nur in einem von beiden steht, ist trotzdem
* vergeben. Vorher fragte der Seed nur nach dem Geraet — wer den Beispiel-PC
* endgueltig geloescht hatte (die eine erlaubte Ausnahme, siehe CLAUDE.md), bekam
* beim naechsten Seed-Lauf denselben Code auf einem neuen Geraet, und der alte
* Aufkleber im Regal zeigte auf ein fremdes Objekt (Reviewbefund zu #017).
*/
export async function geraetSaeen(
db: PrismaClient,
tagCode: string,
daten: {
readonly kundeId: string;
readonly typ: GeraeteTyp;
readonly hostname?: string;
readonly mitarbeiterId?: string | null;
readonly modell?: string;
readonly seriennummer?: string;
readonly registriert: boolean;
},
): Promise<boolean> {
if (!istTagCode(tagCode)) {
throw new Error(`„${tagCode}“ hat nicht die Form eines Tag-Codes.`);
}
const [vorhanden, vergeben] = await Promise.all([
db.geraet.findUnique({ where: { tagCode } }),
db.tagCodeVergabe.findUnique({ where: { tagCode } }),
]);
if (vorhanden) return false;
if (vergeben) {
// Verbrannt: der Code war schon einmal vergeben, das Geraet dazu gibt es
// nicht mehr. Er kommt nicht zurueck – auch nicht als Beispieldatensatz.
console.log(` Tag ${tagCode}: verbrannt, wird nicht neu vergeben`);
return false;
}
await db.$transaction(async (tx) => {
await tx.tagCodeVergabe.create({ data: { tagCode } });
await tx.geraet.create({
data: {
kundeId: daten.kundeId,
tagCode,
typ: daten.typ,
hostname: daten.hostname ?? null,
mitarbeiterId: daten.mitarbeiterId ?? null,
modell: daten.modell ?? null,
seriennummer: daten.seriennummer ?? null,
// Ein Tag ohne `registriertAm` ist ein vorgedruckter Aufkleber, der auf
// sein Geraet wartet; die Datenbank laesst ihn deshalb ohne Namen zu.
registriertAm: daten.registriert ? new Date() : null,
},
});
});
return true;
}
/** Abteilungen, Personen, Geraete und Tags fuer einen Kunden. */
export async function stammdatenSaeen(
db: PrismaClient,
kundeId: string,
optionen: { readonly hostnamePraefix?: string; readonly mitOffenenTags?: boolean } = {},
): Promise<StammdatenErgebnis> {
let abteilungen = 0;
for (const vorgabe of ABTEILUNGEN) {
// Erst nachsehen, dann anlegen: ein `upsert` mit leerem `update` waere
// kuerzer, aber der Rueckgabewert sagt nicht, ob etwas entstanden ist — und
// der Seed soll am Ende ehrlich zaehlen, was er getan hat.
const vorhanden = await db.abteilung.findUnique({
where: { kundeId_name: { kundeId, name: vorgabe.name } },
});
if (vorhanden) continue;
await db.abteilung.create({
data: {
kundeId,
name: vorgabe.name,
zielOu: vorgabe.zielOu,
standardGruppen: [...vorgabe.standardGruppen],
mailDomain: "firma.example",
},
});
abteilungen += 1;
}
let personen = 0;
const nachUpn = new Map<string, string>();
for (const vorgabe of PERSONEN) {
const vorhanden = await db.mitarbeiter.findFirst({ where: { kundeId, upn: vorgabe.upn } });
if (vorhanden) {
nachUpn.set(vorgabe.upn, vorhanden.id);
continue;
}
const angelegt = await db.mitarbeiter.create({
data: {
kundeId,
vorname: vorgabe.vorname,
nachname: vorgabe.nachname,
anzeigename: `${vorgabe.nachname}, ${vorgabe.vorname}`,
mail: vorgabe.upn,
upn: vorgabe.upn,
sam: vorgabe.sam,
abteilung: vorgabe.abteilung,
titel: vorgabe.titel,
herkunft: Herkunft.MANUELL,
aktiv: true,
},
});
nachUpn.set(vorgabe.upn, angelegt.id);
personen += 1;
}
// Im Modus DIENSTLEISTUNG traegt der Hostname das Kundenkuerzel – dieselbe
// Regel, die auch die Namensvorschlaege benutzen (`{kundenkuerzel}-PC-{nnn}`).
const praefix = optionen.hostnamePraefix ? `${optionen.hostnamePraefix}-` : "";
let geraete = 0;
if (
await geraetSaeen(db, PC_TAG, {
kundeId,
typ: GeraeteTyp.PC,
hostname: `${praefix}PC-001`,
// Ueber die Konstante und nicht ueber die Zeichenkette: wer den UPN in
// PERSONEN aendert, verlöre sonst still die Besitzerin von PC-001.
mitarbeiterId: nachUpn.get(PERSONEN[0]?.upn ?? "") ?? null,
modell: "Dell OptiPlex 7010",
registriert: true,
})
) {
geraete += 1;
}
if (
await geraetSaeen(db, NB_TAG, {
kundeId,
typ: GeraeteTyp.NOTEBOOK,
hostname: `${praefix}NB-001`,
modell: "Lenovo ThinkPad T14",
registriert: true,
})
) {
geraete += 1;
}
let offeneTags = 0;
if (optionen.mitOffenenTags !== false) {
for (const tagCode of OFFENE_TAGS) {
if (await geraetSaeen(db, tagCode, { kundeId, typ: GeraeteTyp.SONSTIGES, registriert: false })) {
offeneTags += 1;
}
}
}
return { abteilungen, personen, geraete, offeneTags };
}
/**
* Die Beispiel-Verzeichnisquelle — **deaktiviert**.
*
* Sie ist eine ausgefuellte Vorlage zum Abschauen und kein Zugang: die Adresse
* zeigt auf einen Rechner, den es hier nicht gibt, und `aktiv = false` haelt den
* Zeitplan davon ab, sie anzufassen. Das Bind-Passwort ist ein bei jedem Seed neu
* gewuerfelter Zufallswert, der nirgends ausgegeben wird: ein fest verdrahtetes
* Beispielpasswort im Quelltext waere eines, das am Ende in einer echten
* Installation steht.
*/
export async function beispielQuelleSaeen(db: PrismaClient, kundeId: string): Promise<boolean> {
const name = "Firmen-AD (Beispiel)";
const vorhanden = await db.verzeichnisquelle.findFirst({ where: { kundeId, name } });
if (vorhanden) return false;
await db.verzeichnisquelle.create({
data: {
kundeId,
name,
url: "ldaps://dc01.firma.local:636",
bindDn: "CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local",
bindPasswortVerschluesselt: verschluesseln(randomBytes(24).toString("base64url")),
basisDn: "OU=Benutzer,DC=firma,DC=local",
filter: STANDARD_FILTER,
attributZuordnung: { ...AD_ZUORDNUNG },
abgleichIntervallMinuten: 60,
aktiv: false,
},
});
return true;
}