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>
This commit is contained in:
Florian Wilde
2026-10-23 14:54:10 +02:00
co-authored by Claude Opus 5
parent 2f247b3617
commit a2330ef682
29 changed files with 3051 additions and 146 deletions
@@ -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 $$;