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
+94
View File
@@ -0,0 +1,94 @@
import { Worker, type Job } from "bullmq";
import { env } from "@/lib/env";
import { AUFTRAG, WARTESCHLANGE, redis, type AuftragsDaten } from "@/lib/warteschlange";
import { verbindungenTrennen } from "@/lib/db";
/**
* Hintergrundprozess.
*
* Läuft als eigener Container neben der Anwendung und arbeitet die
* Warteschlange ab. In Phase 0 sind die Auftragsarten angelegt, aber fachlich
* noch leer — die Fristenberechnung kommt in Phase 2, die Erinnerungen in
* Phase 7. Was hier schon steht, ist das Gerüst: Aufnahme, Protokollierung,
* Fehlerbehandlung und ein sauberes Herunterfahren.
*/
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[worker] ${nachricht}`, ...weiteres);
}
async function fristenPruefen(auftrag: Job<AuftragsDaten>): Promise<{ geprueft: number }> {
protokoll(`Fristen prüfen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 2: Geräte mit überschrittener Frist auf PRUEFUNG_FAELLIG setzen.
return { geprueft: 0 };
}
async function erinnerungSenden(auftrag: Job<AuftragsDaten>): Promise<{ versendet: number }> {
protokoll(`Erinnerungen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 7: Mail und Telegram über lib/integrations.
return { versendet: 0 };
}
async function pdfErzeugen(auftrag: Job<AuftragsDaten>): Promise<{ erzeugt: boolean }> {
protokoll(`PDF erzeugen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 2: HTML an Gotenberg, Ergebnis nach MinIO über lib/storage.
return { erzeugt: false };
}
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
// ausgetretene Mitglieder nach PERSON_ANONYMISIERUNG_TAGE anonymisieren.
return { entfernt: 0 };
}
const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknown>> = {
[AUFTRAG.FRISTEN_PRUEFEN]: fristenPruefen,
[AUFTRAG.ERINNERUNG_SENDEN]: erinnerungSenden,
[AUFTRAG.PDF_ERZEUGEN]: pdfErzeugen,
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
};
const worker = new Worker<AuftragsDaten>(
WARTESCHLANGE,
async (auftrag) => {
const bearbeiten = bearbeiter[auftrag.name];
if (!bearbeiten) {
throw new Error(`Unbekannte Auftragsart: ${auftrag.name}`);
}
const beginn = Date.now();
const ergebnis = await bearbeiten(auftrag);
protokoll(`${auftrag.name} erledigt in ${Date.now() - beginn} ms`);
return ergebnis;
},
{
connection: redis(),
concurrency: env.WORKER_CONCURRENCY,
},
);
worker.on("failed", (auftrag, fehler) => {
console.error(
`[worker] Auftrag ${auftrag?.name ?? "?"} (${auftrag?.id ?? "?"}) fehlgeschlagen:`,
fehler.message,
);
});
worker.on("ready", () => {
protokoll(
`bereit — Warteschlange "${WARTESCHLANGE}", ${env.WORKER_CONCURRENCY} parallele Aufträge`,
);
});
/** Laufende Aufträge zu Ende bringen, bevor der Prozess endet. */
async function herunterfahren(signal: string): Promise<void> {
protokoll(`${signal} empfangen — fahre herunter ...`);
await worker.close();
await verbindungenTrennen();
redis().disconnect();
protokoll("beendet.");
process.exit(0);
}
process.on("SIGTERM", () => void herunterfahren("SIGTERM"));
process.on("SIGINT", () => void herunterfahren("SIGINT"));