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
@@ -0,0 +1,97 @@
-- ---------------------------------------------------------------------------
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
-- Loeschbefehle sind bewusst gestrichen.
--
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
-- ---------------------------------------------------------------------------
-- Vier-Augen-Freigabe (#040).
/*
* Ein neuer Meldeanlass. Ohne ihn wartet ein Lauf auf eine Freigabe, von der
* niemand weiss — und das ist der Fall, der die ganze Regel wertlos macht:
* sie haelt den Abschluss auf, und keiner merkt es.
*/
ALTER TYPE "Meldeanlass" ADD VALUE IF NOT EXISTS 'LAUF_FREIGABE_OFFEN' BEFORE 'ABGLEICH_FEHLGESCHLAGEN';
ALTER TABLE "VorlagenVersion" ADD COLUMN "freigabePflicht" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Lauf"
ADD COLUMN "freigegebenVonId" TEXT,
ADD COLUMN "freigegebenAm" TIMESTAMP(3),
ADD COLUMN "freigabeNotiz" TEXT;
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_freigegebenVonId_fkey"
FOREIGN KEY ("freigegebenVonId") REFERENCES "Benutzer"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "Lauf_freigegebenVonId_idx" ON "Lauf"("freigegebenVonId");
/*
* Freigeber und Zeitpunkt gehoeren zusammen, in beide Richtungen — wie bei
* `abgeschlossenAm`. Ohne den Zeitpunkt liesse sich nicht sagen, wann die
* Freigabe erteilt wurde, und ein Zeitpunkt ohne Freigeber behauptete eine
* Freigabe, die niemand gegeben hat.
*/
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_freigabe_mit_zeitpunkt"
CHECK (("freigegebenVonId" IS NULL) = ("freigegebenAm" IS NULL));
/*
* **Der Kern der Vier-Augen-Regel, in der Datenbank.**
*
* Wer den Lauf angelegt hat, gibt ihn nicht frei. Das prueft auch der Dienst,
* und zwar strenger: er sieht ausserdem nach, wer an den Schrittergebnissen
* gearbeitet hat (`Schrittergebnis.aktualisiertVonId`). Dafuer braeuchte ein
* CHECK eine Unterabfrage, und die gehoert nicht hinein.
*
* Diese Haelfte steht trotzdem hier, weil sie die wichtigere ist: der Ersteller
* ist der naheliegende Fall, und die Regel muss auch dann gelten, wenn jemand an
* der Service-Schicht vorbei schreibt (CLAUDE.md). Eine Freigabe, die in der
* Datenbank steht, ist damit nachweislich nicht vom Ersteller.
*/
ALTER TABLE "Lauf"
ADD CONSTRAINT "Lauf_freigabe_nicht_selbst"
CHECK ("freigegebenVonId" IS NULL OR "freigegebenVonId" <> "erstelltVonId");
/*
* **Die Freigabe-Aufforderung geht je Lauf und Empfaenger genau einmal hinaus.**
*
* Derselbe Grund wie bei „ueberfaellig“ (#034): sie entsteht bei einem
* gescheiterten Abschlussversuch, und ein Techniker versucht es mehrmals — ohne
* diesen Index bekaeme jeder Freigabeberechtigte bei jedem Versuch eine Mail,
* und jede davon waere fuer sich korrekt.
*
* Partiell, weil es nur fuer diesen einen Anlass gilt: nach einer Zurueckweisung
* und erneuter Fertigstellung soll **nicht** wieder gemahnt werden — der
* Zurueckweisende weiss ja, dass er wartet. Dafuer traegt die Zurueckweisung
* ihre eigene Notiz an der Abarbeitung.
*/
CREATE UNIQUE INDEX "Zustellung_freigabe_einmal"
ON "Zustellung"("laufId", "empfaenger")
WHERE "anlass" = 'LAUF_FREIGABE_OFFEN';
/*
* Gegenprobe: die neuen Spalten sind ueberall leer, und keine bestehende Version
* verlangt eine Freigabe. `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich
* aus — aber eine Migration, die ihre eigene Annahme nicht nachrechnet, ist
* genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
anzahl INTEGER;
BEGIN
SELECT count(*) INTO anzahl FROM "Lauf" WHERE "freigegebenVonId" IS NOT NULL;
IF anzahl > 0 THEN
RAISE EXCEPTION 'Unerwartet % Laeufe mit Freigabe vor der Migration.', anzahl;
END IF;
SELECT count(*) INTO anzahl FROM "VorlagenVersion" WHERE "freigabePflicht";
IF anzahl > 0 THEN
RAISE EXCEPTION 'Unerwartet % Versionen mit Freigabepflicht vor der Migration.', anzahl;
END IF;
END $$;
+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])