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:
@@ -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.
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user