feat(laeufe): Vier-Augen-Freigabe (#040)
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:
co-authored by
Claude Opus 5
parent
a2330ef682
commit
d30bb6d235
@@ -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,
|
||||
|
||||
@@ -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.` };
|
||||
|
||||
Reference in New Issue
Block a user