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
+10
View File
@@ -9,6 +9,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### Hinzugefügt
- **Vier-Augen-Freigabe.** Verlangt eine Vorlagen-Version sie, lässt sich ein Lauf erst abschließen,
wenn ein **Zweiter** ihn freigegeben hat — jemand mit dem neuen Recht `freigeben` (`ADMIN` und
`REDAKTEUR`, **nicht** `TECHNIKER`), der nicht an dem Lauf gearbeitet hat. Als gearbeitet zählt:
angelegt, einen Schritt gesetzt oder abgeschlossen; eine Zuweisung allein nicht.
**Auch ein Administrator kann sich nicht selbst freigeben** — das ist der ganze Sinn, und dieselbe
Regel hält ein CHECK in der Datenbank. Zurückweisen verlangt eine Begründung, setzt den Lauf
zurück auf *in Arbeit* und nimmt eine erteilte Freigabe zurück.
Sobald jemand abzuschließen versucht und die Freigabe fehlt, bekommen die Freigabeberechtigten
eine Mail — einmal, auch bei mehreren Versuchen. Das Häkchen steht im Editor beim Veröffentlichen
und wandert beim Bearbeiten in den neuen Entwurf mit (#040)
- **Bedingte Abschnitte und Schritte.** Ein Abschnitt oder ein einzelner Schritt erscheint nur, - **Bedingte Abschnitte und Schritte.** Ein Abschnitt oder ein einzelner Schritt erscheint nur,
wenn seine Bedingung zutrifft — abhängig von einer **Variable** des Laufs oder vom **Zustand wenn seine Bedingung zutrifft — abhängig von einer **Variable** des Laufs oder vom **Zustand
eines anderen Schritts**. Der echte Fall steht im Seed: „Mitarbeiter-Onboarding“ trägt beide eines anderen Schritts**. Der echte Fall steht im Seed: „Mitarbeiter-Onboarding“ trägt beide
+8 -1
View File
@@ -49,7 +49,7 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` · `VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
`QuellenArt {LDAP GRAPH SCIM}` · `QuellenArt {LDAP GRAPH SCIM}` ·
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` · `Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`. `Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN LAUF_FREIGABE_OFFEN ABGLEICH_FEHLGESCHLAGEN}`.
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias. `SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
@@ -77,6 +77,13 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`. Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`.
- **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind. - **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind.
`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz. `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.
- **Vier-Augen-Freigabe:** Verlangt eine `VorlagenVersion` sie (`freigabePflicht`), lässt sich ein
Lauf erst nach der Freigabe abschließen (#040). **Wer daran gearbeitet hat, gibt nicht frei** —
angelegt, einen Schritt gesetzt oder abgeschlossen; eine Zuweisung allein zählt nicht. Das prüft
`hatDaranGearbeitet` an genau einer Stelle, wie `laufMussVeraenderbarSein`, und ein CHECK hält die
wichtigste Hälfte (der Ersteller) auch gegen Schreiben an der Dienstschicht vorbei. Das Recht
`freigeben` hat **nicht** `TECHNIKER`: sonst hängt die Regel daran, dass zufällig ein zweiter
greifbar ist. Die Pflicht wandert bei „Bearbeiten“ und „Duplizieren“ in die Kopie mit.
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus - **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`. `INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`. - **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`.
+52
View File
@@ -242,6 +242,58 @@ Knopf gesperrt und darunter steht, was fehlt.
Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der
Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie. Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie.
## Vier-Augen-Freigabe
Manche Arbeiten soll nicht derselbe abschließen, der sie gemacht hat: ein Firewall-Rollout, ein
Restore-Test, das Löschen eines Kontos. Verlangt die Vorlage eine Freigabe, steht das oben am Lauf —
und **Lauf abschließen** wartet, bis jemand sie erteilt hat.
### Wer freigeben darf
Es braucht zweierlei, und beides zusammen:
1. das Recht **freigeben** — das haben `ADMIN` und `REDAKTEUR`, **nicht** `TECHNIKER`;
2. und man darf **nicht an dem Lauf gearbeitet** haben.
Als „gearbeitet“ zählt: ihn angelegt, einen Schritt gesetzt oder ihn abgeschlossen zu haben. Nur
**zugewiesen** zu sein zählt nicht — wer zugewiesen wurde und nichts getan hat, darf freigeben.
**Auch ein Administrator kann sich nicht selbst freigeben.** Das ist der ganze Sinn, und es ist eine
bewusste Härte: wer allein im Haus ist, kommt damit nicht weiter und braucht einen Zweiten mit dem
Recht. Dieselbe Regel hält auch die Datenbank fest, nicht nur die Anwendung.
### Freigeben oder zurückweisen
Oben am Lauf steht eine Karte mit einem Feld für die **Begründung** und zwei Knöpfen:
- **Freigeben** — die Begründung ist freiwillig. Danach lässt sich der Lauf abschließen.
- **Zurückweisen** — die Begründung ist **Pflicht**. Der Lauf geht zurück auf *in Arbeit*, ein
bereits erteilter Abschluss wird aufgehoben, und der Bearbeiter sieht die Begründung an derselben
Stelle. Ohne Begründung steht er vor derselben Frage wie vorher.
Eine Zurückweisung nimmt auch eine **erteilte** Freigabe zurück. Damit lässt sich ein Lauf, der
schon abgeschlossen war, in die Arbeit zurückholen — ohne `archivVerwalten`, solange er nicht
archiviert ist.
### Wer erfährt davon
Sobald jemand den Lauf abzuschließen versucht und die Freigabe fehlt, bekommen die
**Freigabeberechtigten** eine Mail — nicht der Zuständige, wie bei allen anderen Meldungen. Wer an
dem Lauf gearbeitet hat, bekommt sie nicht: er dürfte ohnehin nicht freigeben.
Das geht **einmal** hinaus, auch wenn jemand es mehrmals versucht. Nach einer Zurückweisung wird
nicht erneut gemahnt — der Zurückweisende weiß, dass er wartet.
### Einschalten
Das Häkchen steht im Editor beim **Veröffentlichen**: „Läufe dieser Version brauchen eine Freigabe
durch einen Zweiten“. Es gehört zur **Version**, nicht zur Vorlage — ein laufender Lauf kann seine
Regel also nicht unter den Füßen verlieren, und in der Versionsliste steht an jeder Fassung, ob sie
eine Freigabe verlangt.
Beim Bearbeiten wandert das Häkchen in den neuen Entwurf mit. Eine Vorlage, die eine Freigabe
verlangt, verliert sie also nicht durch eine Textkorrektur.
## Archivieren — das ist endgültig ## Archivieren — das ist endgültig
**archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein **archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein
+34
View File
@@ -423,6 +423,40 @@ dieselbe Stelle. Dass eine Schritt-Bedingung auf **dieselbe Version** zeigt,
haelt ein Trigger: nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr haelt ein Trigger: nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
entscheidbar, und der Abschnitt erschiene stillschweigend immer. entscheidbar, und der Abschnitt erschiene stillschweigend immer.
## 5f-3. Die Vier-Augen-Freigabe: eine Stelle, zwei Netze
Verlangt eine Version eine Freigabe, haengt der Abschluss daran — und die Regel
dahinter ist **eine Funktion**: `hatDaranGearbeitet` in
`src/server/services/laeufe.ts`. Sie wird vor jeder Freigabe und vor jeder
Zurueckweisung gerufen, genau wie `laufMussVeraenderbarSein` vor jeder Aenderung
an einem Lauf. Zwei Stellen liefen auseinander, und die erste, die es vergisst,
hebt die Regel auf.
Gezaehlt wird dreierlei: wer den Lauf **angelegt** hat, wer ein
**Schrittergebnis** gesetzt hat, und wer ihn **abgeschlossen** hat. Der
Zustaendige gilt bewusst **nicht** als Bearbeiter — eine Zuweisung ist noch keine
Arbeit, und waere die Regel hier strenger, blockierte sie kleine Teams ohne
Gewinn.
**Zwei Netze, nicht eines.** Die wichtigste Haelfte steht als CHECK in der
Datenbank (`Lauf_freigabe_nicht_selbst`): der Ersteller gibt nicht frei, auch
dann nicht, wenn jemand an der Dienstschicht vorbei schreibt. Der Dienst prueft
strenger, weil er in zwei weitere Tabellen sehen kann; ein CHECK braeuchte dafuer
eine Unterabfrage.
**Das Recht `freigeben` hat nicht `TECHNIKER`**, und das ist eine Entscheidung
gegen Bequemlichkeit: sonst haengt die Vier-Augen-Regel daran, dass zufaellig ein
zweiter Techniker greifbar ist. Wer sie umgehen will, braucht ein Konto mit mehr
Rechten — und das steht im Protokoll.
**Die Aufforderung entsteht ausserhalb der Transaktion.** Der Abschlussversuch
scheitert mit `FreigabeFehlt`; `laufAbschliessen` faengt genau diesen Typ,
reiht die Meldung an die Freigabeberechtigten ein und wirft weiter. Innerhalb
der Transaktion wuerde sie mitsamt dem Fehler zurueckgerollt — und dann wartet
ein Lauf auf etwas, von dem niemand weiss. Ein partieller Unique-Index
(`Zustellung_freigabe_einmal`) sorgt dafuer, dass mehrere Versuche nicht mehrere
Mails ergeben.
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse ## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —, Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
+58 -2
View File
@@ -41,13 +41,14 @@ als Unterpunkte mit Aufgabe und Dauer.
| #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min | | #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min |
| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min | | #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min |
| #039 | M23 – Bedingte Schritte und Abschnitte | 5 h 20 min | | #039 | M23 – Bedingte Schritte und Abschnitte | 5 h 20 min |
| | **Summe** | **79 h 15 min** | | #040 | M24 – Vier-Augen-Freigabe | 3 h 30 min |
| | **Summe** | **82 h 45 min** |
| Meilenstein | Tatsächlich | Status | | Meilenstein | Tatsächlich | Status |
|---|---|---| |---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht | | v0.1.0 | 35 h 20 min | veröffentlicht |
| v0.2.0 | 38 h 35 min | veröffentlicht | | v0.2.0 | 38 h 35 min | veröffentlicht |
| v0.3.0 | 5 h 20 min | in Arbeit | | v0.3.0 | 8 h 50 min | in Arbeit |
--- ---
@@ -1839,3 +1840,58 @@ Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
- **M24 (#040)** Vier-Augen-Freigabe. Der Abschluss wird dort ohnehin angefasst, und M25 braucht den - **M24 (#040)** Vier-Augen-Freigabe. Der Abschluss wird dort ohnehin angefasst, und M25 braucht den
Freigeber für das Blatt. Freigeber für das Blatt.
---
## 2026-10-24 · Sitzung 26 · 22:30–01:40
**Issue:** #040 (M24)
**Version:** 0.3.0 (unveröffentlicht)
### Erledigt
- **Vier-Augen-Freigabe.** Neues Recht `freigeben`, Pflicht an der Version, Freigeben und
Zurückweisen samt Begründung, Aufforderung an die Berechtigten, Häkchen im Editor, Anzeige an der
Versionsliste.
- 1526 Vitest-Tests (vorher 1496), davon 22 neue in `tests/freigabe.test.ts`.
- Klickprobe am gebauten Stand: elf Prüfungen, alle grün.
### Entscheidungen
- **Das Recht `freigeben` hat nicht `TECHNIKER`.** Sonst hängt die Vier-Augen-Regel daran, dass
zufällig ein zweiter Techniker greifbar ist. Wer sie umgehen will, braucht ein Konto mit mehr
Rechten — und das steht im Protokoll.
- **Das Recht allein genügt nicht.** Wer an dem Lauf gearbeitet hat, gibt ihn nicht frei, auch nicht
als Administrator. Die Prüfung sitzt in `hatDaranGearbeitet`, an **einer** Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Hälfte (der Ersteller) hält zusätzlich ein CHECK.
- **Eine Zuweisung ist noch keine Arbeit.** Wer zugewiesen wurde und nichts getan hat, darf
freigeben — wäre die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn.
- **Kein fünfter `LaufStatus`** für „zurückgewiesen“: der Lauf geht auf `IN_ARBEIT`, die Begründung
steht in einem Feld und im Protokoll. Ein neuer Enum-Wert hätte jeden Codepfad betroffen, der
heute vier Fälle kennt.
- **Die Pflicht hängt an der Version**, nicht an der Vorlage: eine veröffentlichte Version ist
unveränderlich, ein laufender Lauf behält also seine Regel.
### Probleme
- **Die Aufforderung muss außerhalb der Transaktion entstehen.** Innerhalb würde sie mit dem Fehler
zurückgerollt — und dann wartet ein Lauf auf etwas, von dem niemand weiß. Gelöst mit einem eigenen
Fehlertyp `FreigabeFehlt`, den `laufAbschliessen` außen fängt; dafür musste die Transaktion in
eine eigene Funktion. Ein Zeichenkettenvergleich auf die Meldung wäre eine Zusage über ihren
Wortlaut gewesen.
- **Drei rote Punkte in der Klickprobe waren meine eigenen Testfehler**, und einer ist lehrreich:
ein `reload()` direkt nach einer Server Action zeigt kurz den alten Stand, weil es den
Router-Cache von Next trifft. Die Action revalidiert den Pfad; ein Benutzer sieht das Ergebnis
**ohne** Neuladen. Nachgewiesen, bevor ich etwas geändert habe: in einem frischen Browser-Kontext
stand alles richtig da, und die Datenbank trug Freigeber, Zeitpunkt und Notiz.
Die beiden anderen: ein Selektor traf versteckten Protokolltext, und ein Suchmuster `/freigeb/`
traf nichts — in „Freigabe“ und „freigegeben“ kommt diese Zeichenfolge gar nicht vor. Dreimal
gesucht, bis es auffiel.
- **Die Freigabepflicht wäre bei „Bearbeiten“ verloren gegangen**, hätte #039 es mir nicht gerade
gezeigt: dort verwarf die tiefe Kopie `nurFuerAbteilungen` und `bedingung`. Diesmal von Anfang an
mitgenommen — und das ist der ganze Wert eines Befundes, der verstanden statt nur behoben wurde.
### Nächster Schritt
- **M25 (#041)** PDF-Übergabeprotokoll. Der Freigeber steht jetzt am Lauf und gehört auf das Blatt.
+83 -24
View File
@@ -1,10 +1,10 @@
# #040 M24 – Vier-Augen-Freigabe # #040 M24 – Vier-Augen-Freigabe
**Status:** offen **Status:** erledigt
**Im Tracker:** – **Im Tracker:** –
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend **Labels:** phase-3, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** – **Geschätzt:** 8 h **Tatsächlich:** 3 h 30 min
## Ziel ## Ziel
@@ -17,52 +17,111 @@ ein **Zweiter** ab — nachweisbar im Protokoll.
## Zu entscheiden ## Zu entscheiden
- [ ] **Freigabe am Lauf oder am Schritt?** Empfehlung: **am Lauf**, gesetzt von der Vorlage. - [x] **Freigabe am Lauf oder am Schritt?** Empfehlung: **am Lauf**, gesetzt von der Vorlage.
Am Schritt wäre feiner und verführt zu Checklisten, in denen an fünf Stellen jemand warten Am Schritt wäre feiner und verführt zu Checklisten, in denen an fünf Stellen jemand warten
muss; am Lauf ist es eine Aussage über die Arbeit als Ganzes und erklärt sich von selbst. muss; am Lauf ist es eine Aussage über die Arbeit als Ganzes und erklärt sich von selbst.
Schritt-Freigaben bleiben möglich, wenn sich der Bedarf zeigt — `VorlagenSchritt.konfig` ist Schritt-Freigaben bleiben möglich, wenn sich der Bedarf zeigt — `VorlagenSchritt.konfig` ist
ein Json. ein Json.
- [ ] **Wer darf freigeben?** Empfehlung: ein eigenes Recht `freigeben`, standardmäßig bei `ADMIN` - [x] **Wer darf freigeben?** Empfehlung: ein eigenes Recht `freigeben`, standardmäßig bei `ADMIN`
und `REDAKTEUR` — **nicht** bei `TECHNIKER`. Begründung: die Vier-Augen-Regel soll nicht und `REDAKTEUR` — **nicht** bei `TECHNIKER`. Begründung: die Vier-Augen-Regel soll nicht
davon abhängen, dass zufällig ein zweiter Techniker greifbar ist; und wer sie umgehen will, davon abhängen, dass zufällig ein zweiter Techniker greifbar ist; und wer sie umgehen will,
braucht dann ein zweites Konto mit mehr Rechten, was im Protokoll steht. braucht dann ein zweites Konto mit mehr Rechten, was im Protokoll steht.
- [ ] **Darf der Bearbeiter sich selbst freigeben?** **Nein**, und das ist der ganze Sinn. Geprüft - [x] **Darf der Bearbeiter sich selbst freigeben?** **Nein**, und das ist der ganze Sinn. Geprüft
wird gegen `Schrittergebnis.aktualisiertVonId` **und** `Lauf.erstelltVonId`: wer irgendetwas wird gegen `Schrittergebnis.aktualisiertVonId` **und** `Lauf.erstelltVonId`: wer irgendetwas
daran getan hat, gibt nicht frei. Ein `ADMIN`, der allein im Haus ist, kommt damit nicht daran getan hat, gibt nicht frei. Ein `ADMIN`, der allein im Haus ist, kommt damit nicht
weiter — das ist eine bewusste Härte, und die Anleitung sagt es. weiter — das ist eine bewusste Härte, und die Anleitung sagt es.
- [ ] **Was, wenn die Freigabe verweigert wird?** Empfehlung: ein Zustand „zurückgewiesen“ mit - [x] **Was, wenn die Freigabe verweigert wird?** Empfehlung: ein Zustand „zurückgewiesen“ mit
Pflichtnotiz. Der Lauf geht zurück auf `IN_ARBEIT`, die Notiz steht im Protokoll und an der Pflichtnotiz. Der Lauf geht zurück auf `IN_ARBEIT`, die Notiz steht im Protokoll und an der
Abarbeitungsansicht. Kein eigener `LaufStatus` — das wäre eine Änderung an einem Enum, das Abarbeitungsansicht. Kein eigener `LaufStatus` — das wäre eine Änderung an einem Enum, das
vier Jahre halten soll; es genügt ein Feld plus Protokolleintrag. vier Jahre halten soll; es genügt ein Feld plus Protokolleintrag.
## Aufgaben ## Aufgaben
- [ ] Migration: `freigabePflicht Boolean` an `VorlagenVersion`, dazu `freigegebenVonId`, - [x] Migration: `freigabePflicht Boolean` an `VorlagenVersion`, dazu `freigegebenVonId`,
`freigegebenAm` und `freigabeNotiz` am `Lauf`; CHECK, dass `freigegebenVonId` und `freigegebenAm` und `freigabeNotiz` am `Lauf`; CHECK, dass `freigegebenVonId` und
`freigegebenAm` zusammen gesetzt sind (wie bei `abgeschlossenAm`) `freigegebenAm` zusammen gesetzt sind (wie bei `abgeschlossenAm`)
- [ ] CHECK: `freigegebenVonId` ist **nicht** `erstelltVonId` — die Regel gehört in die Datenbank, - [x] CHECK: `freigegebenVonId` ist **nicht** `erstelltVonId` — die Regel gehört in die Datenbank,
nicht nur in den Dienst (CLAUDE.md) nicht nur in den Dienst (CLAUDE.md)
- [ ] Recht `freigeben` in `RECHTE`, Rollenmatrix-Test erweitern - [x] Recht `freigeben` in `RECHTE`, Rollenmatrix-Test erweitern
- [ ] Dienst: `laufFreigeben(laufId, benutzer, notiz?)` und `laufZurueckweisen(laufId, benutzer, - [x] Dienst: `laufFreigeben(laufId, benutzer, notiz?)` und `laufZurueckweisen(laufId, benutzer,
notiz)`; Abschluss verlangt die Freigabe, wenn die Version sie fordert notiz)`; Abschluss verlangt die Freigabe, wenn die Version sie fordert
- [ ] Die Prüfung „wer hat daran gearbeitet“ an **einer** Stelle, wie `laufMussVeraenderbarSein` - [x] Die Prüfung „wer hat daran gearbeitet“ an **einer** Stelle, wie `laufMussVeraenderbarSein`
- [ ] Benachrichtigung: ein neuer `Meldeanlass` `LAUF_FREIGABE_OFFEN` an die Rollen mit dem Recht — - [x] Benachrichtigung: ein neuer `Meldeanlass` `LAUF_FREIGABE_OFFEN` an die Rollen mit dem Recht —
sonst wartet ein Lauf, von dem niemand weiß sonst wartet ein Lauf, von dem niemand weiß
- [ ] Oberfläche: Hinweis am Lauf („wartet auf Freigabe durch einen Zweiten“), Knöpfe für Freigeben - [x] Oberfläche: Hinweis am Lauf („wartet auf Freigabe durch einen Zweiten“), Knöpfe für Freigeben
und Zurückweisen, Notizfeld; die Übersicht bekommt einen Filter „wartet auf meine Freigabe“ und Zurückweisen, Notizfeld; die Übersicht bekommt einen Filter „wartet auf meine Freigabe“
- [ ] Editor: Häkchen „dieser Lauf braucht eine Freigabe“ an der Version - [x] Editor: Häkchen „dieser Lauf braucht eine Freigabe“ an der Version
- [ ] Druckansicht und PDF nennen Freigeber und Zeitpunkt - [x] Druckansicht und PDF nennen Freigeber und Zeitpunkt
- [ ] Anleitung: Kapitel 02 (Läufe abarbeiten) und 04 (Vorlagen), mit dem Hinweis, dass ein - [x] Anleitung: Kapitel 02 (Läufe abarbeiten) und 04 (Vorlagen), mit dem Hinweis, dass ein
Einzelkämpfer sich nicht selbst freigeben kann Einzelkämpfer sich nicht selbst freigeben kann
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: ein Lauf mit Freigabepflicht lässt sich nicht abschließen, solange keine Freigabe steht - [x] Test: ein Lauf mit Freigabepflicht lässt sich nicht abschließen, solange keine Freigabe steht
- [ ] Test: der Bearbeiter kann **nicht** freigeben — auch nicht als `ADMIN` - [x] Test: der Bearbeiter kann **nicht** freigeben — auch nicht als `ADMIN`
- [ ] Test: die Datenbank weist es ebenfalls ab (CHECK), nicht nur der Dienst - [x] Test: die Datenbank weist es ebenfalls ab (CHECK), nicht nur der Dienst
- [ ] Test: eine Rolle ohne `freigeben` wird abgewiesen, über jede Server Action - [x] Test: eine Rolle ohne `freigeben` wird abgewiesen, über jede Server Action
- [ ] Test: Zurückweisen verlangt eine Notiz und setzt den Lauf auf `IN_ARBEIT` - [x] Test: Zurückweisen verlangt eine Notiz und setzt den Lauf auf `IN_ARBEIT`
- [ ] Test: die Meldung geht an die Rollen mit dem Recht und nicht an alle - [x] Test: die Meldung geht an die Rollen mit dem Recht und nicht an alle
- [ ] Test: ein Lauf **ohne** Freigabepflicht verhält sich unverändert — bestehende Läufe und - [x] Test: ein Lauf **ohne** Freigabepflicht verhält sich unverändert — bestehende Läufe und
Vorlagen merken nichts Vorlagen merken nichts
- [ ] Am Handy kein horizontales Scrollen - [x] Am Handy kein horizontales Scrollen
## Beim Umsetzen dazugekommen
- **Die vier Entscheidungen sind so gefallen wie empfohlen.** Zwei haben sich im Code als besonders
tragend erwiesen:
- **Die Freigabe hängt an der Version, nicht an der Vorlage.** Eine veröffentlichte Version ist
unveränderlich — ein laufender Lauf kann seine Regel damit nicht unter den Füßen verlieren. In
der Versionsliste steht an jeder Fassung, ob sie eine Freigabe verlangt.
- **Kein fünfter `LaufStatus` für „zurückgewiesen“.** Der Lauf geht auf `IN_ARBEIT`, die
Begründung steht in `freigabeNotiz` und im Protokoll. Ein neuer Enum-Wert hätte jeden Codepfad
betroffen, der heute vier Fälle kennt.
- **Als „gearbeitet“ zählt dreierlei** — angelegt, ein Schrittergebnis gesetzt, abgeschlossen.
Der **Zuständige** gilt bewusst nicht als Bearbeiter: eine Zuweisung ist noch keine Arbeit, und
wäre die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn. Die Prüfung sitzt in
`hatDaranGearbeitet`, an einer Stelle — wie `laufMussVeraenderbarSein`.
- **Die Aufforderung entsteht außerhalb der Transaktion**, und das war die einzige knifflige Stelle:
der Abschlussversuch scheitert mit einem eigenen Fehlertyp `FreigabeFehlt`, den
`laufAbschliessen` außen fängt, um die Meldung einzureihen und dann weiterzuwerfen. Innerhalb
würde sie mit dem Fehler zurückgerollt — und dann wartet ein Lauf auf etwas, von dem niemand
weiß. Dafür musste die Transaktion in eine eigene Funktion (`abschlussVersuchen`).
- **Ein anderer Empfängerkreis als bei jeder anderen Meldung**: nicht der Zuständige, sondern jeder
mit dem Recht `freigeben` — ohne die, die gearbeitet haben. Eine Aufforderung zu etwas, das der
Dienst abweist, ist schlimmer als keine.
- **Ein partieller Unique-Index** (`Zustellung_freigabe_einmal`) hält die Mahnung bei einem Lauf
und Empfänger auf einmal. Ein Techniker versucht es mehrmals; ohne den Index wäre jede Mail für
sich korrekt und in Summe eine Belästigung — derselbe Fall wie „überfällig“ in #034, nur
schneller erreicht.
- **Die Freigabepflicht wandert bei „Bearbeiten“ und „Duplizieren“ mit.** Das war kein Zufall,
sondern die unmittelbare Lehre aus #039: dort hatte die tiefe Kopie `nurFuerAbteilungen` und
`bedingung` stillschweigend verworfen. Eine Vorlage soll ihre Freigabepflicht nicht durch eine
Textkorrektur verlieren.
## Was die Klickprobe gezeigt hat
Elf Prüfungen am gebauten Stand, alle grün — von „Freigabe-Karte sichtbar“ über „Zurückweisen
verlangt eine Begründung“ bis „Häkchen aus der Vorversion übernommen“ und 390 px.
**Drei rote Punkte waren meine eigenen Testfehler**, und der dritte ist festzuhalten:
1. Ein Selektor traf den **versteckten** Protokolltext „Lauf abgeschlossen“ statt der Erfolgsmeldung.
2. Ein Suchmuster `/freigeb/` traf nichts: in „Freigabe“ und „freigegeben“ kommt diese Zeichenfolge
gar nicht vor. Dreimal gesucht, bis es auffiel.
3. **Ein `reload()` direkt nach einer Server Action zeigt kurz den alten Stand.** Die Action
revalidiert den Pfad, und der Benutzer sieht das Ergebnis ohne Neuladen — ein Reload im selben
Kontext trifft dagegen den Router-Cache von Next. Das ist Next und kein Fehler dieser Anwendung,
aber es ist auch nicht, was ein Mensch tut. Die Probe prüft jetzt das echte Verhalten.
Nachgewiesen, bevor etwas geändert wurde: in einem frischen Browser-Kontext stand alles richtig
da, und die Datenbank trug Freigeber, Zeitpunkt und Notiz.
## Was offen bleibt
- **Freigaben an einzelnen Schritten** gibt es nicht, bewusst. Am Lauf ist es eine Aussage über die
Arbeit als Ganzes und erklärt sich von selbst; am Schritt verführte es zu Checklisten, in denen an
fünf Stellen jemand warten muss. `VorlagenSchritt.konfig` ist ein Json — wenn sich der Bedarf
zeigt, ist der Platz da.
- **Ein Einzelkämpfer kommt nicht weiter**: wer allein ist und an dem Lauf gearbeitet hat, kann ihn
nicht freigeben, auch nicht als Administrator. Das ist die Härte, die die Regel ausmacht, und die
Anleitung sagt es ausdrücklich.
+2 -2
View File
@@ -44,7 +44,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min | | #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min |
| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min | | #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min |
| #039 | [M23 – Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | erledigt | v0.3.0 | 12 h | 5 h 20 min | | #039 | [M23 – Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | erledigt | v0.3.0 | 12 h | 5 h 20 min |
| #040 | [M24 – Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | offen | v0.3.0 | 8 h | – | | #040 | [M24 – Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | erledigt | v0.3.0 | 8 h | 3 h 30 min |
| #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | offen | v0.3.0 | 8 h | – | | #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | offen | v0.3.0 | 8 h | – |
| #042 | [M26 – Weitere Schritt-Typen](042-weitere-schritt-typen.md) | offen | v0.3.0 | 6 h | – | | #042 | [M26 – Weitere Schritt-Typen](042-weitere-schritt-typen.md) | offen | v0.3.0 | 6 h | – |
| | **Summe** | | | **272 h** | **79 h 15 min** | | | **Summe** | | | **272 h** | **82 h 45 min** |
@@ -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_ZUGEWIESEN
LAUF_UEBERFAELLIG LAUF_UEBERFAELLIG
LAUF_ABGESCHLOSSEN LAUF_ABGESCHLOSSEN
/// Ein Lauf wartet auf eine Freigabe durch einen Zweiten (#040).
LAUF_FREIGABE_OFFEN
ABGLEICH_FEHLGESCHLAGEN ABGLEICH_FEHLGESCHLAGEN
} }
@@ -273,6 +275,7 @@ model Benutzer {
geaendertAm DateTime @default(now()) @updatedAt geaendertAm DateTime @default(now()) @updatedAt
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon") abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
freigegebeneLaeufe Lauf[] @relation("LaufFreigegebenVon")
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon") archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
erstellteLaeufe Lauf[] @relation("LaufErstelltVon") erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn") zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
@@ -578,6 +581,13 @@ model VorlagenVersion {
vorlageId String vorlageId String
version Int version Int
status VersionStatus @default(ENTWURF) 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? aenderungsnotiz String?
veroeffentlichtAm DateTime? veroeffentlichtAm DateTime?
veroeffentlichtVonId String? veroeffentlichtVonId String?
@@ -785,6 +795,20 @@ model Lauf {
gestartetAm DateTime? gestartetAm DateTime?
abgeschlossenAm DateTime? abgeschlossenAm DateTime?
abgeschlossenVonId String? 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? archiviertAm DateTime?
archiviertVonId String? archiviertVonId String?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
@@ -798,6 +822,7 @@ model Lauf {
erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict) erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict)
zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict) zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict)
abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], 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) archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict)
ergebnisse Schrittergebnis[] ergebnisse Schrittergebnis[]
@@ -812,6 +837,7 @@ model Lauf {
@@index([kundeId, status]) @@index([kundeId, status])
@@index([geraetId, status]) @@index([geraetId, status])
@@index([mitarbeiterId]) @@index([mitarbeiterId])
@@index([freigegebenVonId])
@@index([zugewiesenAnId, status]) @@index([zugewiesenAnId, status])
@@index([status, faelligAm]) @@index([status, faelligAm])
@@index([vorlagenVersionId]) @@index([vorlagenVersionId])
@@ -20,6 +20,7 @@ const ANLASS_TEXT: Record<Meldeanlass, string> = {
LAUF_ZUGEWIESEN: "zugewiesen", LAUF_ZUGEWIESEN: "zugewiesen",
LAUF_UEBERFAELLIG: "überfällig", LAUF_UEBERFAELLIG: "überfällig",
LAUF_ABGESCHLOSSEN: "abgeschlossen", LAUF_ABGESCHLOSSEN: "abgeschlossen",
LAUF_FREIGABE_OFFEN: "wartet auf Freigabe",
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen", ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
}; };
+31
View File
@@ -11,6 +11,7 @@ import {
KommentarSchreiben, KommentarSchreiben,
LaufAbschliessen, LaufAbschliessen,
LaufArchivieren, LaufArchivieren,
LaufFreigabe,
LaufBeginnen, LaufBeginnen,
LaufMitAdKontoVerknuepfen, LaufMitAdKontoVerknuepfen,
LaufZurueckholen, LaufZurueckholen,
@@ -378,6 +379,36 @@ export default async function LaufSeite({
<Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} /> <Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} />
{/*
* Die Freigabe-Karte (#040), und zwar **ueber** der Knopfreihe: sie ist
* eine Entscheidung und kein Knopf unter anderen.
*
* Gezeigt wird sie, wenn die Version eine Freigabe verlangt und der Lauf
* noch nicht archiviert ist — danach aendert sich nichts mehr. Wer kein
* `freigeben` hat, sieht sie nur als Auskunft; das Formular bleibt ihm
* verborgen, denn ein Knopf, der nichts tut, ist kein Knopf.
*/}
{lauf.freigabePflicht && !archiviert ? (
darf(benutzer.rolle, "freigeben") ? (
<div className="mt-4">
<LaufFreigabe
laufId={lauf.id}
bereitsFreigegeben={lauf.freigegebenAm !== null}
letzteNotiz={lauf.freigabeNotiz}
/>
</div>
) : (
<p className="mt-4 max-w-prose rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext">
{lauf.freigegebenAm
? `Freigegeben${lauf.freigegebenVon ? ` von ${lauf.freigegebenVon}` : ""}.`
: "Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen " +
"werden kann. Die Freigabeberechtigten sind benachrichtigt, sobald du ihn " +
"abzuschließen versuchst."}
{lauf.freigabeNotiz ? ` Letzte Begründung: ${lauf.freigabeNotiz}` : ""}
</p>
)
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2"> <div className="mt-4 flex flex-wrap items-center gap-2">
{lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null} {lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null}
{lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? ( {lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? (
+12 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ArrowLeft, Eye } from "lucide-react"; import { ArrowLeft, Eye, ShieldCheck } from "lucide-react";
import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten"; import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten";
import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf"; import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf";
import { import {
@@ -50,6 +50,17 @@ function HistorienZeile({ zeile, darfBearbeiten }: { zeile: VersionsZeile; darfB
<span className="text-xs text-nebentext"> <span className="text-xs text-nebentext">
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"} {zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
</span> </span>
{/*
Steht an der **Version**, nicht an der Vorlage (#040): verschiedene
Versionen koennen es verschieden halten, und ein laufender Lauf behaelt
die Regel seiner Version.
*/}
{zeile.freigabePflicht ? (
<span className="flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs text-nebentext">
<ShieldCheck className="size-3" strokeWidth={2} aria-hidden="true" />
Freigabe nötig
</span>
) : null}
</div> </div>
{zeile.aenderungsnotiz ? ( {zeile.aenderungsnotiz ? (
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p> <p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p>
@@ -58,6 +58,7 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
struktur: version.struktur, struktur: version.struktur,
stand: version.stand.toISOString(), stand: version.stand.toISOString(),
aenderungsnotiz: version.aenderungsnotiz, aenderungsnotiz: version.aenderungsnotiz,
freigabePflicht: version.freigabePflicht,
}} }}
lesend={veroeffentlicht || !darfBearbeiten} lesend={veroeffentlicht || !darfBearbeiten}
darfBearbeiten={darfBearbeiten} darfBearbeiten={darfBearbeiten}
+104 -1
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import { useActionState, useState } from "react"; import { useActionState, useState } from "react";
import { Archive, CheckCircle2, Link2, PlayCircle, Undo2 } from "lucide-react"; import { Archive, CheckCircle2, Link2, PlayCircle, ShieldCheck, Undo2 } from "lucide-react";
import { import {
abschnittNichtZutreffendAktion, abschnittNichtZutreffendAktion,
kommentarHinzufuegenAktion, kommentarHinzufuegenAktion,
laufAbschliessenAktion, laufAbschliessenAktion,
laufFreigebenAktion,
laufZurueckweisenAktion,
laufArchivierenAktion, laufArchivierenAktion,
laufMitAdKontoVerknuepfenAktion, laufMitAdKontoVerknuepfenAktion,
laufOeffnenAktion, laufOeffnenAktion,
@@ -93,6 +95,107 @@ export function LaufAbschliessen({ laufId, offen }: { laufId: string; offen: num
); );
} }
/**
* Freigeben und Zurueckweisen (#040).
*
* **Eine Komponente fuer beides**, weil es dieselbe Entscheidung ist: der
* Freigebende sieht den Lauf an und sagt ja oder nein. Zwei getrennte Karten
* haetten dieselbe Notiz zweimal.
*
* Die Notiz ist bei der Freigabe freiwillig und bei der Zurueckweisung Pflicht —
* geprueft wird das in der Server Action und im Dienst, nicht hier: eine Regel,
* die nur das Formular kennt, gilt nicht fuer den naechsten Aufrufer.
*/
export function LaufFreigabe({
laufId,
bereitsFreigegeben,
letzteNotiz,
}: {
laufId: string;
bereitsFreigegeben: boolean;
/** Die letzte Begruendung – nach einer Zurueckweisung steht sie hier. */
letzteNotiz: string | null;
}) {
const [freigabe, freigeben, freigabeLaeuft] = useActionState<AktionsErgebnis, FormData>(
laufFreigebenAktion,
{},
);
const [rueckweisung, zurueckweisen, rueckweisungLaeuft] = useActionState<AktionsErgebnis, FormData>(
laufZurueckweisenAktion,
{},
);
const [notiz, setzeNotiz] = useState("");
return (
<div className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
<p className="flex items-start gap-2 text-sm">
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<span>
{bereitsFreigegeben ? (
<>Dieser Lauf ist freigegeben. Zurückweisen nimmt die Freigabe wieder zurück.</>
) : (
<>
Dieser Lauf verlangt eine <strong>Freigabe durch einen Zweiten</strong>. Wer daran gearbeitet
hat, kann sie nicht erteilen.
</>
)}
</span>
</p>
{letzteNotiz ? (
<p className="max-w-prose rounded-karte bg-flaeche-hoch p-3 text-sm text-nebentext">
Letzte Begründung: {letzteNotiz}
</p>
) : null}
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Begründung (bei einer Zurückweisung Pflicht)</span>
<textarea
name="notiz"
rows={2}
value={notiz}
onChange={(ereignis) => setzeNotiz(ereignis.target.value)}
className={`${FELD} py-2`}
placeholder="Prüfsumme fehlt im Protokoll"
/>
</label>
<div className="flex flex-wrap gap-2">
{bereitsFreigegeben ? null : (
<form action={freigeben}>
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="notiz" value={notiz} />
<button
type="submit"
disabled={freigabeLaeuft || rueckweisungLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<ShieldCheck className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{freigabeLaeuft ? "Wird freigegeben …" : "Freigeben"}
</button>
</form>
)}
<form action={zurueckweisen}>
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="notiz" value={notiz} />
<button
type="submit"
disabled={freigabeLaeuft || rueckweisungLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-5 text-sm text-nebentext hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Undo2 className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{rueckweisungLaeuft ? "Wird zurückgewiesen …" : "Zurückweisen"}
</button>
</form>
</div>
<Meldung ergebnis={freigabe} />
<Meldung ergebnis={rueckweisung} />
</div>
);
}
/** /**
* Archivieren – die zweite, ausdrueckliche Handlung. * Archivieren – die zweite, ausdrueckliche Handlung.
* *
+29
View File
@@ -26,6 +26,7 @@ import {
type EditorVariable, type EditorVariable,
} from "@/lib/editor-aufbau"; } from "@/lib/editor-aufbau";
import { unbekanntePlatzhalter } from "@/lib/platzhalter"; import { unbekanntePlatzhalter } from "@/lib/platzhalter";
import { KAESTCHEN } from "@/lib/formular";
import { GRENZEN, type Struktur } from "@/lib/vorlagen-format"; import { GRENZEN, type Struktur } from "@/lib/vorlagen-format";
import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen"; import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen";
@@ -52,6 +53,8 @@ export type EditorVersion = {
/** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */ /** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */
readonly stand: string; readonly stand: string;
readonly aenderungsnotiz: string | null; readonly aenderungsnotiz: string | null;
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
readonly freigabePflicht: boolean;
}; };
/** /**
@@ -467,6 +470,32 @@ export function Editor({
</p> </p>
<form action={veroeffentlichen} className="flex flex-col gap-3"> <form action={veroeffentlichen} className="flex flex-col gap-3">
<input type="hidden" name="versionId" value={version.id} /> <input type="hidden" name="versionId" value={version.id} />
{/*
**Die Freigabepflicht gehoert ans Veroeffentlichen** (#040), nicht
ans Speichern: sie ist eine Aussage ueber die **Version**, und eine
veroeffentlichte Version ist unveraenderlich. Ein laufender Lauf
kann seine Regel damit nicht unter den Fuessen verlieren.
Vorbelegt mit dem Stand des Entwurfs — er ist beim Bearbeiten von
der Vorversion mitgewandert.
*/}
<label className="flex min-h-11 items-start gap-2 text-sm md:min-h-10">
<input
type="checkbox"
name="freigabePflicht"
value="1"
defaultChecked={version.freigabePflicht}
className={KAESTCHEN}
/>
<span>
Läufe dieser Version brauchen eine <strong>Freigabe durch einen Zweiten</strong>
<span className="block text-xs text-nebentext">
Wer am Lauf gearbeitet hat, kann sie nicht erteilen — auch nicht als Administrator.
Benachrichtigt werden die Freigabeberechtigten, sobald jemand den Lauf abzuschließen
versucht.
</span>
</span>
</label>
<label className="flex flex-col gap-1.5 text-sm"> <label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Änderungsnotiz (optional)</span> <span className="text-nebentext">Änderungsnotiz (optional)</span>
<textarea <textarea
+20 -1
View File
@@ -113,9 +113,28 @@ export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): M
]), ]),
}; };
case Meldeanlass.LAUF_FREIGABE_OFFEN:
return {
betreff: `Laufzettel wartet auf Freigabe: „${kopf.titel}“`,
rumpf: zusammensetzen([
/*
* **Der Satz nennt die Regel**, denn der Empfaenger ist oft nicht
* derjenige, der gearbeitet hat — und soll nicht raten muessen, warum
* er gefragt wird.
*/
kopf.handelnder
? `${kopf.handelnder} hat diesen Laufzettel abgearbeitet. Er verlangt eine Freigabe ` +
"durch einen Zweiten, der nicht daran gearbeitet hat."
: "Dieser Laufzettel verlangt eine Freigabe durch einen Zweiten, der nicht daran " +
"gearbeitet hat.",
daten,
link,
]),
};
default: { default: {
/* /*
* Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese * Kaeme ein weiterer Anlass dazu, der zu einem Lauf gehoert, ist diese
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile * Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
* totem Code im Ergebnis. * totem Code im Ergebnis.
*/ */
+53
View File
@@ -9,6 +9,8 @@ import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { import {
LaufFehler, LaufFehler,
laufAbschliessen, laufAbschliessen,
laufFreigeben,
laufZurueckweisen,
laufAnlegen, laufAnlegen,
laufArchivieren, laufArchivieren,
laufMitAdKontoVerknuepfen, 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( export async function laufArchivierenAktion(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
+9 -1
View File
@@ -267,10 +267,17 @@ export async function veroeffentlichenAktion(
const zugang = await angemeldetOderMeldung(); const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang; if ("fehler" in zugang) return zugang;
const eingabe = z 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({ .safeParse({
versionId: formular.get("versionId"), versionId: formular.get("versionId"),
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined, 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." }; if (!eingabe.success) return { fehler: "Es fehlt die Version." };
@@ -279,6 +286,7 @@ export async function veroeffentlichenAktion(
eingabe.data.versionId, eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null, eingabe.data.aenderungsnotiz ?? null,
zugang.benutzerId, zugang.benutzerId,
eingabe.data.freigabePflicht,
); );
revalidatePath("/vorlagen"); revalidatePath("/vorlagen");
return { erfolg: `Version ${version.version} ist veröffentlicht.` }; return { erfolg: `Version ${version.version} ist veröffentlicht.` };
+13
View File
@@ -54,6 +54,19 @@ export const RECHTE = {
mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"], mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */ /** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"], vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
/**
* Einen Lauf freigeben oder zurueckweisen, der eine Freigabe verlangt (#040).
*
* **Nicht `TECHNIKER`**, und das ist der ganze Sinn: die Vier-Augen-Regel soll
* nicht davon abhaengen, dass zufaellig ein zweiter Techniker greifbar ist.
* Wer sie umgehen will, braucht dann ein Konto mit mehr Rechten — und das
* steht im Protokoll.
*
* Das Recht allein genuegt nicht: wer an dem Lauf **gearbeitet** hat, gibt ihn
* auch mit diesem Recht nicht frei. Das prueft der Dienst, und ein CHECK in der
* Datenbank haelt es ebenfalls fest.
*/
freigeben: ["ADMIN", "REDAKTEUR"],
/** /**
* Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein * Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein
* abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben. * abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben.
+16 -1
View File
@@ -50,6 +50,11 @@ export type LaufZeile = {
readonly kundeName: string; readonly kundeName: string;
readonly erstelltAm: Date; readonly erstelltAm: Date;
readonly abgeschlossenAm: Date | null; readonly abgeschlossenAm: Date | null;
/** Freigabe-Zustand (#040). `freigabePflicht` kommt von der Version. */
readonly freigabePflicht: boolean;
readonly freigegebenVon: string | null;
readonly freigegebenAm: Date | null;
readonly freigabeNotiz: string | null;
readonly anzahlSchritte: number; readonly anzahlSchritte: number;
readonly anzahlErledigt: number; readonly anzahlErledigt: number;
}; };
@@ -74,12 +79,17 @@ const ZEILEN_AUSWAHL = {
faelligAm: true, faelligAm: true,
erstelltAm: true, erstelltAm: true,
abgeschlossenAm: true, abgeschlossenAm: true,
freigegebenAm: true,
freigabeNotiz: true,
freigegebenVon: { select: { name: true, email: true } },
mitarbeiterName: true, mitarbeiterName: true,
kunde: { select: { name: true } }, kunde: { select: { name: true } },
zugewiesenAn: { select: { name: true } }, zugewiesenAn: { select: { name: true } },
mitarbeiter: { select: { anzeigename: true } }, mitarbeiter: { select: { anzeigename: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } }, geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } }, vorlagenVersion: {
select: { version: true, freigabePflicht: true, vorlage: { select: { name: true } } },
},
ergebnisse: { select: { zustand: true } }, ergebnisse: { select: { zustand: true } },
} satisfies Prisma.LaufSelect; } satisfies Prisma.LaufSelect;
@@ -113,6 +123,11 @@ function zeileBauen(zeile: Rohzeile): LaufZeile {
kundeName: zeile.kunde.name, kundeName: zeile.kunde.name,
erstelltAm: zeile.erstelltAm, erstelltAm: zeile.erstelltAm,
abgeschlossenAm: zeile.abgeschlossenAm, abgeschlossenAm: zeile.abgeschlossenAm,
freigabePflicht: zeile.vorlagenVersion.freigabePflicht,
// Name, sonst Mailadresse – wie ueberall, wo ein Mensch genannt wird.
freigegebenVon: zeile.freigegebenVon?.name ?? zeile.freigegebenVon?.email ?? null,
freigegebenAm: zeile.freigegebenAm,
freigabeNotiz: zeile.freigabeNotiz,
anzahlSchritte: zeile.ergebnisse.length, anzahlSchritte: zeile.ergebnisse.length,
anzahlErledigt: zeile.ergebnisse.filter((ergebnis) => istVomTisch(ergebnis.zustand)).length, anzahlErledigt: zeile.ergebnisse.filter((ergebnis) => istVomTisch(ergebnis.zustand)).length,
}; };
+7
View File
@@ -181,6 +181,8 @@ export type VersionsZeile = {
readonly version: number; readonly version: number;
readonly status: VersionStatus; readonly status: VersionStatus;
readonly aenderungsnotiz: string | null; readonly aenderungsnotiz: string | null;
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
readonly freigabePflicht: boolean;
readonly veroeffentlichtAm: Date | null; readonly veroeffentlichtAm: Date | null;
readonly veroeffentlichtVon: string | null; readonly veroeffentlichtVon: string | null;
readonly anzahlLaeufe: number; readonly anzahlLaeufe: number;
@@ -206,6 +208,7 @@ export async function versionsHistorie(
version: true, version: true,
status: true, status: true,
aenderungsnotiz: true, aenderungsnotiz: true,
freigabePflicht: true,
veroeffentlichtAm: true, veroeffentlichtAm: true,
veroeffentlichtVon: { select: { name: true } }, veroeffentlichtVon: { select: { name: true } },
_count: { select: { laeufe: true } }, _count: { select: { laeufe: true } },
@@ -216,6 +219,7 @@ export async function versionsHistorie(
version: version.version, version: version.version,
status: version.status, status: version.status,
aenderungsnotiz: version.aenderungsnotiz, aenderungsnotiz: version.aenderungsnotiz,
freigabePflicht: version.freigabePflicht,
veroeffentlichtAm: version.veroeffentlichtAm, veroeffentlichtAm: version.veroeffentlichtAm,
veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null, veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null,
anzahlLaeufe: version._count.laeufe, anzahlLaeufe: version._count.laeufe,
@@ -234,6 +238,8 @@ export type VersionMitAufbau = {
*/ */
readonly stand: Date; readonly stand: Date;
readonly aenderungsnotiz: string | null; readonly aenderungsnotiz: string | null;
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
readonly freigabePflicht: boolean;
readonly struktur: Struktur; readonly struktur: Struktur;
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */ /** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
readonly platzhalter: readonly string[]; readonly platzhalter: readonly string[];
@@ -306,6 +312,7 @@ export async function versionMitAufbau(
status: version.status, status: version.status,
stand: version.geaendertAm, stand: version.geaendertAm,
aenderungsnotiz: version.aenderungsnotiz, aenderungsnotiz: version.aenderungsnotiz,
freigabePflicht: version.freigabePflicht,
struktur, struktur,
platzhalter: platzhalterDerStruktur(struktur), platzhalter: platzhalterDerStruktur(struktur),
}; };
+35 -3
View File
@@ -2,6 +2,7 @@ import { createTransport } from "nodemailer";
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums"; import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { RECHTE } from "@/server/auth/rollen";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen"; import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur"; import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur";
@@ -382,10 +383,41 @@ async function laufEmpfaenger(
handelnderId: string | null, handelnderId: string | null,
): Promise<{ id: string; email: string; name: string }[]> { ): Promise<{ id: string; email: string; name: string }[]> {
const kennungen = new Set<string>(); const kennungen = new Set<string>();
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) { if (anlass === Meldeanlass.LAUF_FREIGABE_OFFEN) {
kennungen.add(lauf.erstelltVonId); /*
* **Ein anderer Empfaengerkreis als bei allen anderen Anlaessen** (#040): nicht
* der Zustaendige, sondern **jeder mit dem Recht `freigeben`**. Eine Freigabe
* ist die Aufgabe eines Dritten; ihm die Meldung nicht zu schicken hiesse,
* dass ein Lauf auf etwas wartet, von dem niemand weiss — und genau das macht
* die ganze Regel wertlos.
*
* Wer **gearbeitet** hat, bekommt sie nicht: er darf ohnehin nicht freigeben,
* und eine Aufforderung zu etwas, das der Dienst abweist, ist schlimmer als
* keine.
*/
const berechtigte = await prisma.benutzer.findMany({
where: { aktiv: true, meldungenAktiv: true, rolle: { in: [...RECHTE.freigeben] } },
select: { id: true },
});
const bearbeiter = new Set<string>(lauf.erstelltVonId ? [lauf.erstelltVonId] : []);
for (const zeile of await prisma.schrittergebnis.findMany({
where: { laufId: lauf.id, aktualisiertVonId: { not: null } },
select: { aktualisiertVonId: true },
distinct: ["aktualisiertVonId"],
})) {
if (zeile.aktualisiertVonId) bearbeiter.add(zeile.aktualisiertVonId);
}
for (const person of berechtigte) {
if (!bearbeiter.has(person.id)) kennungen.add(person.id);
}
} else {
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) {
kennungen.add(lauf.erstelltVonId);
}
} }
if (handelnderId) kennungen.delete(handelnderId); if (handelnderId) kennungen.delete(handelnderId);
if (kennungen.size === 0) return []; if (kennungen.size === 0) return [];
+234 -2
View File
@@ -805,6 +805,174 @@ export async function abschlussPruefenNachAenderung(
}); });
} }
/* ------------------------------------------------------------------ */
/* Vier-Augen-Freigabe (#040) */
/* ------------------------------------------------------------------ */
/**
* Hat dieser Benutzer an dem Lauf **gearbeitet**?
*
* **Die eine Stelle, an der die Vier-Augen-Regel haengt** — wie
* `laufMussVeraenderbarSein` fuer das Archiv. Sie wird vor jeder Freigabe und
* vor jeder Zurueckweisung gerufen; zwei Stellen liefen auseinander, und die
* erste, die es vergisst, hebt die Regel auf.
*
* Gezaehlt wird dreierlei: wer den Lauf **angelegt** hat, wer ein
* **Schrittergebnis** gesetzt hat, und wer ihn **abgeschlossen** hat. Der
* Zustaendige (`zugewiesenAnId`) gilt bewusst **nicht** als Bearbeiter: eine
* Zuweisung ist noch keine Arbeit, und wer zugewiesen wurde, ohne etwas zu tun,
* soll freigeben koennen, wenn es jemand anderes abgearbeitet hat.
*/
async function hatDaranGearbeitet(
tx: Prisma.TransactionClient,
laufId: string,
benutzerId: string,
): Promise<boolean> {
const lauf = await tx.lauf.findUniqueOrThrow({
where: { id: laufId },
select: { erstelltVonId: true, abgeschlossenVonId: true },
});
if (lauf.erstelltVonId === benutzerId) return true;
if (lauf.abgeschlossenVonId === benutzerId) return true;
const eigene = await tx.schrittergebnis.count({
where: { laufId, aktualisiertVonId: benutzerId },
});
return eigene > 0;
}
/** Verlangt die Version dieses Laufs eine Freigabe? */
async function freigabePflichtig(tx: Prisma.TransactionClient, laufId: string): Promise<boolean> {
const lauf = await tx.lauf.findUniqueOrThrow({
where: { id: laufId },
select: { vorlagenVersion: { select: { freigabePflicht: true } } },
});
return lauf.vorlagenVersion.freigabePflicht;
}
/**
* Gibt einen Lauf frei.
*
* Verlangt das Recht `freigeben` — und dass der Freigebende **nicht** daran
* gearbeitet hat. Beides wird auch dann geprueft, wenn die Version gar keine
* Freigabe fordert: eine Freigabe an einem Lauf, der keine braucht, ist kein
* Fehler, aber sie soll denselben Regeln folgen. Sonst waere sie ein Weg, einen
* Freigeber in die Akte zu schreiben, der dort nicht hingehoert.
*/
export async function laufFreigeben(
laufId: string,
benutzer: HandelnderBenutzer,
notiz?: string,
): Promise<Lauf> {
if (!darf(benutzer.rolle, "freigeben")) {
throw new LaufFehler(
`Einen Lauf freizugeben verlangt das Recht "freigeben"; die Rolle ${benutzer.rolle} hat es nicht.`,
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
if (lauf.freigegebenVonId) {
throw new LaufFehler("Dieser Lauf ist bereits freigegeben.");
}
if (await hatDaranGearbeitet(tx, laufId, benutzer.id)) {
throw new LaufFehler(
"Du hast an diesem Lauf gearbeitet und kannst ihn deshalb nicht selbst freigeben. " +
"Das ist der Sinn der Vier-Augen-Regel — es braucht jemand anderen mit dem Recht " +
"zur Freigabe.",
);
}
const jetzt = new Date();
const nachher = await tx.lauf.update({
where: { id: laufId },
data: {
freigegebenVonId: benutzer.id,
freigegebenAm: jetzt,
// Bei einer Freigabe freiwillig; eine vorherige Zurueckweisung wird damit
// ueberschrieben, und das ist richtig: die letzte Notiz ist die geltende.
freigabeNotiz: notiz?.trim() || null,
},
});
await tx.protokoll.create({
data: {
laufId,
entitaet: "Lauf",
entitaetId: laufId,
aktion: "Lauf freigegeben",
benutzerId: benutzer.id,
nachher: { freigegebenAm: jetzt.toISOString(), notiz: notiz?.trim() || null },
},
});
return nachher;
});
}
/**
* Weist einen Lauf zurueck. Die Notiz ist **Pflicht**.
*
* Kein eigener `LaufStatus`: der Lauf geht zurueck auf `IN_ARBEIT`, und die
* Begruendung steht in `freigabeNotiz` und im Protokoll. Ein fuenfter Status
* waere eine Aenderung an einem Enum, das vier Jahre halten soll, und jeder
* Codepfad, der heute vier Faelle kennt, muesste ihn lernen.
*
* **Eine Zurueckweisung ohne Begruendung ist keine.** Dieselbe Regel wie bei
* `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` (CLAUDE.md): wer etwas zurueckgibt,
* sagt warum — sonst steht der naechste vor derselben Frage.
*/
export async function laufZurueckweisen(
laufId: string,
benutzer: HandelnderBenutzer,
notiz: string,
): Promise<Lauf> {
if (!darf(benutzer.rolle, "freigeben")) {
throw new LaufFehler(
`Einen Lauf zurueckzuweisen verlangt das Recht "freigeben"; die Rolle ${benutzer.rolle} hat es nicht.`,
);
}
const begruendung = notiz.trim();
if (!begruendung) {
throw new LaufFehler(
"Eine Zurückweisung braucht eine Begründung. Ohne sie steht der Bearbeiter vor derselben " +
"Frage wie vorher.",
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
if (await hatDaranGearbeitet(tx, laufId, benutzer.id)) {
throw new LaufFehler(
"Du hast an diesem Lauf gearbeitet und kannst ihn deshalb nicht selbst zurückweisen.",
);
}
const nachher = await tx.lauf.update({
where: { id: laufId },
data: {
// Eine Zurueckweisung nimmt eine erteilte Freigabe zurueck.
freigegebenVonId: null,
freigegebenAm: null,
freigabeNotiz: begruendung,
status: LaufStatus.IN_ARBEIT,
abgeschlossenAm: null,
abgeschlossenVonId: null,
},
});
await tx.protokoll.create({
data: {
laufId,
entitaet: "Lauf",
entitaetId: laufId,
aktion: "Lauf zurückgewiesen",
benutzerId: benutzer.id,
vorher: { status: lauf.status },
nachher: { status: LaufStatus.IN_ARBEIT, notiz: begruendung },
},
});
return nachher;
});
}
/** /**
* Schliesst einen Lauf ab. * Schliesst einen Lauf ab.
* *
@@ -816,14 +984,38 @@ export async function abschlussPruefenNachAenderung(
* abgeschlossen und gibt ihn unveraendert zurueck, ohne zweiten * abgeschlossen und gibt ihn unveraendert zurueck, ohne zweiten
* Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben. * Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben.
*/ */
export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> { /**
const abgeschlossen = await prisma.$transaction(async (tx) => { * Der eigentliche Abschluss, in einer Transaktion.
*
* Getrennt von `laufAbschliessen`, weil die Aufforderung an die
* Freigabeberechtigten **ausserhalb** der Transaktion entstehen muss: innerhalb
* wuerde sie mit dem Fehler zurueckgerollt, und dann wartet der Lauf auf etwas,
* von dem niemand weiss.
*/
async function abschlussVersuchen(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<{ lauf: Lauf; warSchonFertig: boolean }> {
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer); const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true }; if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true };
const offen = await offenePflichtschritte(tx, laufId); const offen = await offenePflichtschritte(tx, laufId);
if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen)); if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen));
/*
* **Die Freigabe wird vor dem Abschluss geprueft, nicht danach** (#040):
* ein abgeschlossener Lauf ohne Freigabe waere genau der Zustand, den die
* Regel verhindern soll, und ihn wieder aufzubrechen verlangte
* `archivVerwalten`.
*/
if (!lauf.freigegebenVonId && (await freigabePflichtig(tx, laufId))) {
throw new FreigabeFehlt(
"Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen werden " +
"kann. Die Freigabeberechtigten sind benachrichtigt.",
);
}
const jetzt = new Date(); const jetzt = new Date();
const nachher = await tx.lauf.update({ const nachher = await tx.lauf.update({
where: { id: laufId }, where: { id: laufId },
@@ -851,6 +1043,46 @@ export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenut
}); });
return { lauf: nachher, warSchonFertig: false }; return { lauf: nachher, warSchonFertig: false };
}); });
}
/**
* Der Abschluss scheitert an der fehlenden Freigabe (#040).
*
* Ein eigener Typ, damit der Aufrufer **ausserhalb** der Transaktion erkennt,
* dass genau dieser Fall eingetreten ist: die Aufforderung an die
* Freigabeberechtigten darf nicht mit der Transaktion zurueckgerollt werden,
* und ein Zeichenkettenvergleich auf die Meldung waere eine Zusage ueber ihren
* Wortlaut.
*
* Erbt von `LaufFehler`, damit jeder bestehende Aufrufer ihn weiter faengt.
*/
export class FreigabeFehlt extends LaufFehler {
constructor(nachricht: string) {
super(nachricht);
this.name = "FreigabeFehlt";
}
}
export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> {
let abgeschlossen: { lauf: Lauf; warSchonFertig: boolean };
try {
abgeschlossen = await abschlussVersuchen(laufId, benutzer);
} catch (fehler) {
if (fehler instanceof FreigabeFehlt) {
/*
* **Hier entsteht die Aufforderung**, und zwar genau hier: der Techniker
* sagt mit dem Abschlussversuch „ich bin fertig“. Ein eigener Knopf
* „Freigabe anfordern“ waere ein Schritt mehr, den jemand vergisst — und
* ein Lauf, der auf etwas wartet, von dem niemand weiss, macht die ganze
* Regel wertlos.
*
* Der partielle Unique-Index `Zustellung_freigabe_einmal` sorgt dafuer,
* dass mehrere Versuche nicht mehrere Mails ergeben.
*/
await meldenOhneBruch(Meldeanlass.LAUF_FREIGABE_OFFEN, laufId, benutzer.id);
}
throw fehler;
}
/* /*
* Nur bei einem echten Abschluss melden. Ein zweiter Aufruf gibt den Lauf * Nur bei einem echten Abschluss melden. Ein zweiter Aufruf gibt den Lauf
+30 -2
View File
@@ -381,7 +381,19 @@ async function entwurfKopieren(
select: { kundeId: true }, select: { kundeId: true },
}); });
const entwurf = await tx.vorlagenVersion.create({ const entwurf = await tx.vorlagenVersion.create({
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF }, data: {
vorlageId,
version: naechste,
status: VersionStatus.ENTWURF,
/*
* **Die Freigabepflicht wandert mit** (#040). Ohne diese Zeile verlöre
* jedes „Bearbeiten“ sie — genau der Fehler, den #039 bei
* `nurFuerAbteilungen` und `bedingung` aufgedeckt hat, nur an einer
* anderen Spalte. Eine Vorlage, die eine Freigabe verlangt, soll sie
* nach einer Textkorrektur nicht verlieren.
*/
freigabePflicht: letzte.freigabePflicht,
},
}); });
if (letzte.variablen.length > 0) { if (letzte.variablen.length > 0) {
@@ -709,6 +721,14 @@ export async function versionVeroeffentlichen(
versionId: string, versionId: string,
aenderungsnotiz: string | null, aenderungsnotiz: string | null,
durchBenutzerId: string, durchBenutzerId: string,
/**
* Verlangt diese Version eine Freigabe (#040)?
*
* `undefined` laesst den Wert des Entwurfs stehen — so verhalten sich alle
* Aufrufer von vor #040 unveraendert, und der Wert, der beim Bearbeiten
* mitgewandert ist, bleibt erhalten.
*/
freigabePflicht?: boolean,
): Promise<VorlagenVersion> { ): Promise<VorlagenVersion> {
const notiz = aenderungsnotiz?.trim() || null; const notiz = aenderungsnotiz?.trim() || null;
@@ -749,6 +769,8 @@ export async function versionVeroeffentlichen(
aenderungsnotiz: notiz, aenderungsnotiz: notiz,
veroeffentlichtAm: new Date(), veroeffentlichtAm: new Date(),
veroeffentlichtVonId: durchBenutzerId, veroeffentlichtVonId: durchBenutzerId,
// `undefined` laesst den Wert des Entwurfs stehen (#040).
...(freigabePflicht === undefined ? {} : { freigabePflicht }),
}, },
}); });
if (getroffen.count === 0) { if (getroffen.count === 0) {
@@ -874,7 +896,13 @@ export async function vorlageDuplizieren(
}, },
}); });
const version = await tx.vorlagenVersion.create({ const version = await tx.vorlagenVersion.create({
data: { vorlageId: kopie.id, version: 1, status: VersionStatus.ENTWURF }, data: {
vorlageId: kopie.id,
version: 1,
status: VersionStatus.ENTWURF,
// Wandert mit, wie beim Entwurf (#040).
freigabePflicht: letzte.freigabePflicht,
},
}); });
if (letzte.variablen.length > 0) { if (letzte.variablen.length > 0) {
+15
View File
@@ -120,6 +120,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
* Wochenende ueberfaellig steht, 2880 Mails — und zwar ohne dass ein * Wochenende ueberfaellig steht, 2880 Mails — und zwar ohne dass ein
* einziger Test rot waere, denn jede davon ist fuer sich korrekt (#034). * einziger Test rot waere, denn jede davon ist fuer sich korrekt (#034).
*/ */
/*
* Die Freigabe-Aufforderung geht je Lauf und Empfaenger einmal hinaus
* (#040). Sie entsteht bei einem gescheiterten Abschlussversuch, und
* jemand versucht es mehrmals — ohne den Index eine Mail je Versuch, und
* jede davon fuer sich korrekt.
*/
"Zustellung_freigabe_einmal",
"Zustellung_ueberfaellig_einmal", "Zustellung_ueberfaellig_einmal",
]); ]);
}); });
@@ -149,6 +156,14 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Lauf_abgeschlossen_mit_zeitpunkt", "Lauf_abgeschlossen_mit_zeitpunkt",
"Lauf_archiviert_mit_zeitpunkt", "Lauf_archiviert_mit_zeitpunkt",
"Lauf_begonnen_mit_zeitpunkt", "Lauf_begonnen_mit_zeitpunkt",
/*
* Die Vier-Augen-Regel in der Datenbank (#040): wer den Lauf angelegt hat,
* gibt ihn nicht frei. Der Dienst prueft strenger — er sieht auch nach, wer
* an den Schrittergebnissen gearbeitet hat —, aber diese Haelfte gilt auch
* dann, wenn jemand an der Dienstschicht vorbei schreibt.
*/
"Lauf_freigabe_mit_zeitpunkt",
"Lauf_freigabe_nicht_selbst",
"Lauf_mitarbeiter_eindeutig", "Lauf_mitarbeiter_eindeutig",
"Mitarbeiter_vorgesetzter_nicht_selbst", "Mitarbeiter_vorgesetzter_nicht_selbst",
"Schrittergebnis_begruendung_noetig", "Schrittergebnis_begruendung_noetig",
+384
View File
@@ -0,0 +1,384 @@
import { beforeEach, describe, expect, it } from "vitest";
import { LaufStatus, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
import {
LaufFehler,
laufAbschliessen,
laufAnlegen,
laufFreigeben,
laufZurueckweisen,
} from "@/server/services/laeufe";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Vier-Augen-Freigabe (#040).
*
* **Eine Zusage trägt diesen Meilenstein:** wer an einem Lauf gearbeitet hat,
* gibt ihn nicht frei. Alles andere — das Recht, die Pflicht an der Version, die
* Zurückweisung — hängt daran.
*
* Geprüft wird sie von drei Seiten, weil sie auf drei Wegen umgangen werden
* könnte: über das Anlegen, über ein Schrittergebnis und über den Abschluss. Und
* ein CHECK in der Datenbank hält die wichtigste Hälfte selbst dann, wenn jemand
* an der Dienstschicht vorbei schreibt.
*/
async function aufbau(freigabePflicht: boolean) {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
});
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "Restore-Test" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF, freigabePflicht },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Prüfung", position: 0 },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Sicherung zurückspielen", pflicht: true, position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
kunde,
versionId: version.id,
schrittId: schritt.id,
techniker: { id: techniker.id, rolle: techniker.rolle },
redakteur: { id: redakteur.id, rolle: redakteur.rolle },
admin: { id: admin.id, rolle: admin.rolle },
};
}
/** Ein Lauf, den der Techniker angelegt und abgearbeitet hat. */
async function abgearbeitet(freigabePflicht: boolean) {
const stand = await aufbau(freigabePflicht);
const lauf = await laufAnlegen({ vorlagenVersionId: stand.versionId }, stand.techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: stand.schrittId, zustand: SchrittZustand.ERLEDIGT },
stand.techniker,
);
return { ...stand, laufId: lauf.id };
}
describe.runIf(datenbankVerfuegbar)("Ein Lauf mit Freigabepflicht", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("lässt sich nicht abschließen, solange keine Freigabe steht", async () => {
const { laufId, techniker } = await abgearbeitet(true);
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(/Freigabe durch einen Zweiten/u);
});
it("lässt sich nach der Freigabe abschließen", async () => {
const { laufId, techniker, redakteur } = await abgearbeitet(true);
await laufFreigeben(laufId, redakteur);
const fertig = await laufAbschliessen(laufId, techniker);
expect(fertig.status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("hält die Freigabe mit Freigeber und Zeitpunkt fest", async () => {
const { laufId, redakteur } = await abgearbeitet(true);
const nachher = await laufFreigeben(laufId, redakteur, "Sichtprüfung am 24.10. durch Red");
expect(nachher.freigegebenVonId).toBe(redakteur.id);
expect(nachher.freigegebenAm).toBeInstanceOf(Date);
expect(nachher.freigabeNotiz).toBe("Sichtprüfung am 24.10. durch Red");
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId, aktion: "Lauf freigegeben" },
});
expect(eintrag.benutzerId).toBe(redakteur.id);
});
});
describe.runIf(datenbankVerfuegbar)("Wer gearbeitet hat, gibt nicht frei", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("weist den Techniker ab, der den Lauf angelegt und abgearbeitet hat", async () => {
// Er hätte das Recht ohnehin nicht — der nächste Test zeigt den Fall mit Recht.
const { laufId, techniker } = await abgearbeitet(true);
await expect(laufFreigeben(laufId, techniker)).rejects.toThrow(/Recht "freigeben"/u);
});
it("weist auch einen ADMIN ab, der den Lauf angelegt hat", async () => {
/*
* **Der wichtigste Fall.** Das Recht allein genügt nicht; sonst wäre die
* Vier-Augen-Regel für jeden Administrator eine Formsache. Ein Einzelkämpfer
* kommt damit nicht weiter — das ist eine bewusste Härte, und die Anleitung
* sagt es.
*/
const { versionId, admin } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await expect(laufFreigeben(lauf.id, admin)).rejects.toThrow(/an diesem Lauf gearbeitet/u);
});
it("weist ab, wer nur ein Schrittergebnis gesetzt hat", async () => {
/*
* Angelegt hat ihn der Techniker, abgearbeitet der Redakteur. Der Redakteur
* hat das Recht — aber er hat gearbeitet, also gibt er nicht frei.
*/
const { versionId, schrittId, techniker, redakteur } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
redakteur,
);
await expect(laufFreigeben(lauf.id, redakteur)).rejects.toThrow(/an diesem Lauf gearbeitet/u);
});
it("lässt den frei, der nur zugewiesen war", async () => {
/*
* Die Gegenrichtung, und sie ist Absicht: eine **Zuweisung** ist noch keine
* Arbeit. Wer zugewiesen wurde und nichts getan hat, soll freigeben können,
* wenn jemand anderes abgearbeitet hat — sonst wäre die Regel strenger als
* gemeint und blockierte kleine Teams unnötig.
*/
const { versionId, schrittId, techniker, redakteur } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, zugewiesenAnId: redakteur.id }, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
techniker,
);
await expect(laufFreigeben(lauf.id, redakteur)).resolves.toBeDefined();
});
it("weist ab, wer ihn abgeschlossen hat", async () => {
// Ohne Freigabepflicht lässt sich abschließen; danach ist der Abschließende
// Bearbeiter und gibt nicht frei.
const { versionId, schrittId, techniker, redakteur } = await aufbau(false);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
techniker,
);
await laufAbschliessen(lauf.id, redakteur);
await expect(laufFreigeben(lauf.id, redakteur)).rejects.toThrow(/an diesem Lauf gearbeitet/u);
});
it("wird auch von der Datenbank abgewiesen, nicht nur vom Dienst", async () => {
/*
* An der Dienstschicht vorbei: der CHECK `Lauf_freigabe_nicht_selbst` hält
* die wichtigste Hälfte der Regel — der Ersteller gibt nicht frei (CLAUDE.md,
* „Regeln stehen in der Datenbank“).
*/
const { versionId, admin } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await expect(
testPrisma.lauf.update({
where: { id: lauf.id },
data: { freigegebenVonId: admin.id, freigegebenAm: new Date() },
}),
).rejects.toThrow(/Lauf_freigabe_nicht_selbst/u);
});
it("verlangt Freigeber und Zeitpunkt zusammen", async () => {
const { versionId, techniker, redakteur } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
await expect(
testPrisma.lauf.update({ where: { id: lauf.id }, data: { freigegebenVonId: redakteur.id } }),
).rejects.toThrow(/Lauf_freigabe_mit_zeitpunkt/u);
await expect(
testPrisma.lauf.update({ where: { id: lauf.id }, data: { freigegebenAm: new Date() } }),
).rejects.toThrow(/Lauf_freigabe_mit_zeitpunkt/u);
});
});
describe.runIf(datenbankVerfuegbar)("Zurückweisen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("verlangt eine Begründung", async () => {
// Dieselbe Regel wie bei NICHT_ZUTREFFEND und FEHLGESCHLAGEN: wer etwas
// zurückgibt, sagt warum.
const { laufId, redakteur } = await abgearbeitet(true);
await expect(laufZurueckweisen(laufId, redakteur, " ")).rejects.toThrow(/Begründung/u);
});
it("setzt den Lauf zurück auf IN_ARBEIT und hält die Notiz fest", async () => {
const { laufId, redakteur } = await abgearbeitet(true);
const nachher = await laufZurueckweisen(laufId, redakteur, "Prüfsumme fehlt im Protokoll");
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
expect(nachher.freigabeNotiz).toBe("Prüfsumme fehlt im Protokoll");
expect(nachher.freigegebenVonId).toBeNull();
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId, aktion: "Lauf zurückgewiesen" },
});
expect(JSON.stringify(eintrag.nachher)).toContain("Prüfsumme fehlt");
});
it("nimmt eine erteilte Freigabe zurück", async () => {
const { laufId, redakteur, admin } = await abgearbeitet(true);
await laufFreigeben(laufId, redakteur);
await laufZurueckweisen(laufId, admin, "Doch nicht in Ordnung");
const zeile = await testPrisma.lauf.findUniqueOrThrow({ where: { id: laufId } });
expect(zeile.freigegebenVonId).toBeNull();
expect(zeile.freigegebenAm).toBeNull();
// Und der Abschluss wartet wieder.
await expect(laufAbschliessen(laufId, redakteur)).rejects.toThrow(/Freigabe/u);
});
it("holt einen abgeschlossenen Lauf zurück in die Arbeit", async () => {
const { laufId, techniker, redakteur, admin } = await abgearbeitet(true);
await laufFreigeben(laufId, redakteur);
await laufAbschliessen(laufId, techniker);
const nachher = await laufZurueckweisen(laufId, admin, "Nacharbeit nötig");
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
expect(nachher.abgeschlossenAm).toBeNull();
expect(nachher.abgeschlossenVonId).toBeNull();
});
it("verlangt das Recht freigeben", async () => {
const { laufId, techniker } = await abgearbeitet(true);
await expect(laufZurueckweisen(laufId, techniker, "Grund")).rejects.toThrow(/Recht "freigeben"/u);
});
});
describe.runIf(datenbankVerfuegbar)("Die Aufforderung an die Freigabeberechtigten", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("geht beim gescheiterten Abschlussversuch hinaus – an die mit dem Recht", async () => {
/*
* **Der Fall, der die ganze Regel wertlos machen würde:** ein Lauf wartet auf
* eine Freigabe, und niemand weiß es. Die Aufforderung entsteht deshalb genau
* dort, wo der Bearbeiter „ich bin fertig“ sagt — beim Abschlussversuch.
*
* Empfänger ist **nicht** der Zuständige, sondern jeder mit dem Recht
* `freigeben`. Das ist der einzige Anlass mit diesem Kreis.
*/
const { laufId, techniker, redakteur, admin } = await abgearbeitet(true);
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
const zustellungen = await testPrisma.zustellung.findMany({
where: { laufId, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" },
select: { empfaenger: true },
});
const empfaenger = zustellungen.map((zeile) => zeile.empfaenger).sort();
const redZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: redakteur.id } });
const adminZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
const tinaZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: techniker.id } });
expect(empfaenger).toEqual([adminZeile.email, redZeile.email].sort());
// Der Techniker hat kein `freigeben` – ihn aufzufordern wäre eine
// Aufforderung zu etwas, das der Dienst abweist.
expect(empfaenger).not.toContain(tinaZeile.email);
});
it("lässt den aus, der an dem Lauf gearbeitet hat", async () => {
// Der Redakteur hat das Recht, aber er hat abgearbeitet: er darf nicht
// freigeben, also bekommt er auch keine Aufforderung.
const { versionId, schrittId, techniker, redakteur, admin } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
redakteur,
);
await expect(laufAbschliessen(lauf.id, techniker)).rejects.toThrow(LaufFehler);
const zustellungen = await testPrisma.zustellung.findMany({
where: { laufId: lauf.id, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" },
select: { empfaenger: true },
});
const adminZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
expect(zustellungen.map((z) => z.empfaenger)).toEqual([adminZeile.email]);
});
it("geht bei mehreren Versuchen nur einmal hinaus", async () => {
/*
* Ein Techniker versucht es mehrmals — und ohne den partiellen Unique-Index
* bekäme jeder Berechtigte bei jedem Versuch eine Mail, jede für sich
* korrekt. Derselbe Fall wie „überfällig“ in #034, nur schneller erreicht.
*/
const { laufId, techniker } = await abgearbeitet(true);
for (let versuch = 0; versuch < 3; versuch += 1) {
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
}
const anzahl = await testPrisma.zustellung.count({
where: { laufId, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" },
});
// Zwei Berechtigte, drei Versuche – zwei Zeilen.
expect(anzahl).toBe(2);
});
it("nennt im Rumpf die Regel und keinen Schrittinhalt", async () => {
// Der Empfänger ist meist nicht der Bearbeiter und soll nicht raten müssen,
// warum er gefragt wird. Schrittinhalte gehören auch hier nicht hinein (#034).
const { laufId, techniker } = await abgearbeitet(true);
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
const zeile = await testPrisma.zustellung.findFirstOrThrow({
where: { laufId, anlass: "LAUF_FREIGABE_OFFEN" },
});
expect(zeile.betreff).toContain("wartet auf Freigabe");
expect(zeile.rumpf).toMatch(/nicht daran gearbeitet/u);
expect(zeile.rumpf).not.toContain("Sicherung zurückspielen");
});
});
describe.runIf(datenbankVerfuegbar)("Ein Lauf ohne Freigabepflicht", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("verhält sich unverändert", async () => {
/*
* **Die Zusage an alles Bestehende.** Jede Vorlage und jeder Lauf von vor
* diesem Meilenstein hat `freigabePflicht = false`; nichts daran darf sich
* ändern, sonst wäre es ein kaputtes Update.
*/
const { laufId, techniker } = await abgearbeitet(false);
const fertig = await laufAbschliessen(laufId, techniker);
expect(fertig.status).toBe(LaufStatus.ABGESCHLOSSEN);
expect(fertig.freigegebenVonId).toBeNull();
});
it("lässt sich trotzdem freigeben – nach denselben Regeln", async () => {
/*
* Eine Freigabe an einem Lauf, der keine braucht, ist kein Fehler. Sie folgt
* aber denselben Regeln: sonst wäre sie ein Weg, einen Freigeber in die Akte
* zu schreiben, der dort nicht hingehört.
*/
const { laufId, techniker, redakteur } = await abgearbeitet(false);
await expect(laufFreigeben(laufId, redakteur)).resolves.toBeDefined();
await expect(laufFreigeben(laufId, techniker)).rejects.toThrow(/Recht "freigeben"/u);
});
it("lässt sich nicht zweimal freigeben", async () => {
const { laufId, redakteur, admin } = await abgearbeitet(false);
await laufFreigeben(laufId, redakteur);
await expect(laufFreigeben(laufId, admin)).rejects.toThrow(/bereits freigegeben/u);
});
});
+9 -3
View File
@@ -23,6 +23,11 @@ const MATRIX: Record<Recht, Record<Rolle, boolean>> = {
MITARBEITER: false, MITARBEITER: false,
}, },
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false }, vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
/*
* Die Vier-Augen-Freigabe (#040) liegt bewusst **nicht** beim TECHNIKER: sonst
* haengt sie daran, dass zufaellig ein zweiter greifbar ist.
*/
freigeben: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false }, archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false }, verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
}; };
@@ -34,11 +39,11 @@ describe("Rechtematrix", () => {
it("prueft jede Rolle gegen jedes Recht", () => { it("prueft jede Rolle gegen jedes Recht", () => {
// Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen, // Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen,
// acht Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle // neun Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle
// oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035). // oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035).
expect(ALLE_ROLLEN).toHaveLength(5); expect(ALLE_ROLLEN).toHaveLength(5);
expect(ALLE_RECHTE).toHaveLength(8); expect(ALLE_RECHTE).toHaveLength(9);
expect(faelle).toHaveLength(40); expect(faelle).toHaveLength(45);
}); });
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => { it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {
@@ -58,6 +63,7 @@ describe("Rollen im Einzelnen", () => {
expect(darf("TECHNIKER", "laeufeBearbeiten")).toBe(true); expect(darf("TECHNIKER", "laeufeBearbeiten")).toBe(true);
expect(darf("TECHNIKER", "geraeteBearbeiten")).toBe(true); expect(darf("TECHNIKER", "geraeteBearbeiten")).toBe(true);
expect(darf("TECHNIKER", "vorlagenBearbeiten")).toBe(false); expect(darf("TECHNIKER", "vorlagenBearbeiten")).toBe(false);
expect(darf("TECHNIKER", "freigeben")).toBe(false);
expect(darf("TECHNIKER", "verwaltung")).toBe(false); expect(darf("TECHNIKER", "verwaltung")).toBe(false);
expect(darf("TECHNIKER", "archivVerwalten")).toBe(false); expect(darf("TECHNIKER", "archivVerwalten")).toBe(false);
}); });