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