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
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { rechteVon } from "@/lib/authz";
import { Seitenleiste } from "@/components/layout/seitenleiste";
import { ThemeToggle } from "@/components/layout/theme-toggle";
/**
* Layout für alles, was eine Anmeldung verlangt. Wer nicht angemeldet ist,
* landet auf der Anmeldeseite — geprüft wird serverseitig, nicht im Browser.
*/
export default async function AngemeldetLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
db.organisation.findUnique({
where: { id: sitzung.organisationId },
select: { name: true, kurzname: true },
}),
);
return (
<div className="min-h-screen">
<Seitenleiste
rechte={[...rechteVon(sitzung.rollen)]}
rollen={sitzung.rollen}
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
personName={sitzung.name}
/>
<div className="md:pl-64">
<header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken">
<ThemeToggle />
</header>
<main className="p-6">{children}</main>
</div>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { AlertTriangle, Truck, Users, Wrench } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { ROLLEN_BEZEICHNUNG } from "@/lib/authz";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { alsDatumZeit } from "@/lib/utils";
export const metadata = { title: "Übersicht" };
/** Startseite nach der Anmeldung. */
export default async function UebersichtSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) return null;
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const [organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt] =
await Promise.all([
db.organisation.findUnique({ where: { id: sitzung.organisationId } }),
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
db.fahrzeug.count({ where: { aktiv: true, geloeschtAm: null } }),
db.geraet.count({ where: { geloeschtAm: null } }),
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
db.geraet.count({ where: { status: "GESPERRT", geloeschtAm: null } }),
]);
return { organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt };
});
const kacheln = [
{ titel: "Aktive Mitglieder", wert: daten.mitglieder, symbol: Users },
{ titel: "Fahrzeuge", wert: daten.fahrzeuge, symbol: Truck },
{ titel: "Geräte", wert: daten.geraete, symbol: Wrench },
{ titel: "Prüfung fällig", wert: daten.pruefungFaellig, symbol: AlertTriangle },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{daten.organisation?.name ?? "Feuerwehr"}
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Angemeldet als {sitzung.name} —{" "}
{sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]).join(", ")}
</p>
</div>
{/*
Sicherheitshinweis nach den Projektleitplanken. Er bleibt stehen, bis
die Wehr die Anwendung ausdrücklich freigegeben hat.
*/}
<div
role="note"
className="flex items-start gap-3 rounded-lg border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-4"
>
<AlertTriangle
className="mt-0.5 size-5 shrink-0 text-[var(--pruefung-faellig)]"
aria-hidden
/>
<div className="text-sm">
<p className="font-medium">Diese Installation ist noch nicht freigegeben.</p>
<p className="text-[var(--muted-foreground)]">
Atemschutzüberwachung und Alarmmonitor sind sicherheitsrelevant. Solange die
Wehr das System nicht ausdrücklich freigegeben hat, bleibt die analoge
Überwachung führend. FLORIAN ersetzt sie nicht.
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{kacheln.map((kachel) => {
const Symbol = kachel.symbol;
return (
<Card key={kachel.titel}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{kachel.titel}
</CardTitle>
<Symbol className="size-4 text-[var(--muted-foreground)]" aria-hidden />
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold tabellenzahlen">{kachel.wert}</p>
</CardContent>
</Card>
);
})}
</div>
<Card>
<CardHeader>
<CardTitle>Stand des Aufbaus</CardTitle>
<CardDescription>
Phase 0 ist abgeschlossen: Fundament, Mandantentrennung, Anmeldung,
Dateiablage und Protokollierung stehen. Die Fachmodule kommen Phase für
Phase dazu.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
<Badge variant="outline">Phase 1 — Stammdaten</Badge>
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
<Badge variant="outline">Phase 3 — Einsätze</Badge>
<Badge variant="outline">Phase 4 — Terminal</Badge>
<Badge variant="outline">Phase 5 — Atemschutz</Badge>
</div>
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="text-[var(--muted-foreground)]">Gesperrte Geräte</dt>
<dd className="tabellenzahlen">{daten.gesperrt}</dd>
</div>
<div>
<dt className="text-[var(--muted-foreground)]">Zeitpunkt</dt>
<dd className="tabellenzahlen">{alsDatumZeit(new Date())}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
);
}