import { readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format"; import { GeraeteTyp, VersionStatus } from "../../src/generated/prisma/enums.js"; import type { PrismaClient } from "../../src/generated/prisma/client.js"; /** * Die sieben Vorlagen des Auftrags (#017). * * Sie stehen als **JSON im Austauschformat** neben dieser Datei und nicht als * TypeScript-Literale. Drei Gruende: * * 1. Dasselbe Schema wie Import und Export prueft sie (`AustauschSchema`). Eine * Vorlage, die der Seed anlegt, ist damit genau das, was auch ein Redakteur * einspielen koennte — und ein Fehler faellt hier auf und nicht erst beim * ersten Lauf. * 2. Wer eine Vorlage anpassen will, aendert eine Datei und braucht keinen * TypeScript-Übersetzer. * 3. Die Dateien lassen sich unveraendert exportieren, weitergeben und in eine * andere Installation einspielen. * * **Angelegt wird direkt, nicht ueber `vorlageImportieren`.** Der Import erzeugt * absichtlich einen Entwurf (der Redakteur sieht ihn sich an und veroeffentlicht * dann) und schreibt einen Protokolleintrag mit dem handelnden Benutzer. Der * Seed hat keinen Menschen, der handelt, und braucht **veroeffentlichte** * Versionen: ohne sie liesse sich kein Lauf starten. */ const HIER = dirname(fileURLToPath(import.meta.url)); const VORLAGEN_ORDNER = join(HIER, "vorlagen"); export type VorlagenVorgabe = { /** Dateiname, damit eine Fehlermeldung sagt, wo nachzusehen ist. */ readonly datei: string; readonly dokument: Austausch; }; /** * Liest alle Vorlagendateien und prueft sie. * * Reihenfolge nach Dateinamen — deshalb tragen sie eine fuehrende Nummer. Sie * entscheidet, in welcher Reihenfolge die Vorlagen entstehen, und das ist die * Reihenfolge des Auftrags. */ export function vorlagenLesen(): readonly VorlagenVorgabe[] { const dateien = readdirSync(VORLAGEN_ORDNER) .filter((name) => name.endsWith(".json")) .sort(); return dateien.map((datei) => { const roh: unknown = JSON.parse(readFileSync(join(VORLAGEN_ORDNER, datei), "utf8")); const gelesen = AustauschSchema.safeParse(roh); if (!gelesen.success) { const erste = gelesen.error.issues[0]; throw new Error( `Die Vorlagendatei ${datei} passt nicht zum Austauschformat: ` + `${erste?.path.join(".") ?? "?"}: ${erste?.message ?? "unbekannt"}`, ); } return { datei, dokument: gelesen.data }; }); } export type GesaeteVorlage = { readonly name: string; readonly vorlageId: string; readonly versionId: string; readonly neu: boolean; }; /** * Sucht die mitgelieferte Vorlage – zuerst am Schluessel, dann am Namen. * * Der Schluessel ueberlebt ein Umbenennen, der Name nicht: genau daran legte der * Seed eine umbenannte Vorlage ein zweites Mal an, und der Schnellstart stand * auf der Geraeteseite doppelt (#021). Der Rueckfall auf den Namen bleibt, weil * eine Installation von vor diesem Feld noch keinen Schluessel hat — ohne ihn * bekaeme sie beim ersten Lauf danach genau die Dubletten, die das Feld * verhindern soll. Wird sie so gefunden, traegt der Seed den Schluessel nach. * * Auch eine **archivierte** Vorlage gilt als vorhanden: eine zweite gleichen * Namens danebenzustellen waere keine Hilfe, sondern eine Dublette in jeder * Liste. Wer die Beispielvorlage zurueck will, holt sie aus dem Archiv. */ async function mitgelieferteFinden(db: PrismaClient, dokument: Austausch) { const mitVersion = { versionen: { where: { status: VersionStatus.VEROEFFENTLICHT }, orderBy: { version: "desc" as const }, take: 1, }, }; if (dokument.seedSchluessel !== undefined) { const amSchluessel = await db.vorlage.findFirst({ where: { seedSchluessel: dokument.seedSchluessel }, include: mitVersion, }); if (amSchluessel) return amSchluessel; } const amNamen = await db.vorlage.findFirst({ // Nur eine Zeile **ohne** Schluessel darf hier zugeordnet werden. Sonst // koennte eine fremde Vorlage, die zufaellig so heisst wie die mitgelieferte, // deren Schluessel bekommen - und der naechste Lauf wuerde sie fuer das // Original halten. where: { name: dokument.name, kundeId: null, seedSchluessel: null }, include: mitVersion, }); if (amNamen && dokument.seedSchluessel !== undefined) { await db.vorlage.update({ where: { id: amNamen.id }, data: { seedSchluessel: dokument.seedSchluessel }, }); console.log(` Vorlage „${amNamen.name}“: Schlüssel ${dokument.seedSchluessel} nachgetragen`); } return amNamen; } /** * Legt die Vorlagen als veroeffentlichte Version 1 an. * * Idempotent ueber den **Schluessel** (Rueckfall: Name, siehe * `mitgelieferteFinden`): gibt es die Vorlage schon, bleibt sie unangetastet — * auch dann, wenn jemand sie inzwischen bearbeitet oder umbenannt hat. Ein * zweiter Seed-Lauf darf die Arbeit eines Redakteurs nicht zurueckdrehen, und * eine veroeffentlichte Version wird ohnehin nie veraendert. */ export async function vorlagenSaeen(db: PrismaClient): Promise { const ergebnis: GesaeteVorlage[] = []; /** Vorlagen, die es gibt, aber ohne veroeffentlichte Version – siehe unten. */ let uebersprungen = 0; for (const { dokument } of vorlagenLesen()) { const vorhanden = await mitgelieferteFinden(db, dokument); const veroeffentlichte = vorhanden?.versionen[0]; if (vorhanden && veroeffentlichte) { ergebnis.push({ name: dokument.name, vorlageId: vorhanden.id, versionId: veroeffentlichte.id, neu: false, }); continue; } if (vorhanden) { /* * Es gibt die Vorlage, aber keine veroeffentlichte Version — typischerweise, * weil jemand genau diese Datei ueber „Vorlage aus einer Datei einspielen“ * eingelesen hat: der Import legt absichtlich einen Entwurf an. Sie * trotzdem zu fuellen hiesse, eine zweite Version 1 anzulegen; der * eindeutige Index `(vorlageId, version)` haelt das auf, und der Seed * braeche mitten im Lauf mit einem rohen Prisma-Fehler ab — vor den * Stammdaten und vor den Demo-Kunden (Reviewbefund zu #017). */ console.log(` Vorlage „${dokument.name}“: Entwurf vorhanden, bleibt unberührt`); uebersprungen += 1; continue; } // Alles in einer Transaktion: eine Vorlage ohne Abschnitte stuende sonst in // der Liste und liesse sich starten, ohne dass es etwas abzuarbeiten gaebe. const angelegt = await db.$transaction(async (tx) => { const vorlage = vorhanden ?? (await tx.vorlage.create({ data: { name: dokument.name, beschreibung: dokument.beschreibung ?? null, kategorie: dokument.kategorie ?? null, schlagworte: [...dokument.schlagworte], seedSchluessel: dokument.seedSchluessel ?? null, }, })); /* * Erst Entwurf, dann veroeffentlichen — denselben Weg geht auch ein * Redakteur, und die Datenbank laesst keinen anderen zu: seit #010 * verbieten Trigger jedes Schreiben an Abschnitten, Variablen und * Schritten einer veroeffentlichten Version. Eine Version gleich als * VEROEFFENTLICHT anzulegen und danach zu fuellen scheitert deshalb mit * „Version … ist veroeffentlicht und unveraenderlich“ — richtig so. */ const version = await tx.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, }); if (dokument.struktur.variablen.length > 0) { await tx.vorlagenVariable.createMany({ data: dokument.struktur.variablen.map((variable, position) => ({ versionId: version.id, schluessel: variable.schluessel, beschriftung: variable.beschriftung, typ: variable.typ, optionen: [...variable.optionen], pflicht: variable.pflicht, position, })), }); } for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) { const zeile = await tx.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: abschnitt.titel, position }, }); if (abschnitt.schritte.length === 0) continue; await tx.vorlagenSchritt.createMany({ data: abschnitt.schritte.map((schritt, schrittPosition) => ({ abschnittId: zeile.id, titel: schritt.titel, hilfetext: schritt.hilfetext ?? null, typ: schritt.typ, pflicht: schritt.pflicht, // Der Inhalt kommt aus geparstem JSON und ist damit ein Json-Wert; // das nachsichtige Schema traegt nur eine Index-Signatur auf // `unknown`, die Prisma nicht als solchen erkennt. konfig: schritt.konfig as object, dokuUrl: schritt.dokuUrl ?? null, position: schrittPosition, })), }); } await tx.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date(), // Bleibt leer: es gibt keinen angemeldeten Menschen, der // veroeffentlicht haette. Die Migration laesst das ausdruecklich zu. veroeffentlichtVonId: null, aenderungsnotiz: "Erste Fassung aus dem Seed.", }, }); return { vorlageId: vorlage.id, versionId: version.id }; }); ergebnis.push({ name: dokument.name, ...angelegt, neu: true }); } if (uebersprungen > 0) { console.log(` ${uebersprungen} Vorlage(n) übersprungen: vorhanden, aber ohne veröffentlichte Version`); } return ergebnis; } /** * Welche Vorlage auf der Geraeteseite als Schnellstart vorgeschlagen wird. * * Die Zuordnung steht im Auftrag und haengt am Geraetetyp, nicht am Kunden — * deshalb traegt `GeraetetypVorlage` keine `kundeId`. Verwiesen wird auf die * **Vorlage**, nicht auf eine Version: ein Schnellstart soll die jeweils aktuelle * veroeffentlichte Fassung starten und nicht die von damals. */ export const TYP_VORGABEN: readonly { readonly typ: GeraeteTyp; readonly vorlagen: readonly string[] }[] = [ { typ: GeraeteTyp.PC, vorlagen: ["PC Neuinstallation"] }, { typ: GeraeteTyp.NOTEBOOK, vorlagen: ["PC Neuinstallation"] }, { typ: GeraeteTyp.SERVER, vorlagen: ["Server-Inbetriebnahme Windows-VM", "Backup-Restore-Test"], }, { typ: GeraeteTyp.FIREWALL, vorlagen: ["Firewall-Rollout OPNsense"] }, ]; /** * Legt die Schnellstart-Vorgaben an. * * Verlangt, dass es die genannten Vorlagen gibt: ein Tippfehler im Namen waere * sonst eine Geraeteseite ohne Schnellstart — und niemand wuesste, warum. */ export async function typVorgabenSaeen( db: PrismaClient, vorlagen: readonly GesaeteVorlage[], ): Promise { const nachName = new Map(vorlagen.map((eintrag) => [eintrag.name, eintrag.vorlageId])); let angelegt = 0; for (const vorgabe of TYP_VORGABEN) { for (const [position, name] of vorgabe.vorlagen.entries()) { const vorlageId = nachName.get(name); if (!vorlageId) { throw new Error( `Für den Gerätetyp ${vorgabe.typ} ist die Vorlage „${name}“ vorgesehen, ` + "aber es gibt sie nicht. Stimmt der Name in der JSON-Datei?", ); } const vorhanden = await db.geraetetypVorlage.findUnique({ where: { geraeteTyp_vorlageId: { geraeteTyp: vorgabe.typ, vorlageId } }, }); if (vorhanden) continue; await db.geraetetypVorlage.create({ data: { geraeteTyp: vorgabe.typ, vorlageId, position }, }); angelegt += 1; } } return angelegt; }