Der Seed erkannte seine sieben Vorlagen am Namen. Wer eine umbenannte, bekam beim naechsten Lauf eine zweite -- und der Schnellstart stand auf der Geraeteseite doppelt. Vorlage.seedSchluessel traegt die Kennung, eindeutig wo gesetzt (partieller Index, weil eigene Vorlagen keinen tragen). Das Austauschformat kennt das Feld als seedSchluessel; nicht als schluessel, wie das Issue plante, denn so heisst dort schon das Feld einer Variablen. Der Rueckfall auf den Namen bleibt, sonst bekaeme eine Installation von vor diesem Feld beim ersten Lauf danach genau die Dubletten, die es verhindern soll. Wird sie so gefunden, traegt der Seed den Schluessel nach -- an der Entwicklungsdatenbank gefahren: sieben nachgetragen, nichts neu angelegt. Der Import setzt den Schluessel nicht, auch nicht wenn das Dokument ihn mitbringt, und der Export schreibt ihn nicht: eine eingespielte Kopie soll nicht den Platz des Originals einnehmen. Gegenprobe gefahren: ohne die Suche am Schluessel werden vier Tests rot. Closes #32
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
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<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,
|
||
})),
|
||
});
|
||
}
|
||
|
||
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<number> {
|
||
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;
|
||
}
|