feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #23.
This commit is contained in:
2026-09-25 06:10:01 +00:00
parent ecc3c579df
commit d8d5444e9f
41 changed files with 4006 additions and 104 deletions
+245
View File
@@ -0,0 +1,245 @@
import type { ZodError } from "@/lib/validierung";
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { AustauschSchema, FORMAT_VERSION, type Austausch } from "@/lib/vorlagen-format";
/**
* JSON-Austausch fuer Vorlagen (#009).
*
* Export und Import sind die Bruecke zwischen zwei Installationen: eine Vorlage
* verlaesst die eine als Datei und kommt in der anderen als Entwurf an. Deshalb
* traegt das Dokument keine Kennungen, keine Zeitstempel und keinen Kundenbezug
* — Verweise, die in der Zielinstallation nichts bedeuten wuerden. Das Format
* selbst steht in `docs/vorlagen-format.md`, die Schemata in
* `src/lib/vorlagen-format.ts`.
*
* Rechte werden hier nicht geprueft, das tut die Server Action darueber.
*/
export class AustauschFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "AustauschFehler";
}
}
/** Ein Schritt so, wie er im Austauschdokument steht — ohne Kennung und Position. */
type AustauschSchritt = Austausch["struktur"]["abschnitte"][number]["schritte"][number];
/**
* Baut das Austauschdokument einer Version. Liest nur.
*
* Sortiert wird nach `position`, bei Gleichstand nach `id`: zwei Zeilen mit
* derselben Position duerfen die Reihenfolge nicht dem Zufall der Datenbank
* ueberlassen, sonst waere ein Export nicht wiederholbar.
*/
export async function vorlageExportieren(versionId: string): Promise<Austausch> {
const version = await prisma.vorlagenVersion.findUnique({
where: { id: versionId },
include: {
vorlage: true,
variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] },
abschnitte: {
orderBy: [{ position: "asc" }, { id: "asc" }],
include: { schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] } },
},
},
});
if (!version) {
throw new AustauschFehler("Diese Vorlagen-Version gibt es nicht (mehr).");
}
const { vorlage } = version;
return {
format: FORMAT_VERSION,
name: vorlage.name,
// Leere Felder werden weggelassen statt als null geschrieben: eine von Hand
// geschriebene Datei sieht genauso aus wie eine exportierte.
...(vorlage.beschreibung ? { beschreibung: vorlage.beschreibung } : {}),
...(vorlage.kategorie ? { kategorie: vorlage.kategorie } : {}),
schlagworte: [...vorlage.schlagworte],
version: version.version,
struktur: {
variablen: version.variablen.map((variable) => ({
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
})),
abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
schritte: abschnitt.schritte.map(schrittAbbilden),
})),
},
};
}
/**
* Bildet eine Schritt-Zeile auf das Austauschformat ab.
*
* `konfig` ist in der Datenbank ein Json-Feld und kann alles enthalten.
*
* Frueher lief es durch `SchrittKonfigSchema.safeParse` und wurde bei **jeder**
* Beanstandung komplett verworfen — ein `shell: "zsh"` aus einer spaeteren
* Fassung kostete lautlos auch den `befehl`. Genau das Gegenteil dessen, was das
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
* kein Objekt ist, gilt als leer.
*/
function schrittAbbilden(schritt: VorlagenSchritt): AustauschSchritt {
const roh = schritt.konfig;
const konfig =
typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {};
return {
titel: schritt.titel,
...(schritt.hilfetext ? { hilfetext: schritt.hilfetext } : {}),
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig,
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
};
}
/**
* Legt aus einem Austauschdokument eine neue Vorlage mit einem Entwurf an.
* Nimmt unbekannte Eingaben entgegen und prueft sie mit AustauschSchema.
*
* Immer eine **neue** Vorlage, nie in eine bestehende hinein, und die erste
* Version ist `ENTWURF`: eine eingespielte Vorlage wird bewusst nicht sofort
* veroeffentlicht, der Redakteur sieht sie sich erst an. Ein Name, den es schon
* gibt, ist kein Fehler — Vorlagennamen sind nicht eindeutig, und ein
* angehaengtes „(2)“ waere eine Aenderung, um die niemand gebeten hat.
*/
export async function vorlageImportieren(
dokument: unknown,
durchBenutzerId: string,
): Promise<{ readonly vorlageId: string; readonly versionId: string }> {
const austausch = dokumentPruefen(dokument);
variablenschluesselPruefen(austausch.struktur.variablen);
// Eine Vorlage ist erst mit Variablen, Abschnitten und Schritten sinnvoll.
// Deshalb alles in einer Transaktion: ein Fehler auf halbem Weg darf keine
// halbe Vorlage hinterlassen, die jemand in der Liste findet.
return prisma.$transaction(async (tx) => {
const vorlage = await tx.vorlage.create({
data: {
name: austausch.name,
beschreibung: austausch.beschreibung ?? null,
kategorie: austausch.kategorie ?? null,
schlagworte: [...austausch.schlagworte],
},
});
const version = await tx.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
// `position` kommt aus der Reihenfolge der Listen und wird nicht
// mitgeschickt — sonst gaebe es zwei Wahrheiten.
if (austausch.struktur.variablen.length > 0) {
await tx.vorlagenVariable.createMany({
data: austausch.struktur.variablen.map((variable, position) => ({
versionId: version.id,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
position,
})),
});
}
let schritteGesamt = 0;
for (const [position, abschnitt] of austausch.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,
// Das nachsichtige Schema traegt eine Index-Signatur auf `unknown`,
// die Prisma nicht als Json-Wert erkennt. Der Inhalt kommt aus
// geparstem JSON, ist also einer.
konfig: schritt.konfig as Prisma.InputJsonObject,
dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition,
})),
});
schritteGesamt += abschnitt.schritte.length;
}
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: vorlage.id,
aktion: "Vorlage importiert",
benutzerId: durchBenutzerId,
nachher: {
name: vorlage.name,
version: version.version,
abschnitte: austausch.struktur.abschnitte.length,
schritte: schritteGesamt,
variablen: austausch.struktur.variablen.length,
},
},
});
return { vorlageId: vorlage.id, versionId: version.id };
});
}
/** Prueft das Dokument gegen das Schema und uebersetzt Zod-Fehler in eine lesbare Meldung. */
function dokumentPruefen(dokument: unknown): Austausch {
// Eigene Meldung fuer den haeufigsten Fehlgriff – eine Datei, die gar kein
// JSON-Objekt enthaelt. Zod sagt dazu nur einen englischen Satz.
if (typeof dokument !== "object" || dokument === null || Array.isArray(dokument)) {
throw new AustauschFehler("Das Austauschdokument ist kein JSON-Objekt.");
}
const ergebnis = AustauschSchema.safeParse(dokument);
if (!ergebnis.success) {
throw new AustauschFehler(ersteBeanstandung(ergebnis.error));
}
return ergebnis.data;
}
/**
* Nennt die erste Beanstandung samt Pfad.
*
* Eine rohe Zod-Ausgabe hilft niemandem: wer eine Datei von Hand schreibt,
* braucht die Stelle („abschnitte.0.schritte.2.titel“) und einen Satz dazu, und
* zwar den ersten — eine Liste von zwanzig Folgefehlern liest niemand.
*/
function ersteBeanstandung(fehler: ZodError): string {
const problem = fehler.issues[0];
if (!problem) return "Das Austauschdokument passt nicht zum Format.";
const pfad = problem.path.map(String).join(".");
return pfad.length === 0 ? problem.message : `${pfad}: ${problem.message}`;
}
/**
* Weist doppelte Variablenschluessel ab.
*
* Die Datenbank haelt dasselbe ueber `@@unique([versionId, schluessel])` fest;
* diese Pruefung existiert fuer die Meldung, denn der Fehler des Index nennt
* einen Spaltennamen und keinen Schluessel. Ausserdem faellt er erst mitten in
* der Transaktion auf, also nach halber Arbeit.
*/
function variablenschluesselPruefen(variablen: readonly { readonly schluessel: string }[]): void {
const gesehen = new Set<string>();
for (const variable of variablen) {
if (gesehen.has(variable.schluessel)) {
throw new AustauschFehler(
`Den Variablenschlüssel „${variable.schluessel}“ gibt es zweimal. ` +
"Jeder Schlüssel darf in einer Vorlage nur einmal stehen.",
);
}
gesehen.add(variable.schluessel);
}
}