feat(seed): sieben Vorlagen, Beispielbestand und Demo-Kunden (#33)
This commit was merged in pull request #33.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user