feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
This commit was merged in pull request #23.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user