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>
This commit is contained in:
Florian Wilde
2026-09-03 00:13:53 +02:00
co-authored by Claude Opus 5
commit 845942f36c
84 changed files with 21433 additions and 0 deletions
@@ -0,0 +1,409 @@
-- 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;
@@ -0,0 +1,156 @@
-- =============================================================================
-- Row Level Security: Mandantentrennung auf Datenbankebene.
--
-- Warum auf Datenbankebene und nicht nur im Code?
-- Weil ein vergessener WHERE-Filter sonst Daten einer fremden Wehr ausliefert.
-- Mit RLS ist der schlimmste Fall ein leeres Ergebnis, nicht ein Datenleck.
--
-- Funktionsweise:
-- Die Anwendung verbindet sich als "florian_app" (NOBYPASSRLS) und setzt zu
-- Beginn jeder Transaktion
-- SET LOCAL app.current_organisation = '<uuid>';
-- Siehe src/lib/db.ts. Ohne gesetzten Wert liefert jede Abfrage null Zeilen.
--
-- Der Eigentümer der Tabellen (POSTGRES_USER) umgeht RLS. Das ist gewollt:
-- Migrationen und das Seed-Skript müssen über alle Mandanten hinweg
-- arbeiten. Deshalb steht hier bewusst kein FORCE ROW LEVEL SECURITY.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Hilfsfunktionen
-- -----------------------------------------------------------------------------
-- Liest die aktive Organisation aus der Sitzung. Ist nichts gesetzt oder steht
-- dort kein gültiges UUID, kommt NULL zurück — und NULL vergleicht sich mit
-- nichts, die Abfrage liefert also keine Zeilen.
CREATE OR REPLACE FUNCTION aktuelle_organisation()
RETURNS uuid
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
wert text;
BEGIN
wert := current_setting('app.current_organisation', true);
IF wert IS NULL OR wert = '' THEN
RETURN NULL;
END IF;
RETURN wert::uuid;
EXCEPTION
WHEN invalid_text_representation THEN
RETURN NULL;
END;
$$;
COMMENT ON FUNCTION aktuelle_organisation() IS
'Aktive Organisation der Sitzung, gesetzt per SET LOCAL app.current_organisation.';
-- Verhindert Änderungen am Audit-Log — auch durch den Tabelleneigentümer.
CREATE OR REPLACE FUNCTION audit_log_unveraenderlich()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'Der Audit-Log ist unveraenderlich. % ist auf audit_log nicht zulaessig.',
TG_OP
USING ERRCODE = 'insufficient_privilege';
END;
$$;
-- -----------------------------------------------------------------------------
-- Mandantenpflichtige Tabellen
-- -----------------------------------------------------------------------------
-- Die Organisation selbst: hier ist die id die Mandantenspalte.
ALTER TABLE "organisation" ENABLE ROW LEVEL SECURITY;
CREATE POLICY organisation_mandant ON "organisation"
FOR ALL
USING ("id" = aktuelle_organisation())
WITH CHECK ("id" = aktuelle_organisation());
-- Alle übrigen Tabellen hängen über organisationId am Mandanten.
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'person',
'rolle',
'person_rolle',
'rolle_berechtigung',
'ausweis_token',
'datei',
'fahrzeug',
'geraet'
]
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;
$$;
-- -----------------------------------------------------------------------------
-- Audit-Log: mandantengetrennt UND append-only
-- Bewusst nur SELECT und INSERT — es gibt keine Policy für UPDATE und DELETE,
-- die Anwendungsrolle kann also gar nicht ändern oder löschen.
-- -----------------------------------------------------------------------------
ALTER TABLE "audit_log" ENABLE ROW LEVEL SECURITY;
CREATE POLICY audit_log_lesen ON "audit_log"
FOR SELECT
USING ("organisationId" = aktuelle_organisation());
CREATE POLICY audit_log_schreiben ON "audit_log"
FOR INSERT
WITH CHECK ("organisationId" = aktuelle_organisation());
CREATE TRIGGER audit_log_kein_update
BEFORE UPDATE ON "audit_log"
FOR EACH ROW EXECUTE FUNCTION audit_log_unveraenderlich();
CREATE TRIGGER audit_log_kein_delete
BEFORE DELETE ON "audit_log"
FOR EACH ROW EXECUTE FUNCTION audit_log_unveraenderlich();
COMMENT ON TABLE "audit_log" IS
'Append-only. Aenderungen und Loeschungen sind per Trigger gesperrt. '
'Die Aufbewahrungsfrist raeumt der Worker ab, nachdem er exportiert hat.';
-- -----------------------------------------------------------------------------
-- Berechtigungskatalog: systemweit, kein Mandantenbezug.
-- Er beschreibt, was die Software kann — nicht, was eine Wehr besitzt.
-- Deshalb kein RLS, aber nur Lesezugriff für die Anwendung.
-- -----------------------------------------------------------------------------
COMMENT ON TABLE "berechtigung" IS
'Systemweiter Katalog der Einzelrechte. Bewusst ohne organisationId: '
'mandantenbezogen wird es erst ueber rolle_berechtigung.';
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle. Existiert die Rolle nicht (z. B. bei einer
-- Entwicklungsdatenbank ohne den Init-Container), wird der Block übersprungen.
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT USAGE ON SCHEMA public TO florian_app';
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';
EXECUTE 'GRANT EXECUTE ON FUNCTION aktuelle_organisation() TO florian_app';
-- Der Katalog ist für die Anwendung nur lesbar.
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
-- Der Audit-Log ist für die Anwendung nur schreib- und lesbar.
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
@@ -0,0 +1,60 @@
-- =============================================================================
-- Kontrollierte Ausnahme vom Änderungsverbot des Audit-Logs.
--
-- Ausgangslage: Der Trigger aus 20260902000100_rls_policies verbietet UPDATE
-- und DELETE auf audit_log. Das ist richtig — nachträglich verändern soll den
-- Log niemand.
--
-- Es gibt aber genau einen Vorgang, der löschen können muss: der
-- Aufbewahrungsjob des Workers. Nach AUDIT_AUFBEWAHRUNG_TAGE exportiert er die
-- alten Einträge nach MinIO und räumt sie danach ab. Ohne diese Möglichkeit
-- wächst die Tabelle unbegrenzt, und das Löschkonzept nach DSGVO ließe sich
-- nicht umsetzen.
--
-- Der Ausweg ist ein ausdrücklicher Schalter, den nur der Eigentümer-Zugang
-- setzen kann und der nur für die laufende Transaktion gilt:
--
-- BEGIN;
-- SELECT set_config('app.audit_bereinigung', 'an', true);
-- DELETE FROM audit_log WHERE zeitpunkt < now() - interval '10 years';
-- COMMIT;
--
-- UPDATE bleibt unter allen Umständen verboten. Ein Eintrag kann verschwinden,
-- nachdem er gesichert wurde — verfälscht werden kann er nie.
-- =============================================================================
CREATE OR REPLACE FUNCTION audit_log_unveraenderlich()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Änderungen sind ausnahmslos gesperrt.
IF TG_OP = 'UPDATE' THEN
RAISE EXCEPTION
'Der Audit-Log ist unveraenderlich. UPDATE ist auf audit_log nicht zulaessig.'
USING ERRCODE = 'insufficient_privilege';
END IF;
-- Löschen nur im ausdrücklich gekennzeichneten Aufbewahrungslauf.
IF TG_OP = 'DELETE' THEN
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION
'Der Audit-Log ist unveraenderlich. DELETE ist nur im Aufbewahrungslauf zulaessig '
'(SET LOCAL app.audit_bereinigung = ''an'').'
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NULL;
END;
$$;
COMMENT ON FUNCTION audit_log_unveraenderlich() IS
'Sperrt UPDATE auf audit_log dauerhaft und DELETE ausserhalb des Aufbewahrungslaufs.';
-- Die Anwendungsrolle darf den Schalter gar nicht erst setzen: sie hat kein
-- DELETE-Recht auf audit_log (siehe vorherige Migration). Der Schalter wirkt
-- also nur fuer den Eigentuemer-Zugang, den ausschliesslich Worker und
-- Migrationen benutzen.
+528
View File
@@ -0,0 +1,528 @@
// =============================================================================
// FLORIAN — Datenmodell, Phase 0 (Fundament)
//
// Grundregeln, die für JEDES Modell gelten:
// 1. Mandantentrennung: jede Tabelle trägt organisationId. Postgres Row Level
// Security setzt das durch (siehe migrations/.../rls). Ausnahme ist der
// systemweite Berechtigungskatalog, siehe Kommentar dort.
// 2. Jede schreibende Operation erzeugt einen AuditLog-Eintrag (lib/audit.ts).
// 3. Prüf-, Wartungs- und Buchungsdaten werden nie hart gelöscht:
// geloeschtAm setzen statt DELETE.
// 4. Alle Zeitstempel in UTC speichern, Anzeige in Europe/Berlin.
//
// Bezeichner sind englisch, wo es Technik ist, und deutsch, wo es Fachlichkeit
// ist — so wie es in der Wehr auch gesprochen wird.
// =============================================================================
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_DATABASE_URL")
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm")]
}
// =============================================================================
// Aufzählungen
// =============================================================================
/// Rollen im Sinne der Feuerwehrorganisation. Die Berechtigungen dahinter
/// stehen in lib/authz.ts und werden beim Seed in die Datenbank gespiegelt.
enum RolleSchluessel {
SUPERADMIN
KOMMANDANT
GERAETEWART
ATEMSCHUTZGERAETEWART
KASSIER
SCHRIFTFUEHRER
AUSBILDER
MITGLIED
KIOSK
}
/// Art des Ausweises, mit dem sich jemand am Terminal im Gerätehaus anmeldet.
enum AusweisTokenTyp {
PIN
RFID
}
/// Wie sich jemand angemeldet hat — landet im Audit-Log.
enum AnmeldeArt {
LOKAL
OIDC
KIOSK_PIN
KIOSK_RFID
SYSTEM
}
/// Aktionen im Audit-Log.
enum AuditAktion {
ERSTELLT
GEAENDERT
GELOESCHT
WIEDERHERGESTELLT
ANGEMELDET
ABGEMELDET
ANMELDUNG_FEHLGESCHLAGEN
EXPORTIERT
DATEI_HOCHGELADEN
DATEI_ABGERUFEN
BERECHTIGUNG_GEAENDERT
}
/// Wer die Aktion ausgelöst hat.
enum AkteurTyp {
PERSON
SYSTEM
WORKER
KIOSK
API
}
/// Betriebsstatus eines Geräts nach DGUV-Logik.
enum GeraetStatus {
EINSATZBEREIT
PRUEFUNG_FAELLIG
GESPERRT
AUSGESONDERT
IN_REPARATUR
}
/// Grobe Fahrzeugkategorie. Die vollständige DIN-Typisierung kommt in Phase 1.
enum FahrzeugArt {
LOESCHFAHRZEUG
DREHLEITER
RUESTWAGEN
GERAETEWAGEN
MANNSCHAFTSTRANSPORT
EINSATZLEITWAGEN
WECHSELLADER
ANHAENGER
SONSTIGES
}
/// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl).
enum Dienstgrad {
FEUERWEHRANWAERTER
FEUERWEHRMANN
OBERFEUERWEHRMANN
HAUPTFEUERWEHRMANN
LOESCHMEISTER
OBERLOESCHMEISTER
HAUPTLOESCHMEISTER
BRANDMEISTER
OBERBRANDMEISTER
HAUPTBRANDMEISTER
KEIN
}
// =============================================================================
// Mandant
// =============================================================================
/// Eine Feuerwehr. Oberste Klammer — an ihr hängt alles andere.
model Organisation {
id String @id @default(uuid()) @db.Uuid
/// Kurzform für URLs und Konfiguration, z. B. "ff-musterhausen".
slug String @unique @db.VarChar(64)
name String @db.VarChar(200)
kurzname String @db.VarChar(60)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
landkreis String? @db.VarChar(120)
bundesland String @default("Bayern") @db.VarChar(60)
telefon String? @db.VarChar(40)
email String? @db.VarChar(200)
/// Kennung im Einsatzleitsystem der Integrierten Leitstelle.
ilsKennung String? @db.VarChar(40)
/// Funkrufname-Präfix der Wehr, z. B. "Florian Musterhausen".
funkPraefix String? @db.VarChar(80)
zeitzone String @default("Europe/Berlin") @db.VarChar(60)
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
personen Person[]
rollen Rolle[]
personRollen PersonRolle[]
rolleRechte RolleBerechtigung[]
ausweisTokens AusweisToken[]
auditEintraege AuditLog[]
dateien Datei[]
fahrzeuge Fahrzeug[]
geraete Geraet[]
@@index([aktiv])
@@map("organisation")
}
// =============================================================================
// Personen und Rechte
// =============================================================================
/// Ein Mitglied der Wehr. Gleichzeitig der Anmeldebenutzer — es gibt bewusst
/// keine getrennte "User"-Tabelle, damit Dienstgrad, Qualifikation und Zugang
/// nicht auseinanderlaufen.
model Person {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
vorname String @db.VarChar(100)
nachname String @db.VarChar(100)
/// Fortlaufende Mitgliedsnummer innerhalb der Wehr.
mitgliedsnummer String? @db.VarChar(30)
geburtsdatum DateTime? @db.Date
dienstgrad Dienstgrad @default(KEIN)
email String? @db.VarChar(200)
telefon String? @db.VarChar(40)
mobil String? @db.VarChar(40)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
eintrittsdatum DateTime? @db.Date
austrittsdatum DateTime? @db.Date
/// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied.
abteilung String? @db.VarChar(60)
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
passwortHash String? @db.VarChar(120)
passwortGeaendertAm DateTime? @db.Timestamptz(3)
/// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto.
oidcSubject String? @db.VarChar(255)
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
oidcGruppen String[] @default([])
letzteAnmeldung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
aktiv Boolean @default(true)
/// Profilbild. Liegt wie alles andere in MinIO.
avatarDateiId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
rollen PersonRolle[] @relation("PersonRollenInhaber")
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
ausweisTokens AusweisToken[]
auditEintraege AuditLog[]
hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@unique([organisationId, oidcSubject])
@@unique([organisationId, mitgliedsnummer])
@@index([organisationId, nachname, vorname])
@@index([organisationId, aktiv])
@@index([oidcSubject])
@@map("person")
}
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
/// Rollen kann der Kommandant ergänzen.
model Rolle {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
schluessel RolleSchluessel
name String @db.VarChar(100)
beschreibung String? @db.VarChar(500)
/// Systemrollen dürfen nicht gelöscht werden.
systemRolle Boolean @default(false)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
personen PersonRolle[]
rechte RolleBerechtigung[]
@@unique([organisationId, schluessel])
@@index([organisationId])
@@map("rolle")
}
/// Zuweisung Person zu Rolle. Eine Person kann mehrere Rollen tragen — der
/// Gerätewart ist meist auch aktives Mitglied.
model PersonRolle {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
rolleId String @db.Uuid
zugewiesenAm DateTime @default(now()) @db.Timestamptz(3)
zugewiesenVonId String? @db.Uuid
/// Befristete Zuweisung, z. B. Vertretung während Urlaub.
gueltigBis DateTime? @db.Timestamptz(3)
/// true, wenn die Rolle aus einer authentik-Gruppe vorbelegt wurde. Sobald
/// jemand sie in FLORIAN ändert, steht hier false und OIDC überschreibt nicht
/// mehr.
ausOidcGruppe Boolean @default(false)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("PersonRollenInhaber", fields: [personId], references: [id], onDelete: Cascade)
rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade)
zugewiesenVon Person? @relation("PersonRollenVergeber", fields: [zugewiesenVonId], references: [id], onDelete: SetNull)
@@unique([personId, rolleId])
@@index([organisationId, personId])
@@index([organisationId, rolleId])
@@map("person_rolle")
}
/// Systemweiter Katalog der Einzelrechte, z. B. "geraet.bearbeiten".
///
/// BEWUSSTE AUSNAHME von der organisationId-Regel: das hier sind keine
/// Mandantendaten, sondern eine Liste dessen, was die Software überhaupt kann.
/// Der Katalog wird vom Seed aus lib/authz.ts befüllt und ist für alle Wehren
/// identisch. Mandantenbezogen wird es erst durch RolleBerechtigung, und die
/// trägt organisationId. Die Tabelle ist deshalb für alle lesbar und für die
/// Anwendungsrolle nicht beschreibbar.
model Berechtigung {
id String @id @default(uuid()) @db.Uuid
schluessel String @unique @db.VarChar(80)
/// Fachbereich für die Gruppierung in der Oberfläche.
bereich String @db.VarChar(60)
bezeichnung String @db.VarChar(160)
beschreibung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
rollen RolleBerechtigung[]
@@index([bereich])
@@map("berechtigung")
}
/// Welche Rolle welches Recht hat — je Wehr getrennt, damit eine Wehr dem
/// Gerätewart mehr erlauben kann als eine andere.
model RolleBerechtigung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
rolleId String @db.Uuid
berechtigungId String @db.Uuid
/// false setzt ein Recht gezielt ab, das die Rolle sonst hätte.
gewaehrt Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade)
berechtigung Berechtigung @relation(fields: [berechtigungId], references: [id], onDelete: Cascade)
@@unique([rolleId, berechtigungId])
@@index([organisationId, rolleId])
@@map("rolle_berechtigung")
}
/// Anmeldemittel für das Terminal im Gerätehaus: PIN oder RFID-Karte.
/// Gespeichert wird immer nur ein Hash, nie die PIN oder die UID im Klartext.
model AusweisToken {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
typ AusweisTokenTyp
/// bcrypt-Hash der PIN bzw. der RFID-UID.
tokenHash String @db.VarChar(120)
/// Letzte vier Stellen der Karten-UID, damit man Karten unterscheiden kann.
kennzeichen String? @db.VarChar(20)
bezeichnung String? @db.VarChar(120)
aktiv Boolean @default(true)
gueltigAb DateTime @default(now()) @db.Timestamptz(3)
gueltigBis DateTime? @db.Timestamptz(3)
letzteNutzung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
/// Je Person und Art höchstens ein aktives Mittel dieser Bezeichnung.
@@index([organisationId, personId])
@@index([organisationId, typ, aktiv])
@@map("ausweis_token")
}
// =============================================================================
// Nachvollziehbarkeit
// =============================================================================
/// Append-only. Es gibt keine UPDATE- oder DELETE-Policy für die Anwendung;
/// zusätzlich verhindert ein Trigger Änderungen auch für den Eigentümer.
model AuditLog {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
akteurTyp AkteurTyp @default(PERSON)
akteurPersonId String? @db.Uuid
/// Klarname zum Zeitpunkt der Aktion — bleibt lesbar, auch wenn die Person
/// später anonymisiert wird.
akteurName String? @db.VarChar(200)
anmeldeArt AnmeldeArt?
aktion AuditAktion
/// Modellname, z. B. "Geraet".
entitaet String @db.VarChar(80)
entitaetId String? @db.Uuid
/// Menschenlesbare Kurzfassung für die Oberfläche.
beschreibung String? @db.VarChar(500)
vorher Json? @db.JsonB
nachher Json? @db.JsonB
/// Zusatzangaben: Anfrage-ID, Endpunkt, betroffene Felder.
kontext Json? @db.JsonB
ipAdresse String? @db.Inet
userAgent String? @db.VarChar(400)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
akteur Person? @relation(fields: [akteurPersonId], references: [id], onDelete: SetNull)
@@index([organisationId, zeitpunkt(sort: Desc)])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, akteurPersonId])
@@map("audit_log")
}
// =============================================================================
// Dateien
// =============================================================================
/// Metadaten zu einem Objekt in MinIO. Der Inhalt liegt NIE im Dateisystem und
/// nie in der Datenbank — hier steht nur, wo er zu finden ist.
model Datei {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bucket String @db.VarChar(80)
/// Objektschlüssel in MinIO, z. B. "ff-musterhausen/geraet/<uuid>/foto.jpg".
objektSchluessel String @db.VarChar(500)
dateiname String @db.VarChar(300)
mimeTyp String @db.VarChar(160)
groesseBytes BigInt
/// SHA-256 des Inhalts. Erkennt Dubletten und belegt Unverändertheit.
pruefsumme String? @db.VarChar(64)
/// Woran die Datei hängt (z. B. "Geraet") und an welchem Datensatz.
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
/// Freie Einordnung: "pruefprotokoll", "foto", "rechnung", "export".
kategorie String? @db.VarChar(60)
hochgeladenVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
/// Soft-Delete: das Objekt in MinIO räumt erst der Aufbewahrungs-Job ab.
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull)
alsAvatarGenutzt Person[] @relation("PersonAvatar")
@@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie])
@@map("datei")
}
// =============================================================================
// Vorgriff auf Phase 1
// Diese beiden Modelle sind bewusst schmal gehalten. Sie existieren, damit das
// Seed-Skript eine realistische Wehr aufbauen kann und die Mandantentrennung
// an echten Daten prüfbar ist. Phase 1 erweitert sie um Funkrufname-Systematik,
// DIN-Typisierung, Geräteräume, Prüfintervalle und Lebensdauerkategorien.
// =============================================================================
/// Ein Einsatzfahrzeug der Wehr.
model Fahrzeug {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Funkrufname, z. B. "Florian Musterhausen 40/1".
funkrufname String @db.VarChar(120)
bezeichnung String @db.VarChar(120)
art FahrzeugArt @default(LOESCHFAHRZEUG)
kennzeichen String? @db.VarChar(20)
hersteller String? @db.VarChar(120)
baujahr Int?
/// Nächste Hauptuntersuchung.
tuevBis DateTime? @db.Date
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraete Geraet[]
@@unique([organisationId, funkrufname])
@@index([organisationId, aktiv])
@@map("fahrzeug")
}
/// Ein prüfpflichtiges Gerät — Pressluftatmer, Schlauch, Leiter, Motorsäge.
model Geraet {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
inventarnummer String @db.VarChar(60)
bezeichnung String @db.VarChar(160)
hersteller String? @db.VarChar(120)
seriennummer String? @db.VarChar(120)
herstellungsdatum DateTime? @db.Date
indienststellung DateTime? @db.Date
status GeraetStatus @default(EINSATZBEREIT)
/// Prüfintervall in Monaten aus Vorschrift oder Herstellerangabe.
pruefintervallMonate Int?
naechstePruefung DateTime? @db.Date
/// Maximale Nutzungsdauer in Jahren (Helme, Seile, Schutzkleidung).
lebensdauerJahre Int?
/// Verlastet auf einem Fahrzeug — oder im Gerätehaus, dann null.
fahrzeugId String? @db.Uuid
lagerort String? @db.VarChar(160)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
@@unique([organisationId, inventarnummer])
@@index([organisationId, status])
@@index([organisationId, naechstePruefung])
@@index([organisationId, fahrzeugId])
@@map("geraet")
}
+595
View File
@@ -0,0 +1,595 @@
/**
* Seed — legt eine vollständige Beispielwehr an.
*
* Inhalt: Berechtigungskatalog, 9 Systemrollen, 15 Personen mit Rollen,
* 3 Fahrzeuge, 40 Geräte, PIN und RFID-Ausweise für das Terminal.
*
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
* die Organisation ja erst an, deren Kontext RLS bräuchte.
*
* Es ist idempotent — mehrfaches Ausführen ändert nichts an bereits
* vorhandenen Daten.
*
* npm run db:seed
*/
import "dotenv/config";
import {
Dienstgrad,
FahrzeugArt,
GeraetStatus,
PrismaClient,
RolleSchluessel,
AusweisTokenTyp,
} from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient({
datasources: {
db: { url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "" },
},
});
const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12);
const ORGANISATION_SLUG = "ff-musterhausen";
function log(nachricht: string): void {
console.info(`[seed] ${nachricht}`);
}
/** Deterministisches Datum, damit zwei Läufe dieselben Daten erzeugen. */
function datum(jahr: number, monat: number, tag: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
}
/** Verschiebt ein Datum um Monate — für Prüffristen. */
function plusMonate(basis: Date, monate: number): Date {
const neu = new Date(basis);
neu.setUTCMonth(neu.getUTCMonth() + monate);
return neu;
}
// -----------------------------------------------------------------------------
// Berechtigungskatalog
// -----------------------------------------------------------------------------
/**
* Der Katalog wird aus lib/authz.ts gespiegelt. Der Import geht bewusst über
* einen relativen Pfad, weil das Seed-Skript außerhalb des Next.js-Alias läuft.
*/
async function katalogAbgleichen(): Promise<Map<string, string>> {
const { RECHTE } = await import("../src/lib/authz");
const karte = new Map<string, string>();
for (const [schluessel, angaben] of Object.entries(RECHTE)) {
const eintrag = await prisma.berechtigung.upsert({
where: { schluessel },
update: { bereich: angaben.bereich, bezeichnung: angaben.bezeichnung },
create: {
schluessel,
bereich: angaben.bereich,
bezeichnung: angaben.bezeichnung,
},
});
karte.set(schluessel, eintrag.id);
}
log(`Berechtigungskatalog: ${karte.size} Einzelrechte`);
return karte;
}
// -----------------------------------------------------------------------------
// Organisation und Rollen
// -----------------------------------------------------------------------------
async function organisationAnlegen() {
const organisation = await prisma.organisation.upsert({
where: { slug: ORGANISATION_SLUG },
update: {},
create: {
slug: ORGANISATION_SLUG,
name: "Freiwillige Feuerwehr Musterhausen",
kurzname: "FF Musterhausen",
strasse: "Hauptstraße 12",
plz: "85354",
ort: "Musterhausen",
landkreis: "Freising",
bundesland: "Bayern",
telefon: "08161 123456",
email: "kommandant@ff-musterhausen.de",
ilsKennung: "FS-MUS-01",
funkPraefix: "Florian Musterhausen",
},
});
log(`Organisation: ${organisation.name} (${organisation.id})`);
return organisation;
}
async function rollenAnlegen(organisationId: string, katalog: Map<string, string>) {
const { ROLLEN, ROLLEN_BEZEICHNUNG, ROLLEN_BESCHREIBUNG, ROLLEN_RECHTE, ALLE_RECHTE } =
await import("../src/lib/authz");
const rollenKarte = new Map<string, string>();
for (const schluessel of ROLLEN) {
const rolle = await prisma.rolle.upsert({
where: {
organisationId_schluessel: {
organisationId,
schluessel: schluessel as RolleSchluessel,
},
},
update: {
name: ROLLEN_BEZEICHNUNG[schluessel],
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
},
create: {
organisationId,
schluessel: schluessel as RolleSchluessel,
name: ROLLEN_BEZEICHNUNG[schluessel],
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
systemRolle: true,
},
});
rollenKarte.set(schluessel, rolle.id);
// Rechte der Rolle spiegeln — der Administrator bekommt alles.
const rechte =
schluessel === "SUPERADMIN"
? ALLE_RECHTE
: (ROLLEN_RECHTE[schluessel as Exclude<typeof schluessel, "SUPERADMIN">] ?? []);
for (const recht of rechte) {
const berechtigungId = katalog.get(recht);
if (!berechtigungId) continue;
await prisma.rolleBerechtigung.upsert({
where: { rolleId_berechtigungId: { rolleId: rolle.id, berechtigungId } },
update: { gewaehrt: true },
create: { organisationId, rolleId: rolle.id, berechtigungId, gewaehrt: true },
});
}
}
log(`Rollen: ${rollenKarte.size} Systemrollen samt Rechtezuordnung`);
return rollenKarte;
}
// -----------------------------------------------------------------------------
// Personen
// -----------------------------------------------------------------------------
interface PersonVorlage {
vorname: string;
nachname: string;
dienstgrad: Dienstgrad;
rollen: RolleSchluessel[];
eintritt: Date;
abteilung: string;
/** PIN fürs Terminal. Nur im Seed im Klartext, in der Datenbank gehasht. */
pin?: string;
/** RFID-Kennung der Ausweiskarte. */
rfid?: string;
}
const PERSONEN: PersonVorlage[] = [
{
vorname: "Andreas",
nachname: "Huber",
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
rollen: [RolleSchluessel.KOMMANDANT],
eintritt: datum(1998, 4, 1),
abteilung: "Aktive",
pin: "471203",
rfid: "04A3B7C1D2",
},
{
vorname: "Bernhard",
nachname: "Maier",
dienstgrad: Dienstgrad.OBERBRANDMEISTER,
rollen: [RolleSchluessel.KOMMANDANT],
eintritt: datum(2001, 9, 15),
abteilung: "Aktive",
pin: "938157",
},
{
vorname: "Christian",
nachname: "Bauer",
dienstgrad: Dienstgrad.HAUPTLOESCHMEISTER,
rollen: [RolleSchluessel.GERAETEWART],
eintritt: datum(2005, 3, 1),
abteilung: "Aktive",
pin: "620394",
rfid: "04B8C2D3E4",
},
{
vorname: "Daniela",
nachname: "Schmid",
dienstgrad: Dienstgrad.OBERLOESCHMEISTER,
rollen: [RolleSchluessel.ATEMSCHUTZGERAETEWART],
eintritt: datum(2008, 7, 1),
abteilung: "Aktive",
pin: "184926",
rfid: "04C9D3E4F5",
},
{
vorname: "Elisabeth",
nachname: "Wagner",
dienstgrad: Dienstgrad.LOESCHMEISTER,
rollen: [RolleSchluessel.KASSIER],
eintritt: datum(2010, 1, 15),
abteilung: "Aktive",
pin: "573081",
},
{
vorname: "Franz",
nachname: "Fischer",
dienstgrad: Dienstgrad.LOESCHMEISTER,
rollen: [RolleSchluessel.SCHRIFTFUEHRER],
eintritt: datum(2009, 5, 20),
abteilung: "Aktive",
pin: "306159",
},
{
vorname: "Gabriele",
nachname: "Weber",
dienstgrad: Dienstgrad.BRANDMEISTER,
rollen: [RolleSchluessel.AUSBILDER],
eintritt: datum(2004, 11, 1),
abteilung: "Aktive",
pin: "849265",
},
{
vorname: "Hans",
nachname: "Becker",
dienstgrad: Dienstgrad.HAUPTFEUERWEHRMANN,
rollen: [RolleSchluessel.GERAETEWART, RolleSchluessel.AUSBILDER],
eintritt: datum(2012, 2, 1),
abteilung: "Aktive",
pin: "715384",
},
{
vorname: "Ingrid",
nachname: "Hofmann",
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2015, 6, 1),
abteilung: "Aktive",
},
{
vorname: "Josef",
nachname: "Schneider",
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2016, 4, 1),
abteilung: "Aktive",
},
{
vorname: "Katharina",
nachname: "Koch",
dienstgrad: Dienstgrad.FEUERWEHRMANN,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2019, 9, 1),
abteilung: "Aktive",
},
{
vorname: "Ludwig",
nachname: "Richter",
dienstgrad: Dienstgrad.FEUERWEHRMANN,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2020, 3, 1),
abteilung: "Aktive",
},
{
vorname: "Martina",
nachname: "Klein",
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2024, 1, 15),
abteilung: "Aktive",
},
{
vorname: "Nikolaus",
nachname: "Wolf",
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2025, 2, 1),
abteilung: "Jugendfeuerwehr",
},
{
vorname: "Otto",
nachname: "Neumann",
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(1975, 5, 1),
abteilung: "Passive",
},
];
async function personenAnlegen(
organisationId: string,
rollenKarte: Map<string, string>,
): Promise<void> {
let nummer = 1;
for (const vorlage of PERSONEN) {
const mitgliedsnummer = String(nummer).padStart(4, "0");
const email = `${vorlage.vorname}.${vorlage.nachname}`
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss") + "@ff-musterhausen.de";
const person = await prisma.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer } },
update: {},
create: {
organisationId,
mitgliedsnummer,
vorname: vorlage.vorname,
nachname: vorlage.nachname,
dienstgrad: vorlage.dienstgrad,
email,
telefon: `08161 ${100000 + nummer * 7}`,
strasse: `Beispielweg ${nummer}`,
plz: "85354",
ort: "Musterhausen",
eintrittsdatum: vorlage.eintritt,
abteilung: vorlage.abteilung,
aktiv: vorlage.abteilung !== "Passive",
},
});
for (const schluessel of vorlage.rollen) {
const rolleId = rollenKarte.get(schluessel);
if (!rolleId) continue;
await prisma.personRolle.upsert({
where: { personId_rolleId: { personId: person.id, rolleId } },
update: {},
create: { organisationId, personId: person.id, rolleId },
});
}
// Terminal-Ausweise: PIN und, wo vorhanden, RFID-Karte.
if (vorlage.pin) {
const vorhanden = await prisma.ausweisToken.findFirst({
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.PIN },
});
if (!vorhanden) {
await prisma.ausweisToken.create({
data: {
organisationId,
personId: person.id,
typ: AusweisTokenTyp.PIN,
tokenHash: await bcrypt.hash(vorlage.pin, RUNDEN),
bezeichnung: "Terminal-PIN",
},
});
}
}
if (vorlage.rfid) {
const vorhanden = await prisma.ausweisToken.findFirst({
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.RFID },
});
if (!vorhanden) {
await prisma.ausweisToken.create({
data: {
organisationId,
personId: person.id,
typ: AusweisTokenTyp.RFID,
tokenHash: await bcrypt.hash(vorlage.rfid, RUNDEN),
kennzeichen: vorlage.rfid.slice(-4),
bezeichnung: "Ausweiskarte",
},
});
}
}
nummer += 1;
}
log(`Personen: ${PERSONEN.length} Mitglieder samt Rollen und Terminal-Ausweisen`);
}
/**
* Der erste Administrator. Anders als die Mitglieder hat er ein Passwort für
* den lokalen Login — damit kommt man beim Erststart überhaupt hinein, bevor
* authentik eingerichtet ist.
*/
async function adminAnlegen(organisationId: string, rollenKarte: Map<string, string>) {
const email = (process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de").toLowerCase();
const passwort = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
const admin = await prisma.person.upsert({
where: { organisationId_email: { organisationId, email } },
update: {},
create: {
organisationId,
mitgliedsnummer: "0000",
vorname: "System",
nachname: "Administrator",
email,
dienstgrad: Dienstgrad.KEIN,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
abteilung: "Verwaltung",
},
});
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
if (rolleId) {
await prisma.personRolle.upsert({
where: { personId_rolleId: { personId: admin.id, rolleId } },
update: {},
create: { organisationId, personId: admin.id, rolleId },
});
}
log(`Administrator: ${email} (Passwort aus SEED_ADMIN_PASSWORT)`);
return admin;
}
// -----------------------------------------------------------------------------
// Fahrzeuge und Geräte
// -----------------------------------------------------------------------------
const FAHRZEUGE = [
{
funkrufname: "Florian Musterhausen 40/1",
bezeichnung: "LF 20 (Löschgruppenfahrzeug)",
art: FahrzeugArt.LOESCHFAHRZEUG,
kennzeichen: "FS-MU 401",
hersteller: "MAN / Rosenbauer",
baujahr: 2018,
tuevBis: datum(2027, 5, 31),
},
{
funkrufname: "Florian Musterhausen 42/1",
bezeichnung: "TSF-W (Tragkraftspritzenfahrzeug Wasser)",
art: FahrzeugArt.LOESCHFAHRZEUG,
kennzeichen: "FS-MU 421",
hersteller: "Mercedes-Benz / Ziegler",
baujahr: 2011,
tuevBis: datum(2026, 11, 30),
},
{
funkrufname: "Florian Musterhausen 11/1",
bezeichnung: "MTW (Mannschaftstransportwagen)",
art: FahrzeugArt.MANNSCHAFTSTRANSPORT,
kennzeichen: "FS-MU 111",
hersteller: "Volkswagen",
baujahr: 2021,
tuevBis: datum(2027, 3, 31),
},
];
/**
* 40 Geräte mit realistischen Prüfintervallen. Die Fristen sind bewusst
* gemischt: einige liegen in der Vergangenheit, damit der Status
* "Prüfung fällig" in der Oberfläche auch sichtbar ist.
*/
const GERAETE_VORLAGEN: Array<{
bezeichnung: string;
hersteller: string;
anzahl: number;
intervallMonate: number;
lebensdauerJahre?: number;
aufFahrzeug?: number;
lagerort?: string;
}> = [
{ bezeichnung: "Pressluftatmer PA 94", hersteller: "Dräger", anzahl: 4, intervallMonate: 12, lebensdauerJahre: 20, aufFahrzeug: 0 },
{ bezeichnung: "Atemschutzmaske FPS 7000", hersteller: "Dräger", anzahl: 6, intervallMonate: 6, lebensdauerJahre: 10, lagerort: "Atemschutzwerkstatt" },
{ bezeichnung: "Atemluftflasche 6,8 l / 300 bar", hersteller: "Luxfer", anzahl: 8, intervallMonate: 60, lebensdauerJahre: 20, aufFahrzeug: 0 },
{ bezeichnung: "Feuerwehrhelm F220", hersteller: "Schuberth", anzahl: 6, intervallMonate: 12, lebensdauerJahre: 15, lagerort: "Umkleide" },
{ bezeichnung: "Feuerwehrleine 30 m", hersteller: "Gustav Bauer", anzahl: 3, intervallMonate: 12, lebensdauerJahre: 8, aufFahrzeug: 0 },
{ bezeichnung: "Steckleiterteil", hersteller: "Iturri", anzahl: 4, intervallMonate: 12, lebensdauerJahre: 25, aufFahrzeug: 0 },
{ bezeichnung: "Tragkraftspritze PFPN 10-1000", hersteller: "Rosenbauer", anzahl: 1, intervallMonate: 12, aufFahrzeug: 1 },
{ bezeichnung: "Hydraulischer Rettungssatz", hersteller: "Weber Rescue", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Motorkettensäge MS 261", hersteller: "Stihl", anzahl: 2, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Stromerzeuger 8 kVA", hersteller: "Endress", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Wärmebildkamera", hersteller: "Dräger", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Handsprechfunkgerät TPH900", hersteller: "Airbus", anzahl: 3, intervallMonate: 24, lagerort: "Funkraum" },
];
async function fahrzeugeUndGeraeteAnlegen(organisationId: string): Promise<void> {
const fahrzeugIds: string[] = [];
for (const vorlage of FAHRZEUGE) {
const fahrzeug = await prisma.fahrzeug.upsert({
where: {
organisationId_funkrufname: { organisationId, funkrufname: vorlage.funkrufname },
},
update: {},
create: { organisationId, ...vorlage },
});
fahrzeugIds.push(fahrzeug.id);
}
log(`Fahrzeuge: ${fahrzeugIds.length}`);
const heute = new Date();
let laufend = 1;
let angelegt = 0;
for (const vorlage of GERAETE_VORLAGEN) {
for (let index = 0; index < vorlage.anzahl; index += 1) {
const inventarnummer = `INV-${String(laufend).padStart(4, "0")}`;
// Ein Drittel der Geräte bekommt eine bereits überschrittene Frist,
// damit die Statusanzeige nicht nur grün ist.
const ueberfaellig = laufend % 3 === 0;
const indienststellung = datum(2015 + (laufend % 9), ((laufend % 12) + 1), 15);
const naechstePruefung = ueberfaellig
? plusMonate(heute, -(1 + (laufend % 5)))
: plusMonate(heute, 1 + (laufend % vorlage.intervallMonate || 1));
const status = ueberfaellig
? GeraetStatus.PRUEFUNG_FAELLIG
: laufend % 17 === 0
? GeraetStatus.GESPERRT
: GeraetStatus.EINSATZBEREIT;
await prisma.geraet.upsert({
where: { organisationId_inventarnummer: { organisationId, inventarnummer } },
update: {},
create: {
organisationId,
inventarnummer,
bezeichnung: vorlage.bezeichnung,
hersteller: vorlage.hersteller,
seriennummer: `SN-${vorlage.hersteller.slice(0, 3).toUpperCase()}-${100000 + laufend}`,
herstellungsdatum: datum(indienststellung.getUTCFullYear() - 1, 6, 1),
indienststellung,
status,
pruefintervallMonate: vorlage.intervallMonate,
naechstePruefung,
lebensdauerJahre: vorlage.lebensdauerJahre ?? null,
fahrzeugId:
vorlage.aufFahrzeug !== undefined ? (fahrzeugIds[vorlage.aufFahrzeug] ?? null) : null,
lagerort: vorlage.lagerort ?? null,
},
});
laufend += 1;
angelegt += 1;
}
}
log(`Geräte: ${angelegt}`);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
async function main(): Promise<void> {
log("Starte ...");
const katalog = await katalogAbgleichen();
const organisation = await organisationAnlegen();
const rollen = await rollenAnlegen(organisation.id, katalog);
await adminAnlegen(organisation.id, rollen);
await personenAnlegen(organisation.id, rollen);
await fahrzeugeUndGeraeteAnlegen(organisation.id);
log("Fertig.");
log("");
log("Anmeldung für den Erststart:");
log(` E-Mail: ${process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"}`);
log(` Passwort: ${process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"}`);
log("");
log("Terminal-PIN zum Ausprobieren (Mitgliedsnummer 0001, Andreas Huber): 471203");
}
main()
.catch((fehler) => {
console.error("[seed] Abbruch:", fehler);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});