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 { 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: { kundeId: vorlage.kundeId, 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(); 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); } }