3 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 d30bb6d235 feat(laeufe): Vier-Augen-Freigabe (#040)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst
abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und
beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht
TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat.

Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine
Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine
Teams ohne Gewinn.

Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze
Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt
zusaetzlich ein CHECK in der Datenbank.

Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt
eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert
haette jeden Codepfad betroffen, der heute vier Faelle kennt.

Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die
Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder
anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht
ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt,
und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller
Unique-Index haelt sie bei mehreren Versuchen auf einmal.

Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte
Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim
Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus
#039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte.

1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten
Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen
Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action
trifft den Router-Cache von Next und zeigt kurz den alten Stand.

Closes #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-24 01:14:04 +02:00
Florian WildeandClaude Opus 5 a2330ef682 feat(vorlagen): bedingte Abschnitte und Schritte (#039)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
Ein Abschnitt oder ein einzelner Schritt erscheint nur, wenn seine Bedingung
zutrifft — abhaengig von einer Variable des Laufs oder vom Zustand eines
anderen Schritts. Der echte Fall steht im Seed: "Mitarbeiter-Onboarding" traegt
beide Konto-Abschnitte, fuenf AD-Schritte und vier fuer Entra ID, und
`konto_typ` sagt beim Start, welcher gilt. Bisher klickte ein Techniker die
anderen neun bei jedem Onboarding weg.

Entscheidungen, alle im Issue begruendet:

- Laufend ausgewertet, nicht beim Start festgeschrieben. Fuer die
  Abteilungsabschnitte (#031) ist Festschreiben richtig; fuer Bedingungen nicht
  — die Antwort auf "ist Pruefen fehlgeschlagen?" entsteht erst waehrend der
  Arbeit. Ein struktureller Test prueft, dass alle drei Schichten
  (Abschlussregel, Abarbeitung, Druck) die Auswertung rufen.
- Nicht entscheidbar heisst: gilt. Ein Lauf ohne konto_typ zeigt beide
  Abschnitte — genau wie vorher. Ein Abschnitt zu viel ist sichtbar und
  korrigierbar, ein fehlender faellt niemandem auf.
- OFFEN ist eine Antwort, keine Luecke: waere es unentscheidbar, waere ein
  bedingter Abschnitt von Anfang an sichtbar.
- Eine Bedingung, kein Ausdrucksbaum. Kein und, kein oder.
- Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen: vier Wege schreiben
  eine Struktur neu und geben neue Kennungen.

Zwei stille Datenverluste gefunden, der zweite aelter als dieser Meilenstein:

- Der Import verwarf `nurFuerAbteilungen`. Der Export schreibt es seit #031, das
  Schema nimmt es an, der Import setzte es nicht — eine exportierte Vorlage
  verlor ihre Abteilungsabschnitte und galt dort fuer alle.
- Die tiefe Kopie verwarf es ebenfalls, womit jedes "Bearbeiten" einer Vorlage
  alle Einschraenkungen verlor. Ursache war ein von Hand getippter Zwischentyp,
  der weniger Felder nannte als die Abfrage liefert; jetzt aus Prisma
  abgeleitet, beide Kopierwege teilen eine Stelle, und ein Test vergleicht Kopie
  und Original Feld fuer Feld.

Gefunden hat beides die Klickprobe am gebauten Stand, nicht die Suite: im Editor
stand "Bedingung gesetzt", nach "Bearbeiten" war sie weg. Dreizehn Gegenproben
liefen, alle wurden rot — und der teuerste Befund lag ausserhalb von allen.

1499 Vitest-Tests (vorher 1457), 115 E2E-Ablaeufe gruen.

Closes #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 14:54:10 +02:00
Florian WildeandClaude Opus 5 2f247b3617 docs(issues): Phase 3 schneiden, und Verweise zeigen wieder richtig
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Vier Meilensteine fuer v0.3.0, 34 h geschaetzt: bedingte Schritte und
Abschnitte (M23), Vier-Augen-Freigabe (M24), PDF-Uebergabeprotokoll (M25),
weitere Schritt-Typen (M26). Die Reihenfolge ist begruendet: M23 zuerst, weil
es als einziges am Datenmodell und an der Abschlussregel arbeitet; M26 zuletzt,
weil UNTERSCHRIFT erst im PDF einen Zweck hat.

Jedes Issue nennt die Entscheidungen, die zu treffen sind, mit Empfehlung und
Begruendung. Bei M23 ist die entscheidende: eine Bedingung auf einer Variable
liesse sich beim Start festschreiben wie bei #031, eine auf einem
Schrittergebnis nicht — die Antwort entsteht erst waehrend der Arbeit.

Beim Nummernvergeben einen Fund: vier Codestellen, sechs Changelog-Zeilen und
der Doku-Pruefer nannten dreistellig die Tracker-Nummern 39 und 40 — gemeint
waren die Issue-Dateien 025 und 026. Solange es diese Dateien nicht gab, fiel es
niemandem auf; mit Phase 3 waere es eine falsche Spur geworden, und zwar in
Kommentaren, die eine Codestelle begruenden.

Behoben, und pruefe:doku prueft es ab jetzt: jeder dreistellige Verweis muss auf
eine Issue-Datei zeigen. Gegenprobe mit einer erfundenen Nummer schlaegt an.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 15:36:27 +02:00
52 changed files with 4741 additions and 151 deletions
+45 -6
View File
@@ -7,6 +7,45 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### 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,
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
Konto-Abschnitte, fünf AD-Schritte und vier für Entra ID, und `Kontotyp` sagt beim Start, welcher
gilt. Bisher klickte ein Techniker die anderen neun bei jedem Onboarding weg.
**Lässt sich eine Bedingung nicht entscheiden, erscheint der Teil** — hat niemand den Kontotyp
angegeben, stehen beide Abschnitte da, genau wie vorher. Ein ausgeblendeter Pflichtschritt hält
den Abschluss nicht auf, ausgeblendet heißt nicht gelöscht (eine Notiz bleibt stehen), und die
Nummerierung zählt nur, was man sieht. Ausgewertet wird **laufend**: ein Abschnitt „Nacharbeit“,
der erst erscheint, wenn der Restore-Test fehlschlägt, kommt bei einer offenen zweiten Sitzung
sofort dazu. Es gibt bewusst **kein** „und“ und kein „oder“ (#039)
### Behoben
- **Der Import einer Vorlage verwarf die Abteilungseinschränkung der Abschnitte.** Der Export
schreibt sie seit #031, das Schema nimmt sie an — der Import setzte sie nicht. Eine Vorlage, die
jemand in eine zweite Installation brachte, verlor ihre Abteilungsabschnitte: sie galten dort für
**alle**, also mehr Schritte statt weniger. Niemand hätte es gemerkt (#039)
- **„Bearbeiten“ einer Vorlage verlor ebenfalls alle Abteilungseinschränkungen.** Die tiefe Kopie,
aus der der Entwurf entsteht, nahm sie nicht mit — und `Vorlage duplizieren` genauso wenig, weil
beide die Kopie einzeln ausgeschrieben hatten. Ursache war ein von Hand getippter Zwischentyp, der
weniger Felder nannte als die Abfrage liefert; er ist jetzt aus Prisma abgeleitet, beide Wege
teilen eine Stelle, und ein Test vergleicht Kopie und Original Feld für Feld (#039)
- Die Auswahlfelder der neuen Bedingung trugen ihren Namen über ein umhüllendes `label`, wodurch
der zugängliche Name den Text der gewählten Option mitschluckte — für einen Screenreader eine
Bezeichnung, die sich beim Umschalten ändert (#039)
## [0.2.0] – 2026-10-11 ## [0.2.0] – 2026-10-11
### Geändert ### Geändert
@@ -44,15 +83,15 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne - Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne
Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und
Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die
Begründung geht ins Protokoll des Prozesses (#039) Begründung geht ins Protokoll des Prozesses (#025)
- Die Konvention „nie auf rohe Hexwerte“ nennt ihre sechs begründeten Ausnahmen beim Namen — - Die Konvention „nie auf rohe Hexwerte“ nennt ihre sechs begründeten Ausnahmen beim Namen —
Druckansichten, QR-Code und `themeColor` entscheiden nicht nach unserem Farbschema. Die Regel war Druckansichten, QR-Code und `themeColor` entscheiden nicht nach unserem Farbschema. Die Regel war
strenger formuliert als gemeint (#040) strenger formuliert als gemeint (#026)
- `CLAUDE.md` und `README.md` nennen den LDAP-Testcontainer als Teil des Tors vor dem Commit. Ohne - `CLAUDE.md` und `README.md` nennen den LDAP-Testcontainer als Teil des Tors vor dem Commit. Ohne
ihn überspringt die Suite **22 Tests** — den ganzen Verzeichnis-Abgleich —, sagt das auf stderr, ihn überspringt die Suite **22 Tests** — den ganzen Verzeichnis-Abgleich —, sagt das auf stderr,
und der Lauf sieht trotzdem grün aus (#040) und der Lauf sieht trotzdem grün aus (#026)
- Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010 - Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010
verlangt (#040) verlangt (#026)
### Behoben ### Behoben
@@ -235,9 +274,9 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
eingespielte Kopie bekommt **keinen** Schlüssel: sie soll nicht den Platz des Originals einnehmen eingespielte Kopie bekommt **keinen** Schlüssel: sie soll nicht den Platz des Originals einnehmen
(#021) (#021)
- `pnpm pruefe:doku` vergleicht die Schranken-Befehle in `CLAUDE.md` und `README.md` und verlangt von - `pnpm pruefe:doku` vergleicht die Schranken-Befehle in `CLAUDE.md` und `README.md` und verlangt von
beiden den LDAP-Container. Genau diese Abweichung war unbemerkt entstanden (#040) beiden den LDAP-Container. Genau diese Abweichung war unbemerkt entstanden (#026)
- Fünf Tests für den Healthcheck, darunter einer, der belegt, dass die Begründung ins Protokoll - Fünf Tests für den Healthcheck, darunter einer, der belegt, dass die Begründung ins Protokoll
geschrieben und nicht bloß verschluckt wird (#039) geschrieben und nicht bloß verschluckt wird (#025)
## [0.1.0] – 2026-09-26 ## [0.1.0] – 2026-09-26
+19 -2
View File
@@ -49,7 +49,7 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` · `VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
`QuellenArt {LDAP GRAPH SCIM}` · `QuellenArt {LDAP GRAPH SCIM}` ·
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` · `Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`. `Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN LAUF_FREIGABE_OFFEN ABGLEICH_FEHLGESCHLAGEN}`.
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias. `SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
@@ -77,6 +77,13 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`. Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`.
- **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind. - **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind.
`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz. `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.
- **Vier-Augen-Freigabe:** Verlangt eine `VorlagenVersion` sie (`freigabePflicht`), lässt sich ein
Lauf erst nach der Freigabe abschließen (#040). **Wer daran gearbeitet hat, gibt nicht frei** —
angelegt, einen Schritt gesetzt oder abgeschlossen; eine Zuweisung allein zählt nicht. Das prüft
`hatDaranGearbeitet` an genau einer Stelle, wie `laufMussVeraenderbarSein`, und ein CHECK hält die
wichtigste Hälfte (der Ersteller) auch gegen Schreiben an der Dienstschicht vorbei. Das Recht
`freigeben` hat **nicht** `TECHNIKER`: sonst hängt die Regel daran, dass zufällig ein zweiter
greifbar ist. Die Pflicht wandert bei „Bearbeiten“ und „Duplizieren“ in die Kopie mit.
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus - **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`. `INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`. - **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`.
@@ -105,6 +112,16 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
gedruckte Aufkleber wertlos. gedruckte Aufkleber wertlos.
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in - **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht. `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
- **Bedingte Teile:** Eine Bedingung an `VorlagenAbschnitt` oder `VorlagenSchritt` sagt, wann der
Teil gilt (#039). **Nicht entscheidbar heißt: gilt** — ein Abschnitt zu viel ist sichtbar und
korrigierbar, ein fehlender fällt niemandem auf. Eine Bedingung, kein Ausdrucksbaum: kein `und`,
kein `oder`. Die Auswertung sitzt in `src/lib/bedingungen.ts` und wird von **drei** Schichten
gerufen (Abschlussregel, Abarbeitung, Druck); `geltendeStruktur` legt die Reihenfolge der zwei
Filter fest — erst die beim Start festgeschriebene Abteilungsauswahl aus #031, dann die laufende
Auswertung. `tests/bedingte-laeufe.test.ts` prüft strukturell, dass keine Schicht sie vergisst.
**Verwiesen wird über Titel-Pfade, nicht über Kennungen.** Austausch, Import, Seed und
`entwurfSpeichern` schreiben eine Struktur neu und geben neue Kennungen; ein Titel-Pfad überlebt
das. `bedingungAufloesen` ist für alle vier dieselbe Stelle.
- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt - **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf `Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
@@ -257,7 +274,7 @@ geprüft. In CI bricht sie stattdessen ab, absichtlich.
| `mailpit` | **3 Tests** (nachgezählt: 36 statt 33) | der echte SMTP-Versand in `tests/benachrichtigungen.test.ts` — die vier Anlässe, die Passwort-Prüfung über alle Rumpftexte und „nichts zweimal“ | | `mailpit` | **3 Tests** (nachgezählt: 36 statt 33) | der echte SMTP-Versand in `tests/benachrichtigungen.test.ts` — die vier Anlässe, die Passwort-Prüfung über alle Rumpftexte und „nichts zweimal“ |
`ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**, `ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**,
nicht geschätzt — in #040 hätte beinahe eine geratene in dieser Datei gestanden. nicht geschätzt — in #026 hätte beinahe eine geratene in dieser Datei gestanden.
`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert `format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
+52
View File
@@ -242,6 +242,58 @@ Knopf gesperrt und darunter steht, was fehlt.
Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der
Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie. Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie.
## Vier-Augen-Freigabe
Manche Arbeiten soll nicht derselbe abschließen, der sie gemacht hat: ein Firewall-Rollout, ein
Restore-Test, das Löschen eines Kontos. Verlangt die Vorlage eine Freigabe, steht das oben am Lauf —
und **Lauf abschließen** wartet, bis jemand sie erteilt hat.
### Wer freigeben darf
Es braucht zweierlei, und beides zusammen:
1. das Recht **freigeben** — das haben `ADMIN` und `REDAKTEUR`, **nicht** `TECHNIKER`;
2. und man darf **nicht an dem Lauf gearbeitet** haben.
Als „gearbeitet“ zählt: ihn angelegt, einen Schritt gesetzt oder ihn abgeschlossen zu haben. Nur
**zugewiesen** zu sein zählt nicht — wer zugewiesen wurde und nichts getan hat, darf freigeben.
**Auch ein Administrator kann sich nicht selbst freigeben.** Das ist der ganze Sinn, und es ist eine
bewusste Härte: wer allein im Haus ist, kommt damit nicht weiter und braucht einen Zweiten mit dem
Recht. Dieselbe Regel hält auch die Datenbank fest, nicht nur die Anwendung.
### Freigeben oder zurückweisen
Oben am Lauf steht eine Karte mit einem Feld für die **Begründung** und zwei Knöpfen:
- **Freigeben** — die Begründung ist freiwillig. Danach lässt sich der Lauf abschließen.
- **Zurückweisen** — die Begründung ist **Pflicht**. Der Lauf geht zurück auf *in Arbeit*, ein
bereits erteilter Abschluss wird aufgehoben, und der Bearbeiter sieht die Begründung an derselben
Stelle. Ohne Begründung steht er vor derselben Frage wie vorher.
Eine Zurückweisung nimmt auch eine **erteilte** Freigabe zurück. Damit lässt sich ein Lauf, der
schon abgeschlossen war, in die Arbeit zurückholen — ohne `archivVerwalten`, solange er nicht
archiviert ist.
### Wer erfährt davon
Sobald jemand den Lauf abzuschließen versucht und die Freigabe fehlt, bekommen die
**Freigabeberechtigten** eine Mail — nicht der Zuständige, wie bei allen anderen Meldungen. Wer an
dem Lauf gearbeitet hat, bekommt sie nicht: er dürfte ohnehin nicht freigeben.
Das geht **einmal** hinaus, auch wenn jemand es mehrmals versucht. Nach einer Zurückweisung wird
nicht erneut gemahnt — der Zurückweisende weiß, dass er wartet.
### Einschalten
Das Häkchen steht im Editor beim **Veröffentlichen**: „Läufe dieser Version brauchen eine Freigabe
durch einen Zweiten“. Es gehört zur **Version**, nicht zur Vorlage — ein laufender Lauf kann seine
Regel also nicht unter den Füßen verlieren, und in der Versionsliste steht an jeder Fassung, ob sie
eine Freigabe verlangt.
Beim Bearbeiten wandert das Häkchen in den neuen Entwurf mit. Eine Vorlage, die eine Freigabe
verlangt, verliert sie also nicht durch eine Textkorrektur.
## Archivieren — das ist endgültig ## Archivieren — das ist endgültig
**archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein **archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein
+67
View File
@@ -219,6 +219,73 @@ Import: ein Abteilungsname bedeutet in einer anderen Installation dasselbe.
**Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts **Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts
abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist. abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist.
## Bedingte Abschnitte und Schritte
Die Onboarding-Vorlage enthält **beide** Konto-Abschnitte — „Konto anlegen (Active Directory)“ mit
fünf Schritten und „Konto anlegen (Entra ID)“ mit vier. In jedem einzelnen Lauf gilt nur einer, und
die Variable **Kontotyp** sagt beim Start, welcher. Bis hierher klickte ein Techniker die anderen
neun Schritte weg.
Mit einer **Bedingung** erscheint ein Abschnitt oder ein einzelner Schritt nur dann, wenn er
zutrifft. Im Editor steht an jedem Abschnitt und an jedem Schritt **Bedingung (optional)**; ohne
eine gilt er immer.
### Zwei Arten, und die Wahl entscheidet, wann die Antwort entsteht
| Die Bedingung hängt an … | Beispiel | Wann sie entschieden wird |
|---|---|---|
| einer **Variable** | „gilt, wenn Kontotyp gleich Active Directory ist“ | beim Start, wenn die Variablen erfasst werden |
| einem **Schritt** | „gilt, wenn Prüfen fehlgeschlagen ist“ | während der Arbeit, sobald jemand den Schritt setzt |
Die zweite ist die interessantere: ein Abschnitt „Nacharbeit“, der erst erscheint, wenn der
Restore-Test fehlgeschlagen ist. Er ist zu Beginn nicht da und kommt in dem Moment dazu, in dem der
Schritt auf „fehlgeschlagen“ geht — bei einer offenen zweiten Sitzung sofort.
### Eine Bedingung, nicht mehr
Es gibt **kein** „und“ und kein „oder“. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte oder
zwei Vorlagen. Das ist Absicht: ein Formular, in dem sich beliebige Ausdrücke bauen lassen, wäre
eine kleine Programmiersprache — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
schlimmer als einer zu viel.
Bei einer Schritt-Bedingung lassen sich dagegen **mehrere Zustände** ankreuzen: „erledigt oder nicht
zutreffend“ ist eine Frage, nicht zwei.
### Vier Dinge, die man dazu wissen muss
- **Lässt sich eine Bedingung nicht entscheiden, erscheint der Teil.** Hat niemand den Kontotyp
angegeben, stehen **beide** Konto-Abschnitte da — genau wie vorher. Auszublenden hieße, eine
Antwort zu erfinden, die niemand gegeben hat. Ein Abschnitt zu viel ist sichtbar und
korrigierbar; ein fehlender fällt niemandem auf.
- **Ein ausgeblendeter Pflichtschritt blockiert den Abschluss nicht** — dieselbe Regel wie bei den
Abteilungsabschnitten. Er steht nicht in der Abarbeitung, also lässt er sich nicht abhaken.
- **Ausgeblendet heißt nicht gelöscht.** Hat jemand in einem Schritt eine Notiz hinterlassen und
kippt dessen Bedingung danach, verschwindet der Schritt aus der Ansicht — die Notiz bleibt. Kippt
die Bedingung zurück, ist alles wieder da.
- **Die Nummerierung zählt, was man sieht.** Ein ausgeblendeter Abschnitt hinterlässt keine Lücke;
aus `01 · 02 · 03` wird nicht `01 · 03`.
### Die Warnung „dieser Schritt kommt später“
Zeigt eine Bedingung auf einen Schritt, der **nach** dem bedingten Teil steht, warnt der Editor.
Verboten ist es nicht, aber fast immer ein Versehen: der Abschnitt wäre beim Abarbeiten zunächst
unsichtbar und erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also rückwärts.
Gemeint ist meist ein Schritt davor.
### Export und Import
Eine Bedingung wandert mit. Im Austauschdokument steht sie über **Titel** statt über Kennungen
(`"abschnitt": "Prüfung", "schritt": "Prüfen"`), denn eine Kennung bedeutet in einer anderen
Installation nichts. Beim Import wird daraus wieder der richtige Schritt.
Findet sich der Titel-Pfad in der Datei nicht — etwa nach einem Tippfehler in einer von Hand
geschriebenen Vorlage —, wird die Bedingung **weggelassen**. Der Abschnitt gilt dann immer, und das
steht sichtbar im Editor. Die ganze Vorlage deswegen abzuweisen wäre die teurere Antwort.
**Ein Baustein-Schritt hat keine Bedingung.** Ein Baustein ist wiederverwendbar, und eine Bedingung
auf einer Variable, die es in der Zielvorlage nicht gibt, wäre dort nicht entscheidbar. Der
Abschnitt, in dem der Baustein eingesetzt wird, darf eine haben.
## Bausteine: ein Abschnitt für mehrere Vorlagen ## Bausteine: ein Abschnitt für mehrere Vorlagen
„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
+70
View File
@@ -387,6 +387,76 @@ Vom Mitarbeiter holt dieses Lesemodell **nur Name, UPN und Abteilung** — nicht
alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben; alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben;
was darauf steht, ist aus der Hand gegeben. was darauf steht, ist aus der Hand gegeben.
## 5f-2. Bedingte Teile: zwei Filter, eine Reihenfolge
Was ein Lauf zeigt, entsteht aus **zwei** Filtern uebereinander, und ihre
Reihenfolge ist der ganze Inhalt von `geltendeStruktur` in
`src/lib/bedingungen.ts`:
1. **Die Abteilungsauswahl (#031)** — beim Start festgeschrieben in
`Lauf.geltendeAbschnitte`. Festgeschrieben, weil ein archivierter Lauf genau
die Schritte zeigen muss, die damals abgearbeitet wurden, und ein spaeterer
Abteilungswechsel keinen Schritt entfernen darf, an dem eine Notiz haengt.
2. **Die Bedingungen (#039)** — **laufend** ausgewertet. Eine Bedingung auf einer
Variable liesse sich auch festschreiben; eine auf einem Schrittergebnis nicht,
denn ihre Antwort entsteht erst waehrend der Arbeit. Ein Abschnitt
„Nacharbeit“, der erscheint, wenn der Restore-Test fehlschlaegt, ist der Fall,
fuer den es gebaut ist.
Wer nur den ersten anwendet, bekommt Abschnitte, die nicht gelten; wer nur den
zweiten anwendet, umgeht die Abteilungsregel. Drei Schichten brauchen beide — die
Abschlussregel im Dienst, die Abarbeitung und die Druckansicht —, und
`tests/bedingte-laeufe.test.ts` prueft **strukturell**, dass keine davon die
zweite vergisst. Dieselbe Art Test wie „jede Seite prueft selbst“ (#038): die
Zusage steht in der Vollstaendigkeit einer Liste, und die muss jemand bewachen.
**Nicht entscheidbar heisst: gilt.** Hat niemand den Kontotyp angegeben, stehen
beide Konto-Abschnitte da — genau wie vor #039. Auszublenden hiesse, eine Antwort
zu erfinden, die niemand gegeben hat; und die Richtung, in die der Zweifel
faellt, soll die harmlose sein.
**Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen.** Vier Wege schreiben
eine Struktur neu und geben dabei neue Kennungen: Import, Export, Seed und
`entwurfSpeichern`. Eine Bedingung auf einer Kennung ueberlebte keinen davon —
ein Titel-Pfad ueberlebt alle vier, und `bedingungAufloesen` ist fuer alle
dieselbe Stelle. Dass eine Schritt-Bedingung auf **dieselbe Version** zeigt,
haelt ein Trigger: nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
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` —,
+133 -2
View File
@@ -40,12 +40,15 @@ als Unterpunkte mit Aufgabe und Dauer.
| #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min | | #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min |
| #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 |
| | **Summe** | **73 h 55 min** | | #039 | M23 – Bedingte Schritte und Abschnitte | 5 h 20 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 | in Arbeit | | v0.2.0 | 38 h 35 min | veröffentlicht |
| v0.3.0 | 8 h 50 min | in Arbeit |
--- ---
@@ -1764,3 +1767,131 @@ Entra-Mandanten** und **gegen echte SCIM-Anbieter** ist nichts gelaufen. Die For
stammen aus der Dokumentation, nicht aus Mitschnitten. Stimmt eine Form nicht, stimmen Test und stammen aus der Dokumentation, nicht aus Mitschnitten. Stimmt eine Form nicht, stimmen Test und
Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
`docs/betrieb/scim.md`. `docs/betrieb/scim.md`.
---
## 2026-10-23 · Sitzung 25 · 11:00–15:10
**Issue:** #039 (M23)
**Version:** 0.3.0 (unveröffentlicht)
### Erledigt
- **Bedingte Abschnitte und Schritte**, der erste Meilenstein von Phase 3. Eine Bedingung hängt an
einer Variable des Laufs oder am Zustand eines anderen Schritts; die Auswertung läuft bei jedem
Lesen und sitzt in `src/lib/bedingungen.ts`.
- **Der Seed nutzt es am echten Fall**: „Mitarbeiter-Onboarding“ hat die Bedingung auf `konto_typ`,
beide Konto-Abschnitte bleiben drin und schließen sich aus. Neun Schritte, die niemand mehr
wegklickt.
- Editor, Abarbeitung, Druck, Export, Import, Entwurf und Seed — sieben Wege, alle mit Tests.
- 1496 Vitest-Tests (vorher 1457), davon 29 neue. Klickprobe am gebauten Stand: Bildschirm, Druck
und 390 px zeigen dasselbe.
### Entscheidungen
- **Laufend auswerten, nicht beim Start festschreiben.** Für die Abteilungsabschnitte (#031) ist
Festschreiben richtig: die Abteilung steht beim Start fest. Für Bedingungen nicht — die Antwort auf
„ist Prüfen fehlgeschlagen?“ entsteht erst während der Arbeit. `geltendeStruktur` legt die
Reihenfolge der zwei Filter fest, und ein struktureller Test prüft, dass alle drei Schichten sie
rufen.
- **Nicht entscheidbar heißt: gilt.** Ein Lauf ohne `konto_typ` zeigt beide Konto-Abschnitte. Das ist
genau das Verhalten von vorher; eine erfundene Antwort wäre die Verschlechterung. Ein Abschnitt zu
viel ist sichtbar und korrigierbar, ein fehlender fällt niemandem auf.
- **`OFFEN` ist eine Antwort, keine Lücke** — der scharfe Gegensatz zur nicht gesetzten Variable.
Wäre `OFFEN` unentscheidbar, wäre ein bedingter Abschnitt von Anfang an sichtbar, also das
Gegenteil des Zwecks.
- **Eine Bedingung, kein Ausdrucksbaum.** Kein `und`, kein `oder`, keine Verschachtelung. Wer zwei
braucht, nimmt zwei Abschnitte. Ein Formular für beliebige Ausdrücke wäre eine kleine
Abfragesprache, und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist schlimmer als einer zu
viel.
- **Verwiesen wird über Titel-Pfade, nicht über Kennungen.** Vier Wege schreiben eine Struktur neu
und geben neue Kennungen: Import, Export, Seed, `entwurfSpeichern`. Eine Kennung überlebt keinen
davon.
### Probleme
- **Zwei stille Datenverluste, und der zweite war älter als dieser Meilenstein.**
- Der **Import** verwarf `nurFuerAbteilungen`: der Export schreibt es seit #031, das Schema nimmt
es an, der Import setzte es nicht. Eine exportierte Vorlage verlor ihre Abteilungsabschnitte.
- Die **tiefe Kopie** verwarf es ebenfalls — damit verlor **jedes „Bearbeiten“** einer Vorlage alle
Abteilungseinschränkungen. Beide Kopierstellen hatten den Code einzeln ausgeschrieben und
vergaßen dieselben Felder.
**Die Ursache war ein von Hand getippter Zwischentyp.** `VersionMitInhalt` nannte weniger Felder,
als die Abfrage liefert; die Daten waren da, der Code konnte sie nicht sehen. Jetzt aus Prisma
abgeleitet — ein neues Feld ist dort automatisch vorhanden. Und bewacht von einem Test, der Kopie
und Original **Feld für Feld** vergleicht, nicht stichprobenweise.
- **Gefunden hat beides die Klickprobe am gebauten Stand**, nicht die Suite: im Editor stand
„Bedingung gesetzt“, und nach „Bearbeiten“ war sie weg. Genau dafür steht diese Probe in
`CLAUDE.md`. Dreizehn Gegenproben liefen in dieser Sitzung, alle wurden rot — und trotzdem lag der
teuerste Befund außerhalb von allen.
- **Zwei Schemas für dieselbe Form** liefen fast auseinander: Austauschformat und Editor-Struktur
brauchen die Pfad-Form, und TypeScript meldete die Abweichung erst, als die Typen aufeinandertrafen.
Jetzt eine Stelle.
- **Ein Mangel an der neuen Komponente**, beim Klicken aufgefallen: die Auswahlfelder standen in
einem umhüllenden `label`, und ihr zugänglicher Name schluckte den Text der gewählten Option mit.
Derselbe Fehler wie bei der Adresse im Verzeichnis-Formular (#016) — dort war es ein Reviewbefund.
- **Zwei eigene Testfehler**: dreimal versucht, eine Bedingung an einer **veröffentlichten** Version
zu setzen (die ist unveränderlich, dafür sorgen Trigger), und `vorlageBearbeiten` mit
`entwurfHolenOderErzeugen` verwechselt. Beide Male hat die Datenbank es gesagt.
### Nächster Schritt
- **M24 (#040)** Vier-Augen-Freigabe. Der Abschluss wird dort ohnehin angefasst, und M25 braucht den
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.
+9 -4
View File
@@ -11,10 +11,15 @@ Nicht im MVP umsetzen. Hier gesammelt, damit das Datenmodell nichts verbaut.
## Aufgaben ## Aufgaben
- [ ] PDF-Übergabeprotokoll mit Unterschrift Geschnitten am 2026-10-11, nach dem Tag von v0.2.0. Vier Meilensteine, 34 h geschätzt; die
- [ ] Bedingte Schritte und Abschnitte Reihenfolge ist nicht beliebig.
- [ ] Vier-Augen-Freigabe
- [ ] Weitere Schritt-Typen | | Issue | Warum an dieser Stelle |
|---|---|---|
| M23 | [Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | zuerst, weil es als einziges am Datenmodell und an der Abschlussregel arbeitet — und weil der echte Fall im Seed steht: „Mitarbeiter-Onboarding“ trägt beide Konto-Abschnitte |
| M24 | [Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | danach, weil der Abschluss dann einmal angefasst ist |
| M25 | [PDF-Übergabeprotokoll](041-pdf-uebergabeprotokoll.md) | nach M24, damit das Blatt den Freigeber nennen kann |
| M26 | [Weitere Schritt-Typen](042-weitere-schritt-typen.md) | zuletzt, weil `UNTERSCHRIFT` erst im PDF einen Zweck hat |
## Vorleistung im MVP ## Vorleistung im MVP
+164
View File
@@ -0,0 +1,164 @@
# #039 M23 – Bedingte Schritte und Abschnitte
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
**Geschätzt:** 12 h **Tatsächlich:** 5 h 20 min
## Ziel
Eine Checkliste enthält heute **alles**, und was nicht zutrifft, wird von Hand auf
`NICHT_ZUTREFFEND` gesetzt. Der echte Fall steht im Seed: „Mitarbeiter-Onboarding“ trägt **beide**
Konto-Abschnitte — „Konto anlegen (Active Directory)“ mit 5 Schritten und „Konto anlegen (Entra
ID)“ mit 4 —, obwohl in jedem einzelnen Lauf nur einer gilt. Die Variable `konto_typ` sagt es
bereits; niemand wertet sie aus.
Neun Schritte, die ein Techniker bei jedem Onboarding wegklickt. Die Abschnitts-Aktion aus #013
macht es erträglich, aber sie ist ein Pflaster: der Fortschrittsbalken zählt die Schritte mit, und
im Archiv steht später eine Checkliste mit einem Abschnitt, den niemand je gebraucht hat.
Setzt [#031](031-abteilungsabschnitte.md) voraus: `geltendeAbschnitte` und `nurGeltende` sind die
Stelle, an der das sitzt.
## Zu entscheiden
- [x] **Wann wird ausgewertet?** Das ist die Entscheidung, an der alles hängt.
Für Abteilungen (#031) wird beim **Start festgeschrieben** — die Abteilung des Laufs steht
dann fest, und ein archivierter Lauf muss genau die Schritte zeigen, die damals galten.
Für Bedingungen reicht das **nicht**: eine Bedingung auf einer Variable (`konto_typ`) ließe
sich beim Start auswerten, eine Bedingung auf einem **Schrittergebnis** („wenn Schritt 3 =
nein, dann Abschnitt B“) nicht — die Antwort entsteht erst während der Arbeit.
Empfehlung: **laufend auswerten, Ergebnis mitschreiben.** Die Auswertung läuft bei jeder
Änderung an einem Schrittergebnis; was dabei herauskommt, wird in den Lauf geschrieben (wie
`geltendeAbschnitte`). Damit gilt für das Archiv weiter „der Lauf weiß, was galt“, und der
Ereignisstrom aus #033 trägt die Änderung ohnehin schon zur zweiten Sitzung.
- [x] **Woran darf eine Bedingung hängen?** Empfehlung: an einer **Variable** des Laufs und an
einem **Schrittergebnis** derselben Version. Nicht an Gerätetyp, Abteilung oder Rolle — für
Abteilungen gibt es #031, und je mehr Quellen, desto schwerer zu erklären, warum ein Schritt
fehlt.
- [x] **Wie wird sie gespeichert?** Empfehlung: ein Json-Feld `bedingung` an `VorlagenAbschnitt`
und `VorlagenSchritt`, mit einem engen Schema — `{ art: "variable", schluessel, operator,
wert }` oder `{ art: "schritt", schrittId, zustand }`. Json, weil `konfig` es schon so macht
und ein neuer Operator dann keine Migration braucht.
**Eine Bedingung, kein Ausdrucksbaum.** Kein `und`/`oder`, keine Verschachtelung: das wäre
eine kleine Sprache, die niemand im Editor pflegen kann, und ein Abschnitt, der aus
unerklärlichen Gründen fehlt, ist schlimmer als einer zu viel.
- [x] **Was passiert mit einem Schrittergebnis, das schon Daten trägt**, wenn die Bedingung
umschlägt? Empfehlung: die Zeile **bleibt** und wird nur ausgeblendet. Löschen hieße, eine
Notiz zu verlieren, die jemand geschrieben hat; und schlägt die Bedingung zurück, ist sie
wieder da.
## Aufgaben
- [x] Migration: `bedingung Json?` an `VorlagenAbschnitt` und `VorlagenSchritt`, dazu ein CHECK, der
sicherstellt, dass eine Bedingung auf einen Schritt **derselben Version** zeigt (sonst zeigt
sie nach dem Veröffentlichen ins Leere)
- [x] `src/lib/bedingungen.ts`, framework-frei: Schema, Auswertung, und die Liste der Schritte und
Abschnitte, die gelten. **Dieselbe Stelle für alle drei Schichten**, wie
`src/lib/geltende-abschnitte.ts`
- [x] Auswertung bei jedem Schreiben an einem `Schrittergebnis`; das Ergebnis in den Lauf
- [x] Abschlussregel: ein ausgeblendeter Pflichtschritt hält den Abschluss **nicht** auf (dieselbe
Regel wie bei #031, dieselbe Funktion)
- [x] Fortschrittsbalken und Zählwerte rechnen nur mit dem, was gilt
- [x] Editor: Bedingung an Abschnitt und Schritt setzen, mit Auswahl der Variable bzw. des Schritts
**aus derselben Version**; eine Vorschau, die sagt „gilt, wenn …“
- [x] Warnung im Editor bei einer Bedingung auf einen Schritt, der **nach** dem bedingten steht —
sie wäre beim Abarbeiten noch offen, und der Abschnitt erschiene erst rückwirkend
- [x] Abarbeitung: ausgeblendete Abschnitte verschwinden, ohne die Nummerierung zu verschieben
(`01 · Vorbereitung` kommt aus der Position und wird nie gespeichert)
- [x] Druckansicht und Archiv zeigen, was galt
- [x] Export/Import: die Bedingung wandert mit, und ein Verweis auf einen Schritt wird dabei auf die
**neue** Kennung umgeschrieben — sonst zeigt sie in der zweiten Installation ins Leere
- [x] Seed: „Mitarbeiter-Onboarding“ bekommt die Bedingung auf `konto_typ`, die beiden
Konto-Abschnitte bleiben drin und schließen sich gegenseitig aus. Das ist die Gegenprobe am
echten Fall.
- [x] Anleitung Kapitel 04 (Vorlagen): wann eine Bedingung das richtige Mittel ist und wann eine
zweite Vorlage
## Akzeptanzkriterien
- [x] Test: ein Lauf mit `konto_typ = AD` hat die 5 AD-Schritte und **keinen** der 4 Entra-Schritte
- [x] Test: dasselbe umgekehrt, und bei nicht gesetzter Variable gelten **beide** — eine Bedingung,
die nicht entschieden werden kann, blendet nichts aus
- [x] Test: eine Bedingung auf einem Schrittergebnis schlägt um, **während** der Lauf läuft, und der
Abschnitt erscheint
- [x] Test: ein ausgeblendeter Pflichtschritt hält den Abschluss nicht auf
- [x] Test: ein Schrittergebnis mit Notiz bleibt stehen, wenn seine Bedingung umschlägt, und ist
danach wieder da
- [x] Test: Export und Import einer Vorlage mit Bedingungen ergeben dieselbe Struktur, und der
Schritt-Verweis zeigt auf den richtigen Schritt der **neuen** Version
- [x] Test: ein archivierter Lauf zeigt genau die Schritte, die bei seinem Abschluss galten
- [x] Am Handy kein horizontales Scrollen
## Beim Umsetzen dazugekommen
- **Die Entscheidungen aus dem Issue sind so gefallen wie empfohlen**, und zwei davon haben sich im
Code als richtig erwiesen:
- **Laufend auswerten, nicht beim Start festschreiben.** Eine Bedingung auf einer Variable ließe
sich wie #031 beim Start festhalten; eine auf einem Schrittergebnis nicht — und genau die ist der
interessante Fall (ein Abschnitt „Nacharbeit“, der erscheint, wenn der Restore-Test
fehlschlägt).
- **Nicht entscheidbar heißt: gilt.** Ein Lauf ohne `konto_typ` zeigt beide Konto-Abschnitte —
genau wie vor diesem Meilenstein. Eine erfundene Antwort wäre die Verschlechterung.
- **`OFFEN` ist eine Antwort, keine Lücke.** Der Unterschied zur Variable musste scharf gezogen
werden: eine nicht gesetzte Variable ist nicht entscheidbar, ein offener Schritt dagegen klar
„noch nicht erfüllt“. Wäre `OFFEN` unentscheidbar, wäre ein bedingter Abschnitt von Anfang an
sichtbar — das Gegenteil des Zwecks.
- **Verwiesen wird über Titel-Pfade, nicht über Kennungen**, und das war die größte Einzelerkenntnis.
**Vier** Wege schreiben eine Struktur neu und geben dabei neue Kennungen: Import, Export, Seed und
`entwurfSpeichern`. Eine Bedingung auf einer Kennung überlebt keinen davon. `bedingungAufloesen`
ist für alle vier dieselbe Stelle; dass eine Bedingung auf **dieselbe Version** zeigt, hält
zusätzlich ein Trigger.
- **Ein struktureller Test bewacht die drei Schichten.** Abschlussregel, Abarbeitung und Druck
müssen alle `geltendeStruktur` rufen; vergisst eine es, sieht der Techniker andere Schritte als
der Ausdruck. Der Test liest die drei Dateien — dieselbe Art Prüfung wie „jede Seite prüft selbst“
(#038).
- **Zwei Schemas für dieselbe Form liefen fast auseinander.** Austauschformat und Editor-Struktur
brauchen die Pfad-Form; ich hatte sie zweimal geschrieben, und TypeScript meldete es erst, als die
beiden Typen aufeinandertrafen. Jetzt steht `PfadBedingungSchema` in `src/lib/bedingungen.ts`, und
`vorlagen-format.ts` importiert es.
## Zwei stille Datenverluste, gefunden bei der Klickprobe
Beides hätte keine Suite gezeigt, und das zweite war **älter als dieser Meilenstein**:
- **Der Import verwarf `nurFuerAbteilungen`.** Der Export schreibt die Abteilungseinschränkung seit
[#031](031-abteilungsabschnitte.md), das Schema nimmt sie an — und der Import setzte sie nicht.
Eine Vorlage, die jemand in eine zweite Installation brachte, verlor ihre Abteilungsabschnitte:
sie galten dort für **alle**. Also mehr Schritte statt weniger, und niemand merkt es.
- **Die tiefe Kopie verwarf sie ebenfalls — und seit heute auch die Bedingung.** „Bearbeiten“ einer
Vorlage erzeugt eine tiefe Kopie als Entwurf (#009); `vorlageDuplizieren` dasselbe. Beide hatten
die Kopie einzeln ausgeschrieben, und beide vergaßen dieselben Felder. Damit verlor **jede
Bearbeitung** alle Abteilungseinschränkungen.
**Die Ursache war ein von Hand getippter Zwischentyp.** `VersionMitInhalt` nannte weniger Felder,
als die Abfrage liefert; die Felder waren da, der Code konnte sie nicht sehen. Jetzt ist der Typ
aus Prisma abgeleitet — ein neues Feld am Modell ist dort automatisch vorhanden.
Gefunden hat es die **Klickprobe am gebauten Stand**: der Editor zeigte „Bedingung gesetzt“ in der
veröffentlichten Version, und nach „Bearbeiten“ war sie weg. Das ist genau der Grund, warum diese
Probe in `CLAUDE.md` steht.
Bewacht wird es jetzt von einem Test, der Kopie und Original **Feld für Feld** vergleicht — nicht
stichprobenweise. So fällt auch das nächste Feld auf, das jemand ergänzt und beim Kopieren
vergisst.
- **Ein Mangel an der neuen Komponente**, aufgefallen beim Klicken: die Auswahlfelder standen in
einem umhüllenden `<label>`, und damit schluckte ihr zugänglicher Name den Text der gewählten
Option mit („VariableKontotyp“). Für einen Screenreader ist das eine Bezeichnung, die sich beim
Umschalten ändert. Jetzt `htmlFor`/`id` — derselbe Fehler wie bei der Adresse im Verzeichnis-Formular
(#016).
## Was offen bleibt
- **Kein `und`/`oder`**, bewusst. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte.
- **Ein Baustein-Schritt hat keine Bedingung** (der Abschnitt, in dem er steht, darf eine haben): ein
Baustein ist wiederverwendbar, und eine Bedingung auf einer Variable der Zielvorlage wäre dort
nicht entscheidbar.
- **Zwei Schritte mit demselben Titel im selben Abschnitt** sind über den Titel-Pfad nicht
unterscheidbar; die Bedingung zeigt dann auf den ersten. Benannt in `src/lib/bedingungen.ts` —
zwei gleich benannte Schritte nebeneinander kann auch ein Mensch nicht unterscheiden.
+127
View File
@@ -0,0 +1,127 @@
# #040 M24 – Vier-Augen-Freigabe
**Status:** erledigt
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** 3 h 30 min
## Ziel
Manche Arbeiten soll nicht derselbe abschließen, der sie gemacht hat: ein Firewall-Rollout, ein
Restore-Test, das Löschen eines Kontos. Heute kann jeder `TECHNIKER` jeden Lauf abschließen und
archivieren.
Gebraucht wird: eine Vorlage kann sagen „dieser Lauf braucht eine Freigabe“, und dann schließt ihn
ein **Zweiter** ab — nachweisbar im Protokoll.
## Zu entscheiden
- [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
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
ein Json.
- [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
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.
- [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
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.
- [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
Abarbeitungsansicht. Kein eigener `LaufStatus` — das wäre eine Änderung an einem Enum, das
vier Jahre halten soll; es genügt ein Feld plus Protokolleintrag.
## Aufgaben
- [x] Migration: `freigabePflicht Boolean` an `VorlagenVersion`, dazu `freigegebenVonId`,
`freigegebenAm` und `freigabeNotiz` am `Lauf`; CHECK, dass `freigegebenVonId` und
`freigegebenAm` zusammen gesetzt sind (wie bei `abgeschlossenAm`)
- [x] CHECK: `freigegebenVonId` ist **nicht** `erstelltVonId` — die Regel gehört in die Datenbank,
nicht nur in den Dienst (CLAUDE.md)
- [x] Recht `freigeben` in `RECHTE`, Rollenmatrix-Test erweitern
- [x] Dienst: `laufFreigeben(laufId, benutzer, notiz?)` und `laufZurueckweisen(laufId, benutzer,
notiz)`; Abschluss verlangt die Freigabe, wenn die Version sie fordert
- [x] Die Prüfung „wer hat daran gearbeitet“ an **einer** Stelle, wie `laufMussVeraenderbarSein`
- [x] Benachrichtigung: ein neuer `Meldeanlass` `LAUF_FREIGABE_OFFEN` an die Rollen mit dem Recht —
sonst wartet ein Lauf, von dem niemand weiß
- [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“
- [x] Editor: Häkchen „dieser Lauf braucht eine Freigabe“ an der Version
- [x] Druckansicht und PDF nennen Freigeber und Zeitpunkt
- [x] Anleitung: Kapitel 02 (Läufe abarbeiten) und 04 (Vorlagen), mit dem Hinweis, dass ein
Einzelkämpfer sich nicht selbst freigeben kann
## Akzeptanzkriterien
- [x] Test: ein Lauf mit Freigabepflicht lässt sich nicht abschließen, solange keine Freigabe steht
- [x] Test: der Bearbeiter kann **nicht** freigeben — auch nicht als `ADMIN`
- [x] Test: die Datenbank weist es ebenfalls ab (CHECK), nicht nur der Dienst
- [x] Test: eine Rolle ohne `freigeben` wird abgewiesen, über jede Server Action
- [x] Test: Zurückweisen verlangt eine Notiz und setzt den Lauf auf `IN_ARBEIT`
- [x] Test: die Meldung geht an die Rollen mit dem Recht und nicht an alle
- [x] Test: ein Lauf **ohne** Freigabepflicht verhält sich unverändert — bestehende Läufe und
Vorlagen merken nichts
- [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.
+68
View File
@@ -0,0 +1,68 @@
# #041 M25 – PDF-Übergabeprotokoll mit Unterschrift
**Status:** offen
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
**Geschätzt:** 8 h **Tatsächlich:** –
## Ziel
Wer ein Notebook übergibt, braucht ein Blatt, das der Empfänger unterschreibt — und das danach
auffindbar ist. Heute gibt es die Druckansicht eines Laufs (`/drucken/lauf/[id]`, #015): sie ist
hell, vollständig und als Vorstufe genau dafür gebaut. Was fehlt, ist die Datei.
Ein Browser-Ausdruck genügt nicht: er hängt am Drucker-Dialog des Betrachters, trägt dessen
Kopfzeilen und lässt sich nicht an ein Ticket anhängen.
## Zu entscheiden
- [ ] **Wie entsteht das PDF?** Empfehlung: **Chromium aus `@playwright/test`**, wie
`scripts/anleitung-pdf.ts` es für die Anleitung tut. Keine neue Abhängigkeit, keine zweite
Auslegung des Layouts — gedruckt wird dieselbe Seite, die die Druckansicht zeigt.
**Grenze, die genannt gehört:** damit braucht der Server einen Chromium. Im Compose-Stand ist
das eine Ergänzung am Image, nativ ein Paket. Die Alternative (`pdf-lib` oder ein
Dienst) wäre ein zweites Layout, das auseinanderläuft — der Preis ist höher.
- [ ] **Unterschrift auf Papier oder auf dem Gerät?** Empfehlung: **beides, in dieser Reihenfolge.**
Zuerst ein Unterschriftsfeld im PDF (eine Linie, Name, Datum) — das löst den Fall heute und
kostet fast nichts. Eine Unterschrift mit dem Finger auf dem Handy ist ein eigener Schritt-Typ
und gehört zu [#042](042-weitere-schritt-typen.md); sie landet dann als Bild im PDF.
Eine digitale **Signatur** im kryptografischen Sinn ist ausdrücklich **nicht** gemeint und
wäre eine eigene Phase.
- [ ] **Wird das PDF gespeichert oder bei jedem Abruf erzeugt?** Empfehlung: **bei jedem Abruf
erzeugt**, nicht gespeichert. Ein gespeichertes PDF ist eine zweite Wahrheit neben dem Lauf;
wird eine Notiz später berichtigt, stimmt es nicht mehr. Für den Fall „wir brauchen das Blatt
von damals“ gibt es das Archiv, das den Lauf unveränderlich hält.
**`{{generiertes_passwort}}` erscheint auch hier nicht** — die Druckansicht zeigt
`[Passwort – nicht gespeichert]`, und das PDF übernimmt das.
## Aufgaben
- [ ] `GET /api/laeufe/[id]/protokoll.pdf` — Route Handler, prüft sein Recht **selbst**
(`tests/zugang.test.ts` erzwingt das seit #038)
- [ ] Erzeugung über Chromium, gegen die eigene Druckansicht; eine Seitenvorlage mit Kopf (Kunde,
Lauf, Vorlage, Version), Inhalt und Fuß (Bearbeiter, Freigeber aus
[#040](040-vier-augen-freigabe.md), Unterschriftsfeld)
- [ ] Ein **Chromium-Vorrat** im Prozess: ihn je Anfrage zu starten kostet Sekunden. Eine Instanz,
die nach Leerlauf schließt — und eine Obergrenze gleichzeitiger Erzeugungen, wie
`SSE_MAX_STROEME` in #033
- [ ] Knopf „Protokoll als PDF“ an Lauf und Archiv
- [ ] `Dockerfile` und `deploy/install.sh` um Chromium ergänzen, **mit gemessener Bildgröße** im
Changelog — eine Ergänzung am Image, die niemand bemerkt, ist eine böse Überraschung beim
nächsten Rollout
- [ ] `docs/betrieb/` sagt, was die Erzeugung braucht und was passiert, wenn Chromium fehlt:
**eine Meldung, kein stilles leeres PDF**
- [ ] Anleitung Kapitel 02 und 10 (Archiv)
## Akzeptanzkriterien
- [ ] Test: das PDF enthält Titel, Vorlage mit Version, alle geltenden Schritte mit Zustand, Notizen
und Kommentare — geprüft am extrahierten Text, nicht am Vorhandensein der Datei
- [ ] Test: es enthält **kein** erzeugtes Passwort, über alle Schritt-Typen
- [ ] Test: ein Lauf eines fremden Kunden ist über die Adresse nicht abrufbar (wie #032 nach #036)
- [ ] Test: ohne Recht `401`, mit falschem Kunden `404`
- [ ] Test: fehlt Chromium, kommt eine Meldung mit Hinweis auf die Betriebsdoku — kein leeres PDF
- [ ] Test: zwei gleichzeitige Erzeugungen laufen, die dritte wartet oder wird abgewiesen (die
Obergrenze gilt)
- [ ] Ein Blatt ist ausgedruckt und gegengesehen worden — das ist eine Abnahme von Hand und steht
als solche im Devlog
+62
View File
@@ -0,0 +1,62 @@
# #042 M26 – Weitere Schritt-Typen
**Status:** offen
**Im Tracker:** –
**Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend
**Geschätzt:** 6 h **Tatsächlich:** –
## Ziel
`SchrittTyp` hat heute sechs Werte: `HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL`. Das Enum und das
Json-Feld `konfig` sind seit #006 genau dafür gebaut, dass neue Typen ohne Migration der
bestehenden dazukommen.
Die Frage ist nicht, **wie** ein Typ dazukommt, sondern **welche** — und das Issue nennt sie
absichtlich noch nicht als beschlossen: ein Typ, der im Editor steht und in keiner Vorlage
auftaucht, ist ungenutzter und damit ungeprüfter Code.
## Zu entscheiden
- [ ] **Welche Typen?** Vorschlag, nach Nutzen geordnet, jeder mit dem Fall, der ihn verlangt:
| Typ | Der Fall | Ohne ihn |
|---|---|---|
| `DATEI` | „Foto des Typenschilds“ als **Pflicht** | ein Anhang ist seit #032 möglich, aber nie verlangt — ein Lauf schließt ohne ihn ab |
| `UNTERSCHRIFT` | Übergabe mit dem Finger auf dem Handy | das Blatt muss gedruckt und wieder eingescannt werden ([#041](041-pdf-uebergabeprotokoll.md)) |
| `MEHRFACHAUSWAHL` | „welche Lizenzen zugewiesen“ | heute ein Textfeld, also nicht auswertbar |
| `URL` | „Link zum Ticket“ | ein Textfeld ohne Prüfung und ohne Klick |
| `UEBERSCHRIFT` | eine Zwischenüberschrift ohne Zustand | ein `HAKEN`, den jemand abhaken muss, damit der Lauf fertig wird |
**Empfehlung: `DATEI` und `UNTERSCHRIFT`**, und die drei anderen nur, wenn eine Seed-Vorlage
sie wirklich benutzt. `UEBERSCHRIFT` ist der zweifelhafteste: ein Schritt ohne Zustand bricht
die Annahme „jeder Schritt hat ein `Schrittergebnis`“, und die steckt an vielen Stellen.
- [ ] **Bleibt `Schrittergebnis.wert` ein Json?** Ja — es ist seit #006 eines, genau dafür. Eine
Mehrfachauswahl ist ein Array, eine Unterschrift ein Verweis auf einen `Anhang`.
- [ ] **Wie verträgt sich `DATEI` mit dem Archiv-Schreibschutz?** Ein Pflicht-Anhang muss vor dem
Abschluss da sein; danach kommt keiner mehr weg (#032). Das ist bereits so und braucht nur
einen Test.
## Aufgaben
- [ ] Je gewählten Typ: Enum-Wert, Zod-Schema für `konfig` und `wert`, Darstellung im Editor,
Darstellung in der Abarbeitung, Darstellung im Druck und im PDF
- [ ] Die Abschlussregel kennt den neuen Typ: was zählt als „erledigt“? Bei `DATEI` ein Anhang, bei
`UNTERSCHRIFT` ein Bild, bei `MEHRFACHAUSWAHL` mindestens eine Wahl
- [ ] `UNTERSCHRIFT`: Zeichenfläche am Handy (Pointer-Ereignisse, nicht Touch-spezifisch), Ergebnis
als PNG über den Anhänge-Weg aus #032 — **keine neue Ablage**
- [ ] Export/Import und die Vorlagen-Austauschform um die neuen Typen erweitern, nachsichtig: eine
Datei mit einem unbekannten Typ wird abgewiesen **mit Nennung des Typs**, nicht stillschweigend
verändert
- [ ] Mindestens eine Seed-Vorlage benutzt jeden neuen Typ — sonst ist er ungeprüfter Code
- [ ] Anleitung Kapitel 04, mit dem Satz, wann ein Typ das richtige Mittel ist
## Akzeptanzkriterien
- [ ] Test je Typ: anlegen, abarbeiten, abschließen, drucken
- [ ] Test: ein `DATEI`-Pflichtschritt ohne Anhang hält den Abschluss auf
- [ ] Test: eine Unterschrift landet als `Anhang` und nicht als Json-Blob in `wert`
- [ ] Test: ein Lauf einer **älteren** Version verhält sich unverändert — die neuen Typen tauchen
dort nicht auf, und keine Migration hat Daten angefasst
- [ ] Test: eine Austauschdatei mit unbekanntem Typ wird abgewiesen und nennt den Typ
- [ ] Am Handy bedienbar, bei 390 px geprüft — besonders die Zeichenfläche
+5 -1
View File
@@ -43,4 +43,8 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min | | #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 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 | | #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 |
| | **Summe** | | | **238 h** | **73 h 55 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) | 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 | – |
| #042 | [M26 – Weitere Schritt-Typen](042-weitere-schritt-typen.md) | offen | v0.3.0 | 6 h | – |
| | **Summe** | | | **272 h** | **82 h 45 min** |
@@ -0,0 +1,161 @@
-- ---------------------------------------------------------------------------
-- 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.
-- ---------------------------------------------------------------------------
-- Bedingte Abschnitte und Schritte (#039).
ALTER TABLE "VorlagenAbschnitt" ADD COLUMN "bedingung" JSONB;
ALTER TABLE "VorlagenSchritt" ADD COLUMN "bedingung" JSONB;
/*
* Die **Form** der Bedingung, in der Datenbank.
*
* Das vollstaendige Schema steht in `src/lib/bedingungen.ts` und wird beim
* Schreiben angewandt. Hier steht, was auch dann gelten muss, wenn jemand an
* der Service-Schicht vorbei schreibt (CLAUDE.md) — und das ist mehr als
* Formsache: eine Bedingung mit unbekannter `art` wuerde beim Lesen als „keine
* Bedingung“ gelten, der Abschnitt waere also sichtbar. Das ist die harmlose
* Richtung, aber es waere **stillschweigend** falsch, und niemand koennte sagen,
* warum ein Abschnitt, der eine Bedingung hat, immer erscheint.
*
* Geprueft wird deshalb: `art` ist einer der beiden Werte, und die Felder, die
* diese Art braucht, sind da. Nicht geprueft wird der Inhalt von `zustaende` —
* ein Array-Element einzeln zu pruefen ginge nur mit einer Unterabfrage, und
* die gehoert nicht in einen CHECK.
*/
ALTER TABLE "VorlagenAbschnitt"
ADD CONSTRAINT "VorlagenAbschnitt_bedingung_form"
CHECK (
"bedingung" IS NULL
OR (
"bedingung" ->> 'art' = 'variable'
AND "bedingung" ->> 'schluessel' IS NOT NULL
AND "bedingung" ->> 'operator' IN ('ist', 'istNicht', 'gesetzt', 'leer')
AND (
"bedingung" ->> 'operator' IN ('gesetzt', 'leer')
OR btrim(COALESCE("bedingung" ->> 'wert', '')) <> ''
)
)
OR (
"bedingung" ->> 'art' = 'schritt'
AND "bedingung" ->> 'schrittId' IS NOT NULL
AND jsonb_typeof("bedingung" -> 'zustaende') = 'array'
AND jsonb_array_length("bedingung" -> 'zustaende') > 0
)
);
ALTER TABLE "VorlagenSchritt"
ADD CONSTRAINT "VorlagenSchritt_bedingung_form"
CHECK (
"bedingung" IS NULL
OR (
"bedingung" ->> 'art' = 'variable'
AND "bedingung" ->> 'schluessel' IS NOT NULL
AND "bedingung" ->> 'operator' IN ('ist', 'istNicht', 'gesetzt', 'leer')
AND (
"bedingung" ->> 'operator' IN ('gesetzt', 'leer')
OR btrim(COALESCE("bedingung" ->> 'wert', '')) <> ''
)
)
OR (
"bedingung" ->> 'art' = 'schritt'
AND "bedingung" ->> 'schrittId' IS NOT NULL
AND jsonb_typeof("bedingung" -> 'zustaende') = 'array'
AND jsonb_array_length("bedingung" -> 'zustaende') > 0
)
);
/*
* **Eine Schritt-Bedingung zeigt auf einen Schritt derselben Version.**
*
* Das ist die eigentliche Regel, und ein CHECK kann sie nicht halten: sie
* verlangt einen Blick in zwei andere Tabellen. Ein Fremdschluessel auf
* `VorlagenSchritt` ginge auch nicht — der Wert steckt in einem Json, und
* „derselbe Version“ steht dort nicht.
*
* Ohne diese Pruefung zeigt eine Bedingung nach dem Veroeffentlichen ins Leere:
* „Bearbeiten“ erzeugt eine **tiefe Kopie** mit neuen Kennungen (CLAUDE.md), und
* ein Verweis auf die alte Kennung waere dann nicht mehr entscheidbar. Die
* Auswertung zeigt in diesem Fall an, der Abschnitt erscheint also immer — und
* niemand koennte sagen, warum die Bedingung nicht wirkt. Genau die Art Fehler,
* die eine gruene Suite nicht sieht.
*
* Geprueft wird beim Schreiben der **Bedingung**, nicht beim Loeschen des
* Ziels: einen Schritt zu loeschen, auf den eine Bedingung zeigt, ist nur im
* Entwurf moeglich, und dort faengt es der Dienst mit einer besseren Meldung.
*/
CREATE OR REPLACE FUNCTION laufzettel_bedingung_gleiche_version() RETURNS TRIGGER AS $$
DECLARE
eigene_version TEXT;
ziel_version TEXT;
ziel_id TEXT;
BEGIN
IF NEW."bedingung" IS NULL OR NEW."bedingung" ->> 'art' <> 'schritt' THEN
RETURN NEW;
END IF;
ziel_id := NEW."bedingung" ->> 'schrittId';
-- Die eigene Version: am Abschnitt steht sie direkt, am Schritt eine Ebene hoeher.
IF TG_TABLE_NAME = 'VorlagenAbschnitt' THEN
eigene_version := NEW."versionId";
ELSE
SELECT "versionId" INTO eigene_version
FROM "VorlagenAbschnitt" WHERE id = NEW."abschnittId";
END IF;
SELECT a."versionId" INTO ziel_version
FROM "VorlagenSchritt" s
JOIN "VorlagenAbschnitt" a ON a.id = s."abschnittId"
WHERE s.id = ziel_id;
IF ziel_version IS NULL THEN
RAISE EXCEPTION
'Die Bedingung zeigt auf den Schritt %, den es nicht gibt.', ziel_id
USING ERRCODE = 'integrity_constraint_violation';
END IF;
IF ziel_version <> eigene_version THEN
RAISE EXCEPTION
'Die Bedingung zeigt auf einen Schritt einer anderen Version. Nach dem Veroeffentlichen waere sie nicht mehr entscheidbar.'
USING ERRCODE = 'integrity_constraint_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "VorlagenAbschnitt_bedingung_gleiche_version"
BEFORE INSERT OR UPDATE ON "VorlagenAbschnitt"
FOR EACH ROW EXECUTE FUNCTION laufzettel_bedingung_gleiche_version();
CREATE TRIGGER "VorlagenSchritt_bedingung_gleiche_version"
BEFORE INSERT OR UPDATE ON "VorlagenSchritt"
FOR EACH ROW EXECUTE FUNCTION laufzettel_bedingung_gleiche_version();
/*
* Gegenprobe: die neuen Spalten sind ueberall leer, und die CHECKs lassen die
* bestehenden Zeilen zu. `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
mit_bedingung INTEGER;
BEGIN
SELECT count(*) INTO mit_bedingung FROM "VorlagenAbschnitt" WHERE "bedingung" IS NOT NULL;
IF mit_bedingung > 0 THEN
RAISE EXCEPTION 'Unerwartet % Abschnitte mit Bedingung vor der Migration.', mit_bedingung;
END IF;
SELECT count(*) INTO mit_bedingung FROM "VorlagenSchritt" WHERE "bedingung" IS NOT NULL;
IF mit_bedingung > 0 THEN
RAISE EXCEPTION 'Unerwartet % Schritte mit Bedingung vor der Migration.', mit_bedingung;
END IF;
END $$;
@@ -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 $$;
+40
View File
@@ -162,6 +162,8 @@ enum Meldeanlass {
LAUF_ZUGEWIESEN LAUF_ZUGEWIESEN
LAUF_UEBERFAELLIG LAUF_UEBERFAELLIG
LAUF_ABGESCHLOSSEN LAUF_ABGESCHLOSSEN
/// Ein Lauf wartet auf eine Freigabe durch einen Zweiten (#040).
LAUF_FREIGABE_OFFEN
ABGLEICH_FEHLGESCHLAGEN ABGLEICH_FEHLGESCHLAGEN
} }
@@ -273,6 +275,7 @@ model Benutzer {
geaendertAm DateTime @default(now()) @updatedAt geaendertAm DateTime @default(now()) @updatedAt
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon") abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
freigegebeneLaeufe Lauf[] @relation("LaufFreigegebenVon")
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon") archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
erstellteLaeufe Lauf[] @relation("LaufErstelltVon") erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn") zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
@@ -578,6 +581,13 @@ model VorlagenVersion {
vorlageId String vorlageId String
version Int version Int
status VersionStatus @default(ENTWURF) status VersionStatus @default(ENTWURF)
/// Verlangt dieser Lauf eine Freigabe durch einen Zweiten (#040)?
///
/// An der **Version** und nicht an der Vorlage: eine veroeffentlichte Version
/// ist unveraenderlich, also kann ein laufender Lauf seine Regel nicht unter
/// den Fuessen verlieren. Wer die Pflicht aendert, veroeffentlicht eine neue
/// Fassung — bestehende Laeufe behalten ihre.
freigabePflicht Boolean @default(false)
aenderungsnotiz String? aenderungsnotiz String?
veroeffentlichtAm DateTime? veroeffentlichtAm DateTime?
veroeffentlichtVonId String? veroeffentlichtVonId String?
@@ -636,6 +646,17 @@ model VorlagenAbschnitt {
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites /// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
/// Feld dafuer waere eine zweite Wahrheit. /// Feld dafuer waere eine zweite Wahrheit.
bausteinVersionId String? bausteinVersionId String?
/// Wann dieser Abschnitt gilt (#039). Leer heisst: immer.
///
/// Form und Auswertung stehen in `src/lib/bedingungen.ts`; ein CHECK haelt die
/// Form auch gegen Schreiben an der Dienstschicht vorbei fest, und ein Trigger
/// haelt fest, dass eine Schritt-Bedingung auf **dieselbe Version** zeigt —
/// nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
/// entscheidbar, und der Abschnitt erschiene stillschweigend immer.
///
/// **Nicht entscheidbar heisst: gilt.** Ein Abschnitt zu viel ist sichtbar und
/// korrigierbar; ein fehlender faellt niemandem auf.
bedingung Json?
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict) bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
@@ -719,6 +740,9 @@ model VorlagenSchritt {
/// Parameter fuer Schritte vom Typ AKTION. /// Parameter fuer Schritte vom Typ AKTION.
konfig Json @default("{}") konfig Json @default("{}")
dokuUrl String? dokuUrl String?
/// Wann dieser Schritt gilt (#039). Leer heisst: immer. Siehe
/// `VorlagenAbschnitt.bedingung` — dieselbe Form, dieselben Regeln.
bedingung Json?
abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade) abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade)
ergebnisse Schrittergebnis[] ergebnisse Schrittergebnis[]
@@ -771,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())
@@ -784,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[]
@@ -798,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])
+63 -17
View File
@@ -1,4 +1,5 @@
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync, readdirSync } from "node:fs";
import { bedingungAufloesen, schrittPfad } from "@/lib/bedingungen";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format"; import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format";
@@ -199,26 +200,71 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
}); });
} }
/*
* Erst alles anlegen, dann die Bedingungen (#039) — wie beim Import und
* aus demselben Grund: eine Schritt-Bedingung verweist ueber einen
* Titel-Pfad, und der laesst sich erst aufloesen, wenn die Schritte
* stehen. Die Seed-Vorlagen benutzen bisher nur Variablen-Bedingungen;
* der zweite Durchgang steht trotzdem hier, damit eine kuenftige Vorlage
* mit Schritt-Bedingung nicht stillschweigend ohne sie landet.
*/
const kennungen = new Map<string, string>();
const abschnittsZeilen: string[] = [];
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) { for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({ const zeile = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position }, data: {
}); versionId: version.id,
if (abschnitt.schritte.length === 0) continue; titel: abschnitt.titel,
await tx.vorlagenSchritt.createMany({ position,
data: abschnitt.schritte.map((schritt, schrittPosition) => ({ // Wandert mit, auch wenn bisher keine Seed-Vorlage eine hat (#031).
abschnittId: zeile.id, nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
titel: schritt.titel, },
hilfetext: schritt.hilfetext ?? null,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Der Inhalt kommt aus geparstem JSON und ist damit ein Json-Wert;
// das nachsichtige Schema traegt nur eine Index-Signatur auf
// `unknown`, die Prisma nicht als solchen erkennt.
konfig: schritt.konfig as object,
dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition,
})),
}); });
abschnittsZeilen.push(zeile.id);
for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) {
const angelegt = await tx.vorlagenSchritt.create({
data: {
abschnittId: zeile.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Der Inhalt kommt aus geparstem JSON und ist damit ein Json-Wert;
// das nachsichtige Schema traegt nur eine Index-Signatur auf
// `unknown`, die Prisma nicht als solchen erkennt.
konfig: schritt.konfig as object,
dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition,
},
});
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
}
}
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const abschnittId = abschnittsZeilen[position];
if (abschnittId && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: abschnittId },
data: { bedingung: aufgeloest as object },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as object },
});
}
}
} }
await tx.vorlagenVersion.update({ await tx.vorlagenVersion.update({
@@ -94,6 +94,12 @@
}, },
{ {
"titel": "Konto anlegen (Active Directory)", "titel": "Konto anlegen (Active Directory)",
"bedingung": {
"art": "variable",
"schluessel": "konto_typ",
"operator": "ist",
"wert": "Active Directory"
},
"schritte": [ "schritte": [
{ {
"titel": "Konto anlegen", "titel": "Konto anlegen",
@@ -141,6 +147,12 @@
}, },
{ {
"titel": "Konto anlegen (Entra ID)", "titel": "Konto anlegen (Entra ID)",
"bedingung": {
"art": "variable",
"schluessel": "konto_typ",
"operator": "ist",
"wert": "Entra ID"
},
"schritte": [ "schritte": [
{ {
"titel": "Konto anlegen", "titel": "Konto anlegen",
+55 -1
View File
@@ -141,7 +141,7 @@ function feld(text, name) {
// README fehlte `format:pruefen`, und beide nannten den LDAP-Container nicht, ohne // README fehlte `format:pruefen`, und beide nannten den LDAP-Container nicht, ohne
// den die Suite 22 Tests still ueberspringt. Beides fiel erst bei der Abnahme zu // den die Suite 22 Tests still ueberspringt. Beides fiel erst bei der Abnahme zu
// #019 auf -- ein gruener Lauf sieht genauso aus. Geprueft wird deshalb der // #019 auf -- ein gruener Lauf sieht genauso aus. Geprueft wird deshalb der
// Befehlsblock selbst, nicht seine Umgebung (#040). // Befehlsblock selbst, nicht seine Umgebung (#026).
{ {
/** Der erste eingezaeunte Block nach einer Ueberschrift. */ /** Der erste eingezaeunte Block nach einer Ueberschrift. */
const blockNach = (text, ueberschrift) => { const blockNach = (text, ueberschrift) => {
@@ -197,6 +197,60 @@ function feld(text, name) {
} }
} }
// ---- 7. Jeder (#NNN)-Verweis zeigt auf eine Issue-Datei --------------------
{
/*
* Verweise wie `(#032)` stehen in Kommentaren, im Changelog und in CLAUDE.md
* und sind die einzige Bruecke zwischen einer Codestelle und ihrer
* Begruendung. Zeigt einer ins Leere, ist die Begruendung verloren — und
* schlimmer: zeigt er auf die **falsche** Datei, fuehrt er jemanden in die
* Irre, der ihm vertraut.
*
* Genau das war passiert: vier Codestellen und sechs Changelog-Zeilen nannten
* dreistellig die **Tracker**-Nummern 39 und 40 — gemeint waren die
* Issue-Dateien 025 und 026. Solange es keine Dateien mit diesen Nummern gab,
* fiel es niemandem auf; mit Phase 3 waere es eine falsche Spur geworden.
* Verbindlich ist die **Dateinummer**; die Tracker-Nummer steht in der
* Issue-Datei unter „Im Tracker“.
*
* Diese Datei nennt deshalb selbst keine Nummer in der dreistelligen Form —
* sonst meldete die Pruefung ihren eigenen Kommentar.
*/
const verz = "docs/issues";
const nummern = new Set(
readdirSync(join(wurzel, verz))
.filter((n) => /^\d{3}-.+\.md$/.test(n))
.map((n) => n.slice(0, 3)),
);
const sammeln = (ordner) =>
readdirSync(ordner, { withFileTypes: true }).flatMap((eintrag) => {
if (["node_modules", ".git", ".next", "generated", "test-results"].includes(eintrag.name)) {
return [];
}
const pfad = join(ordner, eintrag.name);
if (eintrag.isDirectory()) return sammeln(pfad);
return /\.(ts|tsx|mjs|md|sql)$/.test(eintrag.name) ? [pfad] : [];
});
for (const datei of sammeln(wurzel)) {
const kurz = datei.replace(`${wurzel}/`, "");
// Der Devlog zitiert Tracker-Nummern aus Pull Requests; dort gilt die Regel nicht.
if (kurz === "docs/devlog.md") continue;
const text = readFileSync(datei, "utf8");
const gemeldet = new Set();
for (const treffer of text.matchAll(/#(\d{3})\b/g)) {
const nummer = treffer[1];
if (nummern.has(nummer) || gemeldet.has(nummer)) continue;
gemeldet.add(nummer);
melde(
`${kurz} verweist auf #${nummer}, aber ${verz}/${nummer}-*.md gibt es nicht. ` +
`Gemeint ist vielleicht eine Tracker-Nummer — verbindlich ist die Dateinummer.`,
);
}
}
}
// ---- Ergebnis -------------------------------------------------------------- // ---- Ergebnis --------------------------------------------------------------
if (befunde.length > 0) { if (befunde.length > 0) {
console.error(`\nDokumentation und Code laufen auseinander (${befunde.length}):\n`); console.error(`\nDokumentation und Code laufen auseinander (${befunde.length}):\n`);
@@ -20,6 +20,7 @@ const ANLASS_TEXT: Record<Meldeanlass, string> = {
LAUF_ZUGEWIESEN: "zugewiesen", LAUF_ZUGEWIESEN: "zugewiesen",
LAUF_UEBERFAELLIG: "überfällig", LAUF_UEBERFAELLIG: "überfällig",
LAUF_ABGESCHLOSSEN: "abgeschlossen", LAUF_ABGESCHLOSSEN: "abgeschlossen",
LAUF_FREIGABE_OFFEN: "wartet auf Freigabe",
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen", ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
}; };
+31
View File
@@ -11,6 +11,7 @@ import {
KommentarSchreiben, KommentarSchreiben,
LaufAbschliessen, LaufAbschliessen,
LaufArchivieren, LaufArchivieren,
LaufFreigabe,
LaufBeginnen, LaufBeginnen,
LaufMitAdKontoVerknuepfen, LaufMitAdKontoVerknuepfen,
LaufZurueckholen, LaufZurueckholen,
@@ -378,6 +379,36 @@ export default async function LaufSeite({
<Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} /> <Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} />
{/*
* Die Freigabe-Karte (#040), und zwar **ueber** der Knopfreihe: sie ist
* eine Entscheidung und kein Knopf unter anderen.
*
* Gezeigt wird sie, wenn die Version eine Freigabe verlangt und der Lauf
* noch nicht archiviert ist — danach aendert sich nichts mehr. Wer kein
* `freigeben` hat, sieht sie nur als Auskunft; das Formular bleibt ihm
* verborgen, denn ein Knopf, der nichts tut, ist kein Knopf.
*/}
{lauf.freigabePflicht && !archiviert ? (
darf(benutzer.rolle, "freigeben") ? (
<div className="mt-4">
<LaufFreigabe
laufId={lauf.id}
bereitsFreigegeben={lauf.freigegebenAm !== null}
letzteNotiz={lauf.freigabeNotiz}
/>
</div>
) : (
<p className="mt-4 max-w-prose rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext">
{lauf.freigegebenAm
? `Freigegeben${lauf.freigegebenVon ? ` von ${lauf.freigegebenVon}` : ""}.`
: "Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen " +
"werden kann. Die Freigabeberechtigten sind benachrichtigt, sobald du ihn " +
"abzuschließen versuchst."}
{lauf.freigabeNotiz ? ` Letzte Begründung: ${lauf.freigabeNotiz}` : ""}
</p>
)
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2"> <div className="mt-4 flex flex-wrap items-center gap-2">
{lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null} {lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null}
{lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? ( {lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? (
+12 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ArrowLeft, Eye } from "lucide-react"; import { ArrowLeft, Eye, ShieldCheck } from "lucide-react";
import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten"; import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten";
import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf"; import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf";
import { import {
@@ -50,6 +50,17 @@ function HistorienZeile({ zeile, darfBearbeiten }: { zeile: VersionsZeile; darfB
<span className="text-xs text-nebentext"> <span className="text-xs text-nebentext">
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"} {zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
</span> </span>
{/*
Steht an der **Version**, nicht an der Vorlage (#040): verschiedene
Versionen koennen es verschieden halten, und ein laufender Lauf behaelt
die Regel seiner Version.
*/}
{zeile.freigabePflicht ? (
<span className="flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs text-nebentext">
<ShieldCheck className="size-3" strokeWidth={2} aria-hidden="true" />
Freigabe nötig
</span>
) : null}
</div> </div>
{zeile.aenderungsnotiz ? ( {zeile.aenderungsnotiz ? (
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p> <p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p>
@@ -58,6 +58,7 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
struktur: version.struktur, struktur: version.struktur,
stand: version.stand.toISOString(), stand: version.stand.toISOString(),
aenderungsnotiz: version.aenderungsnotiz, aenderungsnotiz: version.aenderungsnotiz,
freigabePflicht: version.freigabePflicht,
}} }}
lesend={veroeffentlicht || !darfBearbeiten} lesend={veroeffentlicht || !darfBearbeiten}
darfBearbeiten={darfBearbeiten} darfBearbeiten={darfBearbeiten}
+1 -1
View File
@@ -10,7 +10,7 @@ import { prisma } from "@/lib/db";
* Datenbanknamen. Nach aussen genuegt, **ob** es geht; warum nicht, steht im * Datenbanknamen. Nach aussen genuegt, **ob** es geht; warum nicht, steht im
* Protokoll des Prozesses (`docker compose logs web`, * Protokoll des Prozesses (`docker compose logs web`,
* `journalctl -u laufzettel`). Dasselbe Muster wie in * `journalctl -u laufzettel`). Dasselbe Muster wie in
* `src/server/actions/verzeichnis.ts` (#039). * `src/server/actions/verzeichnis.ts` (#025).
*/ */
export async function GET(): Promise<Response> { export async function GET(): Promise<Response> {
try { try {
+104 -1
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import { useActionState, useState } from "react"; import { useActionState, useState } from "react";
import { Archive, CheckCircle2, Link2, PlayCircle, Undo2 } from "lucide-react"; import { Archive, CheckCircle2, Link2, PlayCircle, ShieldCheck, Undo2 } from "lucide-react";
import { import {
abschnittNichtZutreffendAktion, abschnittNichtZutreffendAktion,
kommentarHinzufuegenAktion, kommentarHinzufuegenAktion,
laufAbschliessenAktion, laufAbschliessenAktion,
laufFreigebenAktion,
laufZurueckweisenAktion,
laufArchivierenAktion, laufArchivierenAktion,
laufMitAdKontoVerknuepfenAktion, laufMitAdKontoVerknuepfenAktion,
laufOeffnenAktion, laufOeffnenAktion,
@@ -93,6 +95,107 @@ export function LaufAbschliessen({ laufId, offen }: { laufId: string; offen: num
); );
} }
/**
* Freigeben und Zurueckweisen (#040).
*
* **Eine Komponente fuer beides**, weil es dieselbe Entscheidung ist: der
* Freigebende sieht den Lauf an und sagt ja oder nein. Zwei getrennte Karten
* haetten dieselbe Notiz zweimal.
*
* Die Notiz ist bei der Freigabe freiwillig und bei der Zurueckweisung Pflicht —
* geprueft wird das in der Server Action und im Dienst, nicht hier: eine Regel,
* die nur das Formular kennt, gilt nicht fuer den naechsten Aufrufer.
*/
export function LaufFreigabe({
laufId,
bereitsFreigegeben,
letzteNotiz,
}: {
laufId: string;
bereitsFreigegeben: boolean;
/** Die letzte Begruendung – nach einer Zurueckweisung steht sie hier. */
letzteNotiz: string | null;
}) {
const [freigabe, freigeben, freigabeLaeuft] = useActionState<AktionsErgebnis, FormData>(
laufFreigebenAktion,
{},
);
const [rueckweisung, zurueckweisen, rueckweisungLaeuft] = useActionState<AktionsErgebnis, FormData>(
laufZurueckweisenAktion,
{},
);
const [notiz, setzeNotiz] = useState("");
return (
<div className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
<p className="flex items-start gap-2 text-sm">
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<span>
{bereitsFreigegeben ? (
<>Dieser Lauf ist freigegeben. Zurückweisen nimmt die Freigabe wieder zurück.</>
) : (
<>
Dieser Lauf verlangt eine <strong>Freigabe durch einen Zweiten</strong>. Wer daran gearbeitet
hat, kann sie nicht erteilen.
</>
)}
</span>
</p>
{letzteNotiz ? (
<p className="max-w-prose rounded-karte bg-flaeche-hoch p-3 text-sm text-nebentext">
Letzte Begründung: {letzteNotiz}
</p>
) : null}
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Begründung (bei einer Zurückweisung Pflicht)</span>
<textarea
name="notiz"
rows={2}
value={notiz}
onChange={(ereignis) => setzeNotiz(ereignis.target.value)}
className={`${FELD} py-2`}
placeholder="Prüfsumme fehlt im Protokoll"
/>
</label>
<div className="flex flex-wrap gap-2">
{bereitsFreigegeben ? null : (
<form action={freigeben}>
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="notiz" value={notiz} />
<button
type="submit"
disabled={freigabeLaeuft || rueckweisungLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<ShieldCheck className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{freigabeLaeuft ? "Wird freigegeben …" : "Freigeben"}
</button>
</form>
)}
<form action={zurueckweisen}>
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="notiz" value={notiz} />
<button
type="submit"
disabled={freigabeLaeuft || rueckweisungLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-5 text-sm text-nebentext hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Undo2 className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{rueckweisungLaeuft ? "Wird zurückgewiesen …" : "Zurückweisen"}
</button>
</form>
</div>
<Meldung ergebnis={freigabe} />
<Meldung ergebnis={rueckweisung} />
</div>
);
}
/** /**
* Archivieren – die zweite, ausdrueckliche Handlung. * Archivieren – die zweite, ausdrueckliche Handlung.
* *
@@ -0,0 +1,273 @@
"use client";
import { useId } from "react";
import { AlertTriangle, GitBranch } from "lucide-react";
import { FELD } from "@/lib/formular";
import {
BEDINGUNGS_ZUSTAENDE,
type BedingungsZustand,
type PfadBedingung,
VARIABLEN_OPERATOREN,
type VariablenOperator,
} from "@/lib/bedingungen";
/**
* Eine Bedingung an einem Abschnitt oder Schritt setzen (#039).
*
* **Eine Bedingung, kein Ausdrucksbaum** — das ist die Entscheidung aus dem
* Dienst, und sie zeigt sich hier: es gibt kein „und“, kein „oder“ und keine
* zweite Zeile. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte. Ein
* Formular, in dem sich beliebige Ausdrücke bauen lassen, wäre eine kleine
* Abfragesprache — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
* schlimmer als einer zu viel.
*
* **Verwiesen wird über Titel, nicht über Kennungen.** `entwurfSpeichern`
* löscht den Entwurf und schreibt ihn neu; eine Kennung überlebte das nicht.
* Titel sind außerdem das, was ein Redakteur ohnehin liest.
*/
const OPERATOR_TEXT: Record<VariablenOperator, string> = {
ist: "ist gleich",
istNicht: "ist nicht",
gesetzt: "ist ausgefüllt",
leer: "ist leer",
};
const ZUSTAND_TEXT: Record<BedingungsZustand, string> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
NICHT_ZUTREFFEND: "nicht zutreffend",
FEHLGESCHLAGEN: "fehlgeschlagen",
};
/** Ein Schritt, auf den sich eine Bedingung beziehen kann. */
export type WaehlbarerSchritt = {
readonly abschnitt: string;
readonly schritt: string;
/** Steht dieser Schritt **nach** dem bedingten Teil? Dann wird gewarnt. */
readonly spaeter: boolean;
};
export function BedingungWaehlen({
bedingung,
variablen,
schritte,
lesend,
setzen,
}: {
bedingung: PfadBedingung | undefined;
/** Die Variablen dieser Version: Schlüssel und Beschriftung. */
variablen: readonly { readonly schluessel: string; readonly beschriftung: string }[];
schritte: readonly WaehlbarerSchritt[];
lesend: boolean;
setzen: (bedingung: PfadBedingung | undefined) => void;
}) {
const kennung = useId();
const art = bedingung?.art ?? "keine";
/** Der gewählte Schritt, falls die Bedingung auf einen zeigt. */
const gewaehlterSchritt =
bedingung?.art === "schritt"
? schritte.find(
(eintrag) => eintrag.abschnitt === bedingung.abschnitt && eintrag.schritt === bedingung.schritt,
)
: undefined;
function artWechseln(neu: string): void {
if (neu === "keine") return setzen(undefined);
if (neu === "variable") {
const erste = variablen[0];
if (!erste) return setzen(undefined);
return setzen({ art: "variable", schluessel: erste.schluessel, operator: "ist", wert: "" });
}
const erster = schritte[0];
if (!erster) return setzen(undefined);
return setzen({
art: "schritt",
abschnitt: erster.abschnitt,
schritt: erster.schritt,
zustaende: ["ERLEDIGT"],
});
}
return (
<details className="rounded-karte border border-linie">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-3 text-sm text-nebentext hover:text-text md:min-h-10">
<GitBranch className="size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
{bedingung ? "Bedingung gesetzt" : "Bedingung (optional)"}
</summary>
<div className="flex flex-col gap-3 border-t border-linie p-3">
{/*
`htmlFor` und `id` statt eines umhuellenden `label`: der zugaengliche
Name eines `select` im Label schluckt sonst den Text der gewaehlten
Option mit („VariableKontotyp“). Fuer einen Screenreader ist das eine
Bezeichnung, die sich beim Umschalten aendert — und kein Werkzeug
findet das Feld noch ueber seinen Namen.
*/}
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-art`} className="text-nebentext">
Gilt
</label>
<select
id={`${kennung}-art`}
value={art}
disabled={lesend}
onChange={(ereignis) => artWechseln(ereignis.target.value)}
className={FELD}
>
<option value="keine">immer</option>
<option value="variable" disabled={variablen.length === 0}>
abhängig von einer Variable
</option>
<option value="schritt" disabled={schritte.length === 0}>
abhängig von einem Schritt
</option>
</select>
</div>
{variablen.length === 0 && schritte.length === 0 ? (
<p className="text-xs text-nebentext">
Für eine Bedingung braucht die Vorlage eine Variable oder einen anderen Schritt.
</p>
) : null}
{bedingung?.art === "variable" ? (
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-variable`} className="text-nebentext">
Variable
</label>
<select
id={`${kennung}-variable`}
value={bedingung.schluessel}
disabled={lesend}
onChange={(ereignis) => setzen({ ...bedingung, schluessel: ereignis.target.value })}
className={FELD}
>
{variablen.map((variable) => (
<option key={variable.schluessel} value={variable.schluessel}>
{variable.beschriftung}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-vergleich`} className="text-nebentext">
Vergleich
</label>
<select
id={`${kennung}-vergleich`}
value={bedingung.operator}
disabled={lesend}
onChange={(ereignis) =>
setzen({ ...bedingung, operator: ereignis.target.value as VariablenOperator })
}
className={FELD}
>
{VARIABLEN_OPERATOREN.map((operator) => (
<option key={operator} value={operator}>
{OPERATOR_TEXT[operator]}
</option>
))}
</select>
</div>
{/*
Nur bei „ist“ und „ist nicht“: `gesetzt` und `leer` vergleichen mit
nichts, und ein Feld, das nichts tut, verwirrt.
*/}
{bedingung.operator === "ist" || bedingung.operator === "istNicht" ? (
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-wert`} className="text-nebentext">
Wert
</label>
<input
id={`${kennung}-wert`}
value={bedingung.wert ?? ""}
disabled={lesend}
onChange={(ereignis) => setzen({ ...bedingung, wert: ereignis.target.value })}
placeholder="AD"
className={FELD}
/>
</div>
) : null}
</div>
) : null}
{bedingung?.art === "schritt" ? (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-schritt`} className="text-nebentext">
Schritt
</label>
<select
id={`${kennung}-schritt`}
value={`${bedingung.abschnitt}\u0000${bedingung.schritt}`}
disabled={lesend}
onChange={(ereignis) => {
const [abschnitt, schritt] = ereignis.target.value.split("\u0000");
if (abschnitt && schritt) setzen({ ...bedingung, abschnitt, schritt });
}}
className={FELD}
>
{schritte.map((eintrag) => (
<option
key={`${eintrag.abschnitt}\u0000${eintrag.schritt}`}
value={`${eintrag.abschnitt}\u0000${eintrag.schritt}`}
>
{eintrag.abschnitt} · {eintrag.schritt}
</option>
))}
</select>
</div>
<fieldset className="flex flex-col gap-2 text-sm">
<legend className="mb-1 text-nebentext">… ist</legend>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{BEDINGUNGS_ZUSTAENDE.map((zustand) => (
<label key={zustand} className="flex min-h-11 items-center gap-2 md:min-h-10">
<input
type="checkbox"
id={`${kennung}-${zustand}`}
checked={bedingung.zustaende.includes(zustand)}
disabled={lesend}
onChange={(ereignis) => {
const naechste = ereignis.target.checked
? [...bedingung.zustaende, zustand]
: bedingung.zustaende.filter((eintrag) => eintrag !== zustand);
/*
* Mindestens einer bleibt gesetzt: eine leere Liste wäre
* eine Bedingung, die nie zutrifft — ein Abschnitt, der
* für immer unsichtbar bleibt.
*/
if (naechste.length > 0) setzen({ ...bedingung, zustaende: naechste });
}}
className="size-4 accent-akzent"
/>
{ZUSTAND_TEXT[zustand]}
</label>
))}
</div>
</fieldset>
{gewaehlterSchritt?.spaeter ? (
/*
* Eine Warnung und kein Verbot: erlaubt ist es, nur fast immer ein
* Versehen. Der bedingte Teil wäre zunächst unsichtbar und
* erschiene erst, nachdem jemand weiter unten etwas abgehakt hat —
* also rückwärts, und damit rechnet niemand.
*/
<p role="status" className="flex items-start gap-2 text-xs text-faellig">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Dieser Schritt kommt <strong>später</strong> als der bedingte Teil. Er wäre beim Abarbeiten
zunächst unsichtbar und erschiene erst rückwärts. Meist ist ein Schritt davor gemeint.
</span>
</p>
) : null}
</div>
) : null}
</div>
</details>
);
}
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { Blocks } from "lucide-react"; import { Blocks } from "lucide-react";
import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen"; import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen";
import { BedingungWaehlen } from "@/components/vorlagen/bedingung-waehlen";
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder"; import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren"; import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import { SchrittKarte } from "@/components/vorlagen/editor-schritt"; import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
@@ -32,6 +33,8 @@ export function AbschnittKarte({
anzahl, anzahl,
lesend, lesend,
abteilungen, abteilungen,
variablen,
alleSchritte,
zuletztAngelegt, zuletztAngelegt,
anfasser, anfasser,
aendern, aendern,
@@ -48,6 +51,14 @@ export function AbschnittKarte({
lesend: boolean; lesend: boolean;
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */ /** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
abteilungen: readonly string[]; abteilungen: readonly string[];
/** Die Variablen dieser Version – für eine Bedingung (#039). */
variablen: readonly { readonly schluessel: string; readonly beschriftung: string }[];
/** Alle Schritte der Version in Abarbeitungsreihenfolge, mit laufender Nummer. */
alleSchritte: readonly {
readonly abschnitt: string;
readonly schritt: string;
readonly stelle: number;
}[];
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */ /** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
zuletztAngelegt: string | null; zuletztAngelegt: string | null;
anfasser: React.ReactNode; anfasser: React.ReactNode;
@@ -62,6 +73,12 @@ export function AbschnittKarte({
const [offen, setzeOffen] = useState(true); const [offen, setzeOffen] = useState(true);
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt; const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
const anzahlSchritte = abschnitt.schritte.length; const anzahlSchritte = abschnitt.schritte.length;
/*
* Die laufende Nummer des ersten Schritts dieses Abschnitts. Alles ab hier
* gilt als „spaeter“ — der Abschnitt beginnt dort.
*/
const ersteStelleDiesesAbschnitts =
alleSchritte.find((eintrag) => eintrag.abschnitt === abschnitt.titel)?.stelle ?? alleSchritte.length;
const baustein = abschnitt.baustein; const baustein = abschnitt.baustein;
return ( return (
@@ -159,6 +176,27 @@ export function AbschnittKarte({
setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })} setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })}
/> />
{/*
Die Bedingung gehoert wie die Abteilungseinschraenkung zur **Vorlage**
und nicht zum Baustein — deshalb steht sie auch bei einem
Baustein-Abschnitt (#039, wie #031).
Als „spaeter“ gilt ein Schritt, der nach dem **ersten** Schritt dieses
Abschnitts kommt: der Abschnitt beginnt dort, und was danach steht, wuerde
ihn rueckwaerts erscheinen lassen.
*/}
<BedingungWaehlen
bedingung={abschnitt.bedingung}
variablen={variablen}
schritte={alleSchritte.map((eintrag) => ({
abschnitt: eintrag.abschnitt,
schritt: eintrag.schritt,
spaeter: eintrag.stelle >= ersteStelleDiesesAbschnitts,
}))}
lesend={lesend}
setzen={(bedingung) => aendern({ bedingung })}
/>
{baustein ? null : anzahlSchritte === 0 ? ( {baustein ? null : anzahlSchritte === 0 ? (
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p> <p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
) : ( ) : (
@@ -175,6 +213,9 @@ export function AbschnittKarte({
<SchrittKarte <SchrittKarte
schritt={schritt} schritt={schritt}
index={stelle} index={stelle}
variablen={variablen}
alleSchritte={alleSchritte}
eigeneStelle={ersteStelleDiesesAbschnitts + stelle}
anzahl={anzahlSchritte} anzahl={anzahlSchritte}
lesend={lesend} lesend={lesend}
neu={schritt.kennung === zuletztAngelegt} neu={schritt.kennung === zuletztAngelegt}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { BedingungWaehlen } from "@/components/vorlagen/bedingung-waehlen";
import { import {
Auswahlfeld, Auswahlfeld,
Flaechenfeld, Flaechenfeld,
@@ -138,6 +139,9 @@ export function SchrittKarte({
anzahl, anzahl,
lesend, lesend,
neu, neu,
variablen,
alleSchritte,
eigeneStelle,
anfasser, anfasser,
aendern, aendern,
loeschen, loeschen,
@@ -149,6 +153,23 @@ export function SchrittKarte({
lesend: boolean; lesend: boolean;
/** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */ /** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */
neu: boolean; neu: boolean;
/**
* Die Variablen dieser Version und alle Schritte – für eine Bedingung (#039).
*
* **Fehlen sie, gibt es keine Bedingung**, und das ist der Fall im
* Baustein-Editor: ein Baustein ist wiederverwendbar, und eine Bedingung auf
* einer Variable, die es in der Zielvorlage nicht gibt, wäre dort nicht
* entscheidbar. Der Abschnitt, in dem er eingesetzt wird, darf eine haben; der
* Schritt im Baustein nicht.
*/
variablen?: readonly { readonly schluessel: string; readonly beschriftung: string }[];
alleSchritte?: readonly {
readonly abschnitt: string;
readonly schritt: string;
readonly stelle: number;
}[];
/** Die laufende Nummer **dieses** Schritts – alles danach gilt als „später“. */
eigeneStelle?: number;
anfasser: React.ReactNode; anfasser: React.ReactNode;
aendern: (teil: Partial<EditorSchritt>) => void; aendern: (teil: Partial<EditorSchritt>) => void;
loeschen: () => void; loeschen: () => void;
@@ -254,6 +275,26 @@ export function SchrittKarte({
vorschlag="https://intern.example/anleitung" vorschlag="https://intern.example/anleitung"
hinweis="Nur http- oder https-Adressen." hinweis="Nur http- oder https-Adressen."
/> />
{/*
Eine Bedingung **am einzelnen Schritt** (#039): sie blendet nur ihn aus,
nicht den Abschnitt. Der eigene Schritt steht in der Liste mit; eine
Bedingung auf sich selbst waere nie entscheidbar, und genau deshalb gilt
er als „spaeter“ und wird angewarnt.
*/}
{variablen && alleSchritte ? (
<BedingungWaehlen
bedingung={schritt.bedingung}
variablen={variablen}
schritte={alleSchritte.map((eintrag) => ({
abschnitt: eintrag.abschnitt,
schritt: eintrag.schritt,
spaeter: eintrag.stelle >= (eigeneStelle ?? 0),
}))}
lesend={lesend}
setzen={(bedingung) => aendern({ bedingung })}
/>
) : null}
</KlappKarte> </KlappKarte>
); );
} }
+45
View File
@@ -26,6 +26,7 @@ import {
type EditorVariable, type EditorVariable,
} from "@/lib/editor-aufbau"; } from "@/lib/editor-aufbau";
import { unbekanntePlatzhalter } from "@/lib/platzhalter"; import { unbekanntePlatzhalter } from "@/lib/platzhalter";
import { KAESTCHEN } from "@/lib/formular";
import { GRENZEN, type Struktur } from "@/lib/vorlagen-format"; import { GRENZEN, type Struktur } from "@/lib/vorlagen-format";
import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen"; import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen";
@@ -52,6 +53,8 @@ export type EditorVersion = {
/** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */ /** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */
readonly stand: string; readonly stand: string;
readonly aenderungsnotiz: string | null; readonly aenderungsnotiz: string | null;
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
readonly freigabePflicht: boolean;
}; };
/** /**
@@ -208,6 +211,20 @@ export function Editor({
const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte; const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte;
/*
* Die Schritte, auf die eine Bedingung zeigen kann (#039) — **alle**, und zu
* jedem die Auskunft, ob er spaeter kommt. Einmal berechnet und an jede Karte
* gegeben: jede Karte selbst rechnen zu lassen waere dieselbe Schleife je
* Abschnitt, und die Reihenfolge ist fuer alle dieselbe.
*
* Die laufende Nummer entscheidet ueber „spaeter“, nicht die Position im
* Abschnitt: ein Schritt in Abschnitt 1 kommt vor jedem in Abschnitt 2.
*/
const reihenfolge = aufbau.abschnitte.flatMap((abschnitt) =>
abschnitt.schritte.map((schritt) => ({ abschnitt: abschnitt.titel, schritt: schritt.titel })),
);
const waehlbareSchritte = reihenfolge.map((eintrag, stelle) => ({ ...eintrag, stelle }));
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<header className="flex flex-col gap-3"> <header className="flex flex-col gap-3">
@@ -340,6 +357,8 @@ export function Editor({
index={index} index={index}
anzahl={aufbau.abschnitte.length} anzahl={aufbau.abschnitte.length}
abteilungen={abteilungen} abteilungen={abteilungen}
variablen={aufbau.variablen}
alleSchritte={waehlbareSchritte}
lesend={lesend} lesend={lesend}
zuletztAngelegt={zuletztAngelegt} zuletztAngelegt={zuletztAngelegt}
anfasser={anfasser} anfasser={anfasser}
@@ -451,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
+426
View File
@@ -0,0 +1,426 @@
import { z } from "@/lib/validierung";
/**
* Bedingte Abschnitte und Schritte (#039).
*
* Framework-frei und ohne Prisma, wie `src/lib/geltende-abschnitte.ts` — und aus
* demselben Grund: **drei Schichten** brauchen die Auswertung. Der Dienst beim
* Schreiben eines Schrittergebnisses und beim Abschluss, die Lesemodelle der
* Abarbeitung und der Druckansicht, und der Editor für seine Vorschau.
*
* ## Was eine Bedingung ist — und was sie nicht ist
*
* **Eine Bedingung, kein Ausdrucksbaum.** Kein `und`, kein `oder`, keine
* Verschachtelung. Das wäre eine kleine Abfragesprache, die im Editor niemand
* pflegen kann — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
* schlimmer als einer zu viel. Wer zwei Bedingungen braucht, nimmt zwei
* Abschnitte oder zwei Vorlagen.
*
* Zwei Arten, und die Wahl entscheidet, **wann** die Antwort entsteht:
*
* - `variable` — hängt an einem Wert, der beim Start erfasst wird. Der echte
* Fall: die Onboarding-Vorlage trägt beide Konto-Abschnitte, und `konto_typ`
* sagt längst, welcher gilt.
* - `schritt` — hängt am Zustand eines anderen Schritts derselben Version. Die
* Antwort entsteht erst **während** der Arbeit; deshalb wird laufend
* ausgewertet und nicht beim Start festgeschrieben wie bei #031.
*/
/* ------------------------------------------------------------------ */
/* Form */
/* ------------------------------------------------------------------ */
/**
* Die Vergleiche auf einer Variable.
*
* `ist` und `istNicht` vergleichen als **Text**, ohne Rücksicht auf Groß- und
* Kleinschreibung und mit getrimmten Rändern: die Werte kommen aus einem
* Auswahlfeld oder aus einem Formular, und „AD“ ist derselbe Wert wie „ad“.
* Ein Zahlenvergleich (`groesser`, `kleiner`) fehlt absichtlich — er hatte
* bisher keinen Fall, und ungenutzter Code ist ungeprüfter Code.
*/
export const VARIABLEN_OPERATOREN = ["ist", "istNicht", "gesetzt", "leer"] as const;
export type VariablenOperator = (typeof VARIABLEN_OPERATOREN)[number];
/** Die Zustände, auf die eine Schritt-Bedingung zeigen kann. */
export const BEDINGUNGS_ZUSTAENDE = ["OFFEN", "ERLEDIGT", "NICHT_ZUTREFFEND", "FEHLGESCHLAGEN"] as const;
export type BedingungsZustand = (typeof BEDINGUNGS_ZUSTAENDE)[number];
const VariablenBedingungSchema = z.object({
art: z.literal("variable"),
schluessel: z.string().trim().min(1),
operator: z.enum(VARIABLEN_OPERATOREN),
/*
* Nur bei `ist` und `istNicht` von Bedeutung. Bewusst **nicht** über
* `discriminatedUnion` je Operator aufgeteilt: das ergäbe vier Schemas für
* einen Unterschied, den ein `refine` in einer Zeile sagt.
*/
wert: z.string().optional(),
});
const SchrittBedingungSchema = z.object({
art: z.literal("schritt"),
schrittId: z.string().trim().min(1),
/*
* Eine **Liste** von Zuständen, nicht einer. Das ist ein `in`-Vergleich und
* kein verstecktes `oder`: „erledigt oder nicht zutreffend“ ist eine Frage,
* nicht zwei. Leer wäre eine Bedingung, die nie zutrifft — deshalb mindestens
* einer.
*/
zustaende: z.array(z.enum(BEDINGUNGS_ZUSTAENDE)).min(1),
});
export const BedingungSchema = z
.discriminatedUnion("art", [VariablenBedingungSchema, SchrittBedingungSchema])
.refine(
(bedingung) =>
bedingung.art !== "variable" ||
(bedingung.operator !== "ist" && bedingung.operator !== "istNicht") ||
(bedingung.wert ?? "").trim().length > 0,
{ message: "Für „ist“ und „ist nicht“ gehört ein Wert dazu." },
);
export type Bedingung = z.infer<typeof BedingungSchema>;
/**
* Liest eine gespeicherte Bedingung aus dem Json-Feld.
*
* **Nachsichtig beim Lesen**: was nicht zum Schema passt, gilt als *keine*
* Bedingung — der Abschnitt wird dann angezeigt. Hier zu werfen hieße, dass eine
* einzige unlesbare Zeile eine ganze Vorlage unbenutzbar macht; und die
* Richtung, in die der Fehler dann fällt, ist die harmlose: ein Abschnitt zu
* viel ist sichtbar und korrigierbar, ein fehlender fällt niemandem auf.
*
* Dass gar nichts Unlesbares hineinkommt, hält das Schema beim **Schreiben**.
*/
export function bedingungLesen(roh: unknown): Bedingung | null {
if (roh === null || roh === undefined) return null;
const gelesen = BedingungSchema.safeParse(roh);
return gelesen.success ? gelesen.data : null;
}
/* ------------------------------------------------------------------ */
/* Auswertung */
/* ------------------------------------------------------------------ */
/** Was zur Auswertung bekannt sein muss. */
export type Umgebung = {
/** Die Werte der Variablen dieses Laufs (`Lauf.variablen`). */
readonly variablen: Readonly<Record<string, unknown>>;
/** Der Zustand je Schritt-Kennung, soweit es ein Schrittergebnis gibt. */
readonly zustaende: ReadonlyMap<string, BedingungsZustand>;
};
function alsText(wert: unknown): string {
if (wert === null || wert === undefined) return "";
if (typeof wert === "string") return wert.trim();
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
return "";
}
const gleich = (a: string, b: string): boolean =>
a.toLocaleLowerCase("de") === b.trim().toLocaleLowerCase("de");
/**
* Trifft diese Bedingung zu?
*
* `undefined` heißt **nicht entscheidbar**, und das ist nicht dasselbe wie
* „nein“. Der Aufrufer zeigt in diesem Fall an (siehe `geltendeTeile`).
*
* **Der Unterschied zwischen den beiden Arten ist wichtig:**
*
* - Eine Bedingung auf einer Variable, die **nicht gesetzt** ist, ist bei `ist`
* und `istNicht` nicht entscheidbar. Ein Lauf, bei dem niemand `konto_typ`
* angegeben hat, soll **beide** Konto-Abschnitte zeigen: ausblenden hieße,
* eine Antwort zu erfinden, die niemand gegeben hat.
* - Eine Bedingung auf einem Schritt ist **immer** entscheidbar, sobald es den
* Schritt gibt: jedes Schrittergebnis hat einen Zustand, und `OFFEN` ist eine
* Antwort und keine Lücke. Ein Abschnitt, der erst nach einem erledigten
* Schritt erscheint, ist der Sinn der Sache.
* Nur ein Schritt, den es in diesem Lauf **gar nicht** gibt (ausgefiltert
* durch #031, oder ein Verweis aus einer anderen Version), ist nicht
* entscheidbar.
*/
export function bedingungErfuellt(bedingung: Bedingung, umgebung: Umgebung): boolean | undefined {
if (bedingung.art === "variable") {
const wert = alsText(umgebung.variablen[bedingung.schluessel]);
switch (bedingung.operator) {
case "gesetzt":
return wert.length > 0;
case "leer":
return wert.length === 0;
case "ist":
return wert.length === 0 ? undefined : gleich(wert, bedingung.wert ?? "");
case "istNicht":
return wert.length === 0 ? undefined : !gleich(wert, bedingung.wert ?? "");
default: {
// Ein neuer Operator faellt beim Uebersetzen auf und nicht im Betrieb.
const unbekannt: never = bedingung.operator;
return void unbekannt as undefined;
}
}
}
const zustand = umgebung.zustaende.get(bedingung.schrittId);
if (zustand === undefined) return undefined;
return bedingung.zustaende.includes(zustand);
}
/** Ein Abschnitt oder Schritt, wie die Auswertung ihn braucht. */
export type MitBedingung = {
readonly id: string;
readonly bedingung: unknown;
};
/**
* Welche Teile gelten.
*
* **Nicht entscheidbar heißt: gilt.** Das ist die Richtung, in die der Zweifel
* fallen soll — ein Abschnitt zu viel ist sichtbar, und jemand setzt ihn auf
* `NICHT_ZUTREFFEND`. Ein fehlender Abschnitt fällt niemandem auf, und genau
* das ist bei einer Checkliste der teure Fall.
*/
export function geltendeTeile(teile: readonly MitBedingung[], umgebung: Umgebung): readonly string[] {
return teile
.filter((teil) => {
const bedingung = bedingungLesen(teil.bedingung);
if (!bedingung) return true;
return bedingungErfuellt(bedingung, umgebung) !== false;
})
.map((teil) => teil.id);
}
/**
* Die Umgebung aus einem Lauf bauen.
*
* Eine eigene Funktion, weil **jede** Schicht sie gleich bauen muss: aus den
* Variablen des Laufs und den Zuständen seiner Schrittergebnisse. Zweimal
* geschrieben lief es auseinander — dieselbe Lehre wie bei den Grenzen des
* Abgleich-Intervalls (#016).
*/
export function umgebungBauen(
variablen: unknown,
ergebnisse: readonly {
readonly vorlagenSchrittId: string;
readonly zustand: string;
}[],
): Umgebung {
const werte =
typeof variablen === "object" && variablen !== null && !Array.isArray(variablen)
? (variablen as Record<string, unknown>)
: {};
const zustaende = new Map<string, BedingungsZustand>();
for (const ergebnis of ergebnisse) {
if ((BEDINGUNGS_ZUSTAENDE as readonly string[]).includes(ergebnis.zustand)) {
zustaende.set(ergebnis.vorlagenSchrittId, ergebnis.zustand as BedingungsZustand);
}
}
return { variablen: werte, zustaende };
}
/**
* Die Struktur eines Laufs, auf das Geltende eingedampft.
*
* **Die Reihenfolge ist nicht beliebig, und sie ist der Grund für diese
* Funktion.** Zwei Filter wirken übereinander:
*
* 1. `nurGeltende` aus #031 — die Abteilungsabschnitte, **beim Start
* festgeschrieben** in `Lauf.geltendeAbschnitte`.
* 2. die Bedingungen von hier — **laufend** ausgewertet, weil eine Bedingung auf
* einem Schrittergebnis erst während der Arbeit eine Antwort hat.
*
* Wer nur den ersten anwendet, bekommt Abschnitte, die nicht gelten; wer nur den
* zweiten anwendet, umgeht die Abteilungsregel. Beide zusammen gehören an **eine**
* Stelle, sonst vergisst sie eine der drei Schichten — genau wie bei #031, und
* `tests/bedingte-laeufe.test.ts` prüft strukturell, dass keine es tut.
*
* Ein Abschnitt, der gilt, kann Schritte enthalten, die **nicht** gelten: eine
* Bedingung steht an beiden.
*/
export function geltendeStruktur<
S extends MitBedingung,
A extends MitBedingung & { readonly schritte: readonly S[] },
>(abschnitte: readonly A[], umgebung: Umgebung): readonly A[] {
const erlaubteAbschnitte = new Set(geltendeTeile(abschnitte, umgebung));
return abschnitte
.filter((abschnitt) => erlaubteAbschnitte.has(abschnitt.id))
.map((abschnitt) => {
const erlaubteSchritte = new Set(geltendeTeile(abschnitt.schritte, umgebung));
return {
...abschnitt,
schritte: abschnitt.schritte.filter((schritt) => erlaubteSchritte.has(schritt.id)),
};
});
}
/* ------------------------------------------------------------------ */
/* Titel-Pfade: Austausch und Entwurf */
/* ------------------------------------------------------------------ */
/**
* Der Schlüssel eines Schritts in einer Struktur, aus Abschnitts- und
* Schritt-Titel.
*
* **Warum Titel und nicht Kennungen.** Zwei Wege schreiben eine Struktur neu und
* geben dabei neue Kennungen: der Import einer Austauschdatei und
* `entwurfSpeichern`, das den Entwurf löscht und wieder anlegt. Eine Bedingung
* auf einer Kennung überlebt keinen von beiden. Ein Titel-Pfad überlebt beide —
* und eine von Hand geschriebene Datei kann ihn setzen.
*
* **Die Grenze, und sie ist benannt:** zwei Schritte mit demselben Titel im
* selben Abschnitt sind so nicht unterscheidbar; die Bedingung zeigt dann auf
* den ersten. Das ist ein Grenzfall, den eine Checkliste nicht braucht — zwei
* gleich benannte Schritte nebeneinander kann auch ein Mensch nicht
* unterscheiden.
*
* `\u0000` als Trenner, weil es in keinem Titel vorkommt: ein `·` oder `/` schon.
*/
export function schrittPfad(abschnittsTitel: string, schrittTitel: string): string {
return `${abschnittsTitel}\u0000${schrittTitel}`;
}
/**
* Eine Bedingung in der Pfad-Form — über Titel statt Kennungen.
*
* **Als Zod-Schema und an genau einer Stelle.** Austauschformat und
* Editor-Struktur brauchen dieselbe Form; zwei Schemas dafür liefen auseinander,
* und TypeScript hätte es erst gemeldet, als die beiden Typen aufeinandertrafen.
* `src/lib/vorlagen-format.ts` importiert es von hier.
*/
const PFAD_TEXT_MAX = 500;
export const PfadBedingungSchema = z.discriminatedUnion("art", [
z.object({
art: z.literal("variable"),
schluessel: z.string().trim().min(1).max(PFAD_TEXT_MAX),
operator: z.enum(VARIABLEN_OPERATOREN),
wert: z.string().trim().max(PFAD_TEXT_MAX).optional(),
}),
z.object({
art: z.literal("schritt"),
abschnitt: z.string().trim().min(1).max(PFAD_TEXT_MAX),
schritt: z.string().trim().min(1).max(PFAD_TEXT_MAX),
zustaende: z.array(z.enum(BEDINGUNGS_ZUSTAENDE)).min(1),
}),
]);
export type PfadBedingung = z.infer<typeof PfadBedingungSchema>;
/**
* Aus einer Pfad-Bedingung die gespeicherte Form.
*
* `null`, wenn der Pfad in dieser Struktur nicht vorkommt. Der Aufrufer lässt
* die Bedingung dann **weg**, statt eine mit einem Verweis ins Leere zu
* schreiben: die wäre nicht entscheidbar, der Abschnitt erschiene also immer —
* und niemand könnte sagen, warum seine Bedingung nicht wirkt.
*/
export function bedingungAufloesen(
bedingung: PfadBedingung,
kennungen: ReadonlyMap<string, string>,
): Bedingung | null {
if (bedingung.art === "variable") {
return {
art: "variable",
schluessel: bedingung.schluessel,
operator: bedingung.operator,
...(bedingung.wert === undefined ? {} : { wert: bedingung.wert }),
};
}
const schrittId = kennungen.get(schrittPfad(bedingung.abschnitt, bedingung.schritt));
if (!schrittId) return null;
return { art: "schritt", schrittId, zustaende: [...bedingung.zustaende] };
}
/**
* Die Gegenrichtung: aus der gespeicherten Form eine Pfad-Bedingung.
*
* `null`, wenn der Schritt nicht in der Karte steht — dann gibt es keinen Pfad,
* den man aufschreiben könnte, und die Bedingung wird weggelassen.
*/
export function bedingungZuPfad(
roh: unknown,
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
): PfadBedingung | null {
const bedingung = bedingungLesen(roh);
if (!bedingung) return null;
if (bedingung.art === "variable") {
return {
art: "variable",
schluessel: bedingung.schluessel,
operator: bedingung.operator,
...(bedingung.wert === undefined ? {} : { wert: bedingung.wert }),
};
}
const pfad = pfade.get(bedingung.schrittId);
if (!pfad) return null;
return {
art: "schritt",
abschnitt: pfad.abschnitt,
schritt: pfad.schritt,
zustaende: [...bedingung.zustaende],
};
}
/* ------------------------------------------------------------------ */
/* Für den Editor */
/* ------------------------------------------------------------------ */
/**
* Zeigt diese Bedingung auf einen Schritt, der **später** kommt?
*
* Eine Warnung und kein Fehler: so etwas ist nicht verboten, nur fast immer ein
* Versehen. Der bedingte Abschnitt wäre beim Abarbeiten zunächst unsichtbar und
* erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also
* rückwärts, und niemand rechnet damit.
*
* `undefined`, wenn die Bedingung nicht auf einen Schritt zeigt oder der Schritt
* nicht gefunden wird; dann gibt es nichts zu warnen.
*/
export function zeigtNachVorne(
bedingung: Bedingung,
/** Alle Schritte der Version, in Abarbeitungsreihenfolge. */
reihenfolge: readonly string[],
/** Die Kennung des bedingten Teils — ein Schritt, oder der erste eines Abschnitts. */
eigeneId: string,
): boolean | undefined {
if (bedingung.art !== "schritt") return undefined;
const ziel = reihenfolge.indexOf(bedingung.schrittId);
const selbst = reihenfolge.indexOf(eigeneId);
if (ziel < 0 || selbst < 0) return undefined;
return ziel > selbst;
}
/** Eine Bedingung als Satz, für Editor und Vorschau. */
export function bedingungText(
bedingung: Bedingung,
/** Beschriftung je Variablenschlüssel bzw. Titel je Schritt-Kennung. */
beschriftung: (kennung: string) => string,
): string {
if (bedingung.art === "variable") {
const name = beschriftung(bedingung.schluessel);
switch (bedingung.operator) {
case "gesetzt":
return `gilt, wenn „${name}“ ausgefüllt ist`;
case "leer":
return `gilt, wenn „${name}“ leer ist`;
case "ist":
return `gilt, wenn „${name}“ gleich „${bedingung.wert ?? ""}“ ist`;
case "istNicht":
return `gilt, wenn „${name}“ nicht „${bedingung.wert ?? ""}“ ist`;
default: {
const unbekannt: never = bedingung.operator;
return String(unbekannt);
}
}
}
const ZUSTAND_TEXT: Record<BedingungsZustand, string> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
NICHT_ZUTREFFEND: "nicht zutreffend",
FEHLGESCHLAGEN: "fehlgeschlagen",
};
const zustaende = bedingung.zustaende.map((zustand) => ZUSTAND_TEXT[zustand]).join(" oder ");
return `gilt, wenn „${beschriftung(bedingung.schrittId)}“ ${zustaende} ist`;
}
+14
View File
@@ -1,3 +1,4 @@
import type { PfadBedingung } from "@/lib/bedingungen";
import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter"; import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter";
import { import {
SCHRITT_TYPEN, SCHRITT_TYPEN,
@@ -35,6 +36,8 @@ export type EditorSchritt = {
readonly pflicht: boolean; readonly pflicht: boolean;
readonly konfig: SchrittKonfig; readonly konfig: SchrittKonfig;
readonly dokuUrl: string; readonly dokuUrl: string;
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
readonly bedingung?: PfadBedingung;
}; };
export type EditorAbschnitt = { export type EditorAbschnitt = {
@@ -43,6 +46,13 @@ export type EditorAbschnitt = {
readonly schritte: readonly EditorSchritt[]; readonly schritte: readonly EditorSchritt[];
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */ /** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
readonly nurFuerAbteilungen: readonly string[]; readonly nurFuerAbteilungen: readonly string[];
/**
* Wann dieser Abschnitt gilt (#039). Fehlt: immer.
*
* Verweist ueber **Titel**, nicht ueber Kennungen: `entwurfSpeichern` loescht
* den Entwurf und schreibt ihn neu, eine Kennung ueberlebte das nicht.
*/
readonly bedingung?: PfadBedingung;
/** /**
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030). * Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
* *
@@ -308,6 +318,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer. // Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
titel: titelOhneNummer(abschnitt.titel), titel: titelOhneNummer(abschnitt.titel),
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung }),
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie // Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030). // getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
...(abschnitt.bausteinVersionId === undefined ...(abschnitt.bausteinVersionId === undefined
@@ -321,6 +332,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig: schritt.konfig, konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl ?? "", dokuUrl: schritt.dokuUrl ?? "",
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
})), })),
})), })),
}; };
@@ -352,6 +364,7 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
nurFuerAbteilungen: [ nurFuerAbteilungen: [
...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)), ...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)),
], ],
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung }),
/* /*
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim * Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur * Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
@@ -367,6 +380,7 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig: schritt.konfig, konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(), dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
})), })),
})), })),
}; };
+20 -1
View File
@@ -113,9 +113,28 @@ export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): M
]), ]),
}; };
case Meldeanlass.LAUF_FREIGABE_OFFEN:
return {
betreff: `Laufzettel wartet auf Freigabe: „${kopf.titel}“`,
rumpf: zusammensetzen([
/*
* **Der Satz nennt die Regel**, denn der Empfaenger ist oft nicht
* derjenige, der gearbeitet hat — und soll nicht raten muessen, warum
* er gefragt wird.
*/
kopf.handelnder
? `${kopf.handelnder} hat diesen Laufzettel abgearbeitet. Er verlangt eine Freigabe ` +
"durch einen Zweiten, der nicht daran gearbeitet hat."
: "Dieser Laufzettel verlangt eine Freigabe durch einen Zweiten, der nicht daran " +
"gearbeitet hat.",
daten,
link,
]),
};
default: { default: {
/* /*
* Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese * Kaeme ein weiterer Anlass dazu, der zu einem Lauf gehoert, ist diese
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile * Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
* totem Code im Ergebnis. * totem Code im Ergebnis.
*/ */
+13
View File
@@ -1,5 +1,6 @@
import { istAlsVariableErlaubt } from "@/lib/platzhalter"; import { istAlsVariableErlaubt } from "@/lib/platzhalter";
import { z } from "@/lib/validierung"; import { z } from "@/lib/validierung";
import { PfadBedingungSchema, type PfadBedingung } from "@/lib/bedingungen";
/** /**
* Aufbau einer Vorlagen-Version (#009). * Aufbau einer Vorlagen-Version (#009).
@@ -72,6 +73,14 @@ export const SchrittKonfigSchema = z.looseObject({
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>; export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
/*
* Die Form einer Bedingung steht in `src/lib/bedingungen.ts` — **an einer
* Stelle** (#039). Austauschformat und Editor-Struktur benutzen dieselbe, und
* zwei Schemas dafuer liefen auseinander.
*/
export const AustauschBedingungSchema = PfadBedingungSchema;
export type AustauschBedingung = PfadBedingung;
export const SchrittSchema = z.object({ export const SchrittSchema = z.object({
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text), titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */ /** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
@@ -84,6 +93,8 @@ export const SchrittSchema = z.object({
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein * und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei. * gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
*/ */
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
bedingung: AustauschBedingungSchema.optional(),
dokuUrl: leerAlsUndefiniert( dokuUrl: leerAlsUndefiniert(
z.url({ z.url({
protocol: /^https?$/, protocol: /^https?$/,
@@ -119,6 +130,8 @@ export const AbschnittSchema = z.object({
.array(z.string().trim().min(1).max(200)) .array(z.string().trim().min(1).max(200))
.max(50, "Höchstens 50 Abteilungen je Abschnitt.") .max(50, "Höchstens 50 Abteilungen je Abschnitt.")
.default([]), .default([]),
/** Wann dieser Abschnitt gilt (#039). Fehlt: immer. */
bedingung: AustauschBedingungSchema.optional(),
schritte: z schritte: z
.array(SchrittSchema) .array(SchrittSchema)
.max( .max(
+53
View File
@@ -9,6 +9,8 @@ import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { import {
LaufFehler, LaufFehler,
laufAbschliessen, laufAbschliessen,
laufFreigeben,
laufZurueckweisen,
laufAnlegen, laufAnlegen,
laufArchivieren, laufArchivieren,
laufMitAdKontoVerknuepfen, laufMitAdKontoVerknuepfen,
@@ -377,6 +379,57 @@ export async function laufAbschliessenAktion(
} }
} }
export async function laufFreigebenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
/*
* **`freigeben` und nicht `laeufeBearbeiten`** (#040): das ist der ganze Sinn
* des eigenen Rechts. Ob der Freigebende ausserdem nicht an dem Lauf
* gearbeitet hat, entscheidet der Dienst — hier wird genau ein Recht geprueft
* (CLAUDE.md).
*/
const zugang = await angemeldetOderMeldung("freigeben");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
const notiz = formular.get("notiz");
try {
await laufFreigeben(laufId.data, zugang.benutzer, typeof notiz === "string" ? notiz : undefined);
neuRendern(laufId.data);
return { erfolg: "Lauf freigegeben. Er lässt sich jetzt abschließen." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht freigegeben werden.");
}
}
export async function laufZurueckweisenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("freigeben");
if ("fehler" in zugang) return zugang;
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
const notiz = z
.string()
.trim()
.min(1, "Eine Zurückweisung braucht eine Begründung.")
.safeParse(formular.get("notiz"));
if (!notiz.success) {
return { fehler: notiz.error.issues[0]?.message ?? "Eine Zurückweisung braucht eine Begründung." };
}
try {
await laufZurueckweisen(laufId.data, zugang.benutzer, notiz.data);
neuRendern(laufId.data);
return { erfolg: "Lauf zurückgewiesen. Der Bearbeiter sieht deine Begründung." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht zurückgewiesen werden.");
}
}
export async function laufArchivierenAktion( export async function laufArchivierenAktion(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
+9 -1
View File
@@ -267,10 +267,17 @@ export async function veroeffentlichenAktion(
const zugang = await angemeldetOderMeldung(); const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang; if ("fehler" in zugang) return zugang;
const eingabe = z const eingabe = z
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() }) .object({
versionId: z.string().min(1),
aenderungsnotiz: z.string().trim().optional(),
freigabePflicht: z.boolean(),
})
.safeParse({ .safeParse({
versionId: formular.get("versionId"), versionId: formular.get("versionId"),
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined, aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
// Ein nicht gesetztes Kaestchen schickt gar nichts mit — das ist `false`
// und nicht „unveraendert“: das Formular zeigt den aktuellen Stand an.
freigabePflicht: formular.get("freigabePflicht") === "1",
}); });
if (!eingabe.success) return { fehler: "Es fehlt die Version." }; if (!eingabe.success) return { fehler: "Es fehlt die Version." };
@@ -279,6 +286,7 @@ export async function veroeffentlichenAktion(
eingabe.data.versionId, eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null, eingabe.data.aenderungsnotiz ?? null,
zugang.benutzerId, zugang.benutzerId,
eingabe.data.freigabePflicht,
); );
revalidatePath("/vorlagen"); revalidatePath("/vorlagen");
return { erfolg: `Version ${version.version} ist veröffentlicht.` }; return { erfolg: `Version ${version.version} ist veröffentlicht.` };
+13
View File
@@ -54,6 +54,19 @@ export const RECHTE = {
mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"], mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */ /** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"], vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
/**
* Einen Lauf freigeben oder zurueckweisen, der eine Freigabe verlangt (#040).
*
* **Nicht `TECHNIKER`**, und das ist der ganze Sinn: die Vier-Augen-Regel soll
* nicht davon abhaengen, dass zufaellig ein zweiter Techniker greifbar ist.
* Wer sie umgehen will, braucht dann ein Konto mit mehr Rechten — und das
* steht im Protokoll.
*
* Das Recht allein genuegt nicht: wer an dem Lauf **gearbeitet** hat, gibt ihn
* auch mit diesem Recht nicht frei. Das prueft der Dienst, und ein CHECK in der
* Datenbank haelt es ebenfalls fest.
*/
freigeben: ["ADMIN", "REDAKTEUR"],
/** /**
* Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein * Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein
* abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben. * abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben.
+1 -1
View File
@@ -149,7 +149,7 @@ export async function tokenHolen(zugang: GraphZugang, jetzt = Date.now()): Promi
/* /*
* Die ausführliche Meldung ins Protokoll des Prozesses, eine kurze nach * Die ausführliche Meldung ins Protokoll des Prozesses, eine kurze nach
* draußen. Dieselbe Trennung wie beim LDAP-Bind-Fehler (#016) und beim * draußen. Dieselbe Trennung wie beim LDAP-Bind-Fehler (#016) und beim
* Healthcheck (#039): eine Meldung, die Mandant und Anwendungs-ID nennt, * Healthcheck (#025): eine Meldung, die Mandant und Anwendungs-ID nennt,
* gehört nicht in eine Oberfläche. * gehört nicht in eine Oberfläche.
*/ */
console.error( console.error(
+27 -3
View File
@@ -1,6 +1,7 @@
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte"; import { nurGeltende } from "@/lib/geltende-abschnitte";
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
import { anhaengeDesLaufs } from "@/server/queries/anhaenge"; import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
@@ -174,6 +175,8 @@ export async function laufZumDrucken(
archiviertVon: { select: { name: true } }, archiviertVon: { select: { name: true } },
// Beim Start festgeschriebene Abschnittsauswahl (#031). // Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true, geltendeAbschnitte: true,
// Speist die Auswertung der Bedingungen (#039).
variablen: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
version: true, version: true,
@@ -183,9 +186,17 @@ export async function laufZumDrucken(
select: { select: {
id: true, id: true,
titel: true, titel: true,
bedingung: true,
schritte: { schritte: {
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { id: true, titel: true, typ: true, pflicht: true, konfig: true }, select: {
id: true,
titel: true,
typ: true,
pflicht: true,
konfig: true,
bedingung: true,
},
}, },
}, },
}, },
@@ -233,8 +244,21 @@ export async function laufZumDrucken(
let anzahlSchritte = 0; let anzahlSchritte = 0;
let anzahlVomTisch = 0; let anzahlVomTisch = 0;
// Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger (#031). /*
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte); * Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger
* (#031, #039). **Dieselbe Funktion** und nicht dieselbe Absicht: zwei
* Auswertungen liefen hier sonst auseinander, und ein Ausdruck, der andere
* Schritte zeigt als der Bildschirm, ist das Gegenteil eines Beweismittels.
*
* Ein archivierter Lauf bleibt dabei stabil: seine Variablen aendern sich
* nicht mehr, und an seinen Schrittergebnissen auch nichts
* (`laufMussVeraenderbarSein`). Was er zeigt, ist also genau das, was bei
* seinem Abschluss galt.
*/
const geltende = geltendeStruktur(
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
umgebungBauen(lauf.variablen, lauf.ergebnisse),
);
const abschnitte = geltende.map((abschnitt, index) => { const abschnitte = geltende.map((abschnitt, index) => {
const schritte = abschnitt.schritte.map((schritt) => { const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1; nummer += 1;
+34 -2
View File
@@ -2,6 +2,7 @@ import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte"; import { nurGeltende } from "@/lib/geltende-abschnitte";
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell"; import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau"; import { STANDARD_SHELL } from "@/lib/editor-aufbau";
@@ -49,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;
}; };
@@ -73,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;
@@ -112,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,
}; };
@@ -239,6 +255,8 @@ export async function laufZumAbarbeiten(
select: { select: {
// Beim Start festgeschriebene Abschnittsauswahl (#031). // Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true, geltendeAbschnitte: true,
// Speist die Auswertung der Bedingungen (#039).
variablen: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
abschnitte: { abschnitte: {
@@ -246,6 +264,7 @@ export async function laufZumAbarbeiten(
select: { select: {
id: true, id: true,
titel: true, titel: true,
bedingung: true,
schritte: { schritte: {
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { select: {
@@ -256,6 +275,7 @@ export async function laufZumAbarbeiten(
pflicht: true, pflicht: true,
dokuUrl: true, dokuUrl: true,
konfig: true, konfig: true,
bedingung: true,
}, },
}, },
}, },
@@ -297,7 +317,19 @@ export async function laufZumAbarbeiten(
let anzahlPflicht = 0; let anzahlPflicht = 0;
let anzahlPflichtOffen = 0; let anzahlPflichtOffen = 0;
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte); /*
* Zwei Filter uebereinander, in dieser Reihenfolge: die beim Start
* festgeschriebene Abteilungsauswahl (#031), dann die laufend ausgewerteten
* Bedingungen (#039). Die Begruendung steht in `geltendeStruktur`.
*
* **Die Nummerierung kommt danach.** `01 · Vorbereitung` zaehlt die Abschnitte,
* die der Techniker **sieht** — ein ausgeblendeter hinterlaesst keine Luecke,
* und gespeichert wird die Nummer nie (CLAUDE.md, „Gestaltung“).
*/
const geltende = geltendeStruktur(
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
umgebungBauen(lauf.variablen, lauf.ergebnisse),
);
const abschnitte = geltende.map((abschnitt, index) => { const abschnitte = geltende.map((abschnitt, index) => {
const befehle: { titel: string; shell: Shell; befehl: string }[] = []; const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
+26
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { bedingungZuPfad } from "@/lib/bedingungen";
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format"; import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
@@ -180,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;
@@ -205,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 } },
@@ -215,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,
@@ -233,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[];
@@ -256,6 +263,22 @@ export async function versionMitAufbau(
}); });
if (!version) return undefined; if (!version) return undefined;
/*
* Kennung → Titel-Pfad, fuer die Bedingungen (#039). Der Editor sieht und
* schickt **Titel**, nie Kennungen: `entwurfSpeichern` loescht und schreibt
* neu, eine Kennung ueberlebte das nicht.
*/
const pfade = new Map<string, { readonly abschnitt: string; readonly schritt: string }>();
for (const abschnitt of version.abschnitte) {
for (const schritt of abschnitt.schritte) {
pfade.set(schritt.id, { abschnitt: abschnitt.titel, schritt: schritt.titel });
}
}
const alsPfad = (roh: unknown) => {
const bedingung = bedingungZuPfad(roh, pfade);
return bedingung ? { bedingung } : {};
};
const struktur: Struktur = { const struktur: Struktur = {
variablen: version.variablen.map((variable) => ({ variablen: version.variablen.map((variable) => ({
schluessel: variable.schluessel, schluessel: variable.schluessel,
@@ -268,12 +291,14 @@ export async function versionMitAufbau(
titel: abschnitt.titel, titel: abschnitt.titel,
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen, nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }), ...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }),
...alsPfad(abschnitt.bedingung),
schritte: abschnitt.schritte.map((schritt) => ({ schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel, titel: schritt.titel,
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }), ...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
typ: schritt.typ, typ: schritt.typ,
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig: (schritt.konfig ?? {}) as Record<string, unknown>, konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
...alsPfad(schritt.bedingung),
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }), ...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
})), })),
})), })),
@@ -287,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),
}; };
+1 -1
View File
@@ -46,7 +46,7 @@ export function alsScimAntwort(fehler: unknown): Response {
/* /*
* Alles andere ist ein Fehler bei uns, nicht beim Anbieter. Die Meldung geht * Alles andere ist ein Fehler bei uns, nicht beim Anbieter. Die Meldung geht
* ins Protokoll des Prozesses und **nicht** in die Antwort: eine * ins Protokoll des Prozesses und **nicht** in die Antwort: eine
* Datenbankmeldung nennt Host, Port und Benutzernamen (#039). * Datenbankmeldung nennt Host, Port und Benutzernamen (#025).
*/ */
console.error( console.error(
"SCIM-Anfrage fehlgeschlagen:", "SCIM-Anfrage fehlgeschlagen:",
+35 -3
View File
@@ -2,6 +2,7 @@ import { createTransport } from "nodemailer";
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums"; import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { RECHTE } from "@/server/auth/rollen";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse"; import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen"; import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen";
import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur"; import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur";
@@ -382,10 +383,41 @@ async function laufEmpfaenger(
handelnderId: string | null, handelnderId: string | null,
): Promise<{ id: string; email: string; name: string }[]> { ): Promise<{ id: string; email: string; name: string }[]> {
const kennungen = new Set<string>(); const kennungen = new Set<string>();
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) { if (anlass === Meldeanlass.LAUF_FREIGABE_OFFEN) {
kennungen.add(lauf.erstelltVonId); /*
* **Ein anderer Empfaengerkreis als bei allen anderen Anlaessen** (#040): nicht
* der Zustaendige, sondern **jeder mit dem Recht `freigeben`**. Eine Freigabe
* ist die Aufgabe eines Dritten; ihm die Meldung nicht zu schicken hiesse,
* dass ein Lauf auf etwas wartet, von dem niemand weiss — und genau das macht
* die ganze Regel wertlos.
*
* Wer **gearbeitet** hat, bekommt sie nicht: er darf ohnehin nicht freigeben,
* und eine Aufforderung zu etwas, das der Dienst abweist, ist schlimmer als
* keine.
*/
const berechtigte = await prisma.benutzer.findMany({
where: { aktiv: true, meldungenAktiv: true, rolle: { in: [...RECHTE.freigeben] } },
select: { id: true },
});
const bearbeiter = new Set<string>(lauf.erstelltVonId ? [lauf.erstelltVonId] : []);
for (const zeile of await prisma.schrittergebnis.findMany({
where: { laufId: lauf.id, aktualisiertVonId: { not: null } },
select: { aktualisiertVonId: true },
distinct: ["aktualisiertVonId"],
})) {
if (zeile.aktualisiertVonId) bearbeiter.add(zeile.aktualisiertVonId);
}
for (const person of berechtigte) {
if (!bearbeiter.has(person.id)) kennungen.add(person.id);
}
} else {
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) {
kennungen.add(lauf.erstelltVonId);
}
} }
if (handelnderId) kennungen.delete(handelnderId); if (handelnderId) kennungen.delete(handelnderId);
if (kennungen.size === 0) return []; if (kennungen.size === 0) return [];
+250 -6
View File
@@ -2,6 +2,7 @@ import { Prisma, type Lauf } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums"; import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte"; import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen"; import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen";
import type { LaufAnlass } from "@/lib/meldungen"; import type { LaufAnlass } from "@/lib/meldungen";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
@@ -703,6 +704,7 @@ export async function offenePflichtschritte(
where: { id: laufId }, where: { id: laufId },
select: { select: {
geltendeAbschnitte: true, geltendeAbschnitte: true,
variablen: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
abschnitte: { abschnitte: {
@@ -710,10 +712,11 @@ export async function offenePflichtschritte(
select: { select: {
id: true, id: true,
titel: true, titel: true,
bedingung: true,
schritte: { schritte: {
where: { pflicht: true }, where: { pflicht: true },
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { id: true, titel: true }, select: { id: true, titel: true, bedingung: true },
}, },
}, },
}, },
@@ -726,11 +729,20 @@ export async function offenePflichtschritte(
const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand])); const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand]));
const offen: OffenerPflichtschritt[] = []; const offen: OffenerPflichtschritt[] = [];
/* /*
* Nur die geltenden Abschnitte (#031). Ein ausgefilterter Pflichtschritt darf * Nur die geltenden Abschnitte (#031) und nur, was seine Bedingung erfuellt
* den Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst * (#039). Ein ausgefilterter oder ausgeblendeter Pflichtschritt darf den
* Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
* sich nur, was in der Abarbeitung steht. * sich nur, was in der Abarbeitung steht.
*
* **Die Reihenfolge der beiden Filter steht in `geltendeStruktur`**, und sie
* ist dort begruendet: erst die festgeschriebene Abteilungsauswahl, dann die
* laufende Auswertung.
*/ */
for (const abschnitt of nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte)) { const umgebung = umgebungBauen(lauf.variablen, lauf.ergebnisse);
for (const abschnitt of geltendeStruktur(
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
umgebung,
)) {
for (const schritt of abschnitt.schritte) { for (const schritt of abschnitt.schritte) {
// Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt. // Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt.
const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN; const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN;
@@ -793,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.
* *
@@ -804,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 },
@@ -839,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
+131 -19
View File
@@ -2,7 +2,13 @@ import type { ZodError } from "@/lib/validierung";
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client"; import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { AustauschSchema, FORMAT_VERSION, type Austausch } from "@/lib/vorlagen-format"; import { bedingungAufloesen, bedingungZuPfad, schrittPfad } from "@/lib/bedingungen";
import {
AustauschSchema,
FORMAT_VERSION,
type Austausch,
type AustauschBedingung,
} from "@/lib/vorlagen-format";
/** /**
* JSON-Austausch fuer Vorlagen (#009). * JSON-Austausch fuer Vorlagen (#009).
@@ -66,6 +72,22 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
} }
const { vorlage } = version; const { vorlage } = version;
/*
* Kennung → Titel-Pfad, fuer die Schritt-Bedingungen. Eine Kennung bedeutet
* in einer anderen Installation nichts; ein Titel-Pfad schon (#039).
*/
const pfade = new Map<string, { readonly abschnitt: string; readonly schritt: string }>();
for (const abschnitt of version.abschnitte) {
const titel =
abschnitt.bausteinVersion && abschnitt.schritte.length === 0
? abschnitt.bausteinVersion.titel
: abschnitt.titel;
for (const schritt of abschnitt.schritte) {
pfade.set(schritt.id, { abschnitt: titel, schritt: schritt.titel });
}
}
return { return {
format: FORMAT_VERSION, format: FORMAT_VERSION,
name: vorlage.name, name: vorlage.name,
@@ -111,7 +133,10 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation // Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
// dasselbe. // dasselbe.
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
schritte: (ausBaustein ? quelle.schritte : abschnitt.schritte).map(schrittAbbilden), ...bedingungAbbilden(abschnitt.bedingung, pfade),
schritte: (ausBaustein ? quelle.schritte : abschnitt.schritte).map((schritt) =>
schrittAbbilden(schritt, pfade),
),
}; };
}), }),
}, },
@@ -129,6 +154,24 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar * nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
* kein Objekt ist, gilt als leer. * kein Objekt ist, gilt als leer.
*/ */
/**
* Eine gespeicherte Bedingung in die Austauschform.
*
* Gibt `{}` zurueck, wenn es keine gibt oder wenn eine Schritt-Bedingung auf
* einen Schritt zeigt, den der Export nicht kennt. **Weglassen und nicht
* mitschreiben**: eine Bedingung mit einem Verweis ins Leere waere in der
* zweiten Installation nicht entscheidbar, der Abschnitt erschiene also immer —
* und niemand koennte sagen, warum. Ohne Bedingung erscheint er auch immer, aber
* das sieht man in der Datei.
*/
function bedingungAbbilden(
roh: unknown,
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
): { readonly bedingung?: AustauschBedingung } {
const pfadBedingung = bedingungZuPfad(roh, pfade);
return pfadBedingung ? { bedingung: pfadBedingung as AustauschBedingung } : {};
}
function schrittAbbilden( function schrittAbbilden(
/* /*
* Nur die gelesenen Felder statt `VorlagenSchritt`: dieselbe Funktion bildet * Nur die gelesenen Felder statt `VorlagenSchritt`: dieselbe Funktion bildet
@@ -136,7 +179,17 @@ function schrittAbbilden(
* Export aufgeloest). Die beiden Modelle unterscheiden sich nur darin, woran * Export aufgeloest). Die beiden Modelle unterscheiden sich nur darin, woran
* ein Schritt haengt — und das steht ohnehin in keinem Export. * ein Schritt haengt — und das steht ohnehin in keinem Export.
*/ */
schritt: Pick<VorlagenSchritt, "titel" | "hilfetext" | "typ" | "pflicht" | "konfig" | "dokuUrl">, schritt: Pick<VorlagenSchritt, "titel" | "hilfetext" | "typ" | "pflicht" | "konfig" | "dokuUrl"> & {
/*
* Optional, weil ein **Baustein**-Schritt keine Bedingung hat — und das ist
* Absicht (#039): ein Baustein ist wiederverwendbar, und eine Bedingung auf
* einer Variable, die es in der Zielvorlage nicht gibt, waere nicht
* entscheidbar. Der Abschnitt, in dem er steht, darf eine haben; der
* einzelne Schritt daraus nicht.
*/
readonly bedingung?: unknown;
},
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
): AustauschSchritt { ): AustauschSchritt {
const roh = schritt.konfig; const roh = schritt.konfig;
const konfig = const konfig =
@@ -147,6 +200,7 @@ function schrittAbbilden(
typ: schritt.typ, typ: schritt.typ,
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig, konfig,
...bedingungAbbilden(schritt.bedingung, pfade),
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}), ...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
}; };
} }
@@ -202,29 +256,87 @@ export async function vorlageImportieren(
} }
let schritteGesamt = 0; let schritteGesamt = 0;
/*
* Kennungen, um die Schritt-Bedingungen danach aufzuloesen (#039). Erst
* anlegen, dann verweisen: eine Bedingung kann auf einen Schritt zeigen, der
* weiter unten steht, und der Trigger verlangt einen Schritt derselben
* Version.
*/
const neueKennungen = new Map<string, string>();
const abschnittsZeilen = new Map<string, string>();
for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) { for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({ const zeile = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position }, data: {
versionId: version.id,
titel: abschnitt.titel,
position,
/*
* **Wandert mit, und das fehlte hier.** Der Export schreibt die
* Abteilungseinschraenkung seit #031, das Schema nimmt sie an — und
* der Import verwarf sie stillschweigend. Eine Vorlage, die jemand in
* eine zweite Installation brachte, verlor ihre Abteilungsabschnitte:
* sie galten dort fuer **alle**. Also mehr Schritte statt weniger, und
* niemand merkt es (gefunden bei #039).
*/
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
},
}); });
abschnittsZeilen.set(abschnitt.titel, zeile.id);
if (abschnitt.schritte.length === 0) continue; if (abschnitt.schritte.length === 0) continue;
await tx.vorlagenSchritt.createMany({ for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) {
data: abschnitt.schritte.map((schritt, schrittPosition) => ({ const angelegt = await tx.vorlagenSchritt.create({
abschnittId: zeile.id, data: {
titel: schritt.titel, abschnittId: zeile.id,
hilfetext: schritt.hilfetext ?? null, titel: schritt.titel,
typ: schritt.typ, hilfetext: schritt.hilfetext ?? null,
pflicht: schritt.pflicht, typ: schritt.typ,
// Das nachsichtige Schema traegt eine Index-Signatur auf `unknown`, pflicht: schritt.pflicht,
// die Prisma nicht als Json-Wert erkennt. Der Inhalt kommt aus // Das nachsichtige Schema traegt eine Index-Signatur auf `unknown`,
// geparstem JSON, ist also einer. // die Prisma nicht als Json-Wert erkennt. Der Inhalt kommt aus
konfig: schritt.konfig as Prisma.InputJsonObject, // geparstem JSON, ist also einer.
dokuUrl: schritt.dokuUrl ?? null, konfig: schritt.konfig as Prisma.InputJsonObject,
position: schrittPosition, dokuUrl: schritt.dokuUrl ?? null,
})), position: schrittPosition,
}); },
});
neueKennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
}
schritteGesamt += abschnitt.schritte.length; schritteGesamt += abschnitt.schritte.length;
} }
/*
* Zweiter Durchgang: die Bedingungen. Ein Titel-Pfad, den es in dieser Datei
* nicht gibt, wird **uebergangen** und nicht als Fehler gemeldet — die Datei
* kann von Hand geschrieben sein, und eine Vorlage wegen eines Tippfehlers in
* einer Bedingung ganz abzuweisen waere die teurere Antwort. Uebergangen
* heisst: der Abschnitt gilt immer, und das steht sichtbar in der Oberflaeche.
*/
for (const abschnitt of austausch.struktur.abschnitte) {
const abschnittId = abschnittsZeilen.get(abschnitt.titel);
if (abschnittId && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, neueKennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: abschnittId },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = neueKennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, neueKennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
}
}
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorlage.kundeId, kundeId: vorlage.kundeId,
+211 -67
View File
@@ -1,6 +1,7 @@
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client"; import { Prisma, type SchrittTyp, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { bedingungAufloesen, bedingungLesen, schrittPfad } from "@/lib/bedingungen";
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format"; import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
/** /**
@@ -82,6 +83,114 @@ export type VorlagenAenderung = {
readonly kundeId?: string | null; readonly kundeId?: string | null;
}; };
/**
* Eine tiefe Kopie der Abschnitte und Schritte einer Version.
*
* **An einer Stelle, weil es zweimal gebraucht wird** — `vorlageBearbeiten`
* macht aus der letzten veroeffentlichten Version einen Entwurf,
* `vorlageDuplizieren` eine neue Vorlage. Beide hatten die Kopie einzeln
* ausgeschrieben, und beide vergassen dabei dasselbe: `nurFuerAbteilungen`
* (seit #031) und `bedingung` (seit #039).
*
* Der Schaden war still und ernst: „Bearbeiten“ einer Vorlage verlor **alle**
* Abteilungseinschraenkungen — die neue Version galt dann fuer jeden Lauf, also
* mehr Schritte statt weniger, und niemand merkt es. Gefunden erst bei der
* Klickprobe am gebauten Stand (#039); keine Suite hat es gesehen, weil keine
* die Kopie gegen das Original verglich.
*
* **Schritt-Bedingungen werden auf die neuen Kennungen umgeschrieben.** Eine
* Kopie hat neue Kennungen; ein Verweis auf die alte waere nach dem
* Veroeffentlichen nicht mehr entscheidbar, und der Abschnitt erschiene
* stillschweigend immer. Deshalb zwei Durchgaenge, wie beim Import.
*/
async function abschnitteKopieren(
tx: Prisma.TransactionClient,
zielVersionId: string,
abschnitte: readonly {
readonly titel: string;
readonly nurFuerAbteilungen: readonly string[];
readonly bedingung: unknown;
readonly bausteinVersionId?: string | null;
readonly schritte: readonly {
readonly id: string;
readonly titel: string;
readonly hilfetext: string | null;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly konfig: unknown;
readonly dokuUrl: string | null;
readonly bedingung: unknown;
}[];
}[],
): Promise<void> {
/*
* Alte Kennung → neue. Die alten Bedingungen zeigen auf die alten Kennungen;
* aufgeloest wird im zweiten Durchgang.
*/
const neueVonAlt = new Map<string, string>();
const zeilen: { readonly id: string; readonly stelle: number }[] = [];
for (const [index, abschnitt] of abschnitte.entries()) {
const kopie = await tx.vorlagenAbschnitt.create({
data: {
versionId: zielVersionId,
titel: abschnitt.titel,
position: index,
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
...(abschnitt.bausteinVersionId === undefined
? {}
: { bausteinVersionId: abschnitt.bausteinVersionId }),
},
});
zeilen.push({ id: kopie.id, stelle: index });
for (const [schrittIndex, schritt] of abschnitt.schritte.entries()) {
const schrittKopie = await tx.vorlagenSchritt.create({
data: {
abschnittId: kopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Json wird als Wert kopiert, nicht als Verweis geteilt.
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
},
});
neueVonAlt.set(schritt.id, schrittKopie.id);
}
}
/** Eine Bedingung auf die neuen Kennungen umschreiben, oder weglassen. */
const umschreiben = (roh: unknown): Prisma.InputJsonObject | undefined => {
const bedingung = bedingungLesen(roh);
if (!bedingung) return undefined;
if (bedingung.art === "variable") return bedingung as unknown as Prisma.InputJsonObject;
const neueId = neueVonAlt.get(bedingung.schrittId);
if (!neueId) return undefined;
return { ...bedingung, schrittId: neueId } as unknown as Prisma.InputJsonObject;
};
for (const [index, abschnitt] of abschnitte.entries()) {
const zeile = zeilen[index];
const fuerAbschnitt = umschreiben(abschnitt.bedingung);
if (zeile && fuerAbschnitt) {
await tx.vorlagenAbschnitt.update({
where: { id: zeile.id },
data: { bedingung: fuerAbschnitt },
});
}
for (const schritt of abschnitt.schritte) {
const fuerSchritt = umschreiben(schritt.bedingung);
const neueId = neueVonAlt.get(schritt.id);
if (neueId && fuerSchritt) {
await tx.vorlagenSchritt.update({ where: { id: neueId }, data: { bedingung: fuerSchritt } });
}
}
}
}
/** /**
* Aendert die Kopfdaten einer Vorlage. * Aendert die Kopfdaten einer Vorlage.
* *
@@ -236,27 +345,27 @@ export async function entwurfHolenOderErzeugen(
} }
} }
type VersionMitInhalt = { /**
readonly version: number; * Was eine Version mitbringen muss, damit sie kopiert werden kann.
readonly variablen: readonly { *
schluessel: string; * **Aus Prisma abgeleitet und nicht von Hand geschrieben** — und das ist die
beschriftung: string; * Lehre aus zwei stillen Datenverlusten. Hier stand ein selbst getippter Typ,
typ: "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM"; * der weniger Felder nannte, als die Abfrage liefert: `nurFuerAbteilungen` (seit
optionen: string[]; * #031) und `bedingung` (seit #039) fehlten darin. Die Abfrage holte sie, der
pflicht: boolean; * Typ warf sie weg, und `abschnitteKopieren` konnte sie nicht sehen — „Bearbeiten“
}[]; * einer Vorlage verlor damit **alle** Abteilungseinschraenkungen, ohne dass
readonly abschnitte: readonly { * etwas rot wurde.
titel: string; *
schritte: readonly { * Ein abgeleiteter Typ kann das nicht: ein neues Feld am Modell ist hier
titel: string; * automatisch vorhanden, und wer es beim Kopieren vergisst, faellt im Test
hilfetext: string | null; * „die Kopie ist inhaltsgleich“ auf.
typ: "HAKEN" | "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM" | "BEFEHL"; */
pflicht: boolean; type VersionMitInhalt = Prisma.VorlagenVersionGetPayload<{
konfig: unknown; include: {
dokuUrl: string | null; variablen: true;
}[]; abschnitte: { include: { schritte: true } };
}[]; };
}; }>;
async function entwurfKopieren( async function entwurfKopieren(
vorlageId: string, vorlageId: string,
@@ -272,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) {
@@ -289,26 +410,7 @@ async function entwurfKopieren(
}); });
} }
for (const [index, abschnitt] of letzte.abschnitte.entries()) { await abschnitteKopieren(tx, entwurf.id, letzte.abschnitte);
const kopie = await tx.vorlagenAbschnitt.create({
data: { versionId: entwurf.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: kopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Json wird als Wert kopiert, nicht als Verweis geteilt.
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
@@ -524,6 +626,23 @@ export async function entwurfSpeichern(
}); });
} }
/*
* Erst alles anlegen, dann die Bedingungen (#039).
*
* Dieser Dienst **loescht und schreibt neu** — die Schritte bekommen also
* bei jedem Speichern neue Kennungen. Eine Bedingung auf einer Kennung
* ueberlebte das nicht; in der Struktur steht deshalb ein **Titel-Pfad**,
* genau wie in einer Austauschdatei, und er wird hier aufgeloest. Dieselbe
* Funktion fuer beide Wege (`bedingungAufloesen`), sonst liefen sie
* auseinander.
*
* Zweiter Durchgang und nicht einer: eine Bedingung darf auf einen Schritt
* zeigen, der weiter unten steht — der Editor warnt davor, verbietet es aber
* nicht.
*/
const kennungen = new Map<string, string>();
const abschnittsZeilen: { readonly id: string; readonly titel: string }[] = [];
for (const [index, abschnitt] of struktur.abschnitte.entries()) { for (const [index, abschnitt] of struktur.abschnitte.entries()) {
const angelegt = await tx.vorlagenAbschnitt.create({ const angelegt = await tx.vorlagenAbschnitt.create({
data: { data: {
@@ -536,9 +655,11 @@ export async function entwurfSpeichern(
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
}, },
}); });
if (abschnitt.schritte.length > 0) { abschnittsZeilen.push({ id: angelegt.id, titel: abschnitt.titel });
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({ for (const [schrittIndex, schritt] of abschnitt.schritte.entries()) {
const angelegterSchritt = await tx.vorlagenSchritt.create({
data: {
abschnittId: angelegt.id, abschnittId: angelegt.id,
titel: schritt.titel, titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null, hilfetext: schritt.hilfetext ?? null,
@@ -547,8 +668,33 @@ export async function entwurfSpeichern(
konfig: schritt.konfig as Prisma.InputJsonValue, konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl ?? null, dokuUrl: schritt.dokuUrl ?? null,
position: schrittIndex, position: schrittIndex,
})), },
}); });
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegterSchritt.id);
}
}
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
const zeile = abschnittsZeilen[index];
if (zeile && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: zeile.id },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
} }
} }
@@ -575,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;
@@ -615,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) {
@@ -740,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) {
@@ -756,25 +918,7 @@ export async function vorlageDuplizieren(
})), })),
}); });
} }
for (const [index, abschnitt] of letzte.abschnitte.entries()) { await abschnitteKopieren(tx, version.id, letzte.abschnitte);
const abschnittKopie = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: abschnittKopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
+721
View File
@@ -0,0 +1,721 @@
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it } from "vitest";
import { SchrittZustand, VariablenTyp, VersionStatus } from "@/generated/prisma/enums";
import { laufZumDrucken } from "@/server/queries/druck";
import { laufZumAbarbeiten } from "@/server/queries/laeufe";
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
import { versionMitAufbau } from "@/server/queries/vorlagen";
import {
entwurfHolenOderErzeugen,
entwurfSpeichern,
versionVeroeffentlichen,
vorlageAnlegen,
vorlageDuplizieren,
} from "@/server/services/vorlagen";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Bedingte Abschnitte und Schritte gegen die Datenbank (#039).
*
* Die reine Auswertung steht in `tests/bedingungen.test.ts`. Hier geht es um die
* Frage, die eine reine Funktion nicht beantworten kann: **greift sie überall?**
*
* Drei Schichten brauchen sie — der Dienst beim Abschluss, das Lesemodell der
* Abarbeitung und das der Druckansicht. Vergisst eine davon die Auswertung,
* sieht der Techniker andere Schritte als der Ausdruck, oder ein unsichtbarer
* Pflichtschritt hält den Abschluss auf. Beides fällt in einer Suite nicht auf,
* die nur die Logik prüft.
*/
type AbschnittsVorgabe = {
readonly titel: string;
readonly bedingung?: unknown;
readonly schritte: readonly {
readonly titel: string;
readonly pflicht?: boolean;
readonly bedingung?: unknown;
}[];
};
/**
* Legt eine Vorlage an und veröffentlicht sie.
*
* `nachtragen` wird mit den Schritt-Kennungen gerufen, **bevor** veröffentlicht
* wird — genau wie im Editor: eine Bedingung auf einem Schritt braucht dessen
* Kennung, und eine veröffentlichte Version ist unveränderlich (#009). Der
* Trigger `…_bedingung_gleiche_version` verlangt zusätzlich, dass der Schritt
* zur selben Version gehört.
*/
async function aufbau(
abschnitte: readonly AbschnittsVorgabe[],
nachtragen?: (schrittIds: ReadonlyMap<string, string>, versionId: string) => Promise<void>,
) {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: "konto_typ",
beschriftung: "Kontotyp",
typ: VariablenTyp.AUSWAHL,
optionen: ["AD", "ENTRA"],
position: 0,
},
});
const schrittIds = new Map<string, string>();
for (const [position, abschnitt] of abschnitte.entries()) {
const angelegt = await testPrisma.vorlagenAbschnitt.create({
data: {
versionId: version.id,
titel: abschnitt.titel,
position,
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung as never }),
},
});
for (const [stelle, schritt] of abschnitt.schritte.entries()) {
const angelegterSchritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: angelegt.id,
titel: schritt.titel,
pflicht: schritt.pflicht ?? false,
position: stelle,
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung as never }),
},
});
schrittIds.set(schritt.titel, angelegterSchritt.id);
}
}
await nachtragen?.(schrittIds, version.id);
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
kunde,
admin: { id: admin.id, rolle: admin.rolle },
versionId: version.id,
schrittIds,
};
}
/** Der echte Fall aus dem Seed: beide Konto-Abschnitte, eine Variable entscheidet. */
const BEIDE_KONTEN: readonly AbschnittsVorgabe[] = [
{ titel: "Vorbereitung", schritte: [{ titel: "Papiere", pflicht: true }] },
{
titel: "Konto anlegen (Active Directory)",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
schritte: [
{ titel: "New-ADUser", pflicht: true },
{ titel: "Gruppen setzen", pflicht: true },
],
},
{
titel: "Konto anlegen (Entra ID)",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
schritte: [{ titel: "New-MgUser", pflicht: true }],
},
];
const titel = (abschnitte: readonly { readonly titel: string }[]): readonly string[] =>
abschnitte.map((abschnitt) => abschnitt.titel);
describe.runIf(datenbankVerfuegbar)("Ein Lauf mit bedingten Abschnitten", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("zeigt bei konto_typ = AD nur den AD-Abschnitt", async () => {
/*
* **Der Fall, für den dieser Meilenstein da ist.** Die Onboarding-Vorlage im
* Seed trägt beide Konto-Abschnitte, und `konto_typ` sagt längst, welcher
* gilt — bis hierher wertete das niemand aus, und ein Techniker klickte neun
* Schritte weg.
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Active Directory)"]);
});
it("zeigt bei konto_typ = ENTRA nur den Entra-Abschnitt", async () => {
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen(
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
admin,
);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Entra ID)"]);
});
it("zeigt bei nicht gesetzter Variable beide – und blendet nichts aus", async () => {
/*
* Eine Bedingung, die sich nicht entscheiden lässt, blendet nichts aus. Das
* ist genau das Verhalten von **vor** diesem Meilenstein, für jeden Lauf —
* eine Verschlechterung ist es also nicht. Eine erfundene Antwort wäre eine.
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(titel(daten!.abschnitte)).toEqual([
"Vorbereitung",
"Konto anlegen (Active Directory)",
"Konto anlegen (Entra ID)",
]);
});
it("nummeriert die sichtbaren Abschnitte lückenlos", async () => {
/*
* `01 · Vorbereitung` zählt, was der Techniker **sieht**. Ein ausgeblendeter
* Abschnitt darf keine Lücke hinterlassen — die Nummer kommt aus der
* Position und wird nie gespeichert (CLAUDE.md, „Gestaltung“).
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen(
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
admin,
);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(daten!.abschnitte.map((abschnitt) => abschnitt.nummer)).toEqual(["01", "02"]);
});
it("hält den Abschluss nicht an einem ausgeblendeten Pflichtschritt auf", async () => {
/*
* **Die zweite Kernzusage.** Der Entra-Abschnitt ist ausgeblendet, sein
* Schritt ist Pflicht. Blockierte er, wäre der Lauf unabschließbar: abhaken
* lässt sich nur, was in der Abarbeitung steht.
*/
const { admin, versionId, schrittIds } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
for (const name of ["Papiere", "New-ADUser", "Gruppen setzen"]) {
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittIds.get(name)!, zustand: SchrittZustand.ERLEDIGT },
admin,
);
}
await expect(laufAbschliessen(lauf.id, admin)).resolves.toBeDefined();
});
it("zeigt im Druck genau dieselben Abschnitte wie in der Abarbeitung", async () => {
/*
* Ein Ausdruck, der andere Schritte zeigt als der Bildschirm, ist das
* Gegenteil eines Beweismittels. Geprüft wird deshalb gegen die **andere**
* Schicht und nicht gegen eine erwartete Liste.
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
const abarbeitung = await laufZumAbarbeiten(lauf.id, admin, "Tester");
const druck = await laufZumDrucken(lauf.id, admin);
expect(titel(druck!.abschnitte)).toEqual(titel(abarbeitung!.abschnitte));
});
});
describe.runIf(datenbankVerfuegbar)("Eine Bedingung auf einem Schrittergebnis", () => {
beforeEach(async () => {
await datenbankLeeren();
});
/**
* Setzt am Abschnitt „Nacharbeit“ die Bedingung „Prüfen ist fehlgeschlagen“.
*
* Als Nachtrag im **Entwurf**, weil die Bedingung die Kennung des Schritts
* braucht und der erst beim Anlegen entsteht. Danach wird veröffentlicht, und
* danach ändert sich an der Version nichts mehr (#009).
*/
const nacharbeitWennFehlgeschlagen = async (
schrittIds: ReadonlyMap<string, string>,
versionId: string,
): Promise<void> => {
await testPrisma.vorlagenAbschnitt.updateMany({
where: { versionId, titel: "Nacharbeit" },
data: {
bedingung: {
art: "schritt",
schrittId: schrittIds.get("Prüfen")!,
zustaende: ["FEHLGESCHLAGEN"],
},
},
});
};
it("erscheint erst, wenn die Bedingung während des Laufs umschlägt", async () => {
/*
* **Der Grund, warum laufend ausgewertet wird und nicht beim Start.** Die
* Antwort auf „ist Prüfen fehlgeschlagen?“ entsteht erst während der Arbeit;
* festgeschrieben wie bei #031 wäre sie beim Start immer „nein“.
*/
// Erst anlegen, um die Schritt-Kennung zu bekommen, dann die Bedingung setzen:
// der Trigger verlangt einen Schritt derselben Version.
const { admin, versionId, schrittIds } = await aufbau(
[
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] },
],
nacharbeitWennFehlgeschlagen,
);
const pruefenId = schrittIds.get("Prüfen")!;
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
// Zu Beginn ist „Prüfen“ OFFEN. Das ist eine **Antwort**, nicht eine Lücke:
// der Abschnitt gilt nicht.
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: pruefenId,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Sicherung nicht lesbar",
},
admin,
);
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual([
"Prüfung",
"Nacharbeit",
]);
});
it("lässt ein Schrittergebnis mit Notiz stehen, wenn seine Bedingung umschlägt", async () => {
/*
* Ausgeblendet heißt **nicht** gelöscht. Jemand hat eine Notiz geschrieben;
* sie zu verlieren, weil eine Bedingung kippt, wäre Datenverlust durch eine
* Anzeigeregel. Schlägt die Bedingung zurück, ist alles wieder da.
*/
const { admin, versionId, schrittIds } = await aufbau(
[
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen" }] },
],
nacharbeitWennFehlgeschlagen,
);
const pruefenId = schrittIds.get("Prüfen")!;
const ticketId = schrittIds.get("Ticket aufmachen")!;
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" },
admin,
);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: ticketId, zustand: SchrittZustand.ERLEDIGT, notiz: "TIC-4711" },
admin,
);
// Bedingung kippt: der Abschnitt verschwindet aus der Ansicht …
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.ERLEDIGT },
admin,
);
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]);
// … die Zeile bleibt aber in der Datenbank.
const zeile = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: ticketId },
});
expect(zeile.notiz).toBe("TIC-4711");
// Und schlägt sie zurück, ist alles wieder da.
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "y" },
admin,
);
const wieder = await laufZumAbarbeiten(lauf.id, admin, "T");
expect(titel(wieder!.abschnitte)).toEqual(["Prüfung", "Nacharbeit"]);
expect(wieder!.abschnitte[1]?.schritte[0]?.notiz).toBe("TIC-4711");
});
it("hält den Abschluss auf, solange ein sichtbarer Pflichtschritt offen ist", async () => {
// Die Gegenrichtung, und sie ist nötig: eine Abschlussregel, die **immer**
// durchlässt, sähe in den Tests oben genauso aus.
const { admin, versionId, schrittIds } = await aufbau(
[
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] },
],
nacharbeitWennFehlgeschlagen,
);
const pruefenId = schrittIds.get("Prüfen")!;
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" },
admin,
);
// „Ticket aufmachen“ ist jetzt sichtbar und Pflicht – der Abschluss wartet.
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(LaufFehler);
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/Ticket aufmachen/u);
});
});
describe.runIf(datenbankVerfuegbar)("Eine Bedingung an einem einzelnen Schritt", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("blendet den Schritt aus, nicht den ganzen Abschnitt", async () => {
const { admin, versionId } = await aufbau([
{
titel: "Konto",
schritte: [
{ titel: "Immer" },
{
titel: "Nur bei AD",
pflicht: true,
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
},
],
},
]);
const lauf = await laufAnlegen(
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
admin,
);
const daten = await laufZumAbarbeiten(lauf.id, admin, "T");
expect(titel(daten!.abschnitte)).toEqual(["Konto"]);
expect(daten!.abschnitte[0]?.schritte.map((schritt) => schritt.titel)).toEqual(["Immer"]);
// Und der ausgeblendete Pflichtschritt hält den Abschluss nicht auf.
expect(daten!.anzahlPflichtOffen).toBe(0);
});
});
describe.runIf(datenbankVerfuegbar)("Der Weg durch den Editor", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("übersteht Speichern, Neuladen und Veröffentlichen", async () => {
/*
* **Der Weg, auf dem eine Bedingung verloren gehen würde.**
* `entwurfSpeichern` löscht den Entwurf und schreibt ihn neu — die Schritte
* bekommen dabei neue Kennungen. Eine Bedingung, die auf eine Kennung zeigt,
* überlebt das nicht; in der Struktur steht deshalb ein **Titel-Pfad**, so
* wie in einer Austauschdatei, und derselbe Code löst beide auf.
*
* Dreimal gespeichert, weil einmal nichts beweist: der Pfad muss **jedes**
* Neuschreiben überstehen.
*/
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const handelnder = { id: redakteur.id, rolle: redakteur.rolle };
const { vorlage } = await vorlageAnlegen({ name: "Mit Bedingung" }, redakteur.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
const struktur = {
variablen: [
{ schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] },
],
abschnitte: [
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{
titel: "Nacharbeit",
bedingung: {
art: "schritt",
abschnitt: "Prüfung",
schritt: "Prüfen",
zustaende: ["FEHLGESCHLAGEN"],
},
schritte: [
{
titel: "Nur bei AD",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
},
],
},
],
} as const;
for (let runde = 1; runde <= 3; runde += 1) {
await entwurfSpeichern(entwurf.id, struktur as never);
// Zurückgelesen steht wieder der Pfad da, nicht die Kennung.
const gelesen = await versionMitAufbau(entwurf.id, handelnder);
expect(gelesen!.struktur.abschnitte[1]?.bedingung, `Runde ${runde}`).toEqual({
art: "schritt",
abschnitt: "Prüfung",
schritt: "Prüfen",
zustaende: ["FEHLGESCHLAGEN"],
});
expect(gelesen!.struktur.abschnitte[1]?.schritte[0]?.bedingung).toEqual({
art: "variable",
schluessel: "konto_typ",
operator: "ist",
wert: "AD",
});
// Und in der Datenbank steht die **aktuelle** Kennung.
const zeilen = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId: entwurf.id },
orderBy: { position: "asc" },
include: { schritte: true },
});
expect(zeilen[1]?.bedingung).toMatchObject({
art: "schritt",
schrittId: zeilen[0]!.schritte[0]!.id,
});
}
// Veröffentlichen lässt die Bedingung stehen – und ein Lauf wertet sie aus.
await versionVeroeffentlichen(entwurf.id, null, redakteur.id);
const lauf = await laufAnlegen(
{ vorlagenVersionId: entwurf.id, kundeId: kunde.id, variablen: { konto_typ: "AD" } },
handelnder,
);
expect(titel((await laufZumAbarbeiten(lauf.id, handelnder, "T"))!.abschnitte)).toEqual(["Prüfung"]);
});
it("lässt eine Bedingung weg, deren Titel-Pfad es nicht gibt", async () => {
/*
* Ein Abschnitt, dessen Zielschritt gelöscht wurde: beim nächsten Speichern
* findet der Pfad nichts. Weggelassen heißt „gilt immer“ — sichtbar in der
* Oberfläche. Eine Bedingung mit einem Verweis ins Leere wäre dasselbe
* Ergebnis, nur unerklärlich.
*/
await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const { vorlage } = await vorlageAnlegen({ name: "Verwaister Verweis" }, redakteur.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
await entwurfSpeichern(entwurf.id, {
variablen: [],
abschnitte: [
{ titel: "Eins", schritte: [{ titel: "A" }] },
{
titel: "Zwei",
bedingung: {
art: "schritt",
abschnitt: "Gibt es nicht",
schritt: "Auch nicht",
zustaende: ["ERLEDIGT"],
},
schritte: [{ titel: "B" }],
},
],
} as never);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId: entwurf.id, titel: "Zwei" },
});
expect(zeile.bedingung).toBeNull();
});
});
describe.runIf(datenbankVerfuegbar)("Die tiefe Kopie", () => {
beforeEach(async () => {
await datenbankLeeren();
});
/**
* Legt eine veröffentlichte Version mit **allen** Struktureigenschaften an.
*
* Absichtlich vollgepackt: der Test darunter vergleicht Kopie und Original
* Feld für Feld, und was hier fehlt, prüft er nicht.
*/
async function reicheVersion() {
await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const { vorlage } = await vorlageAnlegen({ name: "Vollgepackt" }, redakteur.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
await entwurfSpeichern(entwurf.id, {
variablen: [
{ schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] },
],
abschnitte: [
{
titel: "Prüfung",
nurFuerAbteilungen: ["Buchhaltung"],
schritte: [
{
titel: "Prüfen",
pflicht: true,
hilfetext: "Mit Blick aufs Protokoll.",
dokuUrl: "https://intern.example/pruefen",
typ: "ZAHL",
konfig: { min: 1, max: 9, einheit: "GB" },
},
],
},
{
titel: "Nacharbeit",
nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"],
bedingung: {
art: "schritt",
abschnitt: "Prüfung",
schritt: "Prüfen",
zustaende: ["FEHLGESCHLAGEN"],
},
schritte: [
{
titel: "Nur bei AD",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
},
],
},
],
} as never);
await versionVeroeffentlichen(entwurf.id, "Erste", redakteur.id);
return { vorlageId: vorlage.id, versionId: entwurf.id, redakteurId: redakteur.id };
}
/** Die Struktur einer Version, ohne alles, was sich bei einer Kopie ändern darf. */
async function struktur(versionId: string) {
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
// Kennungen wandern bei einer Kopie – verglichen wird der Inhalt.
const ohneKennung = (bedingung: unknown): unknown => {
if (typeof bedingung !== "object" || bedingung === null) return bedingung;
const kopie = { ...(bedingung as Record<string, unknown>) };
if ("schrittId" in kopie) kopie["schrittId"] = "<verweist>";
return kopie;
};
return abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
bedingung: ohneKennung(abschnitt.bedingung),
schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl,
bedingung: ohneKennung(schritt.bedingung),
})),
}));
}
it("ist bei „Bearbeiten“ inhaltsgleich – mit allem", async () => {
/*
* **Der Test, der zwei stille Datenverluste gefunden hätte.** Die tiefe
* Kopie nahm `nurFuerAbteilungen` (seit #031) und `bedingung` (seit #039)
* nicht mit: „Bearbeiten“ verlor alle Abteilungseinschränkungen, die neue
* Version galt für jeden Lauf. Also mehr Schritte statt weniger, und niemand
* merkt es.
*
* Die Ursache war ein von Hand getippter Zwischentyp, der weniger Felder
* nannte als die Abfrage liefert. Gefunden erst bei der Klickprobe am
* gebauten Stand.
*
* Verglichen wird deshalb **Feld für Feld und nicht stichprobenweise**: so
* fällt auch das nächste Feld auf, das jemand am Modell ergänzt und beim
* Kopieren vergisst.
*/
const { vorlageId, versionId, redakteurId } = await reicheVersion();
const vorher = await struktur(versionId);
// „Bearbeiten“ in der Oberfläche ruft `entwurfHolenOderErzeugen`: das ist
// die tiefe Kopie der letzten veröffentlichten Version (#009).
const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId);
expect(await struktur(entwurf.id)).toEqual(vorher);
});
it("ist beim Duplizieren inhaltsgleich – mit allem", async () => {
// Dieselbe Prüfung für den zweiten Weg: er hatte denselben Fehler, weil er
// dieselbe Kopie ausgeschrieben hatte. Jetzt teilen beide eine Stelle.
const { vorlageId, versionId, redakteurId } = await reicheVersion();
const vorher = await struktur(versionId);
const kopie = await vorlageDuplizieren(vorlageId, "Vollgepackt (Kopie)", redakteurId);
const neueVersion = await testPrisma.vorlagenVersion.findFirstOrThrow({
where: { vorlageId: kopie.id },
});
expect(await struktur(neueVersion.id)).toEqual(vorher);
});
it("schreibt die Schritt-Bedingung auf die neue Kennung um", async () => {
/*
* Die Kennungen sind neu; ein Verweis auf die alte wäre nach dem
* Veröffentlichen nicht mehr entscheidbar, und der Abschnitt erschiene
* stillschweigend immer. Der Trigger in der Datenbank verlangt dasselbe.
*/
const { vorlageId, redakteurId } = await reicheVersion();
const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId);
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId: entwurf.id },
orderBy: { position: "asc" },
include: { schritte: true },
});
expect(abschnitte[1]?.bedingung).toMatchObject({
art: "schritt",
// Die Kennung des Schritts der **neuen** Version.
schrittId: abschnitte[0]!.schritte[0]!.id,
});
});
});
describe("Die Auswertung sitzt an jeder Stelle, die filtert", () => {
it("ruft geltendeStruktur überall, wo nurGeltende gerufen wird", () => {
/*
* **Strukturell geprüft, weil eine vergessene Stelle nicht auffällt.** Drei
* Schichten dampfen die Struktur eines Laufs ein: der Dienst beim Abschluss,
* die Abarbeitung und die Druckansicht. Vergisst eine davon die Bedingungen,
* sieht der Techniker andere Schritte als der Ausdruck — oder ein
* unsichtbarer Pflichtschritt hält den Abschluss auf.
*
* Dieselbe Art Test wie „jede Seite prüft selbst“ (#038): die Zusage steht
* nicht in einer Prüfung, sondern in der Vollständigkeit einer Liste, und die
* muss jemand bewachen.
*/
const dateien = [
"src/server/services/laeufe.ts",
"src/server/queries/laeufe.ts",
"src/server/queries/druck.ts",
];
const befunde: string[] = [];
for (const datei of dateien) {
const quelle = readFileSync(datei, "utf8");
if (!quelle.includes("nurGeltende(")) {
befunde.push(`${datei} ruft nurGeltende nicht mehr – ist die Liste noch richtig?`);
continue;
}
if (!quelle.includes("geltendeStruktur(")) {
befunde.push(`${datei} filtert nach #031, wertet aber keine Bedingungen aus (#039).`);
}
}
expect(befunde).toEqual([]);
});
});
+335
View File
@@ -0,0 +1,335 @@
import { describe, expect, it } from "vitest";
import {
BedingungSchema,
type Bedingung,
bedingungErfuellt,
bedingungLesen,
bedingungText,
geltendeTeile,
umgebungBauen,
zeigtNachVorne,
} from "@/lib/bedingungen";
/**
* Bedingte Abschnitte und Schritte: die reine Logik (#039).
*
* Ohne Datenbank, weil hier alles entschieden wird, was zählt — und weil die
* Sonderfälle nur so vollständig durchgehen. Dass die Auswertung auch im Dienst,
* in der Abarbeitung und im Druck greift, steht in `tests/bedingte-laeufe.test.ts`.
*
* **Die wichtigste Zusage dieses Meilensteins:** eine Bedingung, die sich nicht
* entscheiden lässt, blendet **nichts** aus. Ein Abschnitt zu viel ist sichtbar
* und korrigierbar; ein fehlender fällt niemandem auf, und das ist bei einer
* Checkliste der teure Fall.
*/
const leer = umgebungBauen({}, []);
describe("Die Form einer Bedingung", () => {
it("nimmt die beiden Arten an", () => {
expect(
BedingungSchema.safeParse({ art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" })
.success,
).toBe(true);
expect(
BedingungSchema.safeParse({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT"] }).success,
).toBe(true);
});
it("verlangt bei „ist“ einen Wert", () => {
/*
* Ohne Wert wäre „gilt, wenn konto_typ gleich “ ist“ eine Bedingung, die
* niemand lesen kann — und sie träfe auf einen leeren Wert zu, also genau
* dann, wenn nichts angegeben wurde. Das ist nicht gemeint.
*/
for (const operator of ["ist", "istNicht"]) {
expect(
BedingungSchema.safeParse({ art: "variable", schluessel: "k", operator }).success,
operator,
).toBe(false);
expect(
BedingungSchema.safeParse({ art: "variable", schluessel: "k", operator, wert: " " }).success,
`${operator} mit Leerraum`,
).toBe(false);
}
// `gesetzt` und `leer` brauchen keinen.
expect(BedingungSchema.safeParse({ art: "variable", schluessel: "k", operator: "gesetzt" }).success).toBe(
true,
);
});
it("verlangt mindestens einen Zustand", () => {
// Eine leere Liste wäre eine Bedingung, die nie zutrifft — also ein
// Abschnitt, der für immer unsichtbar bleibt.
expect(BedingungSchema.safeParse({ art: "schritt", schrittId: "s1", zustaende: [] }).success).toBe(false);
});
it("weist eine unbekannte Art und einen unbekannten Zustand ab", () => {
expect(BedingungSchema.safeParse({ art: "geraet", typ: "PC" }).success).toBe(false);
expect(
BedingungSchema.safeParse({ art: "schritt", schrittId: "s1", zustaende: ["VIELLEICHT"] }).success,
).toBe(false);
});
it("liest beim Lesen nachsichtig: was nicht passt, ist keine Bedingung", () => {
/*
* Hier zu werfen hieße, dass eine einzige unlesbare Zeile eine ganze Vorlage
* unbenutzbar macht. Die Richtung, in die der Fehler dann fällt, ist die
* harmlose: ohne Bedingung wird angezeigt.
*/
expect(bedingungLesen(null)).toBeNull();
expect(bedingungLesen(undefined)).toBeNull();
expect(bedingungLesen({})).toBeNull();
expect(bedingungLesen("Unsinn")).toBeNull();
expect(bedingungLesen({ art: "variable" })).toBeNull();
expect(bedingungLesen({ art: "variable", schluessel: "k", operator: "ist", wert: "AD" })).toEqual({
art: "variable",
schluessel: "k",
operator: "ist",
wert: "AD",
});
});
});
describe("Eine Bedingung auf einer Variable", () => {
const umgebung = umgebungBauen({ konto_typ: "AD", standort: "", anzahl: 3, aktiv: true }, []);
it("vergleicht ohne Rücksicht auf Groß- und Kleinschreibung und Ränder", () => {
// Die Werte kommen aus einem Auswahlfeld oder einem Formular; „AD“ ist
// derselbe Wert wie „ad“ und wie „ AD “.
const bedingung = (wert: string): Bedingung => ({
art: "variable",
schluessel: "konto_typ",
operator: "ist",
wert,
});
expect(bedingungErfuellt(bedingung("AD"), umgebung)).toBe(true);
expect(bedingungErfuellt(bedingung("ad"), umgebung)).toBe(true);
expect(bedingungErfuellt(bedingung(" AD "), umgebung)).toBe(true);
expect(bedingungErfuellt(bedingung("ENTRA"), umgebung)).toBe(false);
});
it("liest Zahlen und Wahrheitswerte als Text", () => {
// `Lauf.variablen` ist ein Json; eine ZAHL-Variable kommt als Zahl zurück.
expect(
bedingungErfuellt({ art: "variable", schluessel: "anzahl", operator: "ist", wert: "3" }, umgebung),
).toBe(true);
expect(
bedingungErfuellt({ art: "variable", schluessel: "aktiv", operator: "ist", wert: "true" }, umgebung),
).toBe(true);
});
it("unterscheidet gesetzt von leer", () => {
expect(
bedingungErfuellt({ art: "variable", schluessel: "konto_typ", operator: "gesetzt" }, umgebung),
).toBe(true);
// Ein leerer Text zählt als nicht gesetzt — so kommt ein unausgefülltes
// Formularfeld an.
expect(
bedingungErfuellt({ art: "variable", schluessel: "standort", operator: "gesetzt" }, umgebung),
).toBe(false);
expect(bedingungErfuellt({ art: "variable", schluessel: "standort", operator: "leer" }, umgebung)).toBe(
true,
);
expect(bedingungErfuellt({ art: "variable", schluessel: "gibtsnicht", operator: "leer" }, umgebung)).toBe(
true,
);
});
it("ist bei einer nicht gesetzten Variable nicht entscheidbar", () => {
/*
* **Die Zusage dieses Meilensteins.** Ein Lauf, bei dem niemand `konto_typ`
* angegeben hat, soll beide Konto-Abschnitte zeigen: auszublenden hieße,
* eine Antwort zu erfinden, die niemand gegeben hat.
*
* `undefined` und nicht `false` — der Unterschied entscheidet in
* `geltendeTeile` darüber, ob ausgeblendet wird.
*/
expect(
bedingungErfuellt({ art: "variable", schluessel: "gibtsnicht", operator: "ist", wert: "AD" }, leer),
).toBeUndefined();
expect(
bedingungErfuellt(
{ art: "variable", schluessel: "standort", operator: "istNicht", wert: "Nord" },
umgebung,
),
).toBeUndefined();
});
it("kehrt istNicht wirklich um", () => {
expect(
bedingungErfuellt(
{ art: "variable", schluessel: "konto_typ", operator: "istNicht", wert: "ENTRA" },
umgebung,
),
).toBe(true);
expect(
bedingungErfuellt(
{ art: "variable", schluessel: "konto_typ", operator: "istNicht", wert: "AD" },
umgebung,
),
).toBe(false);
});
});
describe("Eine Bedingung auf einem Schritt", () => {
const umgebung = umgebungBauen({}, [
{ vorlagenSchrittId: "s1", zustand: "ERLEDIGT" },
{ vorlagenSchrittId: "s2", zustand: "OFFEN" },
{ vorlagenSchrittId: "s3", zustand: "NICHT_ZUTREFFEND" },
]);
it("trifft zu, wenn der Zustand in der Liste steht", () => {
expect(bedingungErfuellt({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT"] }, umgebung)).toBe(
true,
);
expect(
bedingungErfuellt(
{ art: "schritt", schrittId: "s3", zustaende: ["ERLEDIGT", "NICHT_ZUTREFFEND"] },
umgebung,
),
).toBe(true);
expect(bedingungErfuellt({ art: "schritt", schrittId: "s2", zustaende: ["ERLEDIGT"] }, umgebung)).toBe(
false,
);
});
it("liest OFFEN als Antwort und nicht als Lücke", () => {
/*
* **Der Unterschied zur Variable**, und er ist wichtig: jedes
* Schrittergebnis hat einen Zustand. `OFFEN` heißt „noch nicht erfüllt“ und
* nicht „weiß nicht“. Ein Abschnitt, der erst nach einem erledigten Schritt
* erscheint, ist der Sinn der Sache — wäre `OFFEN` nicht entscheidbar, wäre
* er von Anfang an sichtbar.
*/
expect(bedingungErfuellt({ art: "schritt", schrittId: "s2", zustaende: ["ERLEDIGT"] }, umgebung)).toBe(
false,
);
expect(bedingungErfuellt({ art: "schritt", schrittId: "s2", zustaende: ["OFFEN"] }, umgebung)).toBe(true);
});
it("ist bei einem Schritt, den es in diesem Lauf nicht gibt, nicht entscheidbar", () => {
// Etwa ein Schritt, den #031 ausgefiltert hat, oder ein Verweis aus einer
// anderen Version. Dann gibt es nichts zu entscheiden – und nichts
// auszublenden.
expect(
bedingungErfuellt({ art: "schritt", schrittId: "gibtsnicht", zustaende: ["ERLEDIGT"] }, umgebung),
).toBeUndefined();
});
});
describe("Welche Teile gelten", () => {
it("nimmt Teile ohne Bedingung immer", () => {
expect(
geltendeTeile(
[
{ id: "a", bedingung: null },
{ id: "b", bedingung: {} },
],
leer,
),
).toEqual(["a", "b"]);
});
it("blendet aus, was nachweislich nicht zutrifft", () => {
const umgebung = umgebungBauen({ konto_typ: "AD" }, []);
const teile = [
{ id: "ad", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" } },
{
id: "entra",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
},
{ id: "immer", bedingung: null },
];
expect(geltendeTeile(teile, umgebung)).toEqual(["ad", "immer"]);
});
it("blendet bei nicht entscheidbarer Bedingung nichts aus", () => {
/*
* Der echte Fall: ein Lauf ohne `konto_typ`. Beide Konto-Abschnitte
* erscheinen, und jemand setzt den falschen auf NICHT_ZUTREFFEND — genau so,
* wie es vor diesem Meilenstein für **jeden** Lauf war. Eine Verschlechterung
* ist das also nicht; eine erfundene Antwort wäre eine.
*/
const teile = [
{ id: "ad", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" } },
{
id: "entra",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
},
];
expect(geltendeTeile(teile, leer)).toEqual(["ad", "entra"]);
});
it("behält die Reihenfolge der Eingabe", () => {
// Die Reihenfolge ist die der Version; sie darf hier nicht durcheinander
// geraten, sonst stimmt die Abschnittsnummerierung nicht.
const teile = [
{ id: "eins", bedingung: null },
{ id: "zwei", bedingung: { art: "variable", schluessel: "x", operator: "gesetzt" } },
{ id: "drei", bedingung: null },
];
expect(geltendeTeile(teile, umgebungBauen({ x: "ja" }, []))).toEqual(["eins", "zwei", "drei"]);
});
});
describe("Hilfen für den Editor", () => {
const reihenfolge = ["s1", "s2", "s3", "s4"];
it("erkennt eine Bedingung, die nach vorne zeigt", () => {
/*
* Fast immer ein Versehen: der bedingte Abschnitt wäre zunächst unsichtbar
* und erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also
* rückwärts.
*/
expect(
zeigtNachVorne({ art: "schritt", schrittId: "s4", zustaende: ["ERLEDIGT"] }, reihenfolge, "s2"),
).toBe(true);
expect(
zeigtNachVorne({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT"] }, reihenfolge, "s2"),
).toBe(false);
});
it("warnt nicht, wo es nichts zu warnen gibt", () => {
expect(
zeigtNachVorne({ art: "variable", schluessel: "k", operator: "gesetzt" }, reihenfolge, "s2"),
).toBeUndefined();
expect(
zeigtNachVorne({ art: "schritt", schrittId: "fremd", zustaende: ["ERLEDIGT"] }, reihenfolge, "s2"),
).toBeUndefined();
});
it("schreibt eine Bedingung als Satz", () => {
const namen = (kennung: string) =>
({ konto_typ: "Kontotyp", s1: "Benutzer anlegen" })[kennung] ?? kennung;
expect(
bedingungText({ art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" }, namen),
).toBe("gilt, wenn „Kontotyp“ gleich „AD“ ist");
expect(bedingungText({ art: "variable", schluessel: "konto_typ", operator: "leer" }, namen)).toBe(
"gilt, wenn „Kontotyp“ leer ist",
);
expect(
bedingungText({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT", "NICHT_ZUTREFFEND"] }, namen),
).toBe("gilt, wenn „Benutzer anlegen“ erledigt oder nicht zutreffend ist");
});
});
describe("Die Umgebung", () => {
it("verträgt ein Json, das kein Objekt ist", () => {
// `Lauf.variablen` ist ein Json-Feld und kann alles enthalten.
for (const roh of [null, undefined, "Text", 42, ["a"]]) {
expect(umgebungBauen(roh, []).variablen).toEqual({});
}
});
it("übergeht einen Zustand, den das Enum nicht kennt", () => {
// Eine Zeile aus einer späteren Fassung soll die Auswertung nicht anhalten.
const umgebung = umgebungBauen({}, [
{ vorlagenSchrittId: "s1", zustand: "ERLEDIGT" },
{ vorlagenSchrittId: "s2", zustand: "WARTET_AUF_AKTION" },
]);
expect(umgebung.zustaende.get("s1")).toBe("ERLEDIGT");
expect(umgebung.zustaende.has("s2")).toBe(false);
});
});
+32
View File
@@ -70,7 +70,17 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
*/ */
"Mitarbeiter_konto_mitziehen", "Mitarbeiter_konto_mitziehen",
"VorlagenAbschnitt_baustein_veroeffentlicht", "VorlagenAbschnitt_baustein_veroeffentlicht",
/*
* Eine Schritt-Bedingung zeigt auf einen Schritt **derselben Version**
* (#039). Ein CHECK kann das nicht: es verlangt einen Blick in zwei andere
* Tabellen. Ohne diese Pruefung zeigt eine Bedingung nach dem
* Veroeffentlichen ins Leere — „Bearbeiten“ erzeugt eine tiefe Kopie mit
* neuen Kennungen —, und der Abschnitt erscheint dann stillschweigend
* immer.
*/
"VorlagenAbschnitt_bedingung_gleiche_version",
"VorlagenAbschnitt_unveraenderlich", "VorlagenAbschnitt_unveraenderlich",
"VorlagenSchritt_bedingung_gleiche_version",
"VorlagenSchritt_unveraenderlich", "VorlagenSchritt_unveraenderlich",
"VorlagenVariable_unveraenderlich", "VorlagenVariable_unveraenderlich",
"VorlagenVersion_status_einbahn", "VorlagenVersion_status_einbahn",
@@ -110,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",
]); ]);
}); });
@@ -139,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",
@@ -159,6 +184,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Verzeichnisquelle_scimtoken_gehasht", "Verzeichnisquelle_scimtoken_gehasht",
// Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen. // Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen.
"Verzeichnisquelle_scimtoken_mit_zeitpunkt", "Verzeichnisquelle_scimtoken_mit_zeitpunkt",
/*
* Die Form einer Bedingung (#039). Eine mit unbekannter `art` wuerde beim
* Lesen als „keine Bedingung“ gelten — der Abschnitt waere sichtbar, und
* niemand koennte sagen, warum seine Bedingung nicht wirkt.
*/
"VorlagenAbschnitt_bedingung_form",
"VorlagenSchritt_bedingung_form",
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt", "VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
"Zustellung_bezug_passt_zum_anlass", "Zustellung_bezug_passt_zum_anlass",
"Zustellung_empfaenger_nicht_leer", "Zustellung_empfaenger_nicht_leer",
+384
View File
@@ -0,0 +1,384 @@
import { beforeEach, describe, expect, it } from "vitest";
import { LaufStatus, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
import {
LaufFehler,
laufAbschliessen,
laufAnlegen,
laufFreigeben,
laufZurueckweisen,
} from "@/server/services/laeufe";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Vier-Augen-Freigabe (#040).
*
* **Eine Zusage trägt diesen Meilenstein:** wer an einem Lauf gearbeitet hat,
* gibt ihn nicht frei. Alles andere — das Recht, die Pflicht an der Version, die
* Zurückweisung — hängt daran.
*
* Geprüft wird sie von drei Seiten, weil sie auf drei Wegen umgangen werden
* könnte: über das Anlegen, über ein Schrittergebnis und über den Abschluss. Und
* ein CHECK in der Datenbank hält die wichtigste Hälfte selbst dann, wenn jemand
* an der Dienstschicht vorbei schreibt.
*/
async function aufbau(freigabePflicht: boolean) {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
});
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "Restore-Test" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF, freigabePflicht },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Prüfung", position: 0 },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Sicherung zurückspielen", pflicht: true, position: 0 },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
kunde,
versionId: version.id,
schrittId: schritt.id,
techniker: { id: techniker.id, rolle: techniker.rolle },
redakteur: { id: redakteur.id, rolle: redakteur.rolle },
admin: { id: admin.id, rolle: admin.rolle },
};
}
/** Ein Lauf, den der Techniker angelegt und abgearbeitet hat. */
async function abgearbeitet(freigabePflicht: boolean) {
const stand = await aufbau(freigabePflicht);
const lauf = await laufAnlegen({ vorlagenVersionId: stand.versionId }, stand.techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: stand.schrittId, zustand: SchrittZustand.ERLEDIGT },
stand.techniker,
);
return { ...stand, laufId: lauf.id };
}
describe.runIf(datenbankVerfuegbar)("Ein Lauf mit Freigabepflicht", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("lässt sich nicht abschließen, solange keine Freigabe steht", async () => {
const { laufId, techniker } = await abgearbeitet(true);
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(/Freigabe durch einen Zweiten/u);
});
it("lässt sich nach der Freigabe abschließen", async () => {
const { laufId, techniker, redakteur } = await abgearbeitet(true);
await laufFreigeben(laufId, redakteur);
const fertig = await laufAbschliessen(laufId, techniker);
expect(fertig.status).toBe(LaufStatus.ABGESCHLOSSEN);
});
it("hält die Freigabe mit Freigeber und Zeitpunkt fest", async () => {
const { laufId, redakteur } = await abgearbeitet(true);
const nachher = await laufFreigeben(laufId, redakteur, "Sichtprüfung am 24.10. durch Red");
expect(nachher.freigegebenVonId).toBe(redakteur.id);
expect(nachher.freigegebenAm).toBeInstanceOf(Date);
expect(nachher.freigabeNotiz).toBe("Sichtprüfung am 24.10. durch Red");
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId, aktion: "Lauf freigegeben" },
});
expect(eintrag.benutzerId).toBe(redakteur.id);
});
});
describe.runIf(datenbankVerfuegbar)("Wer gearbeitet hat, gibt nicht frei", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("weist den Techniker ab, der den Lauf angelegt und abgearbeitet hat", async () => {
// Er hätte das Recht ohnehin nicht — der nächste Test zeigt den Fall mit Recht.
const { laufId, techniker } = await abgearbeitet(true);
await expect(laufFreigeben(laufId, techniker)).rejects.toThrow(/Recht "freigeben"/u);
});
it("weist auch einen ADMIN ab, der den Lauf angelegt hat", async () => {
/*
* **Der wichtigste Fall.** Das Recht allein genügt nicht; sonst wäre die
* Vier-Augen-Regel für jeden Administrator eine Formsache. Ein Einzelkämpfer
* kommt damit nicht weiter — das ist eine bewusste Härte, und die Anleitung
* sagt es.
*/
const { versionId, admin } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await expect(laufFreigeben(lauf.id, admin)).rejects.toThrow(/an diesem Lauf gearbeitet/u);
});
it("weist ab, wer nur ein Schrittergebnis gesetzt hat", async () => {
/*
* Angelegt hat ihn der Techniker, abgearbeitet der Redakteur. Der Redakteur
* hat das Recht — aber er hat gearbeitet, also gibt er nicht frei.
*/
const { versionId, schrittId, techniker, redakteur } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
redakteur,
);
await expect(laufFreigeben(lauf.id, redakteur)).rejects.toThrow(/an diesem Lauf gearbeitet/u);
});
it("lässt den frei, der nur zugewiesen war", async () => {
/*
* Die Gegenrichtung, und sie ist Absicht: eine **Zuweisung** ist noch keine
* Arbeit. Wer zugewiesen wurde und nichts getan hat, soll freigeben können,
* wenn jemand anderes abgearbeitet hat — sonst wäre die Regel strenger als
* gemeint und blockierte kleine Teams unnötig.
*/
const { versionId, schrittId, techniker, redakteur } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, zugewiesenAnId: redakteur.id }, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
techniker,
);
await expect(laufFreigeben(lauf.id, redakteur)).resolves.toBeDefined();
});
it("weist ab, wer ihn abgeschlossen hat", async () => {
// Ohne Freigabepflicht lässt sich abschließen; danach ist der Abschließende
// Bearbeiter und gibt nicht frei.
const { versionId, schrittId, techniker, redakteur } = await aufbau(false);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
techniker,
);
await laufAbschliessen(lauf.id, redakteur);
await expect(laufFreigeben(lauf.id, redakteur)).rejects.toThrow(/an diesem Lauf gearbeitet/u);
});
it("wird auch von der Datenbank abgewiesen, nicht nur vom Dienst", async () => {
/*
* An der Dienstschicht vorbei: der CHECK `Lauf_freigabe_nicht_selbst` hält
* die wichtigste Hälfte der Regel — der Ersteller gibt nicht frei (CLAUDE.md,
* „Regeln stehen in der Datenbank“).
*/
const { versionId, admin } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await expect(
testPrisma.lauf.update({
where: { id: lauf.id },
data: { freigegebenVonId: admin.id, freigegebenAm: new Date() },
}),
).rejects.toThrow(/Lauf_freigabe_nicht_selbst/u);
});
it("verlangt Freigeber und Zeitpunkt zusammen", async () => {
const { versionId, techniker, redakteur } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
await expect(
testPrisma.lauf.update({ where: { id: lauf.id }, data: { freigegebenVonId: redakteur.id } }),
).rejects.toThrow(/Lauf_freigabe_mit_zeitpunkt/u);
await expect(
testPrisma.lauf.update({ where: { id: lauf.id }, data: { freigegebenAm: new Date() } }),
).rejects.toThrow(/Lauf_freigabe_mit_zeitpunkt/u);
});
});
describe.runIf(datenbankVerfuegbar)("Zurückweisen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("verlangt eine Begründung", async () => {
// Dieselbe Regel wie bei NICHT_ZUTREFFEND und FEHLGESCHLAGEN: wer etwas
// zurückgibt, sagt warum.
const { laufId, redakteur } = await abgearbeitet(true);
await expect(laufZurueckweisen(laufId, redakteur, " ")).rejects.toThrow(/Begründung/u);
});
it("setzt den Lauf zurück auf IN_ARBEIT und hält die Notiz fest", async () => {
const { laufId, redakteur } = await abgearbeitet(true);
const nachher = await laufZurueckweisen(laufId, redakteur, "Prüfsumme fehlt im Protokoll");
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
expect(nachher.freigabeNotiz).toBe("Prüfsumme fehlt im Protokoll");
expect(nachher.freigegebenVonId).toBeNull();
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { laufId, aktion: "Lauf zurückgewiesen" },
});
expect(JSON.stringify(eintrag.nachher)).toContain("Prüfsumme fehlt");
});
it("nimmt eine erteilte Freigabe zurück", async () => {
const { laufId, redakteur, admin } = await abgearbeitet(true);
await laufFreigeben(laufId, redakteur);
await laufZurueckweisen(laufId, admin, "Doch nicht in Ordnung");
const zeile = await testPrisma.lauf.findUniqueOrThrow({ where: { id: laufId } });
expect(zeile.freigegebenVonId).toBeNull();
expect(zeile.freigegebenAm).toBeNull();
// Und der Abschluss wartet wieder.
await expect(laufAbschliessen(laufId, redakteur)).rejects.toThrow(/Freigabe/u);
});
it("holt einen abgeschlossenen Lauf zurück in die Arbeit", async () => {
const { laufId, techniker, redakteur, admin } = await abgearbeitet(true);
await laufFreigeben(laufId, redakteur);
await laufAbschliessen(laufId, techniker);
const nachher = await laufZurueckweisen(laufId, admin, "Nacharbeit nötig");
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
expect(nachher.abgeschlossenAm).toBeNull();
expect(nachher.abgeschlossenVonId).toBeNull();
});
it("verlangt das Recht freigeben", async () => {
const { laufId, techniker } = await abgearbeitet(true);
await expect(laufZurueckweisen(laufId, techniker, "Grund")).rejects.toThrow(/Recht "freigeben"/u);
});
});
describe.runIf(datenbankVerfuegbar)("Die Aufforderung an die Freigabeberechtigten", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("geht beim gescheiterten Abschlussversuch hinaus – an die mit dem Recht", async () => {
/*
* **Der Fall, der die ganze Regel wertlos machen würde:** ein Lauf wartet auf
* eine Freigabe, und niemand weiß es. Die Aufforderung entsteht deshalb genau
* dort, wo der Bearbeiter „ich bin fertig“ sagt — beim Abschlussversuch.
*
* Empfänger ist **nicht** der Zuständige, sondern jeder mit dem Recht
* `freigeben`. Das ist der einzige Anlass mit diesem Kreis.
*/
const { laufId, techniker, redakteur, admin } = await abgearbeitet(true);
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
const zustellungen = await testPrisma.zustellung.findMany({
where: { laufId, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" },
select: { empfaenger: true },
});
const empfaenger = zustellungen.map((zeile) => zeile.empfaenger).sort();
const redZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: redakteur.id } });
const adminZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
const tinaZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: techniker.id } });
expect(empfaenger).toEqual([adminZeile.email, redZeile.email].sort());
// Der Techniker hat kein `freigeben` – ihn aufzufordern wäre eine
// Aufforderung zu etwas, das der Dienst abweist.
expect(empfaenger).not.toContain(tinaZeile.email);
});
it("lässt den aus, der an dem Lauf gearbeitet hat", async () => {
// Der Redakteur hat das Recht, aber er hat abgearbeitet: er darf nicht
// freigeben, also bekommt er auch keine Aufforderung.
const { versionId, schrittId, techniker, redakteur, admin } = await aufbau(true);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
redakteur,
);
await expect(laufAbschliessen(lauf.id, techniker)).rejects.toThrow(LaufFehler);
const zustellungen = await testPrisma.zustellung.findMany({
where: { laufId: lauf.id, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" },
select: { empfaenger: true },
});
const adminZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
expect(zustellungen.map((z) => z.empfaenger)).toEqual([adminZeile.email]);
});
it("geht bei mehreren Versuchen nur einmal hinaus", async () => {
/*
* Ein Techniker versucht es mehrmals — und ohne den partiellen Unique-Index
* bekäme jeder Berechtigte bei jedem Versuch eine Mail, jede für sich
* korrekt. Derselbe Fall wie „überfällig“ in #034, nur schneller erreicht.
*/
const { laufId, techniker } = await abgearbeitet(true);
for (let versuch = 0; versuch < 3; versuch += 1) {
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
}
const anzahl = await testPrisma.zustellung.count({
where: { laufId, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" },
});
// Zwei Berechtigte, drei Versuche – zwei Zeilen.
expect(anzahl).toBe(2);
});
it("nennt im Rumpf die Regel und keinen Schrittinhalt", async () => {
// Der Empfänger ist meist nicht der Bearbeiter und soll nicht raten müssen,
// warum er gefragt wird. Schrittinhalte gehören auch hier nicht hinein (#034).
const { laufId, techniker } = await abgearbeitet(true);
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
const zeile = await testPrisma.zustellung.findFirstOrThrow({
where: { laufId, anlass: "LAUF_FREIGABE_OFFEN" },
});
expect(zeile.betreff).toContain("wartet auf Freigabe");
expect(zeile.rumpf).toMatch(/nicht daran gearbeitet/u);
expect(zeile.rumpf).not.toContain("Sicherung zurückspielen");
});
});
describe.runIf(datenbankVerfuegbar)("Ein Lauf ohne Freigabepflicht", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("verhält sich unverändert", async () => {
/*
* **Die Zusage an alles Bestehende.** Jede Vorlage und jeder Lauf von vor
* diesem Meilenstein hat `freigabePflicht = false`; nichts daran darf sich
* ändern, sonst wäre es ein kaputtes Update.
*/
const { laufId, techniker } = await abgearbeitet(false);
const fertig = await laufAbschliessen(laufId, techniker);
expect(fertig.status).toBe(LaufStatus.ABGESCHLOSSEN);
expect(fertig.freigegebenVonId).toBeNull();
});
it("lässt sich trotzdem freigeben – nach denselben Regeln", async () => {
/*
* Eine Freigabe an einem Lauf, der keine braucht, ist kein Fehler. Sie folgt
* aber denselben Regeln: sonst wäre sie ein Weg, einen Freigeber in die Akte
* zu schreiben, der dort nicht hingehört.
*/
const { laufId, techniker, redakteur } = await abgearbeitet(false);
await expect(laufFreigeben(laufId, redakteur)).resolves.toBeDefined();
await expect(laufFreigeben(laufId, techniker)).rejects.toThrow(/Recht "freigeben"/u);
});
it("lässt sich nicht zweimal freigeben", async () => {
const { laufId, redakteur, admin } = await abgearbeitet(false);
await laufFreigeben(laufId, redakteur);
await expect(laufFreigeben(laufId, admin)).rejects.toThrow(/bereits freigegeben/u);
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** /**
* Der Healthcheck (#039). * Der Healthcheck (#025).
* *
* Dieser Pfad ist der einzige, der ohne Anmeldung **und** mit Datenbankzugriff * Dieser Pfad ist der einzige, der ohne Anmeldung **und** mit Datenbankzugriff
* antwortet. Geprueft wird deshalb nicht, dass er funktioniert — das tun der * antwortet. Geprueft wird deshalb nicht, dass er funktioniert — das tun der
+9 -3
View File
@@ -23,6 +23,11 @@ const MATRIX: Record<Recht, Record<Rolle, boolean>> = {
MITARBEITER: false, MITARBEITER: false,
}, },
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false }, vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
/*
* Die Vier-Augen-Freigabe (#040) liegt bewusst **nicht** beim TECHNIKER: sonst
* haengt sie daran, dass zufaellig ein zweiter greifbar ist.
*/
freigeben: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false }, archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false }, verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
}; };
@@ -34,11 +39,11 @@ describe("Rechtematrix", () => {
it("prueft jede Rolle gegen jedes Recht", () => { it("prueft jede Rolle gegen jedes Recht", () => {
// Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen, // Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen,
// acht Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle // neun Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle
// oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035). // oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035).
expect(ALLE_ROLLEN).toHaveLength(5); expect(ALLE_ROLLEN).toHaveLength(5);
expect(ALLE_RECHTE).toHaveLength(8); expect(ALLE_RECHTE).toHaveLength(9);
expect(faelle).toHaveLength(40); expect(faelle).toHaveLength(45);
}); });
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => { it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {
@@ -58,6 +63,7 @@ describe("Rollen im Einzelnen", () => {
expect(darf("TECHNIKER", "laeufeBearbeiten")).toBe(true); expect(darf("TECHNIKER", "laeufeBearbeiten")).toBe(true);
expect(darf("TECHNIKER", "geraeteBearbeiten")).toBe(true); expect(darf("TECHNIKER", "geraeteBearbeiten")).toBe(true);
expect(darf("TECHNIKER", "vorlagenBearbeiten")).toBe(false); expect(darf("TECHNIKER", "vorlagenBearbeiten")).toBe(false);
expect(darf("TECHNIKER", "freigeben")).toBe(false);
expect(darf("TECHNIKER", "verwaltung")).toBe(false); expect(darf("TECHNIKER", "verwaltung")).toBe(false);
expect(darf("TECHNIKER", "archivVerwalten")).toBe(false); expect(darf("TECHNIKER", "archivVerwalten")).toBe(false);
}); });
+40 -6
View File
@@ -150,13 +150,47 @@ describe("Die sieben Vorlagen", () => {
expect(gruppen).toContain('-Members "{{sam}}"'); expect(gruppen).toContain('-Members "{{sam}}"');
}); });
it("enthaelt beide Konto-Abschnitte des Onboardings", () => { it("enthaelt beide Konto-Abschnitte des Onboardings – jeden mit seiner Bedingung", () => {
// Bedingte Abschnitte kommen erst in Phase 3; bis dahin wird der nicht /*
// zutreffende als Ganzes auf „nicht zutreffend“ gesetzt. * **Der Fall, für den #039 gebaut wurde.** Beide Abschnitte bleiben in der
* Vorlage — sie schließen sich über `konto_typ` gegenseitig aus. Bis Phase 3
* standen sie ohne Bedingung drin, und ein Techniker setzte den nicht
* zutreffenden von Hand auf „nicht zutreffend“: neun Schritte bei jedem
* Onboarding.
*
* Geprüft wird hier die **Vorlage**; dass die Auswertung greift, steht in
* `tests/bedingte-laeufe.test.ts`.
*/
const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding"); const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding");
const titel = (onboarding?.dokument.struktur.abschnitte ?? []).map((abschnitt) => abschnitt.titel); const abschnitte = onboarding?.dokument.struktur.abschnitte ?? [];
expect(titel).toContain("Konto anlegen (Active Directory)"); const nach = (titel: string) => abschnitte.find((abschnitt) => abschnitt.titel === titel);
expect(titel).toContain("Konto anlegen (Entra ID)");
const optionen =
onboarding?.dokument.struktur.variablen.find((variable) => variable.schluessel === "konto_typ")
?.optionen ?? [];
for (const [titel, wert] of [
["Konto anlegen (Active Directory)", "Active Directory"],
["Konto anlegen (Entra ID)", "Entra ID"],
] as const) {
const abschnitt = nach(titel);
expect(abschnitt, titel).toBeDefined();
expect(abschnitt?.bedingung, titel).toEqual({
art: "variable",
schluessel: "konto_typ",
operator: "ist",
wert,
});
/*
* **Der Wert muss eine der Optionen sein.** Ein Tippfehler hier wäre
* stillschweigend: die Bedingung träfe nie zu, der Abschnitt erschiene
* nie, und die Hälfte der Checkliste fehlte — ohne Fehlermeldung.
*/
expect(optionen, `${titel}: „${wert}“ ist keine Option von konto_typ`).toContain(wert);
}
// Und der gemeinsame Abschnitt hat keine: er gilt immer.
expect(nach("Konto, gemeinsam")?.bedingung).toBeUndefined();
}); });
it("speichert nirgends ein Passwort im Klartext", () => { it("speichert nirgends ein Passwort im Klartext", () => {
+156 -1
View File
@@ -34,7 +34,15 @@ async function redakteurAnlegen() {
* der Export, dass er nach `position` sortiert und nicht danach, wie die * der Export, dass er nach `position` sortiert und nicht danach, wie die
* Datenbank die Zeilen gerade zurueckgibt. * Datenbank die Zeilen gerade zurueckgibt.
*/ */
async function beispielVersionAnlegen(): Promise<string> { /**
* Legt die Beispielvorlage an und veröffentlicht sie.
*
* `nachtragen` wird **vor** dem Veröffentlichen gerufen: an einer
* veröffentlichten Version lässt sich nichts mehr ändern, dafür sorgen Trigger
* in der Datenbank (#009). Wer eine Abteilungseinschränkung oder eine Bedingung
* braucht, setzt sie dort — genau wie im Editor.
*/
async function beispielVersionAnlegen(nachtragen?: (versionId: string) => Promise<void>): Promise<string> {
const vorlage = await testPrisma.vorlage.create({ const vorlage = await testPrisma.vorlage.create({
data: { data: {
name: "Onboarding Notebook", name: "Onboarding Notebook",
@@ -128,6 +136,8 @@ async function beispielVersionAnlegen(): Promise<string> {
], ],
}); });
await nachtragen?.(version.id);
await testPrisma.vorlagenVersion.update({ await testPrisma.vorlagenVersion.update({
where: { id: version.id }, where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() }, data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
@@ -136,6 +146,15 @@ async function beispielVersionAnlegen(): Promise<string> {
return version.id; return version.id;
} }
/** Die Abschnitte einer Version samt Schritten, in Reihenfolge. */
async function abschnitteVon(versionId: string) {
return testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
}
/** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */ /** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> { function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
return { return {
@@ -503,6 +522,142 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] }); expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] });
}); });
it("bewahrt die Abteilungseinschränkung eines Abschnitts", async () => {
/*
* **Das ging stillschweigend verloren.** Der Export schreibt
* `nurFuerAbteilungen` seit #031, das Schema nimmt es an — und der Import
* verwarf es. Eine Vorlage, die jemand in eine zweite Installation
* brachte, verlor ihre Abteilungsabschnitte: sie galten dort für **alle**.
* Also mehr Schritte statt weniger, und niemand merkt es. Gefunden bei
* #039, als derselbe Weg für Bedingungen gebaut wurde.
*/
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen(async (versionId) => {
await testPrisma.vorlagenAbschnitt.updateMany({
where: { versionId, position: 0 },
data: { nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"] },
});
});
const erst = await vorlageExportieren(quelle);
expect(erst.struktur.abschnitte[0]?.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
const { versionId } = await vorlageImportieren(erst, redakteur.id);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId, position: 0 },
});
expect(zeile.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
});
it("schreibt eine Schritt-Bedingung als Titel-Pfad und löst sie wieder auf", async () => {
/*
* Eine Bedingung zeigt gespeichert auf eine **Kennung**, und die bedeutet
* in einer anderen Installation nichts. Im Austausch steht deshalb ein
* Titel-Pfad; beim Import wird daraus die neue Kennung — sonst wäre die
* Bedingung dort nicht entscheidbar, und der Abschnitt erschiene immer,
* ohne dass jemand sagen könnte, warum (#039).
*/
const redakteur = await redakteurAnlegen();
let abschnitte: Awaited<ReturnType<typeof abschnitteVon>> = [];
const quelle = await beispielVersionAnlegen(async (versionId) => {
abschnitte = await abschnitteVon(versionId);
await testPrisma.vorlagenAbschnitt.update({
where: { id: abschnitte[1]!.id },
data: {
bedingung: {
art: "schritt",
schrittId: abschnitte[0]!.schritte[0]!.id,
zustaende: ["FEHLGESCHLAGEN"],
},
},
});
});
const ziel = abschnitte[0]!.schritte[0]!;
// Im Export steht der Pfad, nicht die Kennung.
const erst = await vorlageExportieren(quelle);
const ausgetauscht = erst.struktur.abschnitte[1]?.bedingung;
expect(ausgetauscht).toEqual({
art: "schritt",
abschnitt: abschnitte[0]!.titel,
schritt: ziel.titel,
zustaende: ["FEHLGESCHLAGEN"],
});
expect(JSON.stringify(erst)).not.toContain(ziel.id);
// Und beim Import wird daraus die neue Kennung.
const { versionId } = await vorlageImportieren(erst, redakteur.id);
const neu = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
expect(neu[1]?.bedingung).toEqual({
art: "schritt",
schrittId: neu[0]!.schritte[0]!.id,
zustaende: ["FEHLGESCHLAGEN"],
});
});
it("übergeht eine Bedingung, deren Titel-Pfad in der Datei fehlt", async () => {
/*
* Eine von Hand geschriebene Datei kann sich vertippen. Die ganze Vorlage
* deswegen abzuweisen wäre die teurere Antwort — übergangen heißt: der
* Abschnitt gilt immer, und das steht sichtbar in der Oberfläche.
*/
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const erst = await vorlageExportieren(quelle);
const { versionId } = await vorlageImportieren(
{
...erst,
name: "Mit Tippfehler",
struktur: {
...erst.struktur,
abschnitte: erst.struktur.abschnitte.map((abschnitt, stelle) =>
stelle === 1
? {
...abschnitt,
bedingung: {
art: "schritt",
abschnitt: "Gibt es nicht",
schritt: "Auch nicht",
zustaende: ["ERLEDIGT"],
},
}
: abschnitt,
),
},
},
redakteur.id,
);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId, position: 1 },
});
expect(zeile.bedingung).toBeNull();
});
it("bewahrt eine Variablen-Bedingung unverändert", async () => {
const redakteur = await redakteurAnlegen();
const bedingung = { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" };
const quelle = await beispielVersionAnlegen(async (versionId) => {
await testPrisma.vorlagenAbschnitt.updateMany({
where: { versionId, position: 0 },
data: { bedingung },
});
});
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId, position: 0 },
});
// Ein Variablenschluessel ist ein Name, den die Vorlage selbst mitbringt —
// er wandert unveraendert.
expect(zeile.bedingung).toEqual(bedingung);
});
it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => { it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => {
const redakteur = await redakteurAnlegen(); const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen(); const quelle = await beispielVersionAnlegen();