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
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod";
/**
* Zod mit deutschen Standardmeldungen (#009).
*
* Ohne diese Einstellung kommen bei fehlenden oder falsch typisierten Feldern
* englische Saetze heraus („Invalid input: expected string“), und die landen
* ueber die Server Actions unmittelbar in der Oberflaeche. Eigene `message`-Texte
* helfen nur dort, wo man an sie gedacht hat — die Lokalisierung deckt auch alles
* ab, woran niemand gedacht hat.
*
* Deshalb wird `z` **hier** eingestellt und von hier weitergereicht: ein Import
* direkt aus "zod" umginge die Einstellung. Die ESLint-Regel
* `no-restricted-imports` sorgt dafuer, dass das auffaellt.
*/
z.config(z.locales.de());
export { z };
// Was sonst noch aus Zod gebraucht wird, laeuft ebenfalls ueber diesen Pfad –
// sonst muesste doch wieder jemand direkt aus "zod" importieren.
export { ZodError } from "zod";
export type { ZodIssue, ZodType } from "zod";
+203
View File
@@ -0,0 +1,203 @@
import { z } from "@/lib/validierung";
/**
* Aufbau einer Vorlagen-Version (#009).
*
* Ein Schema fuer drei Zwecke: der Editor schickt den Entwurf in dieser Form,
* der JSON-Export schreibt sie, der Import liest sie. Gaebe es drei Schemata,
* liefen sie frueher oder spaeter auseinander — und der Import waere der Ort, an
* dem das erst auffiele.
*
* Framework-frei und ohne Datenbank, damit es sich ohne Next testen laesst.
*/
/**
* Ein leeres Formularfeld kommt als `""` an, nicht als `undefined`.
*
* Ohne diese Vorstufe scheiterte ein nicht ausgefuelltes optionales Feld an
* seiner eigenen Pruefung — beim Doku-Link etwa mit „keine gültige Adresse“,
* obwohl gar keine angegeben werden sollte.
*/
function leerAlsUndefiniert<T extends z.ZodType>(schema: T) {
return z.preprocess(
(wert) => (typeof wert === "string" && wert.trim() === "" ? undefined : wert),
schema.optional(),
);
}
/** Die Schritt-Typen des MVP. `AKTION` kommt in Phase 4 dazu. */
export const SCHRITT_TYPEN = ["HAKEN", "TEXT", "ZAHL", "AUSWAHL", "DATUM", "BEFEHL"] as const;
export const VARIABLEN_TYPEN = ["TEXT", "ZAHL", "AUSWAHL", "DATUM"] as const;
export const SHELLS = ["powershell", "bash", "cmd"] as const;
/**
* Obergrenzen fuer ein Austauschdokument.
*
* Ohne sie nimmt der Import beliebig grosse Listen an, laeuft in die
* Transaktions-Zeitgrenze von Postgres und meldet einen Fehler, der nichts ueber
* die Ursache sagt. Die Werte sind grosszuegig gewaehlt: die groesste Vorlage im
* Seed hat sieben Abschnitte und rund dreissig Schritte.
*/
export const GRENZEN = {
abschnitte: 200,
schritteJeAbschnitt: 500,
variablen: 200,
text: 20_000,
optionen: 200,
} as const;
/**
* Typabhaengige Einzelheiten eines Schritts.
*
* Bewusst nachsichtig: unbekannte Schluessel bleiben erhalten, damit ein
* Export aus einer spaeteren Fassung hier nicht stillschweigend Angaben
* verliert. Was die Anwendung kennt, wird geprueft.
*/
export const SchrittKonfigSchema = z.looseObject({
/** AUSWAHL: die Optionen in der angezeigten Reihenfolge. */
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).optional(),
/** ZAHL: Grenzen und Einheit. Ein Wert ausserhalb wird markiert, nicht blockiert. */
min: z.number().optional(),
max: z.number().optional(),
einheit: z.string().trim().max(40).optional(),
/** TEXT: mehrzeilig statt einzeilig. */
mehrzeilig: z.boolean().optional(),
/** BEFEHL: Shell, Mehrzeiligkeit und der Befehlstext mit Platzhaltern. */
shell: z.enum(SHELLS).optional(),
befehl: z.string().max(GRENZEN.text).optional(),
});
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
export const SchrittSchema = z.object({
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
hilfetext: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
typ: z.enum(SCHRITT_TYPEN).default("HAKEN"),
pflicht: z.boolean().default(false),
konfig: SchrittKonfigSchema.default({}),
/**
* Nur http und https. `z.url()` allein liesse `javascript:` und `data:` durch —
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
*/
dokuUrl: leerAlsUndefiniert(
z.url({
protocol: /^https?$/,
message: "Der Doku-Link muss eine http- oder https-Adresse sein.",
}),
),
});
export type SchrittEingabe = z.input<typeof SchrittSchema>;
export const AbschnittSchema = z.object({
titel: z.string().trim().min(1, "Ein Abschnitt braucht einen Titel.").max(GRENZEN.text),
schritte: z
.array(SchrittSchema)
.max(
GRENZEN.schritteJeAbschnitt,
`Ein Abschnitt fasst höchstens ${GRENZEN.schritteJeAbschnitt} Schritte.`,
)
.default([]),
});
export const VariableSchema = z.object({
/**
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
* Befehlen. Deshalb nur Kleinbuchstaben, Ziffern und Unterstrich — alles
* andere liesse sich im Platzhalter nicht sauber wiedererkennen.
*/
schluessel: z
.string()
.trim()
.regex(
/^[a-z][a-z0-9_]{0,39}$/,
"Ein Variablenschlüssel beginnt mit einem Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich.",
),
beschriftung: z.string().trim().min(1, "Eine Variable braucht eine Beschriftung.").max(GRENZEN.text),
typ: z.enum(VARIABLEN_TYPEN).default("TEXT"),
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]),
pflicht: z.boolean().default(false),
});
export type VariableEingabe = z.input<typeof VariableSchema>;
/**
* Der gesamte Aufbau einer Version. Die Reihenfolge steckt in der Reihenfolge
* der Listen; `position` wird daraus abgeleitet und nicht mitgeschickt — sonst
* gaebe es zwei Wahrheiten, die auseinanderlaufen koennen.
*/
export const StrukturSchema = z.object({
variablen: z
.array(VariableSchema)
.max(GRENZEN.variablen, `Eine Vorlage fasst höchstens ${GRENZEN.variablen} Variablen.`)
.default([]),
abschnitte: z
.array(AbschnittSchema)
.max(GRENZEN.abschnitte, `Eine Vorlage fasst höchstens ${GRENZEN.abschnitte} Abschnitte.`)
.default([]),
});
export type Struktur = z.infer<typeof StrukturSchema>;
export type StrukturEingabe = z.input<typeof StrukturSchema>;
/** Fassung des Austauschformats. Wird beim Import geprueft. */
export const FORMAT_VERSION = 1;
/**
* Was eine exportierte Datei enthaelt.
*
* Bewusst ohne Kennungen, Zeitstempel und Kundenbezug: eine exportierte Vorlage
* soll sich in eine andere Laufzettel-Installation einspielen lassen, ohne
* Verweise mitzubringen, die dort nichts bedeuten.
*/
export const AustauschSchema = z.object({
format: z.literal(FORMAT_VERSION, {
message: `Unbekanntes Format. Diese Fassung liest Format ${FORMAT_VERSION}.`,
}),
name: z.string().trim().min(1, "Die Vorlage braucht einen Namen.").max(200),
beschreibung: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
kategorie: leerAlsUndefiniert(z.string().trim().max(100)),
schlagworte: z.array(z.string().trim().min(1).max(100)).max(50).default([]),
/** Nur zur Information, welche Version der Quelle exportiert wurde. */
version: z.number().int().positive().optional(),
struktur: StrukturSchema,
});
export type Austausch = z.infer<typeof AustauschSchema>;
/**
* Alle Platzhalter `{{key}}` aus einem Text.
*
* Steht hier und nicht in #012, weil der Editor schon beim Bearbeiten warnen
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
* gehoert. Das Ersetzen selbst kommt in #012.
*/
export function platzhalterFinden(text: string): readonly string[] {
const gefunden = new Set<string>();
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
if (treffer[1]) gefunden.add(treffer[1]);
}
return [...gefunden];
}
/** Alle Platzhalter, die irgendwo in einer Struktur vorkommen. */
export function platzhalterDerStruktur(struktur: Struktur): readonly string[] {
const gefunden = new Set<string>();
const sammeln = (text: string | undefined) => {
if (text) for (const name of platzhalterFinden(text)) gefunden.add(name);
};
for (const abschnitt of struktur.abschnitte) {
sammeln(abschnitt.titel);
for (const schritt of abschnitt.schritte) {
sammeln(schritt.titel);
sammeln(schritt.hilfetext);
const befehl = schritt.konfig["befehl"];
if (typeof befehl === "string") sammeln(befehl);
}
}
return [...gefunden];
}
+1 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { redirect } from "next/navigation";
import { z } from "zod";
import { z } from "@/lib/validierung";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
import { AnmeldeFehler, lokalAnmelden } from "@/server/auth/benutzer";
+1 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { z } from "@/lib/validierung";
import { Rolle } from "@/generated/prisma/enums";
import {
AnmeldeFehler,
+1 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { z } from "@/lib/validierung";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
EinstellungsFehler,
+1 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { z } from "@/lib/validierung";
import { KundenFehler, kundeAnlegen, kundeArchivieren, kundeBearbeiten } from "@/server/services/kunden";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
+347
View File
@@ -0,0 +1,347 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { StrukturSchema } from "@/lib/vorlagen-format";
import { kundeZugriffPruefen, kundenEbeneSichtbar } from "@/server/mandant";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
VorlagenFehler,
entwurfHolenOderErzeugen,
entwurfSpeichern,
entwurfVerwerfen,
versionVeroeffentlichen,
vorlageAnlegen,
vorlageArchivieren,
vorlageBearbeiten,
vorlageDuplizieren,
} from "@/server/services/vorlagen";
import {
AustauschFehler,
vorlageExportieren,
vorlageImportieren,
} from "@/server/services/vorlagen-austausch";
/**
* Server Actions der Vorlagen (#009).
*
* Jede beginnt mit `rechtErforderlich("vorlagenBearbeiten")` – das haben nur
* ADMIN und REDAKTEUR. Techniker starten Laeufe, sie bauen keine Vorlagen.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
/** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const AnlegenSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
beschreibung: z.string().trim().optional(),
kategorie: z.string().trim().optional(),
schlagworte: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
export async function vorlageAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = AnlegenSchema.safeParse({
name: formular.get("name"),
beschreibung: formular.get("beschreibung") ?? undefined,
kategorie: formular.get("kategorie") ?? undefined,
schlagworte: formular.get("schlagworte") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
// Eine kundenspezifische Vorlage gibt es nur im Modus Dienstleistung – und nur
// fuer einen Kunden, den dieser Benutzer sehen darf.
const kundeId = eingabe.data.kundeId || null;
if (kundeId) {
if (!(await kundenEbeneSichtbar())) {
return { fehler: "Kundenspezifische Vorlagen gibt es nur im Modus Dienstleistung." };
}
await kundeZugriffPruefen(kundeId, benutzer);
}
try {
const { vorlage } = await vorlageAnlegen(
{
name: eingabe.data.name,
...(eingabe.data.beschreibung ? { beschreibung: eingabe.data.beschreibung } : {}),
...(eingabe.data.kategorie ? { kategorie: eingabe.data.kategorie } : {}),
schlagworte: (eingabe.data.schlagworte ?? "")
.split(",")
.map((wort) => wort.trim())
.filter(Boolean),
kundeId,
},
benutzer.id,
);
revalidatePath("/vorlagen");
return { erfolg: `„${vorlage.name}“ wurde angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorlage konnte nicht angelegt werden.");
}
}
export async function entwurfOeffnenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis & { readonly versionId?: string }> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const vorlageId = z.string().min(1).safeParse(formular.get("vorlageId"));
if (!vorlageId.success) return { fehler: "Es fehlt die Vorlage." };
try {
const entwurf = await entwurfHolenOderErzeugen(vorlageId.data, benutzer.id);
revalidatePath("/vorlagen");
return { versionId: entwurf.id };
} catch (fehler) {
return alsErgebnis(fehler, "Der Entwurf konnte nicht geöffnet werden.");
}
}
const SpeichernSchema = z.object({
versionId: z.string().min(1),
struktur: StrukturSchema,
/**
* Stand, den der Editor beim Laden bekommen hat. Weicht er ab, hat jemand
* anderes zwischenzeitlich gespeichert – und dessen Arbeit waere sonst weg.
*/
stand: z.coerce.date().optional(),
});
export async function entwurfSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis & { readonly stand?: string }> {
await rechtErforderlich("vorlagenBearbeiten");
const roh = formular.get("struktur");
let geparst: unknown;
try {
geparst = typeof roh === "string" ? JSON.parse(roh) : undefined;
} catch {
return { fehler: "Der Aufbau kam nicht als gültiges JSON an." };
}
const eingabe = SpeichernSchema.safeParse({
versionId: formular.get("versionId"),
struktur: geparst,
stand: formular.get("stand") ?? undefined,
});
if (!eingabe.success) {
const erster = eingabe.error.issues[0];
return {
fehler: erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
};
}
try {
const { stand } = await entwurfSpeichern(
eingabe.data.versionId,
eingabe.data.struktur,
eingabe.data.stand,
);
revalidatePath("/vorlagen");
return { erfolg: "Entwurf gespeichert.", stand: stand.toISOString() };
} catch (fehler) {
return alsErgebnis(fehler, "Der Entwurf konnte nicht gespeichert werden.");
}
}
const BearbeitenSchema = z.object({
vorlageId: z.string().min(1),
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
beschreibung: z.string().trim().optional(),
kategorie: z.string().trim().optional(),
schlagworte: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
export async function vorlageBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = BearbeitenSchema.safeParse({
vorlageId: formular.get("vorlageId"),
name: formular.get("name"),
beschreibung: formular.get("beschreibung") ?? undefined,
kategorie: formular.get("kategorie") ?? undefined,
schlagworte: formular.get("schlagworte") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const kundeId = eingabe.data.kundeId || null;
if (kundeId) {
if (!(await kundenEbeneSichtbar())) {
return { fehler: "Kundenspezifische Vorlagen gibt es nur im Modus Dienstleistung." };
}
await kundeZugriffPruefen(kundeId, benutzer);
}
try {
await vorlageBearbeiten(
eingabe.data.vorlageId,
{
name: eingabe.data.name,
beschreibung: eingabe.data.beschreibung ?? null,
kategorie: eingabe.data.kategorie ?? null,
schlagworte: (eingabe.data.schlagworte ?? "")
.split(",")
.map((wort) => wort.trim())
.filter(Boolean),
kundeId,
},
benutzer.id,
);
revalidatePath("/vorlagen");
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function veroeffentlichenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
.safeParse({
versionId: formular.get("versionId"),
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
});
if (!eingabe.success) return { fehler: "Es fehlt die Version." };
try {
const version = await versionVeroeffentlichen(
eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null,
benutzer.id,
);
revalidatePath("/vorlagen");
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Version konnte nicht veröffentlicht werden.");
}
}
export async function entwurfVerwerfenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const versionId = z.string().min(1).safeParse(formular.get("versionId"));
if (!versionId.success) return { fehler: "Es fehlt die Version." };
try {
await entwurfVerwerfen(versionId.data, benutzer.id);
revalidatePath("/vorlagen");
return { erfolg: "Entwurf verworfen." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Entwurf konnte nicht verworfen werden.");
}
}
export async function vorlageDuplizierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({ vorlageId: z.string().min(1), name: z.string().trim().min(1, "Bitte einen Namen angeben.") })
.safeParse({ vorlageId: formular.get("vorlageId"), name: formular.get("name") });
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const kopie = await vorlageDuplizieren(eingabe.data.vorlageId, eingabe.data.name, benutzer.id);
revalidatePath("/vorlagen");
return { erfolg: `„${kopie.name}“ wurde als Entwurf angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorlage konnte nicht dupliziert werden.");
}
}
export async function vorlageArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({ vorlageId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ vorlageId: formular.get("vorlageId"), archiviert: formular.get("archiviert") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await vorlageArchivieren(eingabe.data.vorlageId, eingabe.data.archiviert === "true", benutzer.id);
revalidatePath("/vorlagen");
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
/** Gibt das Austauschdokument als JSON-Text zurueck – der Browser bietet es zum Sichern an. */
export async function vorlageExportierenAktion(
versionId: string,
): Promise<{ readonly dateiname: string; readonly inhalt: string } | AktionsErgebnis> {
await rechtErforderlich("vorlagenBearbeiten");
try {
const dokument = await vorlageExportieren(versionId);
const sauber = dokument.name.replace(/[^\p{L}\p{N}]+/gu, "-").toLowerCase();
return {
dateiname: `vorlage-${sauber}-v${dokument.version ?? 1}.json`,
inhalt: JSON.stringify(dokument, null, 2),
};
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorlage konnte nicht exportiert werden.");
}
}
export async function vorlageImportierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const roh = formular.get("dokument");
if (typeof roh !== "string" || !roh.trim()) {
return { fehler: "Bitte eine Vorlagendatei auswählen oder den Inhalt einfügen." };
}
let geparst: unknown;
try {
geparst = JSON.parse(roh);
} catch {
return { fehler: "Die Datei enthält kein gültiges JSON." };
}
try {
await vorlageImportieren(geparst, benutzer.id);
revalidatePath("/vorlagen");
return { erfolg: "Die Vorlage wurde als Entwurf importiert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Vorlage konnte nicht importiert werden.");
}
}
+244
View File
@@ -0,0 +1,244 @@
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Vorlagen (#009).
*
* Wie bei den Kunden wenden sie die Mandantensicht selbst an: eine Vorlage kann
* kundenspezifisch sein, und wer den Kunden nicht sehen darf, soll auch seine
* Vorlagen nicht sehen. Vorlagen ohne Kundenbezug gelten fuer alle.
*/
export type VorlagenZeile = {
readonly id: string;
readonly name: string;
readonly beschreibung: string | null;
readonly kategorie: string | null;
readonly schlagworte: readonly string[];
readonly archiviert: boolean;
readonly kundeId: string | null;
/** Nummer der zuletzt veroeffentlichten Version, falls es eine gibt. */
readonly veroeffentlichteVersion: number | null;
readonly veroeffentlichtAm: Date | null;
/** Wird gerade an einem Entwurf gearbeitet? */
readonly hatEntwurf: boolean;
readonly anzahlLaeufe: number;
};
export type VorlagenFilter = {
readonly suche?: string;
readonly kategorie?: string;
readonly schlagwort?: string;
readonly auchArchivierte?: boolean;
};
/**
* Die Mandantenbedingung als **eigener** Ausdruck, nicht als Objekt zum
* Hineinspreizen.
*
* Vorher wurde `{ OR: [...] }` in dasselbe Literal gespreizt wie die Suche, die
* ebenfalls ein `OR` mitbringt — der zweite Schluessel gewann, und die
* Mandantengrenze war durch einen Buchstaben im Suchfeld umgangen. Alle
* Teilbedingungen liegen deshalb in einem `AND`, wo keine die andere
* ueberschreiben kann.
*/
async function kundenBedingung(benutzer: HandelnderBenutzer) {
const sichtbar = await sichtbareKundenIds(benutzer);
if (sichtbar === undefined) return undefined;
// Vorlagen ohne Kundenbezug gelten fuer alle.
return { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] };
}
export async function vorlagenListe(
benutzer: HandelnderBenutzer,
filter: VorlagenFilter = {},
): Promise<readonly VorlagenZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
await kundenBedingung(benutzer),
filter.auchArchivierte ? undefined : { archiviert: false },
filter.kategorie ? { kategorie: filter.kategorie } : undefined,
filter.schlagwort ? { schlagworte: { has: filter.schlagwort } } : undefined,
suche
? {
OR: [
{ name: { contains: suche, mode: "insensitive" as const } },
{ beschreibung: { contains: suche, mode: "insensitive" as const } },
],
}
: undefined,
].filter((bedingung) => bedingung !== undefined);
const zeilen = await prisma.vorlage.findMany({
where: { AND: bedingungen },
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
beschreibung: true,
kategorie: true,
schlagworte: true,
archiviert: true,
kundeId: true,
versionen: {
select: {
version: true,
status: true,
veroeffentlichtAm: true,
_count: { select: { laeufe: true } },
},
},
},
});
return zeilen.map((zeile) => {
const veroeffentlichte = zeile.versionen
.filter((v) => v.status === VersionStatus.VEROEFFENTLICHT)
.sort((a, b) => b.version - a.version);
const neueste = veroeffentlichte[0];
return {
id: zeile.id,
name: zeile.name,
beschreibung: zeile.beschreibung,
kategorie: zeile.kategorie,
schlagworte: zeile.schlagworte,
archiviert: zeile.archiviert,
kundeId: zeile.kundeId,
veroeffentlichteVersion: neueste?.version ?? null,
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
hatEntwurf: zeile.versionen.some((v) => v.status === VersionStatus.ENTWURF),
anzahlLaeufe: zeile.versionen.reduce((summe, v) => summe + v._count.laeufe, 0),
};
});
}
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
export async function vorlagenFilterwerte(
benutzer: HandelnderBenutzer,
): Promise<{ readonly kategorien: readonly string[]; readonly schlagworte: readonly string[] }> {
const mandant = await kundenBedingung(benutzer);
const zeilen = await prisma.vorlage.findMany({
where: { AND: [{ archiviert: false }, ...(mandant ? [mandant] : [])] },
select: { kategorie: true, schlagworte: true },
});
const kategorien = new Set<string>();
const schlagworte = new Set<string>();
for (const zeile of zeilen) {
if (zeile.kategorie) kategorien.add(zeile.kategorie);
for (const wort of zeile.schlagworte) schlagworte.add(wort);
}
return {
kategorien: [...kategorien].sort((a, b) => a.localeCompare(b, "de")),
schlagworte: [...schlagworte].sort((a, b) => a.localeCompare(b, "de")),
};
}
export type VersionsZeile = {
readonly id: string;
readonly version: number;
readonly status: VersionStatus;
readonly aenderungsnotiz: string | null;
readonly veroeffentlichtAm: Date | null;
readonly veroeffentlichtVon: string | null;
readonly anzahlLaeufe: number;
};
/**
* Versionshistorie einer Vorlage, neueste zuerst.
*
* Wendet die Mandantensicht selbst an: wer die Vorlage nicht sehen darf, sieht
* auch ihre Historie nicht. Eine leere Liste ist hier die richtige Antwort – wer
* die Vorlage nicht kennt, soll nicht einmal erfahren, dass es sie gibt.
*/
export async function versionsHistorie(
vorlageId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly VersionsZeile[]> {
const mandant = await kundenBedingung(benutzer);
const versionen = await prisma.vorlagenVersion.findMany({
where: { vorlageId, ...(mandant ? { vorlage: mandant } : {}) },
orderBy: { version: "desc" },
select: {
id: true,
version: true,
status: true,
aenderungsnotiz: true,
veroeffentlichtAm: true,
veroeffentlichtVon: { select: { name: true } },
_count: { select: { laeufe: true } },
},
});
return versionen.map((version) => ({
id: version.id,
version: version.version,
status: version.status,
aenderungsnotiz: version.aenderungsnotiz,
veroeffentlichtAm: version.veroeffentlichtAm,
veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null,
anzahlLaeufe: version._count.laeufe,
}));
}
export type VersionMitAufbau = {
readonly id: string;
readonly vorlageId: string;
readonly vorlageName: string;
readonly version: number;
readonly status: VersionStatus;
readonly struktur: Struktur;
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
readonly platzhalter: readonly string[];
};
export async function versionMitAufbau(
versionId: string,
benutzer: HandelnderBenutzer,
): Promise<VersionMitAufbau | undefined> {
const mandant = await kundenBedingung(benutzer);
const version = await prisma.vorlagenVersion.findFirst({
where: { id: versionId, ...(mandant ? { vorlage: mandant } : {}) },
include: {
vorlage: { select: { id: true, name: true } },
variablen: { orderBy: { position: "asc" } },
abschnitte: {
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
},
},
});
if (!version) return undefined;
const struktur: Struktur = {
variablen: version.variablen.map((variable) => ({
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: variable.optionen,
pflicht: variable.pflicht,
})),
abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
})),
})),
};
return {
id: version.id,
vorlageId: version.vorlage.id,
vorlageName: version.vorlage.name,
version: version.version,
status: version.status,
struktur,
platzhalter: platzhalterDerStruktur(struktur),
};
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "@/lib/validierung";
import type { Prisma } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
@@ -180,7 +180,7 @@ async function pruefungMit(
moeglich: true,
brauchtAngaben: true,
folgen: [
`Die bisherige eigene Organisation „${organisation.name}" wird zum ersten Kunden.`,
`Die bisherige eigene Organisation „${organisation.name}“ wird zum ersten Kunden.`,
"Alle Geräte, Personen, Abteilungen und Läufe bleiben daran hängen.",
"Kunden werden zu einer sichtbaren Ebene: Kundenliste, Kundenwahl beim Start eines Laufs, Kundenkürzel auf den Etiketten.",
],
@@ -204,7 +204,7 @@ async function pruefungMit(
moeglich: true,
brauchtAngaben: false,
folgen: [
`„${einziger.name}" wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
`„${einziger.name}“ wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
"Alle Daten bleiben erhalten, nur die Kundenebene wird ausgeblendet.",
"Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf.",
],
+245
View File
@@ -0,0 +1,245 @@
import type { ZodError } from "@/lib/validierung";
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { AustauschSchema, FORMAT_VERSION, type Austausch } from "@/lib/vorlagen-format";
/**
* JSON-Austausch fuer Vorlagen (#009).
*
* Export und Import sind die Bruecke zwischen zwei Installationen: eine Vorlage
* verlaesst die eine als Datei und kommt in der anderen als Entwurf an. Deshalb
* traegt das Dokument keine Kennungen, keine Zeitstempel und keinen Kundenbezug
* — Verweise, die in der Zielinstallation nichts bedeuten wuerden. Das Format
* selbst steht in `docs/vorlagen-format.md`, die Schemata in
* `src/lib/vorlagen-format.ts`.
*
* Rechte werden hier nicht geprueft, das tut die Server Action darueber.
*/
export class AustauschFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "AustauschFehler";
}
}
/** Ein Schritt so, wie er im Austauschdokument steht — ohne Kennung und Position. */
type AustauschSchritt = Austausch["struktur"]["abschnitte"][number]["schritte"][number];
/**
* Baut das Austauschdokument einer Version. Liest nur.
*
* Sortiert wird nach `position`, bei Gleichstand nach `id`: zwei Zeilen mit
* derselben Position duerfen die Reihenfolge nicht dem Zufall der Datenbank
* ueberlassen, sonst waere ein Export nicht wiederholbar.
*/
export async function vorlageExportieren(versionId: string): Promise<Austausch> {
const version = await prisma.vorlagenVersion.findUnique({
where: { id: versionId },
include: {
vorlage: true,
variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] },
abschnitte: {
orderBy: [{ position: "asc" }, { id: "asc" }],
include: { schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] } },
},
},
});
if (!version) {
throw new AustauschFehler("Diese Vorlagen-Version gibt es nicht (mehr).");
}
const { vorlage } = version;
return {
format: FORMAT_VERSION,
name: vorlage.name,
// Leere Felder werden weggelassen statt als null geschrieben: eine von Hand
// geschriebene Datei sieht genauso aus wie eine exportierte.
...(vorlage.beschreibung ? { beschreibung: vorlage.beschreibung } : {}),
...(vorlage.kategorie ? { kategorie: vorlage.kategorie } : {}),
schlagworte: [...vorlage.schlagworte],
version: version.version,
struktur: {
variablen: version.variablen.map((variable) => ({
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
})),
abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
schritte: abschnitt.schritte.map(schrittAbbilden),
})),
},
};
}
/**
* Bildet eine Schritt-Zeile auf das Austauschformat ab.
*
* `konfig` ist in der Datenbank ein Json-Feld und kann alles enthalten.
*
* Frueher lief es durch `SchrittKonfigSchema.safeParse` und wurde bei **jeder**
* Beanstandung komplett verworfen — ein `shell: "zsh"` aus einer spaeteren
* Fassung kostete lautlos auch den `befehl`. Genau das Gegenteil dessen, was das
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
* kein Objekt ist, gilt als leer.
*/
function schrittAbbilden(schritt: VorlagenSchritt): AustauschSchritt {
const roh = schritt.konfig;
const konfig =
typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {};
return {
titel: schritt.titel,
...(schritt.hilfetext ? { hilfetext: schritt.hilfetext } : {}),
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig,
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
};
}
/**
* Legt aus einem Austauschdokument eine neue Vorlage mit einem Entwurf an.
* Nimmt unbekannte Eingaben entgegen und prueft sie mit AustauschSchema.
*
* Immer eine **neue** Vorlage, nie in eine bestehende hinein, und die erste
* Version ist `ENTWURF`: eine eingespielte Vorlage wird bewusst nicht sofort
* veroeffentlicht, der Redakteur sieht sie sich erst an. Ein Name, den es schon
* gibt, ist kein Fehler — Vorlagennamen sind nicht eindeutig, und ein
* angehaengtes „(2)“ waere eine Aenderung, um die niemand gebeten hat.
*/
export async function vorlageImportieren(
dokument: unknown,
durchBenutzerId: string,
): Promise<{ readonly vorlageId: string; readonly versionId: string }> {
const austausch = dokumentPruefen(dokument);
variablenschluesselPruefen(austausch.struktur.variablen);
// Eine Vorlage ist erst mit Variablen, Abschnitten und Schritten sinnvoll.
// Deshalb alles in einer Transaktion: ein Fehler auf halbem Weg darf keine
// halbe Vorlage hinterlassen, die jemand in der Liste findet.
return prisma.$transaction(async (tx) => {
const vorlage = await tx.vorlage.create({
data: {
name: austausch.name,
beschreibung: austausch.beschreibung ?? null,
kategorie: austausch.kategorie ?? null,
schlagworte: [...austausch.schlagworte],
},
});
const version = await tx.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
// `position` kommt aus der Reihenfolge der Listen und wird nicht
// mitgeschickt — sonst gaebe es zwei Wahrheiten.
if (austausch.struktur.variablen.length > 0) {
await tx.vorlagenVariable.createMany({
data: austausch.struktur.variablen.map((variable, position) => ({
versionId: version.id,
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
position,
})),
});
}
let schritteGesamt = 0;
for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position },
});
if (abschnitt.schritte.length === 0) continue;
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
abschnittId: zeile.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Das nachsichtige Schema traegt eine Index-Signatur auf `unknown`,
// die Prisma nicht als Json-Wert erkennt. Der Inhalt kommt aus
// geparstem JSON, ist also einer.
konfig: schritt.konfig as Prisma.InputJsonObject,
dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition,
})),
});
schritteGesamt += abschnitt.schritte.length;
}
await tx.protokoll.create({
data: {
entitaet: "Vorlage",
entitaetId: vorlage.id,
aktion: "Vorlage importiert",
benutzerId: durchBenutzerId,
nachher: {
name: vorlage.name,
version: version.version,
abschnitte: austausch.struktur.abschnitte.length,
schritte: schritteGesamt,
variablen: austausch.struktur.variablen.length,
},
},
});
return { vorlageId: vorlage.id, versionId: version.id };
});
}
/** Prueft das Dokument gegen das Schema und uebersetzt Zod-Fehler in eine lesbare Meldung. */
function dokumentPruefen(dokument: unknown): Austausch {
// Eigene Meldung fuer den haeufigsten Fehlgriff – eine Datei, die gar kein
// JSON-Objekt enthaelt. Zod sagt dazu nur einen englischen Satz.
if (typeof dokument !== "object" || dokument === null || Array.isArray(dokument)) {
throw new AustauschFehler("Das Austauschdokument ist kein JSON-Objekt.");
}
const ergebnis = AustauschSchema.safeParse(dokument);
if (!ergebnis.success) {
throw new AustauschFehler(ersteBeanstandung(ergebnis.error));
}
return ergebnis.data;
}
/**
* Nennt die erste Beanstandung samt Pfad.
*
* Eine rohe Zod-Ausgabe hilft niemandem: wer eine Datei von Hand schreibt,
* braucht die Stelle („abschnitte.0.schritte.2.titel“) und einen Satz dazu, und
* zwar den ersten — eine Liste von zwanzig Folgefehlern liest niemand.
*/
function ersteBeanstandung(fehler: ZodError): string {
const problem = fehler.issues[0];
if (!problem) return "Das Austauschdokument passt nicht zum Format.";
const pfad = problem.path.map(String).join(".");
return pfad.length === 0 ? problem.message : `${pfad}: ${problem.message}`;
}
/**
* Weist doppelte Variablenschluessel ab.
*
* Die Datenbank haelt dasselbe ueber `@@unique([versionId, schluessel])` fest;
* diese Pruefung existiert fuer die Meldung, denn der Fehler des Index nennt
* einen Spaltennamen und keinen Schluessel. Ausserdem faellt er erst mitten in
* der Transaktion auf, also nach halber Arbeit.
*/
function variablenschluesselPruefen(variablen: readonly { readonly schluessel: string }[]): void {
const gesehen = new Set<string>();
for (const variable of variablen) {
if (gesehen.has(variable.schluessel)) {
throw new AustauschFehler(
`Den Variablenschlüssel „${variable.schluessel}“ gibt es zweimal. ` +
"Jeder Schlüssel darf in einer Vorlage nur einmal stehen.",
);
}
gesehen.add(variable.schluessel);
}
}
+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.`);
}
}