Phase 3: Einsatz- und Uebungserfassung

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>
This commit is contained in:
Florian Wilde
2026-09-03 13:50:58 +02:00
co-authored by Claude Opus 5
parent bc62a9c1e8
commit 91954c402b
100 changed files with 9667 additions and 38 deletions
@@ -0,0 +1,347 @@
-- =============================================================================
-- Phase 3: Tabellen für Einsätze und Übungen.
--
-- Erzeugt mit `prisma migrate dev --create-only` und wie in Phase 2 an einer
-- Stelle bereinigt: die DROP INDEX-Zeilen für die Volltext- und Geoindizes
-- sind entfernt. Prisma kennt gin_trgm_ops und gist nicht und würde sie bei
-- jeder Migration erneut löschen wollen.
--
-- Mandantentrennung, Koordinaten-Trigger, Unveränderbarkeit freigegebener
-- Berichte und die Suchindizes stehen in der nachfolgenden Migration.
-- =============================================================================
-- CreateEnum
CREATE TYPE "EinsatzKategorie" AS ENUM ('BRAND_KLEIN', 'BRAND_MITTEL', 'BRAND_GROSS', 'TECHNISCHE_HILFE_KLEIN', 'TECHNISCHE_HILFE_MITTEL', 'TECHNISCHE_HILFE_GROSS', 'GEFAHRGUT', 'RETTUNGSDIENST', 'BRANDMELDEANLAGE', 'BOESWILLIGER_ALARM', 'SICHERHEITSWACHE', 'UNTERSTUETZUNG', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "EinsatzStatus" AS ENUM ('ENTWURF', 'LAUFEND', 'ABGESCHLOSSEN', 'FREIGEGEBEN');
-- CreateEnum
CREATE TYPE "Alarmierungsart" AS ENUM ('SIRENE', 'MELDEEMPFAENGER', 'TELEFON', 'APP', 'PERSOENLICH', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "EinsatzFunktion" AS ENUM ('EINSATZLEITER', 'ZUGFUEHRER', 'GRUPPENFUEHRER', 'STAFFELFUEHRER', 'TRUPPFUEHRER', 'MASCHINIST', 'MELDER', 'TRUPPMANN', 'ATEMSCHUTZTRAEGER', 'SICHERHEITSTRUPP', 'SANITAETER', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "UebungsArt" AS ENUM ('PRAXIS', 'THEORIE', 'ATEMSCHUTZ', 'MASCHINIST', 'FUNK', 'ERSTE_HILFE', 'GEMEINSCHAFTSUEBUNG', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "LagemeldungQuelle" AS ENUM ('INTERN', 'PORTAL');
-- CreateTable
CREATE TABLE "einsatz" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatznummer" VARCHAR(40) NOT NULL,
"stichwort" VARCHAR(160) NOT NULL,
"kategorie" "EinsatzKategorie" NOT NULL DEFAULT 'SONSTIGES',
"status" "EinsatzStatus" NOT NULL DEFAULT 'ENTWURF',
"ilsNummer" VARCHAR(60),
"alarmierungsart" "Alarmierungsart",
"strasse" VARCHAR(200),
"hausnummer" VARCHAR(20),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"ortsteil" VARCHAR(120),
"objekt" VARCHAR(200),
"breitengrad" DECIMAL(9,6),
"laengengrad" DECIMAL(9,6),
"koordinaten" geography(Point, 4326),
"koordinatenQuelle" VARCHAR(40),
"alarmiertUm" TIMESTAMPTZ(3),
"ausgeruecktUm" TIMESTAMPTZ(3),
"eingetroffenUm" TIMESTAMPTZ(3),
"einsatzendeUm" TIMESTAMPTZ(3),
"eingeruecktUm" TIMESTAMPTZ(3),
"einsatzbereitUm" TIMESTAMPTZ(3),
"einsatzleiterId" UUID,
"beschreibung" VARCHAR(4000),
"bericht" TEXT,
"wetter" VARCHAR(200),
"temperatur" INTEGER,
"weitereKraefte" VARCHAR(500),
"geretteteePersonen" INTEGER NOT NULL DEFAULT 0,
"verletztePersonen" INTEGER NOT NULL DEFAULT 0,
"totePersonen" INTEGER NOT NULL DEFAULT 0,
"sachschaden" DECIMAL(12,2),
"kostenpflichtig" BOOLEAN NOT NULL DEFAULT false,
"berichtDateiId" UUID,
"freigegebenAm" TIMESTAMPTZ(3),
"freigegebenVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "einsatz_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "einsatzfahrzeug" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"fahrzeugId" UUID NOT NULL,
"ausgeruecktUm" TIMESTAMPTZ(3),
"eingetroffenUm" TIMESTAMPTZ(3),
"eingeruecktUm" TIMESTAMPTZ(3),
"kilometerAbfahrt" INTEGER,
"kilometerRueckkehr" INTEGER,
"wasserabgabe" INTEGER,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "einsatzfahrzeug_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "einsatzkraft" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"personId" UUID NOT NULL,
"funktion" "EinsatzFunktion" NOT NULL DEFAULT 'TRUPPMANN',
"fahrzeugId" UUID,
"vonUm" TIMESTAMPTZ(3),
"bisUm" TIMESTAMPTZ(3),
"atemschutz" BOOLEAN NOT NULL DEFAULT false,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "einsatzkraft_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "materialverbrauch" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"geraetId" UUID,
"bezeichnung" VARCHAR(200) NOT NULL,
"menge" DECIMAL(10,2) NOT NULL,
"einheit" VARCHAR(20) NOT NULL,
"kosten" DECIMAL(10,2),
"beschaedigt" BOOLEAN NOT NULL DEFAULT false,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "materialverbrauch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lagemeldung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"text" VARCHAR(2000) NOT NULL,
"quelle" "LagemeldungQuelle" NOT NULL DEFAULT 'INTERN',
"verfasserId" UUID,
"verfasserName" VARCHAR(200),
"fotoDateiId" UUID,
"wichtig" BOOLEAN NOT NULL DEFAULT false,
"portalZugangId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "lagemeldung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "portal_zugang" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"tokenHash" VARCHAR(120) NOT NULL,
"gueltigBis" TIMESTAMPTZ(3) NOT NULL,
"widerrufenAm" TIMESTAMPTZ(3),
"aufrufe" INTEGER NOT NULL DEFAULT 0,
"letzterAufruf" TIMESTAMPTZ(3),
"letzteIp" INET,
"erstelltVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "portal_zugang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "uebung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"nummer" VARCHAR(40) NOT NULL,
"thema" VARCHAR(200) NOT NULL,
"art" "UebungsArt" NOT NULL DEFAULT 'PRAXIS',
"datum" DATE NOT NULL,
"beginnUm" TIMESTAMPTZ(3),
"endeUm" TIMESTAMPTZ(3),
"ort" VARCHAR(200),
"leiterId" UUID,
"lernziele" VARCHAR(2000),
"beschreibung" VARCHAR(4000),
"bericht" TEXT,
"berichtDateiId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "uebung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "uebungsteilnahme" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"uebungId" UUID NOT NULL,
"personId" UUID NOT NULL,
"funktion" "EinsatzFunktion",
"vonUm" TIMESTAMPTZ(3),
"bisUm" TIMESTAMPTZ(3),
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "uebungsteilnahme_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "einsatz_organisationId_alarmiertUm_idx" ON "einsatz"("organisationId", "alarmiertUm" DESC);
-- CreateIndex
CREATE INDEX "einsatz_organisationId_status_idx" ON "einsatz"("organisationId", "status");
-- CreateIndex
CREATE INDEX "einsatz_organisationId_kategorie_idx" ON "einsatz"("organisationId", "kategorie");
-- CreateIndex
CREATE UNIQUE INDEX "einsatz_organisationId_einsatznummer_key" ON "einsatz"("organisationId", "einsatznummer");
-- CreateIndex
CREATE INDEX "einsatzfahrzeug_organisationId_einsatzId_idx" ON "einsatzfahrzeug"("organisationId", "einsatzId");
-- CreateIndex
CREATE INDEX "einsatzfahrzeug_organisationId_fahrzeugId_idx" ON "einsatzfahrzeug"("organisationId", "fahrzeugId");
-- CreateIndex
CREATE UNIQUE INDEX "einsatzfahrzeug_einsatzId_fahrzeugId_key" ON "einsatzfahrzeug"("einsatzId", "fahrzeugId");
-- CreateIndex
CREATE INDEX "einsatzkraft_organisationId_einsatzId_idx" ON "einsatzkraft"("organisationId", "einsatzId");
-- CreateIndex
CREATE INDEX "einsatzkraft_organisationId_personId_idx" ON "einsatzkraft"("organisationId", "personId");
-- CreateIndex
CREATE UNIQUE INDEX "einsatzkraft_einsatzId_personId_key" ON "einsatzkraft"("einsatzId", "personId");
-- CreateIndex
CREATE INDEX "materialverbrauch_organisationId_einsatzId_idx" ON "materialverbrauch"("organisationId", "einsatzId");
-- CreateIndex
CREATE INDEX "lagemeldung_organisationId_einsatzId_zeitpunkt_idx" ON "lagemeldung"("organisationId", "einsatzId", "zeitpunkt");
-- CreateIndex
CREATE INDEX "portal_zugang_organisationId_einsatzId_idx" ON "portal_zugang"("organisationId", "einsatzId");
-- CreateIndex
CREATE INDEX "portal_zugang_gueltigBis_idx" ON "portal_zugang"("gueltigBis");
-- CreateIndex
CREATE INDEX "uebung_organisationId_datum_idx" ON "uebung"("organisationId", "datum" DESC);
-- CreateIndex
CREATE INDEX "uebung_organisationId_art_idx" ON "uebung"("organisationId", "art");
-- CreateIndex
CREATE UNIQUE INDEX "uebung_organisationId_nummer_key" ON "uebung"("organisationId", "nummer");
-- CreateIndex
CREATE INDEX "uebungsteilnahme_organisationId_personId_idx" ON "uebungsteilnahme"("organisationId", "personId");
-- CreateIndex
CREATE UNIQUE INDEX "uebungsteilnahme_uebungId_personId_key" ON "uebungsteilnahme"("uebungId", "personId");
-- AddForeignKey
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_einsatzleiterId_fkey" FOREIGN KEY ("einsatzleiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_freigegebenVonId_fkey" FOREIGN KEY ("freigegebenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_verfasserId_fkey" FOREIGN KEY ("verfasserId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_portalZugangId_fkey" FOREIGN KEY ("portalZugangId") REFERENCES "portal_zugang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_leiterId_fkey" FOREIGN KEY ("leiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,243 @@
-- =============================================================================
-- 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;
$$;