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