Files
Florian WildeandClaude Opus 5 a2330ef682
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): bedingte Abschnitte und Schritte (#039)
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

162 lines
6.4 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ---------------------------------------------------------------------------
-- 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 $$;