Phase 2: Wartungs- und Pruefmanagement
Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf, Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung. Elf neue Tabellen, alle mit organisationId und Row Level Security. Die Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung mit Verweis auf die alte, beide bleiben lesbar. 79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f866ac35c
commit
e503aafc18
@@ -0,0 +1,243 @@
|
||||
-- =============================================================================
|
||||
-- Phase 2: Mandantentrennung, Unveränderbarkeit der Prüfprotokolle, Indizes.
|
||||
--
|
||||
-- Die vorige Migration hat die Tabellen angelegt. Drei Dinge lassen sich nur
|
||||
-- in der Datenbank verlässlich durchsetzen und stehen deshalb hier:
|
||||
--
|
||||
-- 1. Row Level Security für die elf neuen Tabellen — dieselbe Behandlung wie
|
||||
-- für die Tabellen aus Phase 0 und 1.
|
||||
--
|
||||
-- 2. Die Unveränderbarkeit abgeschlossener Prüfprotokolle. Ein Protokoll ist
|
||||
-- ein Nachweis gegenüber der Unfallkasse. Wäre es nachträglich änderbar,
|
||||
-- wäre es als Nachweis wertlos — und ein vergessener Zustandscheck im
|
||||
-- Code reicht, um genau das zu ermöglichen. Deshalb sitzt die Sperre in
|
||||
-- der Datenbank, nicht nur in lib/ und server/.
|
||||
--
|
||||
-- 3. Indizes für das Fristen-Dashboard und die Suche über Mängel und
|
||||
-- Rückrufe.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
tabelle text;
|
||||
BEGIN
|
||||
FOREACH tabelle IN ARRAY ARRAY[
|
||||
'wartungsvorlage',
|
||||
'checklistenpunkt',
|
||||
'wartungstermin',
|
||||
'pruefung',
|
||||
'pruefergebnis',
|
||||
'signatur',
|
||||
'mangel',
|
||||
'rueckruf',
|
||||
'rueckruf_geraetetyp',
|
||||
'rueckruf_betroffenheit',
|
||||
'erinnerung'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON %I FOR ALL '
|
||||
'USING ("organisationId" = aktuelle_organisation()) '
|
||||
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||
tabelle || '_mandant', tabelle
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Unveränderbarkeit abgeschlossener Prüfungen
|
||||
--
|
||||
-- Solange eine Prüfung im Status ENTWURF ist, darf der Wizard alles ändern.
|
||||
-- Mit dem Abschluss ist Schluss. Genau drei Änderungen bleiben danach zulässig,
|
||||
-- und alle drei ergänzen das Protokoll, statt es zu verfälschen:
|
||||
--
|
||||
-- * status ABGESCHLOSSEN -> ERSETZT, wenn eine Korrekturprüfung nachfolgt
|
||||
-- * protokollDateiId und protokollNummer, solange sie noch leer sind: das
|
||||
-- PDF entsteht im Hintergrundprozess und wird nachgetragen
|
||||
-- * geaendertAm, das Prisma bei jedem Schreibvorgang mitsetzt
|
||||
--
|
||||
-- Alles andere — Ergebnis, Datum, Prüfer, Messwerte — ist festgeschrieben.
|
||||
-- Korrekturen laufen über eine neue Prüfung mit Verweis auf die alte.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION pruefung_unveraenderlich()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
vergleich "pruefung"%ROWTYPE;
|
||||
BEGIN
|
||||
-- Entwürfe sind frei änderbar.
|
||||
IF OLD."status" = 'ENTWURF' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Die zulässigen Änderungen auf den alten Stand zurückdrehen. Bleibt danach
|
||||
-- ein Unterschied übrig, wurde etwas Unzulässiges angefasst.
|
||||
vergleich := NEW;
|
||||
vergleich."geaendertAm" := OLD."geaendertAm";
|
||||
|
||||
IF OLD."status" = 'ABGESCHLOSSEN' AND NEW."status" = 'ERSETZT' THEN
|
||||
vergleich."status" := OLD."status";
|
||||
END IF;
|
||||
|
||||
IF OLD."protokollDateiId" IS NULL THEN
|
||||
vergleich."protokollDateiId" := OLD."protokollDateiId";
|
||||
END IF;
|
||||
|
||||
IF OLD."protokollNummer" IS NULL THEN
|
||||
vergleich."protokollNummer" := OLD."protokollNummer";
|
||||
END IF;
|
||||
|
||||
IF vergleich IS DISTINCT FROM OLD THEN
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen und damit unveraenderlich. '
|
||||
'Eine Korrektur wird als neue Pruefung mit Verweis auf diese erfasst.',
|
||||
OLD."id"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION pruefung_unveraenderlich() IS
|
||||
'Sperrt Aenderungen an abgeschlossenen Pruefungen, bis auf Ersetzung und Nachtrag des Protokolls.';
|
||||
|
||||
-- Gelöscht wird ein Protokoll gar nicht — auch nicht weich. Die einzige
|
||||
-- Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie beim Audit-Log.
|
||||
CREATE OR REPLACE FUNCTION pruefung_kein_loeschen()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF OLD."status" = 'ENTWURF' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen und darf nicht geloescht werden '
|
||||
'(nur im Aufbewahrungslauf).',
|
||||
OLD."id"
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER pruefung_abschluss_schuetzen
|
||||
BEFORE UPDATE ON "pruefung"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefung_unveraenderlich();
|
||||
|
||||
CREATE TRIGGER pruefung_loeschschutz
|
||||
BEFORE DELETE ON "pruefung"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefung_kein_loeschen();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Dasselbe für die Bestandteile eines Protokolls.
|
||||
--
|
||||
-- Ohne diese Sperre ließe sich ein festgeschriebenes Protokoll über den Umweg
|
||||
-- der Einzelergebnisse verändern: die Prüfung bliebe unangetastet, ihr Inhalt
|
||||
-- nicht. Beim Löschen der Prüfung selbst greift die Kaskade — dann gibt es die
|
||||
-- Prüfung schon nicht mehr, und die Abfrage unten liefert NULL.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION pruefungsinhalt_gesperrt()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
betroffene_pruefung uuid;
|
||||
zustand "PruefungStatus";
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
betroffene_pruefung := OLD."pruefungId";
|
||||
ELSE
|
||||
betroffene_pruefung := NEW."pruefungId";
|
||||
END IF;
|
||||
|
||||
SELECT "status" INTO zustand FROM "pruefung" WHERE "id" = betroffene_pruefung;
|
||||
|
||||
-- Prüfung existiert nicht mehr (Kaskade) oder ist noch Entwurf: erlaubt.
|
||||
IF zustand IS NULL OR zustand = 'ENTWURF' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION
|
||||
'Die Pruefung % ist abgeschlossen. % auf % ist nicht mehr zulaessig.',
|
||||
betroffene_pruefung, TG_OP, TG_TABLE_NAME
|
||||
USING ERRCODE = 'insufficient_privilege';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER pruefergebnis_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "pruefergebnis"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||
|
||||
CREATE TRIGGER signatur_gesperrt
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON "signatur"
|
||||
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Ein Messwert außerhalb seines Bereichs ist kein Messwert, sondern ein Fehler
|
||||
-- in der Vorlage: die Untergrenze darf nicht über der Obergrenze liegen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_bereich_sinnvoll" CHECK (
|
||||
"minWert" IS NULL OR "maxWert" IS NULL OR "minWert" <= "maxWert"
|
||||
);
|
||||
|
||||
-- Ein Intervall von null Monaten würde im nächtlichen Lauf endlos Termine
|
||||
-- erzeugen.
|
||||
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_intervall_positiv" CHECK (
|
||||
"intervallMonate" > 0 AND "vorwarnzeitTage" >= 0
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Indizes
|
||||
--
|
||||
-- Das Fristen-Dashboard fragt immer dasselbe: offene Termine, sortiert nach
|
||||
-- Fälligkeit. Ein Teilindex darauf ist klein und bleibt es auch, wenn die
|
||||
-- Tabelle nach Jahren zehntausende erledigte Termine enthält.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX "wartungstermin_offen" ON "wartungstermin" ("organisationId", "faelligAm")
|
||||
WHERE "status" = 'OFFEN' AND "geloeschtAm" IS NULL;
|
||||
|
||||
CREATE INDEX "mangel_offen" ON "mangel" ("organisationId", "prioritaet", "gemeldetAm")
|
||||
WHERE "status" <> 'BEHOBEN' AND "status" <> 'VERWORFEN' AND "geloeschtAm" IS NULL;
|
||||
|
||||
-- Volltextsuche wie in Phase 1: pg_trgm macht ILIKE '%text%' indexfähig.
|
||||
CREATE INDEX "mangel_suche_beschreibung" ON "mangel" USING gin ("beschreibung" gin_trgm_ops);
|
||||
CREATE INDEX "rueckruf_suche_titel" ON "rueckruf" USING gin ("titel" gin_trgm_ops);
|
||||
CREATE INDEX "rueckruf_suche_hersteller" ON "rueckruf" USING gin ("hersteller" gin_trgm_ops);
|
||||
CREATE INDEX "wartungsvorlage_suche_name" ON "wartungsvorlage" USING gin ("name" gin_trgm_ops);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||
|
||||
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
|
||||
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||
|
||||
-- DELETE auf "pruefung" bleibt erlaubt: ein abgebrochener Entwurf soll
|
||||
-- verschwinden koennen. Abgeschlossene Protokolle schuetzt der Trigger
|
||||
-- pruefung_loeschschutz, und der gilt fuer jede Rolle.
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
Reference in New Issue
Block a user