feat(laeufe): Vier-Augen-Freigabe (#040)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions

Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst
abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und
beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht
TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat.

Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine
Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine
Teams ohne Gewinn.

Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze
Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt
zusaetzlich ein CHECK in der Datenbank.

Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt
eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert
haette jeden Codepfad betroffen, der heute vier Faelle kennt.

Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die
Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder
anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht
ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt,
und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller
Unique-Index haelt sie bei mehreren Versuchen auf einmal.

Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte
Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim
Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus
#039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte.

1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten
Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen
Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action
trifft den Router-Cache von Next und zeigt kurz den alten Stand.

Closes #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-24 01:14:04 +02:00
co-authored by Claude Opus 5
parent a2330ef682
commit d30bb6d235
27 changed files with 1373 additions and 44 deletions
+53
View File
@@ -9,6 +9,8 @@ import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
LaufFehler,
laufAbschliessen,
laufFreigeben,
laufZurueckweisen,
laufAnlegen,
laufArchivieren,
laufMitAdKontoVerknuepfen,
@@ -377,6 +379,57 @@ export async function laufAbschliessenAktion(
}
}
export async function laufFreigebenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
/*
* **`freigeben` und nicht `laeufeBearbeiten`** (#040): das ist der ganze Sinn
* des eigenen Rechts. Ob der Freigebende ausserdem nicht an dem Lauf
* gearbeitet hat, entscheidet der Dienst — hier wird genau ein Recht geprueft
* (CLAUDE.md).
*/
const zugang = await angemeldetOderMeldung("freigeben");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
const notiz = formular.get("notiz");
try {
await laufFreigeben(laufId.data, zugang.benutzer, typeof notiz === "string" ? notiz : undefined);
neuRendern(laufId.data);
return { erfolg: "Lauf freigegeben. Er lässt sich jetzt abschließen." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht freigegeben werden.");
}
}
export async function laufZurueckweisenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("freigeben");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
const notiz = z
.string()
.trim()
.min(1, "Eine Zurückweisung braucht eine Begründung.")
.safeParse(formular.get("notiz"));
if (!notiz.success) {
return { fehler: notiz.error.issues[0]?.message ?? "Eine Zurückweisung braucht eine Begründung." };
}
try {
await laufZurueckweisen(laufId.data, zugang.benutzer, notiz.data);
neuRendern(laufId.data);
return { erfolg: "Lauf zurückgewiesen. Der Bearbeiter sieht deine Begründung." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht zurückgewiesen werden.");
}
}
export async function laufArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
+9 -1
View File
@@ -267,10 +267,17 @@ export async function veroeffentlichenAktion(
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
.object({
versionId: z.string().min(1),
aenderungsnotiz: z.string().trim().optional(),
freigabePflicht: z.boolean(),
})
.safeParse({
versionId: formular.get("versionId"),
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
// Ein nicht gesetztes Kaestchen schickt gar nichts mit — das ist `false`
// und nicht „unveraendert“: das Formular zeigt den aktuellen Stand an.
freigabePflicht: formular.get("freigabePflicht") === "1",
});
if (!eingabe.success) return { fehler: "Es fehlt die Version." };
@@ -279,6 +286,7 @@ export async function veroeffentlichenAktion(
eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null,
zugang.benutzerId,
eingabe.data.freigabePflicht,
);
revalidatePath("/vorlagen");
return { erfolg: `Version ${version.version} ist veröffentlicht.` };