246 lines
9.3 KiB
TypeScript
246 lines
9.3 KiB
TypeScript
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);
|
||
}
|
||
}
|