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;
|
||
}
|