Files
Florian WildeandClaude Opus 5 845942f36c Phase 0: Fundament der Feuerwehr-Verwaltungsplattform
Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen.

Betrieb:
- docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS,
  Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer
  mit Mailpit und Adminer
- Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck
- .env.example mit einem Kommentar zu jeder Variable

Mandantentrennung:
- Row Level Security auf allen mandantenpflichtigen Tabellen
- Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer
  die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung
- fuerOrganisation() als einziger Weg zu Fachdaten

Nachvollziehbarkeit:
- AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE
  nur im gekennzeichneten Aufbewahrungslauf
- Geheimnisse werden vor dem Schreiben entfernt

Anmeldung:
- OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als
  Vorbelegung beim ersten Login
- Lokaler E-Mail/Passwort-Login als Rueckfallweg
- Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person)

Weiteres:
- lib/authz.ts mit 9 Rollen und 38 Einzelrechten
- lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ)
- Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces
- tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung
- Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete
- 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests
- README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog,
  Versionierungsregeln und Changelog

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 00:13:53 +02:00

410 lines
15 KiB
SQL

-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "citext";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "unaccent";
-- CreateEnum
CREATE TYPE "RolleSchluessel" AS ENUM ('SUPERADMIN', 'KOMMANDANT', 'GERAETEWART', 'ATEMSCHUTZGERAETEWART', 'KASSIER', 'SCHRIFTFUEHRER', 'AUSBILDER', 'MITGLIED', 'KIOSK');
-- CreateEnum
CREATE TYPE "AusweisTokenTyp" AS ENUM ('PIN', 'RFID');
-- CreateEnum
CREATE TYPE "AnmeldeArt" AS ENUM ('LOKAL', 'OIDC', 'KIOSK_PIN', 'KIOSK_RFID', 'SYSTEM');
-- CreateEnum
CREATE TYPE "AuditAktion" AS ENUM ('ERSTELLT', 'GEAENDERT', 'GELOESCHT', 'WIEDERHERGESTELLT', 'ANGEMELDET', 'ABGEMELDET', 'ANMELDUNG_FEHLGESCHLAGEN', 'EXPORTIERT', 'DATEI_HOCHGELADEN', 'DATEI_ABGERUFEN', 'BERECHTIGUNG_GEAENDERT');
-- CreateEnum
CREATE TYPE "AkteurTyp" AS ENUM ('PERSON', 'SYSTEM', 'WORKER', 'KIOSK', 'API');
-- CreateEnum
CREATE TYPE "GeraetStatus" AS ENUM ('EINSATZBEREIT', 'PRUEFUNG_FAELLIG', 'GESPERRT', 'AUSGESONDERT', 'IN_REPARATUR');
-- CreateEnum
CREATE TYPE "FahrzeugArt" AS ENUM ('LOESCHFAHRZEUG', 'DREHLEITER', 'RUESTWAGEN', 'GERAETEWAGEN', 'MANNSCHAFTSTRANSPORT', 'EINSATZLEITWAGEN', 'WECHSELLADER', 'ANHAENGER', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "Dienstgrad" AS ENUM ('FEUERWEHRANWAERTER', 'FEUERWEHRMANN', 'OBERFEUERWEHRMANN', 'HAUPTFEUERWEHRMANN', 'LOESCHMEISTER', 'OBERLOESCHMEISTER', 'HAUPTLOESCHMEISTER', 'BRANDMEISTER', 'OBERBRANDMEISTER', 'HAUPTBRANDMEISTER', 'KEIN');
-- CreateTable
CREATE TABLE "organisation" (
"id" UUID NOT NULL,
"slug" VARCHAR(64) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"kurzname" VARCHAR(60) NOT NULL,
"strasse" VARCHAR(200),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"landkreis" VARCHAR(120),
"bundesland" VARCHAR(60) NOT NULL DEFAULT 'Bayern',
"telefon" VARCHAR(40),
"email" VARCHAR(200),
"ilsKennung" VARCHAR(40),
"funkPraefix" VARCHAR(80),
"zeitzone" VARCHAR(60) NOT NULL DEFAULT 'Europe/Berlin',
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "organisation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "person" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"vorname" VARCHAR(100) NOT NULL,
"nachname" VARCHAR(100) NOT NULL,
"mitgliedsnummer" VARCHAR(30),
"geburtsdatum" DATE,
"dienstgrad" "Dienstgrad" NOT NULL DEFAULT 'KEIN',
"email" VARCHAR(200),
"telefon" VARCHAR(40),
"mobil" VARCHAR(40),
"strasse" VARCHAR(200),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"eintrittsdatum" DATE,
"austrittsdatum" DATE,
"abteilung" VARCHAR(60),
"passwortHash" VARCHAR(120),
"passwortGeaendertAm" TIMESTAMPTZ(3),
"oidcSubject" VARCHAR(255),
"oidcGruppen" TEXT[] DEFAULT ARRAY[]::TEXT[],
"letzteAnmeldung" TIMESTAMPTZ(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"gesperrtBis" TIMESTAMPTZ(3),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"avatarDateiId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "person_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rolle" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"schluessel" "RolleSchluessel" NOT NULL,
"name" VARCHAR(100) NOT NULL,
"beschreibung" VARCHAR(500),
"systemRolle" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "rolle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "person_rolle" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"rolleId" UUID NOT NULL,
"zugewiesenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zugewiesenVonId" UUID,
"gueltigBis" TIMESTAMPTZ(3),
"ausOidcGruppe" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "person_rolle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "berechtigung" (
"id" UUID NOT NULL,
"schluessel" VARCHAR(80) NOT NULL,
"bereich" VARCHAR(60) NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"beschreibung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "berechtigung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rolle_berechtigung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rolleId" UUID NOT NULL,
"berechtigungId" UUID NOT NULL,
"gewaehrt" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rolle_berechtigung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ausweis_token" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"typ" "AusweisTokenTyp" NOT NULL,
"tokenHash" VARCHAR(120) NOT NULL,
"kennzeichen" VARCHAR(20),
"bezeichnung" VARCHAR(120),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"gueltigAb" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"gueltigBis" TIMESTAMPTZ(3),
"letzteNutzung" TIMESTAMPTZ(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"gesperrtBis" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "ausweis_token_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_log" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"akteurTyp" "AkteurTyp" NOT NULL DEFAULT 'PERSON',
"akteurPersonId" UUID,
"akteurName" VARCHAR(200),
"anmeldeArt" "AnmeldeArt",
"aktion" "AuditAktion" NOT NULL,
"entitaet" VARCHAR(80) NOT NULL,
"entitaetId" UUID,
"beschreibung" VARCHAR(500),
"vorher" JSONB,
"nachher" JSONB,
"kontext" JSONB,
"ipAdresse" INET,
"userAgent" VARCHAR(400),
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "datei" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"bucket" VARCHAR(80) NOT NULL,
"objektSchluessel" VARCHAR(500) NOT NULL,
"dateiname" VARCHAR(300) NOT NULL,
"mimeTyp" VARCHAR(160) NOT NULL,
"groesseBytes" BIGINT NOT NULL,
"pruefsumme" VARCHAR(64),
"entitaet" VARCHAR(80),
"entitaetId" UUID,
"kategorie" VARCHAR(60),
"hochgeladenVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "datei_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "fahrzeug" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"funkrufname" VARCHAR(120) NOT NULL,
"bezeichnung" VARCHAR(120) NOT NULL,
"art" "FahrzeugArt" NOT NULL DEFAULT 'LOESCHFAHRZEUG',
"kennzeichen" VARCHAR(20),
"hersteller" VARCHAR(120),
"baujahr" INTEGER,
"tuevBis" DATE,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "fahrzeug_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "geraet" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"inventarnummer" VARCHAR(60) NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"hersteller" VARCHAR(120),
"seriennummer" VARCHAR(120),
"herstellungsdatum" DATE,
"indienststellung" DATE,
"status" "GeraetStatus" NOT NULL DEFAULT 'EINSATZBEREIT',
"pruefintervallMonate" INTEGER,
"naechstePruefung" DATE,
"lebensdauerJahre" INTEGER,
"fahrzeugId" UUID,
"lagerort" VARCHAR(160),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "geraet_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organisation_slug_key" ON "organisation"("slug");
-- CreateIndex
CREATE INDEX "organisation_aktiv_idx" ON "organisation"("aktiv");
-- CreateIndex
CREATE INDEX "person_organisationId_nachname_vorname_idx" ON "person"("organisationId", "nachname", "vorname");
-- CreateIndex
CREATE INDEX "person_organisationId_aktiv_idx" ON "person"("organisationId", "aktiv");
-- CreateIndex
CREATE INDEX "person_oidcSubject_idx" ON "person"("oidcSubject");
-- CreateIndex
CREATE UNIQUE INDEX "person_organisationId_email_key" ON "person"("organisationId", "email");
-- CreateIndex
CREATE UNIQUE INDEX "person_organisationId_oidcSubject_key" ON "person"("organisationId", "oidcSubject");
-- CreateIndex
CREATE UNIQUE INDEX "person_organisationId_mitgliedsnummer_key" ON "person"("organisationId", "mitgliedsnummer");
-- CreateIndex
CREATE INDEX "rolle_organisationId_idx" ON "rolle"("organisationId");
-- CreateIndex
CREATE UNIQUE INDEX "rolle_organisationId_schluessel_key" ON "rolle"("organisationId", "schluessel");
-- CreateIndex
CREATE INDEX "person_rolle_organisationId_personId_idx" ON "person_rolle"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "person_rolle_organisationId_rolleId_idx" ON "person_rolle"("organisationId", "rolleId");
-- CreateIndex
CREATE UNIQUE INDEX "person_rolle_personId_rolleId_key" ON "person_rolle"("personId", "rolleId");
-- CreateIndex
CREATE UNIQUE INDEX "berechtigung_schluessel_key" ON "berechtigung"("schluessel");
-- CreateIndex
CREATE INDEX "berechtigung_bereich_idx" ON "berechtigung"("bereich");
-- CreateIndex
CREATE INDEX "rolle_berechtigung_organisationId_rolleId_idx" ON "rolle_berechtigung"("organisationId", "rolleId");
-- CreateIndex
CREATE UNIQUE INDEX "rolle_berechtigung_rolleId_berechtigungId_key" ON "rolle_berechtigung"("rolleId", "berechtigungId");
-- CreateIndex
CREATE INDEX "ausweis_token_organisationId_personId_idx" ON "ausweis_token"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "ausweis_token_organisationId_typ_aktiv_idx" ON "ausweis_token"("organisationId", "typ", "aktiv");
-- CreateIndex
CREATE INDEX "audit_log_organisationId_zeitpunkt_idx" ON "audit_log"("organisationId", "zeitpunkt" DESC);
-- CreateIndex
CREATE INDEX "audit_log_organisationId_entitaet_entitaetId_idx" ON "audit_log"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "audit_log_organisationId_akteurPersonId_idx" ON "audit_log"("organisationId", "akteurPersonId");
-- CreateIndex
CREATE INDEX "datei_organisationId_entitaet_entitaetId_idx" ON "datei"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "datei_organisationId_kategorie_idx" ON "datei"("organisationId", "kategorie");
-- CreateIndex
CREATE UNIQUE INDEX "datei_bucket_objektSchluessel_key" ON "datei"("bucket", "objektSchluessel");
-- CreateIndex
CREATE INDEX "fahrzeug_organisationId_aktiv_idx" ON "fahrzeug"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "fahrzeug_organisationId_funkrufname_key" ON "fahrzeug"("organisationId", "funkrufname");
-- CreateIndex
CREATE INDEX "geraet_organisationId_status_idx" ON "geraet"("organisationId", "status");
-- CreateIndex
CREATE INDEX "geraet_organisationId_naechstePruefung_idx" ON "geraet"("organisationId", "naechstePruefung");
-- CreateIndex
CREATE INDEX "geraet_organisationId_fahrzeugId_idx" ON "geraet"("organisationId", "fahrzeugId");
-- CreateIndex
CREATE UNIQUE INDEX "geraet_organisationId_inventarnummer_key" ON "geraet"("organisationId", "inventarnummer");
-- AddForeignKey
ALTER TABLE "person" ADD CONSTRAINT "person_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person" ADD CONSTRAINT "person_avatarDateiId_fkey" FOREIGN KEY ("avatarDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle" ADD CONSTRAINT "rolle_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_rolleId_fkey" FOREIGN KEY ("rolleId") REFERENCES "rolle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_zugewiesenVonId_fkey" FOREIGN KEY ("zugewiesenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle_berechtigung" ADD CONSTRAINT "rolle_berechtigung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle_berechtigung" ADD CONSTRAINT "rolle_berechtigung_rolleId_fkey" FOREIGN KEY ("rolleId") REFERENCES "rolle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle_berechtigung" ADD CONSTRAINT "rolle_berechtigung_berechtigungId_fkey" FOREIGN KEY ("berechtigungId") REFERENCES "berechtigung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ausweis_token" ADD CONSTRAINT "ausweis_token_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ausweis_token" ADD CONSTRAINT "ausweis_token_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_akteurPersonId_fkey" FOREIGN KEY ("akteurPersonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "datei" ADD CONSTRAINT "datei_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "datei" ADD CONSTRAINT "datei_hochgeladenVonId_fkey" FOREIGN KEY ("hochgeladenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrzeug" ADD CONSTRAINT "fahrzeug_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;