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
+3 -3
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "@/lib/validierung";
import type { Prisma } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
@@ -180,7 +180,7 @@ async function pruefungMit(
moeglich: true,
brauchtAngaben: true,
folgen: [
`Die bisherige eigene Organisation „${organisation.name}" wird zum ersten Kunden.`,
`Die bisherige eigene Organisation „${organisation.name}“ wird zum ersten Kunden.`,
"Alle Geräte, Personen, Abteilungen und Läufe bleiben daran hängen.",
"Kunden werden zu einer sichtbaren Ebene: Kundenliste, Kundenwahl beim Start eines Laufs, Kundenkürzel auf den Etiketten.",
],
@@ -204,7 +204,7 @@ async function pruefungMit(
moeglich: true,
brauchtAngaben: false,
folgen: [
`„${einziger.name}" wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
`„${einziger.name}“ wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
"Alle Daten bleiben erhalten, nur die Kundenebene wird ausgeblendet.",
"Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf.",
],
+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);
}
}
+727
View File
@@ -0,0 +1,727 @@
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
/**
* Vorlagen und ihre Versionierung (#009).
*
* Die eine Regel, die alles traegt: **eine veroeffentlichte Version wird nie
* wieder veraendert.** Ein Lauf verweist auf eine bestimmte Version und zeigt
* deshalb auch in zehn Jahren noch genau die Schritte, die damals abgearbeitet
* wurden. „Bearbeiten“ heisst deswegen nicht "aendern", sondern "tiefe Kopie als
* neuer Entwurf".
*
* Je Vorlage gibt es hoechstens einen Entwurf. Das steht zusaetzlich als
* partieller Eindeutigkeitsindex in der Datenbank (`VorlagenVersion_ein_entwurf`);
* die Pruefung hier existiert fuer die bessere Meldung.
*/
export class VorlagenFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "VorlagenFehler";
}
}
export type NeueVorlage = {
readonly name: string;
readonly beschreibung?: string;
readonly kategorie?: string;
readonly schlagworte?: readonly string[];
/** Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer gilt fuer alle. */
readonly kundeId?: string | null;
};
function namePruefen(roh: string): string {
const name = roh.trim();
if (!name) throw new VorlagenFehler("Eine Vorlage braucht einen Namen.");
return name;
}
/** Legt eine Vorlage mit einem leeren Entwurf als Version 1 an. */
export async function vorlageAnlegen(
eingabe: NeueVorlage,
durchBenutzerId: string,
): Promise<{ readonly vorlage: Vorlage; readonly version: VorlagenVersion }> {
const name = namePruefen(eingabe.name);
return prisma.$transaction(async (tx) => {
const vorlage = await tx.vorlage.create({
data: {
name,
beschreibung: eingabe.beschreibung?.trim() || null,
kategorie: eingabe.kategorie?.trim() || null,
schlagworte: [...(eingabe.schlagworte ?? [])],
kundeId: eingabe.kundeId ?? null,
},
});
const version = await tx.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: vorlage.id,
aktion: "Vorlage angelegt",
benutzerId: durchBenutzerId,
nachher: { name: vorlage.name, version: 1 },
},
});
return { vorlage, version };
});
}
export type VorlagenAenderung = {
readonly name?: string;
readonly beschreibung?: string | null;
readonly kategorie?: string | null;
readonly schlagworte?: readonly string[];
/** `null` macht aus einer kundenspezifischen Vorlage eine fuer alle. */
readonly kundeId?: string | null;
};
/**
* Aendert die Kopfdaten einer Vorlage.
*
* Betrifft die Vorlage, nicht ihre Versionen: Name, Kategorie und Schlagworte
* sind kein Teil des abgearbeiteten Inhalts und aendern deshalb nichts an der
* Nachvollziehbarkeit eines Laufs.
*/
export async function vorlageBearbeiten(
vorlageId: string,
aenderung: VorlagenAenderung,
durchBenutzerId: string,
): Promise<Vorlage> {
const vorher = await prisma.vorlage.findUnique({ where: { id: vorlageId } });
if (!vorher) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
const daten: {
name?: string;
beschreibung?: string | null;
kategorie?: string | null;
schlagworte?: string[];
kundeId?: string | null;
} = {};
const geaendert: string[] = [];
if (aenderung.name !== undefined) {
const name = namePruefen(aenderung.name);
if (name !== vorher.name) {
daten.name = name;
geaendert.push("Name");
}
}
if (aenderung.beschreibung !== undefined) {
const wert = aenderung.beschreibung?.trim() || null;
if (wert !== vorher.beschreibung) {
daten.beschreibung = wert;
geaendert.push("Beschreibung");
}
}
if (aenderung.kategorie !== undefined) {
const wert = aenderung.kategorie?.trim() || null;
if (wert !== vorher.kategorie) {
daten.kategorie = wert;
geaendert.push("Kategorie");
}
}
if (aenderung.schlagworte !== undefined) {
const wert = [...aenderung.schlagworte];
if (JSON.stringify(wert) !== JSON.stringify(vorher.schlagworte)) {
daten.schlagworte = wert;
geaendert.push("Schlagworte");
}
}
if (aenderung.kundeId !== undefined) {
const wert = aenderung.kundeId || null;
if (wert !== vorher.kundeId) {
daten.kundeId = wert;
geaendert.push("Kundenzuordnung");
}
}
if (geaendert.length === 0) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten });
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: vorlageId,
aktion: `${geaendert.join(" und ")} geändert`,
benutzerId: durchBenutzerId,
vorher: {
name: vorher.name,
kategorie: vorher.kategorie,
schlagworte: vorher.schlagworte,
kundeId: vorher.kundeId,
},
nachher: {
name: nachher.name,
kategorie: nachher.kategorie,
schlagworte: nachher.schlagworte,
kundeId: nachher.kundeId,
},
},
});
return nachher;
});
}
/**
* Die Version, an der gerade gearbeitet wird.
*
* Gibt es schon einen Entwurf, wird der zurueckgegeben — je Vorlage darf es nur
* einen geben. Sonst entsteht eine **tiefe Kopie** der zuletzt veroeffentlichten
* Version: Variablen, Abschnitte und Schritte samt `konfig` werden mitkopiert,
* nicht verlinkt. Wuerden sie geteilt, aenderte eine Bearbeitung rueckwirkend
* die veroeffentlichte Version und damit alle Laeufe, die daran haengen.
*/
export async function entwurfHolenOderErzeugen(
vorlageId: string,
durchBenutzerId: string,
): Promise<VorlagenVersion> {
const vorlage = await prisma.vorlage.findUnique({
where: { id: vorlageId },
select: { id: true, archiviert: true, name: true },
});
if (!vorlage) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
if (vorlage.archiviert) {
throw new VorlagenFehler(`„${vorlage.name}“ ist archiviert. Erst wieder freigeben, dann bearbeiten.`);
}
const vorhandener = await prisma.vorlagenVersion.findFirst({
where: { vorlageId, status: VersionStatus.ENTWURF },
});
if (vorhandener) return vorhandener;
const letzte = await prisma.vorlagenVersion.findFirst({
where: { vorlageId, status: VersionStatus.VEROEFFENTLICHT },
orderBy: { version: "desc" },
include: {
variablen: { orderBy: { position: "asc" } },
abschnitte: {
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
},
},
});
if (!letzte) {
throw new VorlagenFehler("Diese Vorlage hat weder einen Entwurf noch eine veröffentlichte Version.");
}
const hoechste = await prisma.vorlagenVersion.aggregate({
where: { vorlageId },
_max: { version: true },
});
const naechste = (hoechste._max.version ?? 0) + 1;
try {
return await entwurfKopieren(vorlageId, naechste, letzte, durchBenutzerId);
} catch (fehler) {
// Zwei gleichzeitige Aufrufe: der zweite laeuft in den eindeutigen Index –
// mal in den auf (vorlageId, version), mal in den auf "ein Entwurf". Beides
// heisst dasselbe: jemand anderes war schneller, und der Entwurf liegt
// bereits vor. Ihn zurueckzugeben ist richtiger als eine Fehlermeldung.
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
const inzwischen = await prisma.vorlagenVersion.findFirst({
where: { vorlageId, status: VersionStatus.ENTWURF },
});
if (inzwischen) return inzwischen;
}
throw fehler;
}
}
type VersionMitInhalt = {
readonly version: number;
readonly variablen: readonly {
schluessel: string;
beschriftung: string;
typ: "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM";
optionen: string[];
pflicht: boolean;
}[];
readonly abschnitte: readonly {
titel: string;
schritte: readonly {
titel: string;
hilfetext: string | null;
typ: "HAKEN" | "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM" | "BEFEHL";
pflicht: boolean;
konfig: unknown;
dokuUrl: string | null;
}[];
}[];
};
async function entwurfKopieren(
vorlageId: string,
naechste: number,
letzte: VersionMitInhalt,
durchBenutzerId: string,
): Promise<VorlagenVersion> {
return prisma.$transaction(async (tx) => {
const entwurf = await tx.vorlagenVersion.create({
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
});
if (letzte.variablen.length > 0) {
await tx.vorlagenVariable.createMany({
data: letzte.variablen.map((variable, index) => ({
versionId: entwurf.id,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: variable.optionen,
pflicht: variable.pflicht,
position: index,
})),
});
}
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
const kopie = await tx.vorlagenAbschnitt.create({
data: { versionId: entwurf.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: kopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Json wird als Wert kopiert, nicht als Verweis geteilt.
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({
data: {
entitaet: "VorlagenVersion",
entitaetId: entwurf.id,
aktion: "Entwurf aus veröffentlichter Version erstellt",
benutzerId: durchBenutzerId,
vorher: { version: letzte.version },
nachher: { version: naechste },
},
});
return entwurf;
});
}
/**
* Sperrt die Versionszeile und gibt ihren Stand zurueck.
*
* Ohne die Sperre lief folgendes durch: Speichern liest den Status, findet
* ENTWURF, und waehrend es schreibt, veroeffentlicht ein zweiter Aufruf die
* Version. Danach trug eine veroeffentlichte Version Inhalt, der nach dem
* Veroeffentlichen geschrieben wurde — genau die Regel, die das Archiv traegt,
* durchbrochen an einem Doppelklick.
*/
async function versionSperren(
tx: Prisma.TransactionClient,
versionId: string,
): Promise<{ status: VersionStatus; version: number; geaendertAm: Date }> {
const zeilen = await tx.$queryRaw<{ status: VersionStatus; version: number; geaendertAm: Date }[]>`
SELECT status, version, "geaendertAm" FROM "VorlagenVersion" WHERE id = ${versionId} FOR UPDATE
`;
const zeile = zeilen[0];
if (!zeile) throw new VorlagenFehler("Diese Version gibt es nicht.");
return zeile;
}
/**
* Wirft, wenn an dieser Version nicht mehr geschrieben werden darf.
*
* Eine einzige Stelle, wie `laufMussVeraenderbarSein` beim Archiv: verstreute
* Pruefungen werden irgendwann an einer Stelle vergessen.
*/
export function versionMussEntwurfSein(version: {
readonly status: VersionStatus;
readonly version: number;
}): void {
if (version.status !== VersionStatus.ENTWURF) {
throw new VorlagenFehler(
`Version ${version.version} ist veröffentlicht und damit unveränderlich. ` +
"„Bearbeiten“ erzeugt daraus einen neuen Entwurf.",
);
}
}
/**
* Ersetzt den Aufbau eines Entwurfs vollstaendig.
*
* Bewusst als Ganzes statt Schritt fuer Schritt: der Editor haelt den ganzen
* Baum im Speicher und sortiert per Drag & Drop, ein Teil-Update waere dort
* staendig mit dem Sortieren im Streit. Alles in einer Transaktion — ein halb
* gespeicherter Entwurf waere schlimmer als gar keiner.
*
* Kein Protokolleintrag: ein Entwurf wird beim Bauen dutzendfach gespeichert,
* das Protokoll wuerde davon unlesbar. Festgehalten wird das Veroeffentlichen —
* der Punkt, ab dem die Version unveraenderlich ist.
*/
export async function entwurfSpeichern(
versionId: string,
eingabe: StrukturEingabe,
erwarteterStand?: Date,
): Promise<{ readonly struktur: Struktur; readonly stand: Date }> {
const gelesen = StrukturSchema.safeParse(eingabe);
if (!gelesen.success) {
const erster = gelesen.error.issues[0];
throw new VorlagenFehler(
erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
);
}
const struktur = gelesen.data;
const schluessel = struktur.variablen.map((variable) => variable.schluessel);
const doppelt = schluessel.find((wert, index) => schluessel.indexOf(wert) !== index);
if (doppelt) {
throw new VorlagenFehler(`Den Variablenschlüssel „${doppelt}“ gibt es zweimal.`);
}
const stand = await prisma.$transaction(async (tx) => {
// Erst sperren, dann pruefen: sonst kann zwischen Pruefung und Schreiben
// veroeffentlicht werden.
const zeile = await versionSperren(tx, versionId);
versionMussEntwurfSein(zeile);
if (erwarteterStand && zeile.geaendertAm.getTime() !== erwarteterStand.getTime()) {
throw new VorlagenFehler(
"Der Entwurf wurde zwischenzeitlich von jemand anderem gespeichert. " +
"Bitte neu laden, sonst geht dessen Arbeit verloren.",
);
}
// Abschnitte raeumen ihre Schritte per Cascade mit ab. Haengt an einem
// Schritt ein Schrittergebnis, verhindert der Fremdschluessel das Loeschen.
// Das darf bei einem Entwurf nicht vorkommen – Laeufe haengen nur an
// veroeffentlichten Versionen, siehe veroeffentlichteVersionPruefen (#013).
// Sollte es doch passieren, wird der rohe Fremdschluesselfehler unten in eine
// verstaendliche Meldung uebersetzt.
try {
await tx.vorlagenAbschnitt.deleteMany({ where: { versionId } });
await tx.vorlagenVariable.deleteMany({ where: { versionId } });
} catch (fehler) {
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2003") {
throw new VorlagenFehler(
"An diesem Entwurf hängen bereits Schrittergebnisse. Er lässt sich nicht mehr umbauen.",
);
}
throw fehler;
}
if (struktur.variablen.length > 0) {
await tx.vorlagenVariable.createMany({
data: struktur.variablen.map((variable, index) => ({
versionId,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
position: index,
})),
});
}
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
const angelegt = await tx.vorlagenAbschnitt.create({
data: { versionId, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: angelegt.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl ?? null,
position: schrittIndex,
})),
});
}
}
// Der Stand wandert weiter, damit ein zweiter Editor beim Speichern merkt,
// dass hier inzwischen etwas passiert ist.
const frisch = await tx.vorlagenVersion.update({
where: { id: versionId },
data: { geaendertAm: new Date() },
select: { geaendertAm: true },
});
return frisch.geaendertAm;
});
return { struktur, stand };
}
/**
* Veroeffentlicht einen Entwurf.
*
* Ab hier ist die Version unveraenderlich. Bestehende Laeufe bleiben davon
* unberuehrt: sie verweisen auf die Version, mit der sie gestartet wurden.
*/
export async function versionVeroeffentlichen(
versionId: string,
aenderungsnotiz: string | null,
durchBenutzerId: string,
): Promise<VorlagenVersion> {
const notiz = aenderungsnotiz?.trim() || null;
return prisma.$transaction(async (tx) => {
const zeile = await versionSperren(tx, versionId);
versionMussEntwurfSein(zeile);
const kopf = await tx.vorlagenVersion.findUniqueOrThrow({
where: { id: versionId },
select: {
vorlage: { select: { archiviert: true, name: true } },
_count: { select: { abschnitte: true } },
},
});
if (kopf.vorlage.archiviert) {
throw new VorlagenFehler(
`„${kopf.vorlage.name}“ ist archiviert. Erst wieder freigeben, dann veröffentlichen.`,
);
}
if (kopf._count.abschnitte === 0) {
throw new VorlagenFehler(
"Eine Vorlage ohne Abschnitte lässt sich nicht veröffentlichen — es gäbe nichts abzuarbeiten.",
);
}
// Bedingtes Update: trifft es keine Zeile, hat jemand anderes in derselben
// Sekunde veroeffentlicht. Ohne diese Bedingung entstuenden zwei
// Protokolleintraege fuer denselben Vorgang.
const getroffen = await tx.vorlagenVersion.updateMany({
where: { id: versionId, status: VersionStatus.ENTWURF },
data: {
status: VersionStatus.VEROEFFENTLICHT,
aenderungsnotiz: notiz,
veroeffentlichtAm: new Date(),
veroeffentlichtVonId: durchBenutzerId,
},
});
if (getroffen.count === 0) {
throw new VorlagenFehler("Diese Version wurde bereits veröffentlicht.");
}
await tx.protokoll.create({
data: {
entitaet: "VorlagenVersion",
entitaetId: versionId,
aktion: "Version veröffentlicht",
benutzerId: durchBenutzerId,
vorher: { status: VersionStatus.ENTWURF },
nachher: {
status: VersionStatus.VEROEFFENTLICHT,
version: zeile.version,
aenderungsnotiz: notiz,
},
},
});
return tx.vorlagenVersion.findUniqueOrThrow({ where: { id: versionId } });
});
}
/**
* Verwirft einen Entwurf.
*
* Nur ein Entwurf laesst sich verwerfen, und nur wenn die Vorlage noch eine
* veroeffentlichte Version hat — sonst bliebe eine Vorlage ohne jeden Inhalt
* zurueck, die niemand mehr bearbeiten kann.
*/
export async function entwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise<void> {
const version = await prisma.vorlagenVersion.findUnique({
where: { id: versionId },
select: { id: true, status: true, version: true, vorlageId: true },
});
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
versionMussEntwurfSein(version);
const veroeffentlichte = await prisma.vorlagenVersion.count({
where: { vorlageId: version.vorlageId, status: VersionStatus.VEROEFFENTLICHT },
});
if (veroeffentlichte === 0) {
throw new VorlagenFehler(
"Das ist die einzige Version dieser Vorlage. Statt sie zu verwerfen, archiviere die Vorlage.",
);
}
await prisma.$transaction(async (tx) => {
await tx.vorlagenVersion.delete({ where: { id: versionId } });
await tx.protokoll.create({
data: {
entitaet: "VorlagenVersion",
entitaetId: versionId,
aktion: "Entwurf verworfen",
benutzerId: durchBenutzerId,
vorher: { version: version.version },
},
});
});
}
/**
* Dupliziert eine Vorlage samt ihrer zuletzt veroeffentlichten Version.
*
* Die Kopie beginnt als Entwurf: eine duplizierte Vorlage ist fast nie
* unveraendert brauchbar, und sofort zu veroeffentlichen wuerde sie in der
* Auswahl erscheinen lassen, bevor jemand sie angesehen hat.
*/
export async function vorlageDuplizieren(
vorlageId: string,
neuerName: string,
durchBenutzerId: string,
): Promise<Vorlage> {
const name = namePruefen(neuerName);
const quelle = await prisma.vorlage.findUnique({
where: { id: vorlageId },
include: {
versionen: {
where: { status: VersionStatus.VEROEFFENTLICHT },
orderBy: { version: "desc" },
take: 1,
include: {
variablen: { orderBy: { position: "asc" } },
abschnitte: {
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
},
},
},
},
});
if (!quelle) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
const letzte = quelle.versionen[0];
if (!letzte) {
throw new VorlagenFehler(
"Diese Vorlage hat noch keine veröffentlichte Version, es gibt also nichts zu duplizieren.",
);
}
return prisma.$transaction(async (tx) => {
const kopie = await tx.vorlage.create({
data: {
name,
beschreibung: quelle.beschreibung,
kategorie: quelle.kategorie,
schlagworte: quelle.schlagworte,
kundeId: quelle.kundeId,
},
});
const version = await tx.vorlagenVersion.create({
data: { vorlageId: kopie.id, version: 1, status: VersionStatus.ENTWURF },
});
if (letzte.variablen.length > 0) {
await tx.vorlagenVariable.createMany({
data: letzte.variablen.map((variable, index) => ({
versionId: version.id,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: variable.optionen,
pflicht: variable.pflicht,
position: index,
})),
});
}
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
const abschnittKopie = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: abschnittKopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: kopie.id,
aktion: "Vorlage dupliziert",
benutzerId: durchBenutzerId,
vorher: { ausVorlage: quelle.name, version: letzte.version },
nachher: { name },
},
});
return kopie;
});
}
/**
* Archivieren statt Loeschen.
*
* An einer Vorlage haengen Laeufe, und die muessen fuer immer nachvollziehbar
* bleiben. Eine archivierte Vorlage erscheint nicht mehr in der Auswahl, ihre
* Laeufe aber weiter im Archiv.
*/
export async function vorlageArchivieren(
vorlageId: string,
archiviert: boolean,
durchBenutzerId: string,
): Promise<void> {
const vorlage = await prisma.vorlage.findUnique({ where: { id: vorlageId } });
if (!vorlage) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
if (vorlage.archiviert === archiviert) return;
await prisma.$transaction(async (tx) => {
await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } });
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: vorlageId,
aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben",
benutzerId: durchBenutzerId,
vorher: { archiviert: vorlage.archiviert },
nachher: { archiviert },
},
});
});
}
/**
* Prueft, dass ein Lauf an dieser Version haengen darf.
*
* Steht hier, damit #013 die Regel „Laeufe verweisen immer auf eine
* veroeffentlichte Version" nicht neu erfinden muss.
*/
export async function veroeffentlichteVersionPruefen(versionId: string): Promise<void> {
const version = await prisma.vorlagenVersion.findUnique({
where: { id: versionId },
select: { status: true, version: true, vorlage: { select: { archiviert: true, name: true } } },
});
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
if (version.status !== VersionStatus.VEROEFFENTLICHT) {
throw new VorlagenFehler(
"Ein Lauf braucht eine veröffentlichte Version. Version " + `${version.version} ist noch ein Entwurf.`,
);
}
if (version.vorlage.archiviert) {
throw new VorlagenFehler(`„${version.vorlage.name}“ ist archiviert und startet keine Läufe mehr.`);
}
}