Files
Florian WildeandClaude Opus 5 a2330ef682
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): bedingte Abschnitte und Schritte (#039)
Ein Abschnitt oder ein einzelner Schritt erscheint nur, wenn seine Bedingung
zutrifft — abhaengig von einer Variable des Laufs oder vom Zustand eines
anderen Schritts. Der echte Fall steht im Seed: "Mitarbeiter-Onboarding" traegt
beide Konto-Abschnitte, fuenf AD-Schritte und vier fuer Entra ID, und
`konto_typ` sagt beim Start, welcher gilt. Bisher klickte ein Techniker die
anderen neun bei jedem Onboarding weg.

Entscheidungen, alle im Issue begruendet:

- Laufend ausgewertet, nicht beim Start festgeschrieben. Fuer die
  Abteilungsabschnitte (#031) ist Festschreiben richtig; fuer Bedingungen nicht
  — die Antwort auf "ist Pruefen fehlgeschlagen?" entsteht erst waehrend der
  Arbeit. Ein struktureller Test prueft, dass alle drei Schichten
  (Abschlussregel, Abarbeitung, Druck) die Auswertung rufen.
- Nicht entscheidbar heisst: gilt. Ein Lauf ohne konto_typ zeigt beide
  Abschnitte — genau wie vorher. Ein Abschnitt zu viel ist sichtbar und
  korrigierbar, ein fehlender faellt niemandem auf.
- OFFEN ist eine Antwort, keine Luecke: waere es unentscheidbar, waere ein
  bedingter Abschnitt von Anfang an sichtbar.
- Eine Bedingung, kein Ausdrucksbaum. Kein und, kein oder.
- Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen: vier Wege schreiben
  eine Struktur neu und geben neue Kennungen.

Zwei stille Datenverluste gefunden, der zweite aelter als dieser Meilenstein:

- Der Import verwarf `nurFuerAbteilungen`. Der Export schreibt es seit #031, das
  Schema nimmt es an, der Import setzte es nicht — eine exportierte Vorlage
  verlor ihre Abteilungsabschnitte und galt dort fuer alle.
- Die tiefe Kopie verwarf es ebenfalls, womit jedes "Bearbeiten" einer Vorlage
  alle Einschraenkungen verlor. Ursache war ein von Hand getippter Zwischentyp,
  der weniger Felder nannte als die Abfrage liefert; jetzt aus Prisma
  abgeleitet, beide Kopierwege teilen eine Stelle, und ein Test vergleicht Kopie
  und Original Feld fuer Feld.

Gefunden hat beides die Klickprobe am gebauten Stand, nicht die Suite: im Editor
stand "Bedingung gesetzt", nach "Bearbeiten" war sie weg. Dreizehn Gegenproben
liefen, alle wurden rot — und der teuerste Befund lag ausserhalb von allen.

1499 Vitest-Tests (vorher 1457), 115 E2E-Ablaeufe gruen.

Closes #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 14:54:10 +02:00

362 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync, readdirSync } from "node:fs";
import { bedingungAufloesen, schrittPfad } from "@/lib/bedingungen";
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<readonly GesaeteVorlage[]> {
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,
})),
});
}
/*
* Erst alles anlegen, dann die Bedingungen (#039) — wie beim Import und
* aus demselben Grund: eine Schritt-Bedingung verweist ueber einen
* Titel-Pfad, und der laesst sich erst aufloesen, wenn die Schritte
* stehen. Die Seed-Vorlagen benutzen bisher nur Variablen-Bedingungen;
* der zweite Durchgang steht trotzdem hier, damit eine kuenftige Vorlage
* mit Schritt-Bedingung nicht stillschweigend ohne sie landet.
*/
const kennungen = new Map<string, string>();
const abschnittsZeilen: string[] = [];
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({
data: {
versionId: version.id,
titel: abschnitt.titel,
position,
// Wandert mit, auch wenn bisher keine Seed-Vorlage eine hat (#031).
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
},
});
abschnittsZeilen.push(zeile.id);
for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) {
const angelegt = await tx.vorlagenSchritt.create({
data: {
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,
},
});
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
}
}
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const abschnittId = abschnittsZeilen[position];
if (abschnittId && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: abschnittId },
data: { bedingung: aufgeloest as object },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as object },
});
}
}
}
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<number> {
/*
* Hat jemand den Schnellstart in der Oberflaeche gespeichert, haelt der Seed
* sich heraus — auch bei einer Vorgabe, die dort fehlt (#022). Eine fehlende
* Zeile sagt nicht, ob sie noch nie da war oder bewusst entfernt wurde; ohne
* diese Marke kaeme eine entfernte Kachel beim naechsten Lauf zurueck.
*/
const einstellung = await db.einstellung.findUnique({
where: { id: "global" },
select: { schnellstartGepflegt: true },
});
if (einstellung?.schnellstartGepflegt) {
console.log(" Schnellstart je Geraetetyp: in der Oberflaeche gepflegt, bleibt unberührt");
return 0;
}
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;
}