feat(laeufe): Vier-Augen-Freigabe (#040)
Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat. Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn. Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie `laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt zusaetzlich ein CHECK in der Datenbank. Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert haette jeden Codepfad betroffen, der heute vier Faelle kennt. Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt, und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller Unique-Index haelt sie bei mehreren Versuchen auf einmal. Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus #039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte. 1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action trifft den Router-Cache von Next und zeigt kurz den alten Stand. Closes #40 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a2330ef682
commit
d30bb6d235
@@ -9,6 +9,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
|
||||||
|
|||||||
@@ -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()`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.` };
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user