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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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.`); } }