Files
Laufzettel/src/server/services/vorlagen.ts
T
florian.wilde 80870133b8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(bausteine): Abschnitte, die mehrere Vorlagen teilen (#57)
2026-10-04 17:07:12 +00:00

846 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { stelleBenennen, 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: {
kundeId: vorlage.kundeId,
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: {
kundeId: nachher.kundeId,
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) => {
// Der Kunde fuer den Protokolleintrag; ein indizierter Lesevorgang auf dem
// Primaerschluessel (#023).
const vorlage = await tx.vorlage.findUniqueOrThrow({
where: { id: vorlageId },
select: { kundeId: true },
});
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: {
kundeId: vorlage.kundeId,
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.",
);
}
}
/**
* Setzt die Schritte verwiesener Bausteine in den Entwurf ein (#030).
*
* Der Kern der Entscheidung aus dem Issue: ein Entwurf **verweist**, eine
* veroeffentlichte Version traegt **Kopien**. Danach sieht ein Lauf weiterhin
* konkrete Schritte, und `Schrittergebnis`, Druckansicht und Archiv muessen
* nichts von Bausteinen wissen. Eine spaetere Baustein-Fassung kann diese
* Version nicht mehr anfassen — genau das soll die Unveraenderlichkeit leisten.
*
* Der Verweis bleibt stehen. In einer veroeffentlichten Version ist er nur noch
* Herkunft, und die will man beim Lesen sehen.
*
* Gibt zurueck, was eingesetzt wurde — fuer den Protokolleintrag.
*/
async function bausteineAufloesen(
tx: Prisma.TransactionClient,
versionId: string,
): Promise<readonly { readonly name: string; readonly version: number }[]> {
const abschnitte = await tx.vorlagenAbschnitt.findMany({
where: { versionId, bausteinVersionId: { not: null } },
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
bausteinVersion: {
select: {
version: true,
titel: true,
baustein: { select: { name: true, archiviert: true } },
schritte: { orderBy: { position: "asc" } },
},
},
_count: { select: { schritte: true } },
},
});
const eingesetzt: { name: string; version: number }[] = [];
for (const abschnitt of abschnitte) {
const quelle = abschnitt.bausteinVersion;
if (!quelle) continue;
if (quelle.baustein.archiviert) {
throw new VorlagenFehler(
`Der Baustein „${quelle.baustein.name}“ ist archiviert. Erst wieder freigeben, ` +
"dann veröffentlichen — oder den Abschnitt aus dem Entwurf nehmen.",
);
}
if (quelle.schritte.length === 0) {
throw new VorlagenFehler(
`Der Baustein „${quelle.baustein.name}“ hat keine Schritte. Ein Abschnitt daraus ` +
"wäre leer, und eine Vorlage mit leerem Abschnitt lässt sich nicht abarbeiten.",
);
}
// Eigene Schritte hat ein Baustein-Abschnitt nicht; falls doch etwas liegen
// geblieben ist, weg damit, sonst stuenden die Schritte doppelt.
if (abschnitt._count.schritte > 0) {
await tx.vorlagenSchritt.deleteMany({ where: { abschnittId: abschnitt.id } });
}
await tx.vorlagenSchritt.createMany({
data: quelle.schritte.map((schritt, index) => ({
abschnittId: abschnitt.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: index,
})),
});
// Der Titel kommt aus dem Baustein: er gehoert zum Abschnitt, den der
// Baustein beschreibt, und soll nicht in jeder Vorlage einzeln gepflegt
// werden.
if (abschnitt.titel !== quelle.titel) {
await tx.vorlagenAbschnitt.update({
where: { id: abschnitt.id },
data: { titel: quelle.titel },
});
}
eingesetzt.push({ name: quelle.baustein.name, version: quelle.version });
}
return eingesetzt;
}
/**
* 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 ? `${stelleBenennen(erster.path)}${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,
// Ein Trigger laesst nur veroeffentlichte Baustein-Versionen zu; der
// Dienst prueft dasselbe mit besserer Meldung (#030).
bausteinVersionId: abschnitt.bausteinVersionId ?? null,
},
});
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, kundeId: 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.",
);
}
// **Vor** dem Umschalten: Bausteine in echte Schritte aufloesen. Danach
// greifen die Trigger auf veroeffentlichten Versionen, und es waere zu spaet
// (#030).
const eingesetzt = await bausteineAufloesen(tx, versionId);
// 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: {
kundeId: kopf.vorlage.kundeId,
entitaet: "VorlagenVersion",
entitaetId: versionId,
aktion: "Version veröffentlicht",
benutzerId: durchBenutzerId,
vorher: { status: VersionStatus.ENTWURF },
nachher: {
status: VersionStatus.VEROEFFENTLICHT,
version: zeile.version,
aenderungsnotiz: notiz,
// Welche Bausteine in dieser Version steckengeblieben sind, in welcher
// Fassung. Spaeter die einzige Stelle, an der das noch nachlesbar ist:
// der Verweis am Abschnitt nennt die Kennung, nicht den Namen.
...(eingesetzt.length > 0 ? { bausteine: eingesetzt } : {}),
},
},
});
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,
vorlage: { select: { kundeId: 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: {
kundeId: version.vorlage.kundeId,
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: {
kundeId: kopie.kundeId,
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: {
kundeId: vorlage.kundeId,
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.`);
}
}