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
+26
View File
@@ -162,6 +162,8 @@ enum Meldeanlass {
LAUF_ZUGEWIESEN
LAUF_UEBERFAELLIG
LAUF_ABGESCHLOSSEN
/// Ein Lauf wartet auf eine Freigabe durch einen Zweiten (#040).
LAUF_FREIGABE_OFFEN
ABGLEICH_FEHLGESCHLAGEN
}
@@ -273,6 +275,7 @@ model Benutzer {
geaendertAm DateTime @default(now()) @updatedAt
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
freigegebeneLaeufe Lauf[] @relation("LaufFreigegebenVon")
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
@@ -578,6 +581,13 @@ model VorlagenVersion {
vorlageId String
version Int
status VersionStatus @default(ENTWURF)
/// Verlangt dieser Lauf eine Freigabe durch einen Zweiten (#040)?
///
/// An der **Version** und nicht an der Vorlage: eine veroeffentlichte Version
/// ist unveraenderlich, also kann ein laufender Lauf seine Regel nicht unter
/// den Fuessen verlieren. Wer die Pflicht aendert, veroeffentlicht eine neue
/// Fassung — bestehende Laeufe behalten ihre.
freigabePflicht Boolean @default(false)
aenderungsnotiz String?
veroeffentlichtAm DateTime?
veroeffentlichtVonId String?
@@ -785,6 +795,20 @@ model Lauf {
gestartetAm DateTime?
abgeschlossenAm DateTime?
abgeschlossenVonId String?
/// Wer diesen Lauf freigegeben hat (#040), und wann.
///
/// **Nicht der Ersteller** — das haelt ein CHECK fest
/// (`Lauf_freigabe_nicht_selbst`). Der Dienst prueft strenger: er sieht auch
/// nach, wer an den Schrittergebnissen gearbeitet hat; dafuer braeuchte ein
/// CHECK eine Unterabfrage.
///
/// `freigabeNotiz` traegt die Begruendung — bei einer Freigabe freiwillig, bei
/// einer Zurueckweisung Pflicht. Sie bleibt stehen, wenn der Lauf danach
/// wieder freigegeben wird: das Protokoll haelt beide Vorgaenge fest, und die
/// letzte Notiz ist die, die jemand an der Abarbeitung lesen soll.
freigegebenVonId String?
freigegebenAm DateTime?
freigabeNotiz String?
archiviertAm DateTime?
archiviertVonId String?
erstelltAm DateTime @default(now())
@@ -798,6 +822,7 @@ model Lauf {
erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict)
zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict)
abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], references: [id], onDelete: Restrict)
freigegebenVon Benutzer? @relation("LaufFreigegebenVon", fields: [freigegebenVonId], references: [id], onDelete: Restrict)
archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict)
ergebnisse Schrittergebnis[]
@@ -812,6 +837,7 @@ model Lauf {
@@index([kundeId, status])
@@index([geraetId, status])
@@index([mitarbeiterId])
@@index([freigegebenVonId])
@@index([zugewiesenAnId, status])
@@index([status, faelligAm])
@@index([vorlagenVersionId])