846 lines
29 KiB
TypeScript
846 lines
29 KiB
TypeScript
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.`);
|
||
}
|
||
}
|