Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde, Statistik und Uebungen mit Anwesenheitsliste. Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung, zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich erreichbaren Endpunkte in FLORIAN. Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt das durch. Koordinaten liegen doppelt — als Dezimalgrad und als PostGIS-Punkt, zusammengehalten von einem Trigger. 57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
244 lines
8.8 KiB
PL/PgSQL
244 lines
8.8 KiB
PL/PgSQL
-- =============================================================================
|
|
-- Phase 3: Mandantentrennung, Koordinaten, Berichtsfreigabe, Indizes.
|
|
--
|
|
-- Vier Dinge, die in der Datenbank besser aufgehoben sind als im Code:
|
|
--
|
|
-- 1. Row Level Security für die acht neuen Tabellen.
|
|
--
|
|
-- 2. Der Gleichlauf von Dezimalgrad und PostGIS-Punkt. Die Anwendung
|
|
-- schreibt Breite und Länge als Zahl; den geography-Punkt setzt ein
|
|
-- Trigger. Sonst müsste jede Schreiboperation Roh-SQL sein, und die
|
|
-- Karte ab Phase 6 fände Einsätze, die über einen anderen Weg entstanden
|
|
-- sind, nicht wieder.
|
|
--
|
|
-- 3. Die Unveränderbarkeit freigegebener Einsatzberichte. Der Bericht geht
|
|
-- an die Gemeinde und im Schadensfall an die Versicherung. Dieselbe Logik
|
|
-- wie beim Prüfprotokoll aus Phase 2.
|
|
--
|
|
-- 4. Suchindizes und ein Teilindex auf die laufenden Einsätze.
|
|
-- =============================================================================
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- Row Level Security
|
|
-- -----------------------------------------------------------------------------
|
|
DO $$
|
|
DECLARE
|
|
tabelle text;
|
|
BEGIN
|
|
FOREACH tabelle IN ARRAY ARRAY[
|
|
'einsatz',
|
|
'einsatzfahrzeug',
|
|
'einsatzkraft',
|
|
'materialverbrauch',
|
|
'lagemeldung',
|
|
'portal_zugang',
|
|
'uebung',
|
|
'uebungsteilnahme'
|
|
]
|
|
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;
|
|
$$;
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- Koordinaten: Dezimalgrad und PostGIS-Punkt zusammenhalten
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION einsatz_koordinaten_setzen()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
IF NEW."breitengrad" IS NULL OR NEW."laengengrad" IS NULL THEN
|
|
NEW."koordinaten" := NULL;
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
-- Reihenfolge beachten: ST_MakePoint nimmt erst die Länge, dann die Breite.
|
|
NEW."koordinaten" := ST_SetSRID(
|
|
ST_MakePoint(NEW."laengengrad"::double precision, NEW."breitengrad"::double precision),
|
|
4326
|
|
)::geography;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
COMMENT ON FUNCTION einsatz_koordinaten_setzen() IS
|
|
'Haelt den PostGIS-Punkt eines Einsatzes mit den Dezimalgraden im Gleichlauf.';
|
|
|
|
CREATE TRIGGER einsatz_koordinaten
|
|
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "einsatz"
|
|
FOR EACH ROW EXECUTE FUNCTION einsatz_koordinaten_setzen();
|
|
|
|
-- Koordinaten, die es auf der Erde nicht gibt, sind ein Tippfehler.
|
|
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_koordinaten_gueltig" CHECK (
|
|
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
|
|
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
|
|
);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- Freigegebene Einsatzberichte sind unveränderbar
|
|
--
|
|
-- Bis zur Freigabe darf alles nachgetragen werden — ein Einsatz wird tagelang
|
|
-- ergänzt, und genau dafür ist der Entwurfsstand da. Mit der Freigabe ist
|
|
-- Schluss: der Bericht ist dann ein Dokument, kein Arbeitsstand.
|
|
--
|
|
-- Zwei Ausnahmen bleiben, beide nachtragend statt verändernd: das PDF, solange
|
|
-- keines hinterlegt ist, und geaendertAm.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION einsatz_freigabe_schuetzen()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
vergleich "einsatz"%ROWTYPE;
|
|
BEGIN
|
|
IF OLD."status" <> 'FREIGEGEBEN' THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
vergleich := NEW;
|
|
vergleich."geaendertAm" := OLD."geaendertAm";
|
|
|
|
IF OLD."berichtDateiId" IS NULL THEN
|
|
vergleich."berichtDateiId" := OLD."berichtDateiId";
|
|
END IF;
|
|
|
|
IF vergleich IS DISTINCT FROM OLD THEN
|
|
RAISE EXCEPTION
|
|
'Der Einsatzbericht % ist freigegeben und damit unveraenderlich.',
|
|
OLD."einsatznummer"
|
|
USING ERRCODE = 'insufficient_privilege';
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION einsatz_kein_loeschen()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
IF OLD."status" <> 'FREIGEGEBEN' THEN
|
|
RETURN OLD;
|
|
END IF;
|
|
|
|
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
|
|
RETURN OLD;
|
|
END IF;
|
|
|
|
RAISE EXCEPTION
|
|
'Der Einsatzbericht % ist freigegeben und darf nicht geloescht werden '
|
|
'(nur im Aufbewahrungslauf).',
|
|
OLD."einsatznummer"
|
|
USING ERRCODE = 'insufficient_privilege';
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER einsatz_freigabe
|
|
BEFORE UPDATE ON "einsatz"
|
|
FOR EACH ROW EXECUTE FUNCTION einsatz_freigabe_schuetzen();
|
|
|
|
CREATE TRIGGER einsatz_loeschschutz
|
|
BEFORE DELETE ON "einsatz"
|
|
FOR EACH ROW EXECUTE FUNCTION einsatz_kein_loeschen();
|
|
|
|
-- Dasselbe für die Bestandteile: sonst ließe sich ein freigegebener Bericht
|
|
-- über die Einsatzkräfte oder den Materialverbrauch nachträglich verändern.
|
|
CREATE OR REPLACE FUNCTION einsatzinhalt_gesperrt()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
betroffener_einsatz uuid;
|
|
zustand "EinsatzStatus";
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
betroffener_einsatz := OLD."einsatzId";
|
|
ELSE
|
|
betroffener_einsatz := NEW."einsatzId";
|
|
END IF;
|
|
|
|
SELECT "status" INTO zustand FROM "einsatz" WHERE "id" = betroffener_einsatz;
|
|
|
|
IF zustand IS NULL OR zustand <> 'FREIGEGEBEN' 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
|
|
'Der Einsatz % ist freigegeben. % auf % ist nicht mehr zulaessig.',
|
|
betroffener_einsatz, TG_OP, TG_TABLE_NAME
|
|
USING ERRCODE = 'insufficient_privilege';
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER einsatzfahrzeug_gesperrt
|
|
BEFORE INSERT OR UPDATE OR DELETE ON "einsatzfahrzeug"
|
|
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
|
|
|
|
CREATE TRIGGER einsatzkraft_gesperrt
|
|
BEFORE INSERT OR UPDATE OR DELETE ON "einsatzkraft"
|
|
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
|
|
|
|
CREATE TRIGGER materialverbrauch_gesperrt
|
|
BEFORE INSERT OR UPDATE OR DELETE ON "materialverbrauch"
|
|
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
|
|
|
|
-- Lagemeldungen bleiben ausdrücklich ausgenommen: sie sind ein Protokoll des
|
|
-- Verlaufs, kein Bestandteil des Berichts, und eine Meldung aus dem Portal
|
|
-- kann eintreffen, während der Bericht schon freigegeben ist.
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- Portalzugänge
|
|
-- -----------------------------------------------------------------------------
|
|
-- Ein Zugang, der nie gültig war, ist ein Fehler in der Erzeugung.
|
|
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_gueltigkeit" CHECK (
|
|
"gueltigBis" > "erstelltAm"
|
|
);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- Indizes
|
|
-- -----------------------------------------------------------------------------
|
|
-- Der Alarmmonitor fragt immer dasselbe: was läuft gerade.
|
|
CREATE INDEX "einsatz_laufend" ON "einsatz" ("organisationId", "alarmiertUm" DESC)
|
|
WHERE "status" = 'LAUFEND' AND "geloeschtAm" IS NULL;
|
|
|
|
-- Räumliche Abfragen ab Phase 6.
|
|
CREATE INDEX "einsatz_koordinaten_idx" ON "einsatz" USING gist ("koordinaten");
|
|
|
|
-- Volltextsuche wie in den Phasen davor.
|
|
CREATE INDEX "einsatz_suche_stichwort" ON "einsatz" USING gin ("stichwort" gin_trgm_ops);
|
|
CREATE INDEX "einsatz_suche_ort" ON "einsatz" USING gin ("ort" gin_trgm_ops);
|
|
CREATE INDEX "einsatz_suche_nummer" ON "einsatz" USING gin ("einsatznummer" gin_trgm_ops);
|
|
CREATE INDEX "uebung_suche_thema" ON "uebung" USING gin ("thema" 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 den Phasen davor gelten unveraendert weiter.
|
|
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
|
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
|
ELSE
|
|
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
|
END IF;
|
|
END;
|
|
$$;
|