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