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>
244 lines
9.6 KiB
PL/PgSQL
244 lines
9.6 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 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;
|
|
$$;
|