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
+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();
});