feat(bausteine): Abschnitte, die mehrere Vorlagen teilen (#57)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #57.
This commit is contained in:
2026-10-04 17:07:12 +00:00
parent b688e2ebc8
commit 80870133b8
24 changed files with 2862 additions and 46 deletions
+216
View File
@@ -0,0 +1,216 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "@/lib/validierung";
import { AbschnittSchema } from "@/lib/vorlagen-format";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
BausteinFehler,
bausteinAnlegen,
bausteinArchivieren,
bausteinBearbeiten,
bausteinEntwurfHolenOderErzeugen,
bausteinEntwurfSpeichern,
bausteinEntwurfVerwerfen,
bausteinVersionVeroeffentlichen,
} from "@/server/services/bausteine";
/**
* Server Actions der Bausteine (#030).
*
* Recht ist `vorlagenBearbeiten`: wer Vorlagen pflegt, pflegt Bausteine. Ein
* Baustein ist nichts anderes als ein Abschnitt, der an mehreren Stellen steht.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG = "Dafür fehlt dir die Berechtigung.";
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof BausteinFehler) return { fehler: fehler.message };
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
console.error(
`${neutral}:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler",
);
return { fehler: neutral };
}
function alles(): void {
revalidatePath("/vorlagen/bausteine");
}
export async function bausteinAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
let ziel: string | undefined;
try {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({ name: z.string().trim().min(1, "Der Baustein braucht einen Namen.") })
.safeParse({ name: formular.get("name") });
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const { bausteinId } = await bausteinAnlegen({ name: eingabe.data.name }, benutzer.id);
alles();
ziel = `/vorlagen/bausteine/${bausteinId}`;
} catch (fehler) {
return alsErgebnis(fehler, "Der Baustein konnte nicht angelegt werden.");
}
// `redirect` wirft – deshalb außerhalb des try, sonst fängt der catch es ab
// und aus einem Erfolg würde eine Fehlermeldung.
redirect(ziel);
}
export async function bausteinBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
try {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({
bausteinId: z.string().min(1),
name: z.string().trim().min(1, "Der Baustein braucht einen Namen."),
beschreibung: z.string().optional(),
})
.safeParse({
bausteinId: formular.get("bausteinId"),
name: formular.get("name"),
beschreibung: formular.get("beschreibung") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
await bausteinBearbeiten(
eingabe.data.bausteinId,
{ name: eingabe.data.name, beschreibung: eingabe.data.beschreibung ?? null },
benutzer.id,
);
alles();
revalidatePath(`/vorlagen/bausteine/${eingabe.data.bausteinId}`);
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
/** Speichert Titel und Schritte eines Entwurfs. Erzeugt ihn, wenn es keinen gibt. */
export async function bausteinEntwurfSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
try {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const roh = formular.get("abschnitt");
if (typeof roh !== "string") return { fehler: "Die Eingabe ist unvollständig." };
const bausteinId = formular.get("bausteinId");
if (typeof bausteinId !== "string" || bausteinId.length === 0) {
return { fehler: "Die Eingabe ist unvollständig." };
}
let gelesen: unknown;
try {
gelesen = JSON.parse(roh);
} catch {
return { fehler: "Der Aufbau ließ sich nicht lesen. Bitte neu laden." };
}
const geprueft = AbschnittSchema.safeParse(gelesen);
if (!geprueft.success) {
return { fehler: geprueft.error.issues[0]?.message ?? "Der Aufbau ist unvollständig." };
}
const versionId = await bausteinEntwurfHolenOderErzeugen(bausteinId, benutzer.id);
await bausteinEntwurfSpeichern(versionId, geprueft.data);
alles();
revalidatePath(`/vorlagen/bausteine/${bausteinId}`);
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Entwurf konnte nicht gespeichert werden.");
}
}
export async function bausteinVeroeffentlichenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
try {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({
versionId: z.string().min(1),
bausteinId: z.string().min(1),
aenderungsnotiz: z.string().optional(),
})
.safeParse({
versionId: formular.get("versionId"),
bausteinId: formular.get("bausteinId"),
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
});
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
await bausteinVersionVeroeffentlichen(
eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null,
benutzer.id,
);
alles();
revalidatePath(`/vorlagen/bausteine/${eingabe.data.bausteinId}`);
// Vorlagen-Entwürfe bieten ab jetzt die neue Fassung an.
revalidatePath("/vorlagen", "layout");
return { erfolg: "Veröffentlicht. Die neue Fassung wirkt in der nächsten Vorlagenversion." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Baustein konnte nicht veröffentlicht werden.");
}
}
export async function bausteinEntwurfVerwerfenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
try {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z.object({ versionId: z.string().min(1), bausteinId: z.string().min(1) }).safeParse({
versionId: formular.get("versionId"),
bausteinId: formular.get("bausteinId"),
});
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
await bausteinEntwurfVerwerfen(eingabe.data.versionId, benutzer.id);
alles();
revalidatePath(`/vorlagen/bausteine/${eingabe.data.bausteinId}`);
return { erfolg: "Entwurf verworfen." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Entwurf konnte nicht verworfen werden.");
}
}
export async function bausteinArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
try {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const eingabe = z
.object({ bausteinId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({
bausteinId: formular.get("bausteinId"),
archiviert: formular.get("archiviert"),
});
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
const archiviert = eingabe.data.archiviert === "true";
await bausteinArchivieren(eingabe.data.bausteinId, archiviert, benutzer.id);
alles();
revalidatePath(`/vorlagen/bausteine/${eingabe.data.bausteinId}`);
return { erfolg: archiviert ? "Archiviert." : "Wieder freigegeben." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}