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>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import * as React from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { KeyRound, LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export interface AnmeldeFormularProps {
oidcVerfuegbar: boolean;
oidcName: string;
lokalVerfuegbar: boolean;
fehler?: string;
weiterZu?: string;
}
/** Wandelt die Fehlerkennungen von Auth.js in verständliches Deutsch. */
function fehlertext(kennung?: string): string | null {
if (!kennung) return null;
switch (kennung) {
case "CredentialsSignin":
return "E-Mail-Adresse oder Passwort stimmt nicht. Nach mehreren Fehlversuchen wird das Konto vorübergehend gesperrt.";
case "AccessDenied":
return "Für dieses Konto gibt es in FLORIAN keinen Zugang. Bitte beim Kommandanten melden.";
case "OAuthAccountNotLinked":
return "Dieses Konto ist bereits mit einem anderen Anmeldeweg verknüpft.";
case "Configuration":
return "Die Anmeldung ist nicht vollständig eingerichtet. Bitte in der Serverkonfiguration prüfen.";
default:
return "Die Anmeldung ist fehlgeschlagen. Bitte erneut versuchen.";
}
}
export function AnmeldeFormular({
oidcVerfuegbar,
oidcName,
lokalVerfuegbar,
fehler,
weiterZu,
}: AnmeldeFormularProps) {
const router = useRouter();
const [laeuft, setLaeuft] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(fehlertext(fehler));
async function lokalAnmelden(ereignis: React.FormEvent<HTMLFormElement>) {
ereignis.preventDefault();
setMeldung(null);
setLaeuft(true);
const daten = new FormData(ereignis.currentTarget);
const ergebnis = await signIn("passwort", {
email: String(daten.get("email") ?? ""),
passwort: String(daten.get("passwort") ?? ""),
redirect: false,
});
setLaeuft(false);
if (ergebnis?.error) {
setMeldung(fehlertext("CredentialsSignin"));
return;
}
router.push(weiterZu ?? "/");
router.refresh();
}
return (
<div className="space-y-5">
{meldung && (
<p
role="alert"
data-testid="anmeldefehler"
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
>
{meldung}
</p>
)}
{oidcVerfuegbar && (
<Button
type="button"
className="w-full"
disabled={laeuft}
onClick={() => {
setLaeuft(true);
void signIn("authentik", { callbackUrl: weiterZu ?? "/" });
}}
>
<KeyRound aria-hidden />
{oidcName}
</Button>
)}
{oidcVerfuegbar && lokalVerfuegbar && (
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-[var(--border)]" />
<span className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
oder
</span>
<span className="h-px flex-1 bg-[var(--border)]" />
</div>
)}
{lokalVerfuegbar && (
<form onSubmit={lokalAnmelden} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="username"
required
placeholder="kommandant@beispielwehr.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="passwort">Passwort</Label>
<Input
id="passwort"
name="passwort"
type="password"
autoComplete="current-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={laeuft}>
<LogIn aria-hidden />
{laeuft ? "Wird geprüft ..." : "Anmelden"}
</Button>
</form>
)}
{!oidcVerfuegbar && !lokalVerfuegbar && (
<p className="text-sm text-[var(--muted-foreground)]">
Es ist kein Anmeldeweg eingerichtet. Bitte in der Serverkonfiguration
AUTH_LOCAL_ENABLED oder AUTH_OIDC_ENABLED setzen.
</p>
)}
<p className="text-xs text-[var(--muted-foreground)]">
Am Terminal im Gerätehaus wird stattdessen mit PIN oder Ausweiskarte
angemeldet.
</p>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { redirect } from "next/navigation";
import { Flame } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { env, oidcVerfuegbar } from "@/lib/env";
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import { AnmeldeFormular } from "@/app/anmelden/formular";
export const metadata = { title: "Anmelden" };
/**
* Anmeldeseite. Zeigt genau die Wege, die in dieser Installation eingerichtet
* sind: OIDC gegen authentik, lokaler Login, oder beides.
*/
export default async function AnmeldenSeite({
searchParams,
}: {
searchParams: Promise<{ error?: string; callbackUrl?: string }>;
}) {
const sitzung = await aktuelleSitzung();
if (sitzung) redirect("/");
const { error, callbackUrl } = await searchParams;
return (
<main className="flex min-h-screen items-center justify-center bg-[var(--muted)] p-6">
<Card className="w-full max-w-md">
<CardHeader className="space-y-3">
<div className="flex items-center gap-3">
<Flame className="size-8 text-[var(--primary)]" aria-hidden />
<div>
{/* Echte h1: die Seite braucht eine Ueberschrift, die
Hilfsmittel und Tests ansteuern koennen. */}
<h1 className="text-xl font-semibold leading-none tracking-tight">
FLORIAN
</h1>
<CardDescription className="mt-1.5">Feuerwehrverwaltung</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<AnmeldeFormular
oidcVerfuegbar={oidcVerfuegbar}
oidcName={env.AUTH_OIDC_NAME}
lokalVerfuegbar={env.AUTH_LOCAL_ENABLED}
fehler={error}
weiterZu={callbackUrl}
/>
</CardContent>
</Card>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { handlers } from "@/lib/auth";
/**
* Auth.js-Endpunkte. Die Rückleitung von authentik landet hier unter
* /api/auth/callback/authentik — genau diese Adresse muss im authentik-Provider
* als Redirect-URI eingetragen sein.
*/
export const { GET, POST } = handlers;
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { datenbankErreichbar } from "@/lib/db";
import { speicherErreichbar } from "@/lib/storage";
import { warteschlangeErreichbar } from "@/lib/warteschlange";
/**
* Erreichbarkeitsprüfung für Docker, Traefik und die Überwachung.
* 200, wenn Datenbank, Objektspeicher und Warteschlange antworten — sonst 503.
* Bewusst ohne personenbezogene Angaben, damit der Endpunkt offen sein kann.
*/
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET() {
const beginn = Date.now();
const [datenbank, speicher, warteschlange] = await Promise.all([
datenbankErreichbar(),
speicherErreichbar(),
warteschlangeErreichbar(),
]);
const gesund = datenbank && speicher && warteschlange;
return NextResponse.json(
{
status: gesund ? "ok" : "gestoert",
dienste: { datenbank, speicher, warteschlange },
dauerMs: Date.now() - beginn,
zeitpunkt: new Date().toISOString(),
},
{
status: gesund ? 200 : 503,
headers: { "Cache-Control": "no-store" },
},
);
}
+22
View File
@@ -0,0 +1,22 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { kontextErzeugen } from "@/server/context";
import { env } from "@/lib/env";
/** Einstiegspunkt für alle internen tRPC-Aufrufe. */
function verarbeiten(anfrage: Request) {
return fetchRequestHandler({
endpoint: "/api/trpc",
req: anfrage,
router: appRouter,
createContext: () => kontextErzeugen({ req: anfrage }),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(`[trpc] Fehler in ${path ?? "<unbekannt>"}:`, error.message);
}
: undefined,
});
}
export { verarbeiten as GET, verarbeiten as POST };
+170
View File
@@ -0,0 +1,170 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/*
Farbwelt FLORIAN.
Grundton ist das Rot der Feuerwehr, bewusst entsättigt — eine Oberfläche, in
der jemand acht Stunden Prüfungen dokumentiert, darf nicht schreien. Signalrot
bleibt für das reserviert, was wirklich Alarm ist: gesperrte Geräte,
überzogene Fristen, laufende Atemschutzüberwachung.
Keine Google Fonts, keine externen CDNs — es werden ausschließlich Schriften
benutzt, die auf dem Gerät vorhanden sind.
*/
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Statusfarben der Gerätezustände — überall dieselben. */
--color-einsatzbereit: var(--einsatzbereit);
--color-pruefung-faellig: var(--pruefung-faellig);
--color-gesperrt: var(--gesperrt);
--color-ausgesondert: var(--ausgesondert);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: "Inter", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace;
}
:root {
--radius: 0.625rem;
--background: oklch(0.99 0.002 60);
--foreground: oklch(0.21 0.01 40);
--card: oklch(1 0 0);
--card-foreground: oklch(0.21 0.01 40);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.21 0.01 40);
/* Feuerwehrrot, für Flächen nutzbar gedämpft. */
--primary: oklch(0.52 0.19 27);
--primary-foreground: oklch(0.99 0.005 60);
--secondary: oklch(0.96 0.005 60);
--secondary-foreground: oklch(0.28 0.015 40);
--muted: oklch(0.96 0.005 60);
--muted-foreground: oklch(0.53 0.012 45);
--accent: oklch(0.95 0.012 40);
--accent-foreground: oklch(0.28 0.015 40);
--destructive: oklch(0.58 0.23 27);
--destructive-foreground: oklch(0.99 0.005 60);
--border: oklch(0.91 0.005 60);
--input: oklch(0.91 0.005 60);
--ring: oklch(0.52 0.19 27);
--einsatzbereit: oklch(0.62 0.15 150);
--pruefung-faellig: oklch(0.72 0.16 75);
--gesperrt: oklch(0.58 0.23 27);
--ausgesondert: oklch(0.6 0.01 60);
}
/*
Dunkle Darstellung. Nicht bloß invertiert: im Gerätehaus wird abends und
nachts gearbeitet, deshalb ein warmes, kontrastarmes Grau statt Schwarz —
das blendet nicht, wenn man vom Fahrzeug hereinkommt.
*/
.dark {
--background: oklch(0.17 0.008 40);
--foreground: oklch(0.94 0.004 60);
--card: oklch(0.21 0.009 40);
--card-foreground: oklch(0.94 0.004 60);
--popover: oklch(0.21 0.009 40);
--popover-foreground: oklch(0.94 0.004 60);
--primary: oklch(0.63 0.19 27);
--primary-foreground: oklch(0.98 0.005 60);
--secondary: oklch(0.26 0.01 40);
--secondary-foreground: oklch(0.94 0.004 60);
--muted: oklch(0.26 0.01 40);
--muted-foreground: oklch(0.68 0.01 50);
--accent: oklch(0.29 0.012 40);
--accent-foreground: oklch(0.94 0.004 60);
--destructive: oklch(0.65 0.21 27);
--destructive-foreground: oklch(0.98 0.005 60);
--border: oklch(0.29 0.01 40);
--input: oklch(0.29 0.01 40);
--ring: oklch(0.63 0.19 27);
--einsatzbereit: oklch(0.68 0.15 150);
--pruefung-faellig: oklch(0.78 0.15 75);
--gesperrt: oklch(0.65 0.21 27);
--ausgesondert: oklch(0.55 0.01 60);
}
@layer base {
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Tastaturbedienung muss sichtbar sein — am Terminal wird auch getabbt. */
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Zahlen in Tabellen sollen untereinander stehen. */
table,
.tabellenzahlen {
font-variant-numeric: tabular-nums;
}
}
/* Am Kiosk-Terminal wird mit dem Finger bedient: größere Ziele, keine Auswahl. */
.kiosk {
--radius: 0.875rem;
user-select: none;
}
.kiosk button,
.kiosk [role="button"] {
min-height: 3.5rem;
font-size: 1.125rem;
}
@media print {
.nicht-drucken {
display: none !important;
}
body {
background: white;
color: black;
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/theme-provider";
export const metadata: Metadata = {
title: {
default: "FLORIAN — Feuerwehrverwaltung",
template: "%s · FLORIAN",
},
description:
"Verwaltungsplattform für Freiwillige Feuerwehren: Mitglieder, Fahrzeuge, Geräte, Prüfungen, Atemschutz, Einsätze.",
applicationName: "FLORIAN",
// Keine Suchmaschinen: die Installation steht im Gerätehaus, nicht im Netz.
robots: { index: false, follow: false },
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
// Am Terminal soll nicht versehentlich gezoomt werden.
maximumScale: 5,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#1c1a19" },
],
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
+216
View File
@@ -0,0 +1,216 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Album,
BookOpenCheck,
CalendarDays,
ClipboardCheck,
Flame,
Gauge,
Map,
Menu,
MonitorSmartphone,
Siren,
Truck,
Users,
Wrench,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import type { Recht, Rolle } from "@/lib/authz";
/**
* Hauptnavigation.
*
* Welche Einträge jemand sieht, entscheidet NICHT diese Komponente. Sie bekommt
* die Rechte der Sitzung übergeben und blendet danach aus — die Prüfung selbst
* bleibt in lib/authz.ts, wie es die Projektregeln verlangen. Ein ausgeblendeter
* Eintrag ist Bequemlichkeit, kein Schutz; der Schutz sitzt in der tRPC-Schicht.
*
* Die Phasenangaben entsprechen den Dateien Phase-0.md bis Phase-8.md.
*/
interface Eintrag {
titel: string;
pfad: string;
symbol: React.ComponentType<{ className?: string }>;
/** Sichtbar, wenn eines dieser Rechte vorliegt. Leer = für alle. */
rechte?: Recht[];
/** Ab welcher Phase es die Ansicht wirklich gibt. */
abPhase?: number;
}
const EINTRAEGE: Eintrag[] = [
{ titel: "Übersicht", pfad: "/", symbol: Gauge },
{ titel: "Mitglieder", pfad: "/mitglieder", symbol: Users, rechte: ["person.lesen"], abPhase: 1 },
{ titel: "Fahrzeuge", pfad: "/fahrzeuge", symbol: Truck, rechte: ["fahrzeug.lesen"], abPhase: 1 },
{ titel: "Geräte", pfad: "/geraete", symbol: Wrench, rechte: ["geraet.lesen"], abPhase: 1 },
{
titel: "Qualifikationen",
pfad: "/qualifikationen",
symbol: BookOpenCheck,
rechte: ["qualifikation.lesen"],
abPhase: 1,
},
{
titel: "Prüfungen",
pfad: "/pruefungen",
symbol: ClipboardCheck,
rechte: ["pruefung.lesen"],
abPhase: 2,
},
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
{
titel: "Terminal",
pfad: "/kiosk",
symbol: MonitorSmartphone,
rechte: ["kiosk.erfassen"],
abPhase: 4,
},
{
titel: "Atemschutz",
pfad: "/atemschutz",
symbol: Siren,
rechte: ["atemschutz.lesen"],
abPhase: 5,
},
{ titel: "Karte", pfad: "/karte", symbol: Map, rechte: ["einsatz.lesen"], abPhase: 6 },
{
titel: "Termine",
pfad: "/termine",
symbol: CalendarDays,
rechte: ["ausbildung.lesen"],
abPhase: 7,
},
{
titel: "Protokoll",
pfad: "/protokoll",
symbol: Album,
rechte: ["system.auditlog"],
abPhase: 1,
},
];
/** In Phase 0 gibt es nur die Übersicht — alles andere ist noch Ausblick. */
const AKTUELLE_PHASE = 0;
export interface SeitenleisteProps {
rechte: Recht[];
rollen: Rolle[];
organisationName: string;
personName: string;
}
export function Seitenleiste({ rechte, organisationName, personName }: SeitenleisteProps) {
const pfad = usePathname();
const [offen, setOffen] = React.useState(false);
const rechteMenge = React.useMemo(() => new Set(rechte), [rechte]);
const sichtbar = EINTRAEGE.filter(
(eintrag) => !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht)),
);
return (
<>
{/* Auf schmalen Geräten aufklappbar. */}
<Button
variant="ghost"
size="icon"
className="fixed left-3 top-3 z-50 md:hidden nicht-drucken"
onClick={() => setOffen((zustand) => !zustand)}
aria-label={offen ? "Navigation schließen" : "Navigation öffnen"}
aria-expanded={offen}
>
{offen ? <X aria-hidden /> : <Menu aria-hidden />}
</Button>
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-[var(--border)] bg-[var(--card)] transition-transform nicht-drucken",
"md:translate-x-0",
offen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex h-16 items-center gap-3 border-b border-[var(--border)] px-6">
<Flame className="size-6 text-[var(--primary)]" aria-hidden />
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-tight">FLORIAN</p>
<p className="truncate text-xs text-[var(--muted-foreground)]">
{organisationName}
</p>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-3" aria-label="Hauptnavigation">
<ul className="space-y-1">
{sichtbar.map((eintrag) => {
const aktiv =
eintrag.pfad === "/" ? pfad === "/" : pfad?.startsWith(eintrag.pfad);
const nochNicht = (eintrag.abPhase ?? 0) > AKTUELLE_PHASE;
const Symbol = eintrag.symbol;
if (nochNicht) {
return (
<li key={eintrag.pfad}>
<span
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-[var(--muted-foreground)] opacity-60"
title={`Kommt in Phase ${eintrag.abPhase}`}
>
<Symbol className="size-4 shrink-0" aria-hidden />
<span className="truncate">{eintrag.titel}</span>
<span className="ml-auto text-[10px] uppercase tracking-wide">
P{eintrag.abPhase}
</span>
</span>
</li>
);
}
return (
<li key={eintrag.pfad}>
<Link
href={eintrag.pfad}
onClick={() => setOffen(false)}
aria-current={aktiv ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
aktiv
? "bg-[var(--primary)] text-[var(--primary-foreground)] font-medium"
: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
)}
>
<Symbol className="size-4 shrink-0" aria-hidden />
<span className="truncate">{eintrag.titel}</span>
</Link>
</li>
);
})}
</ul>
</nav>
<div className="border-t border-[var(--border)] p-4">
<p className="truncate text-sm font-medium">{personName}</p>
<Link
href="/api/auth/signout"
className="text-xs text-[var(--muted-foreground)] underline-offset-4 hover:underline"
>
Abmelden
</Link>
</div>
</aside>
{offen && (
<button
type="button"
aria-label="Navigation schließen"
className="fixed inset-0 z-30 bg-black/40 md:hidden"
onClick={() => setOffen(false)}
/>
)}
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
/**
* Hell/Dunkel. Standard ist die Systemeinstellung — wer im Gerätehaus abends
* am Terminal steht, bekommt dann von selbst die dunkle Darstellung.
*/
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
/** Umschalter zwischen heller und dunkler Darstellung. */
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [bereit, setBereit] = React.useState(false);
// Vor der Hydratation ist das Thema unbekannt — sonst blinkt das Symbol.
React.useEffect(() => setBereit(true), []);
const dunkel = resolvedTheme === "dark";
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(dunkel ? "light" : "dark")}
aria-label={dunkel ? "Zur hellen Darstellung wechseln" : "Zur dunklen Darstellung wechseln"}
title={dunkel ? "Helle Darstellung" : "Dunkle Darstellung"}
>
{bereit && dunkel ? <Sun aria-hidden /> : <Moon aria-hidden />}
</Button>
);
}
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* Statusabzeichen. Die Gerätezustände haben feste Farben, die überall gleich
* bedeuten — grün einsatzbereit, gelb Prüfung fällig, rot gesperrt, grau
* ausgesondert. Wer die Farbe kennt, muss den Text nicht lesen.
*/
const badgeVarianten = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default:
"border-transparent bg-[var(--primary)] text-[var(--primary-foreground)]",
secondary:
"border-transparent bg-[var(--secondary)] text-[var(--secondary-foreground)]",
outline: "text-[var(--foreground)] border-[var(--border)]",
einsatzbereit:
"border-transparent bg-[var(--einsatzbereit)]/15 text-[var(--einsatzbereit)]",
pruefungFaellig:
"border-transparent bg-[var(--pruefung-faellig)]/20 text-[var(--pruefung-faellig)]",
gesperrt: "border-transparent bg-[var(--gesperrt)]/15 text-[var(--gesperrt)]",
ausgesondert:
"border-transparent bg-[var(--ausgesondert)]/15 text-[var(--ausgesondert)]",
},
},
defaultVariants: { variant: "default" },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVarianten> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVarianten({ variant }), className)} {...props} />;
}
export { Badge, badgeVarianten };
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVarianten = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 shadow-sm",
destructive:
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90 shadow-sm",
outline:
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
secondary:
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
ghost: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
link: "text-[var(--primary)] underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-8",
/** Für das Terminal im Gerätehaus — mit Handschuhen bedienbar. */
kiosk: "h-16 rounded-xl px-8 text-lg",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVarianten> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Komponente = asChild ? Slot : "button";
return (
<Komponente
className={cn(buttonVarianten({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVarianten };
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className,
)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-sm shadow-sm transition-colors",
"placeholder:text-[var(--muted-foreground)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--background)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };
+22
View File
@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+238
View File
@@ -0,0 +1,238 @@
import "server-only";
import { AkteurTyp, AnmeldeArt, AuditAktion, Prisma } from "@prisma/client";
import { fuerOrganisation, prismaSystem, type MandantenClient } from "@/lib/db";
import type { Sitzung } from "@/lib/authz";
/**
* Audit-Log.
*
* Regel aus CLAUDE.md: Jede schreibende Operation erzeugt einen Eintrag mit
* Akteur, Zeitpunkt, Entität und Vorher/Nachher als JSONB. Die Tabelle ist
* append-only — auf Datenbankebene per Trigger abgesichert, nicht nur hier.
*
* Zwei Aufrufarten:
* protokolliere(...) — eigene Transaktion, für Einzelaktionen
* protokolliereIn(db, ...) — innerhalb einer bestehenden Transaktion, damit
* Änderung und Protokoll gemeinsam gültig werden
* oder gemeinsam zurückgerollt.
*
* Der zweite Weg ist der richtige, sobald es um Daten geht, die wirklich
* zusammengehören.
*/
export { AuditAktion, AkteurTyp, AnmeldeArt };
/** Felder, die niemals ins Protokoll wandern. */
const GEHEIME_FELDER = new Set([
"passwortHash",
"passwort",
"tokenHash",
"pin",
"secret",
"clientSecret",
"accessToken",
"refreshToken",
]);
/**
* Entfernt Geheimnisse und wandelt Werte, die JSONB nicht kennt (Date, BigInt),
* in etwas Speicherbares um.
*/
export function fuerProtokoll(wert: unknown, tiefe = 0): Prisma.InputJsonValue | null {
if (wert === null || wert === undefined) return null;
if (tiefe > 6) return "[zu tief verschachtelt]";
if (wert instanceof Date) return wert.toISOString();
if (typeof wert === "bigint") return wert.toString();
if (typeof wert === "string" || typeof wert === "number" || typeof wert === "boolean") {
return wert;
}
if (Array.isArray(wert)) {
return wert.map((eintrag) => fuerProtokoll(eintrag, tiefe + 1) ?? null);
}
if (typeof wert === "object") {
const ergebnis: Record<string, Prisma.InputJsonValue | null> = {};
for (const [schluessel, inhalt] of Object.entries(wert as Record<string, unknown>)) {
ergebnis[schluessel] = GEHEIME_FELDER.has(schluessel)
? "[entfernt]"
: fuerProtokoll(inhalt, tiefe + 1);
}
return ergebnis as Prisma.InputJsonValue;
}
return String(wert);
}
/**
* Nur die tatsächlich geänderten Felder — sonst bläht jeder Speichervorgang das
* Protokoll mit unveränderten Werten auf.
*/
export function unterschied(
vorher: Record<string, unknown> | null | undefined,
nachher: Record<string, unknown> | null | undefined,
): { vorher: Prisma.InputJsonValue | null; nachher: Prisma.InputJsonValue | null; felder: string[] } {
if (!vorher) return { vorher: null, nachher: fuerProtokoll(nachher), felder: Object.keys(nachher ?? {}) };
if (!nachher) return { vorher: fuerProtokoll(vorher), nachher: null, felder: Object.keys(vorher) };
const felder: string[] = [];
const vorherGefiltert: Record<string, unknown> = {};
const nachherGefiltert: Record<string, unknown> = {};
for (const schluessel of new Set([...Object.keys(vorher), ...Object.keys(nachher)])) {
const alt = vorher[schluessel];
const neu = nachher[schluessel];
if (JSON.stringify(alt) === JSON.stringify(neu)) continue;
felder.push(schluessel);
vorherGefiltert[schluessel] = alt;
nachherGefiltert[schluessel] = neu;
}
return {
vorher: fuerProtokoll(vorherGefiltert),
nachher: fuerProtokoll(nachherGefiltert),
felder,
};
}
export interface ProtokollEingabe {
organisationId: string;
aktion: AuditAktion;
/** Modellname, z. B. "Geraet". */
entitaet: string;
entitaetId?: string | null;
beschreibung?: string;
/** Wer war es. Entweder eine Sitzung übergeben oder Akteur einzeln setzen. */
sitzung?: Sitzung | null;
akteurTyp?: AkteurTyp;
akteurPersonId?: string | null;
akteurName?: string | null;
anmeldeArt?: AnmeldeArt | null;
vorher?: Record<string, unknown> | null;
nachher?: Record<string, unknown> | null;
kontext?: Record<string, unknown> | null;
ipAdresse?: string | null;
userAgent?: string | null;
}
function datensatzBauen(eingabe: ProtokollEingabe): Prisma.AuditLogUncheckedCreateInput {
const abweichung = unterschied(eingabe.vorher, eingabe.nachher);
const kontext = {
...(eingabe.kontext ?? {}),
...(abweichung.felder.length > 0 ? { geaenderteFelder: abweichung.felder } : {}),
};
return {
organisationId: eingabe.organisationId,
aktion: eingabe.aktion,
entitaet: eingabe.entitaet,
entitaetId: eingabe.entitaetId ?? null,
beschreibung: eingabe.beschreibung ?? null,
akteurTyp: eingabe.akteurTyp ?? (eingabe.sitzung ? AkteurTyp.PERSON : AkteurTyp.SYSTEM),
akteurPersonId: eingabe.akteurPersonId ?? eingabe.sitzung?.personId ?? null,
akteurName: eingabe.akteurName ?? eingabe.sitzung?.name ?? null,
anmeldeArt:
eingabe.anmeldeArt ??
(eingabe.sitzung ? (eingabe.sitzung.anmeldeArt as AnmeldeArt) : null),
vorher: abweichung.vorher ?? Prisma.JsonNull,
nachher: abweichung.nachher ?? Prisma.JsonNull,
kontext: Object.keys(kontext).length > 0 ? (fuerProtokoll(kontext) as Prisma.InputJsonValue) : Prisma.JsonNull,
ipAdresse: eingabe.ipAdresse ?? null,
userAgent: eingabe.userAgent?.slice(0, 400) ?? null,
};
}
/**
* Schreibt den Eintrag in eine BESTEHENDE Transaktion. So werden Änderung und
* Protokoll gemeinsam wirksam.
*
* @example
* await fuerOrganisation(orgId, async (db) => {
* const vorher = await db.geraet.findUniqueOrThrow({ where: { id } });
* const nachher = await db.geraet.update({ where: { id }, data });
* await protokolliereIn(db, {
* organisationId: orgId, aktion: AuditAktion.GEAENDERT,
* entitaet: "Geraet", entitaetId: id, sitzung, vorher, nachher,
* });
* });
*/
export async function protokolliereIn(
db: MandantenClient,
eingabe: ProtokollEingabe,
): Promise<void> {
await db.auditLog.create({ data: datensatzBauen(eingabe) });
}
/**
* Schreibt den Eintrag in einer eigenen Transaktion. Für Vorgänge ohne
* begleitende Datenänderung — Anmeldungen, Exporte, Dateiabrufe.
*/
export async function protokolliere(eingabe: ProtokollEingabe): Promise<void> {
await fuerOrganisation(eingabe.organisationId, async (db) => {
await protokolliereIn(db, eingabe);
});
}
/**
* Anmeldevorgänge. Die laufen, bevor ein Mandantenkontext feststeht, deshalb
* schreibt diese Funktion ausnahmsweise über den Eigentümer-Zugang.
* Fehlschläge dürfen den Anmeldeweg nicht blockieren — deshalb nur geloggt.
*/
export async function protokolliereAnmeldung(eingabe: {
organisationId: string;
personId?: string | null;
name?: string | null;
anmeldeArt: AnmeldeArt;
erfolgreich: boolean;
grund?: string;
ipAdresse?: string | null;
userAgent?: string | null;
}): Promise<void> {
try {
await prismaSystem.auditLog.create({
data: datensatzBauen({
organisationId: eingabe.organisationId,
aktion: eingabe.erfolgreich
? AuditAktion.ANGEMELDET
: AuditAktion.ANMELDUNG_FEHLGESCHLAGEN,
entitaet: "Person",
entitaetId: eingabe.personId ?? null,
akteurTyp: eingabe.anmeldeArt === AnmeldeArt.SYSTEM ? AkteurTyp.SYSTEM : AkteurTyp.PERSON,
akteurPersonId: eingabe.personId ?? null,
akteurName: eingabe.name ?? null,
anmeldeArt: eingabe.anmeldeArt,
beschreibung: eingabe.erfolgreich
? `Anmeldung über ${eingabe.anmeldeArt}`
: `Anmeldung fehlgeschlagen: ${eingabe.grund ?? "unbekannt"}`,
ipAdresse: eingabe.ipAdresse,
userAgent: eingabe.userAgent,
}),
});
} catch (fehler) {
console.error("[audit] Anmeldung konnte nicht protokolliert werden:", fehler);
}
}
/** Die letzten Einträge zu einem Datensatz — für die Verlaufsansicht. */
export async function verlauf(
organisationId: string,
entitaet: string,
entitaetId: string,
anzahl = 50,
) {
return fuerOrganisation(organisationId, (db) =>
db.auditLog.findMany({
where: { entitaet, entitaetId },
orderBy: { zeitpunkt: "desc" },
take: anzahl,
include: {
akteur: { select: { id: true, vorname: true, nachname: true } },
},
}),
);
}
+613
View File
@@ -0,0 +1,613 @@
import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import type { Provider } from "next-auth/providers";
import { AnmeldeArt, type RolleSchluessel } from "@prisma/client";
import { z } from "zod";
import { env, oidcGruppenZuordnung, oidcVerfuegbar } from "@/lib/env";
import { prismaSystem, fuerOrganisation } from "@/lib/db";
import { pruefen } from "@/lib/passwort";
import { rfidNormalisieren } from "@/lib/passwort";
import { protokolliereAnmeldung } from "@/lib/audit";
import { alsRolle, type Rolle, type Sitzung } from "@/lib/authz";
/**
* Anmeldung — drei Wege, ein Sitzungsbegriff.
*
* 1. OIDC gegen authentik — der Regelweg im laufenden Betrieb.
* 2. E-Mail und Passwort — Rückfalloption für den Erststart und für den
* Fall, dass authentik gerade nicht erreichbar ist.
* 3. Terminal (PIN/RFID) — für das Kiosk-Gerät im Gerätehaus.
*
* Alle drei führen zu derselben Sitzung (siehe lib/authz.ts). Die Sitzung liegt
* als signiertes JWT im Cookie — keine Sitzungstabelle, damit ein Neustart der
* Anwendung niemanden abmeldet.
*
* Die Rollen stehen im Token. Damit eine Rollenänderung nicht erst beim
* nächsten Login greift, werden sie bei jedem Zugriff nachgeladen, wenn das
* Token älter als fünf Minuten ist.
*/
const ROLLEN_NACHLADEN_NACH_MS = 5 * 60 * 1000;
// -----------------------------------------------------------------------------
// Hilfsfunktionen
// -----------------------------------------------------------------------------
/** Lädt Person samt Rollen. Läuft vor der Mandantenauflösung, daher ohne RLS. */
async function personMitRollen(personId: string) {
return prismaSystem.person.findFirst({
where: { id: personId, aktiv: true, geloeschtAm: null },
include: {
rollen: {
where: { OR: [{ gueltigBis: null }, { gueltigBis: { gt: new Date() } }] },
include: { rolle: true },
},
organisation: { select: { id: true, slug: true, name: true, aktiv: true } },
},
});
}
function rollenAus(
eintraege: Array<{ rolle: { schluessel: RolleSchluessel } }>,
): Rolle[] {
const rollen = eintraege
.map((eintrag) => alsRolle(eintrag.rolle.schluessel))
.filter((rolle): rolle is Rolle => rolle !== null);
// Wer gar keine Rolle hat, ist trotzdem Mitglied — sonst sieht er nicht
// einmal die eigenen Daten.
return rollen.length > 0 ? rollen : ["MITGLIED"];
}
/** Ist das Konto gerade wegen zu vieler Fehlversuche gesperrt? */
function istGesperrt(person: { gesperrtBis: Date | null }): boolean {
return person.gesperrtBis !== null && person.gesperrtBis > new Date();
}
/** Fehlversuch zählen und ab der Grenze sperren. */
async function fehlversuchVermerken(personId: string): Promise<void> {
const person = await prismaSystem.person.findUnique({
where: { id: personId },
select: { fehlversuche: true },
});
const neu = (person?.fehlversuche ?? 0) + 1;
await prismaSystem.person.update({
where: { id: personId },
data: {
fehlversuche: neu,
gesperrtBis:
neu >= env.KIOSK_MAX_FEHLVERSUCHE
? new Date(Date.now() + env.KIOSK_SPERRDAUER * 1000)
: null,
},
});
}
async function anmeldungVermerken(personId: string): Promise<void> {
await prismaSystem.person.update({
where: { id: personId },
data: { letzteAnmeldung: new Date(), fehlversuche: 0, gesperrtBis: null },
});
}
// -----------------------------------------------------------------------------
// Anbieter
// -----------------------------------------------------------------------------
const lokaleAnmeldung = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse eingeben."),
passwort: z.string().min(1, "Bitte das Passwort eingeben."),
});
const terminalAnmeldung = z.object({
geraeteSchluessel: z.string().min(1),
organisationSlug: z.string().min(1),
art: z.enum(["PIN", "RFID"]),
/** Bei PIN zusätzlich die Mitgliedsnummer, bei RFID nur die Kennung. */
kennung: z.string().min(1),
geheimnis: z.string().min(1),
});
/** E-Mail und Passwort — der Rückfallweg. */
const passwortAnbieter = Credentials({
id: "passwort",
name: "E-Mail und Passwort",
credentials: {
email: { label: "E-Mail", type: "email" },
passwort: { label: "Passwort", type: "password" },
},
async authorize(eingabe) {
const geprueft = lokaleAnmeldung.safeParse(eingabe);
if (!geprueft.success) return null;
const { email, passwort } = geprueft.data;
// Die E-Mail-Suche geht bewusst über alle Wehren — zu diesem Zeitpunkt ist
// der Mandant noch nicht bekannt.
const person = await prismaSystem.person.findFirst({
where: { email: email.toLowerCase(), aktiv: true, geloeschtAm: null },
include: {
rollen: { include: { rolle: true } },
organisation: { select: { id: true, aktiv: true } },
},
});
if (!person || !person.organisation.aktiv) {
// Trotzdem hashen, damit "Konto gibt es nicht" zeitlich nicht auffällt.
await pruefen(passwort, null);
return null;
}
if (istGesperrt(person)) {
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: false,
grund: "Konto vorübergehend gesperrt",
});
return null;
}
const stimmt = await pruefen(passwort, person.passwortHash);
if (!stimmt) {
await fehlversuchVermerken(person.id);
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: false,
grund: "Falsches Passwort",
});
return null;
}
await anmeldungVermerken(person.id);
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: true,
});
return {
id: person.id,
name: `${person.vorname} ${person.nachname}`,
email: person.email ?? undefined,
organisationId: person.organisationId,
rollen: rollenAus(person.rollen),
anmeldeArt: "LOKAL" as const,
};
},
});
/**
* Terminal im Gerätehaus. Zwei Schranken: erst weist sich das GERÄT mit
* KIOSK_DEVICE_SECRET aus, dann die PERSON mit PIN oder RFID-Karte. Ohne die
* erste Schranke nützt eine erratene PIN nichts.
*/
const terminalAnbieter = Credentials({
id: "terminal",
name: "Terminal im Gerätehaus",
credentials: {
geraeteSchluessel: { label: "Gerät", type: "text" },
organisationSlug: { label: "Wehr", type: "text" },
art: { label: "Art", type: "text" },
kennung: { label: "Kennung", type: "text" },
geheimnis: { label: "PIN oder Kartenkennung", type: "password" },
},
async authorize(eingabe) {
if (!env.KIOSK_ENABLED) return null;
const geprueft = terminalAnmeldung.safeParse(eingabe);
if (!geprueft.success) return null;
const { geraeteSchluessel, organisationSlug, art, kennung, geheimnis } = geprueft.data;
// Erste Schranke: Ist das ein Terminal, das wir kennen?
if (geraeteSchluessel !== env.KIOSK_DEVICE_SECRET) return null;
const organisation = await prismaSystem.organisation.findFirst({
where: { slug: organisationSlug, aktiv: true, geloeschtAm: null },
select: { id: true },
});
if (!organisation) return null;
// Zweite Schranke: die Person. Bei PIN identifiziert die Mitgliedsnummer,
// bei RFID die Kartenkennung.
const tokens = await fuerOrganisation(organisation.id, (db) =>
db.ausweisToken.findMany({
where: {
typ: art,
aktiv: true,
geloeschtAm: null,
gueltigAb: { lte: new Date() },
OR: [{ gueltigBis: null }, { gueltigBis: { gt: new Date() } }],
...(art === "PIN"
? { person: { mitgliedsnummer: kennung, aktiv: true, geloeschtAm: null } }
: { kennzeichen: rfidNormalisieren(kennung).slice(-4) }),
},
include: {
person: {
include: { rollen: { include: { rolle: true } } },
},
},
}),
);
const vergleichswert = art === "RFID" ? rfidNormalisieren(geheimnis) : geheimnis;
for (const token of tokens) {
if (token.gesperrtBis && token.gesperrtBis > new Date()) continue;
if (!(await pruefen(vergleichswert, token.tokenHash))) continue;
await fuerOrganisation(organisation.id, (db) =>
db.ausweisToken.update({
where: { id: token.id },
data: { letzteNutzung: new Date(), fehlversuche: 0, gesperrtBis: null },
}),
);
const anmeldeArt = art === "PIN" ? AnmeldeArt.KIOSK_PIN : AnmeldeArt.KIOSK_RFID;
await protokolliereAnmeldung({
organisationId: organisation.id,
personId: token.person.id,
name: `${token.person.vorname} ${token.person.nachname}`,
anmeldeArt,
erfolgreich: true,
});
return {
id: token.person.id,
name: `${token.person.vorname} ${token.person.nachname}`,
email: token.person.email ?? undefined,
organisationId: organisation.id,
rollen: rollenAus(token.person.rollen),
anmeldeArt: art === "PIN" ? ("KIOSK_PIN" as const) : ("KIOSK_RFID" as const),
};
}
// Fehlversuche werden auf allen in Frage kommenden Tokens gezählt.
await fuerOrganisation(organisation.id, async (db) => {
for (const token of tokens) {
const neu = token.fehlversuche + 1;
await db.ausweisToken.update({
where: { id: token.id },
data: {
fehlversuche: neu,
gesperrtBis:
neu >= env.KIOSK_MAX_FEHLVERSUCHE
? new Date(Date.now() + env.KIOSK_SPERRDAUER * 1000)
: null,
},
});
}
});
await protokolliereAnmeldung({
organisationId: organisation.id,
anmeldeArt: art === "PIN" ? AnmeldeArt.KIOSK_PIN : AnmeldeArt.KIOSK_RFID,
erfolgreich: false,
grund: `Terminal-Anmeldung fehlgeschlagen (${art})`,
});
return null;
},
});
/**
* OIDC gegen authentik. Wird nur eingehängt, wenn Issuer, Client-ID und Secret
* gesetzt sind — sonst bliebe ein Knopf stehen, der ins Leere führt.
*/
function authentikAnbieter(): Provider {
return {
id: "authentik",
name: env.AUTH_OIDC_NAME,
type: "oidc",
issuer: env.AUTH_OIDC_ISSUER,
clientId: env.AUTH_OIDC_CLIENT_ID,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
authorization: { params: { scope: "openid email profile" } },
checks: ["pkce", "state"],
};
}
// -----------------------------------------------------------------------------
// OIDC: Person finden oder anlegen, Rollen aus Gruppen vorbelegen
// -----------------------------------------------------------------------------
interface OidcProfil {
sub: string;
email?: string;
name?: string;
given_name?: string;
family_name?: string;
[claim: string]: unknown;
}
/**
* Verknüpft ein authentik-Konto mit einer Person. Reihenfolge:
* 1. bereits verknüpft (oidcSubject)
* 2. E-Mail-Adresse stimmt überein -> verknüpfen
* 3. AUTH_OIDC_AUTO_PROVISION -> neue Person anlegen
* Sonst: Anmeldung abgelehnt.
*/
async function personAusOidc(profil: OidcProfil): Promise<{
personId: string;
organisationId: string;
name: string;
rollen: Rolle[];
} | null> {
const email = profil.email?.toLowerCase();
const vorhanden = await prismaSystem.person.findFirst({
where: {
OR: [{ oidcSubject: profil.sub }, ...(email ? [{ email }] : [])],
geloeschtAm: null,
},
include: {
rollen: { include: { rolle: true } },
organisation: { select: { id: true, aktiv: true } },
},
});
const gruppen = gruppenAusProfil(profil);
if (vorhanden) {
if (!vorhanden.aktiv || !vorhanden.organisation.aktiv) return null;
await prismaSystem.person.update({
where: { id: vorhanden.id },
data: {
oidcSubject: profil.sub,
oidcGruppen: gruppen,
letzteAnmeldung: new Date(),
fehlversuche: 0,
gesperrtBis: null,
},
});
// Gruppen wirken nur als Vorbelegung: Rollen, die jemand in FLORIAN von
// Hand vergeben hat, bleiben unangetastet.
await gruppenRollenVorbelegen(vorhanden.organisationId, vorhanden.id, gruppen);
const aktualisiert = await personMitRollen(vorhanden.id);
if (!aktualisiert) return null;
return {
personId: aktualisiert.id,
organisationId: aktualisiert.organisationId,
name: `${aktualisiert.vorname} ${aktualisiert.nachname}`,
rollen: rollenAus(aktualisiert.rollen),
};
}
if (!env.AUTH_OIDC_AUTO_PROVISION) return null;
const slug = env.AUTH_OIDC_STANDARD_ORGANISATION;
if (!slug) {
console.error(
"[auth] AUTH_OIDC_AUTO_PROVISION=true, aber AUTH_OIDC_STANDARD_ORGANISATION fehlt.",
);
return null;
}
const organisation = await prismaSystem.organisation.findFirst({
where: { slug, aktiv: true, geloeschtAm: null },
select: { id: true },
});
if (!organisation) return null;
const vorname = profil.given_name ?? profil.name?.split(" ")[0] ?? "Unbekannt";
const nachname =
profil.family_name ?? profil.name?.split(" ").slice(1).join(" ") ?? "Unbekannt";
const neu = await fuerOrganisation(organisation.id, (db) =>
db.person.create({
data: {
organisationId: organisation.id,
vorname,
nachname,
email: email ?? null,
oidcSubject: profil.sub,
oidcGruppen: gruppen,
letzteAnmeldung: new Date(),
},
}),
);
await gruppenRollenVorbelegen(organisation.id, neu.id, gruppen);
const mitRollen = await personMitRollen(neu.id);
return {
personId: neu.id,
organisationId: organisation.id,
name: `${vorname} ${nachname}`,
rollen: mitRollen ? rollenAus(mitRollen.rollen) : ["MITGLIED"],
};
}
function gruppenAusProfil(profil: OidcProfil): string[] {
const claim = env.AUTH_OIDC_GROUP_CLAIM;
if (!claim) return [];
const wert = profil[claim];
if (Array.isArray(wert)) return wert.map(String);
if (typeof wert === "string") return wert.split(",").map((teil) => teil.trim());
return [];
}
/**
* Belegt Rollen aus authentik-Gruppen vor. Bestehende Zuweisungen werden nicht
* angefasst: Was in FLORIAN von Hand gesetzt wurde, ist führend.
*/
async function gruppenRollenVorbelegen(
organisationId: string,
personId: string,
gruppen: string[],
): Promise<void> {
if (gruppen.length === 0) return;
const zuordnung = oidcGruppenZuordnung();
if (zuordnung.size === 0) return;
const gewuenscht = new Set<string>();
for (const gruppe of gruppen) {
const rolle = zuordnung.get(gruppe.toLowerCase());
if (rolle) gewuenscht.add(rolle);
}
if (gewuenscht.size === 0) return;
await fuerOrganisation(organisationId, async (db) => {
const bestehend = await db.personRolle.findMany({
where: { personId },
select: { rolleId: true },
});
// Nur beim allerersten Mal vorbelegen — danach entscheidet die Wehr.
if (bestehend.length > 0) return;
const rollen = await db.rolle.findMany({
where: {
organisationId,
schluessel: { in: [...gewuenscht] as RolleSchluessel[] },
},
select: { id: true },
});
if (rollen.length === 0) return;
await db.personRolle.createMany({
data: rollen.map((rolle) => ({
organisationId,
personId,
rolleId: rolle.id,
ausOidcGruppe: true,
})),
skipDuplicates: true,
});
});
}
// -----------------------------------------------------------------------------
// Auth.js-Konfiguration
// -----------------------------------------------------------------------------
function anbieterListe(): Provider[] {
const liste: Provider[] = [];
if (oidcVerfuegbar) liste.push(authentikAnbieter());
if (env.AUTH_LOCAL_ENABLED) liste.push(passwortAnbieter);
if (env.KIOSK_ENABLED) liste.push(terminalAnbieter);
return liste;
}
export const authConfig: NextAuthConfig = {
trustHost: env.AUTH_TRUST_HOST,
secret: env.AUTH_SECRET,
session: { strategy: "jwt", maxAge: env.AUTH_SESSION_MAX_AGE },
pages: { signIn: "/anmelden", error: "/anmelden" },
providers: anbieterListe(),
callbacks: {
async signIn({ account, profile }) {
// Die Credentials-Anbieter haben in authorize() bereits alles geprüft.
if (account?.provider !== "authentik") return true;
if (!profile?.sub) return false;
const ergebnis = await personAusOidc(profile as unknown as OidcProfil);
if (!ergebnis) return false;
await protokolliereAnmeldung({
organisationId: ergebnis.organisationId,
personId: ergebnis.personId,
name: ergebnis.name,
anmeldeArt: AnmeldeArt.OIDC,
erfolgreich: true,
});
return true;
},
async jwt({ token, user, account, trigger }) {
// Erste Anmeldung über einen Credentials-Anbieter.
if (user && "organisationId" in user) {
const angemeldet = user as unknown as {
id: string;
name: string;
organisationId: string;
rollen: Rolle[];
anmeldeArt: Sitzung["anmeldeArt"];
};
token.personId = angemeldet.id;
token.organisationId = angemeldet.organisationId;
token.rollen = angemeldet.rollen;
token.anmeldeArt = angemeldet.anmeldeArt;
token.geladenAm = Date.now();
return token;
}
// Erste Anmeldung über OIDC — hier steht das Profil im account/profile.
if (account?.provider === "authentik" && token.sub) {
const person = await prismaSystem.person.findFirst({
where: { oidcSubject: token.sub, geloeschtAm: null },
include: { rollen: { include: { rolle: true } } },
});
if (person) {
token.personId = person.id;
token.organisationId = person.organisationId;
token.rollen = rollenAus(person.rollen);
token.anmeldeArt = "OIDC";
token.geladenAm = Date.now();
}
return token;
}
// Rollen nachladen, damit eine Änderung nicht bis zum nächsten Login wartet.
const veraltet =
typeof token.geladenAm !== "number" ||
Date.now() - token.geladenAm > ROLLEN_NACHLADEN_NACH_MS;
if (trigger === "update" || (veraltet && typeof token.personId === "string")) {
const person = await personMitRollen(token.personId as string);
if (!person) {
// Person deaktiviert oder gelöscht: Sitzung entwerten.
return { ...token, personId: undefined, organisationId: undefined, rollen: [] };
}
token.rollen = rollenAus(person.rollen);
token.organisationId = person.organisationId;
token.geladenAm = Date.now();
}
return token;
},
async session({ session, token }) {
session.user = {
...session.user,
id: (token.personId as string) ?? "",
organisationId: (token.organisationId as string) ?? "",
rollen: (token.rollen as Rolle[]) ?? [],
anmeldeArt: (token.anmeldeArt as Sitzung["anmeldeArt"]) ?? "LOKAL",
};
return session;
},
},
};
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
/**
* Die Sitzung, wie der Rest der Anwendung sie erwartet. Gibt null zurück,
* wenn niemand angemeldet ist oder die Sitzung entwertet wurde.
*/
export async function aktuelleSitzung(): Promise<Sitzung | null> {
const sitzung = await auth();
if (!sitzung?.user?.id || !sitzung.user.organisationId) return null;
return {
personId: sitzung.user.id,
organisationId: sitzung.user.organisationId,
name: sitzung.user.name ?? "Unbekannt",
rollen: sitzung.user.rollen ?? [],
anmeldeArt: sitzung.user.anmeldeArt ?? "LOKAL",
};
}
+338
View File
@@ -0,0 +1,338 @@
/**
* Zentrale Berechtigungslogik.
*
* Regel aus CLAUDE.md: Keine Rollenprüfung in Komponenten. Alles, was mit
* "darf jemand das?" zu tun hat, steht hier — und nur hier.
*
* Aufbau:
* Recht = ein einzelnes Können, z. B. "geraet.bearbeiten"
* Rolle = ein Bündel von Rechten, z. B. GERAETEWART
* Sitzung = wer gerade angemeldet ist, mit welchen Rollen, in welcher Wehr
*
* Die Rechte einer Rolle sind hier als Vorgabe hinterlegt. Beim Seed werden sie
* in die Tabellen berechtigung/rolle_berechtigung gespiegelt, damit eine Wehr
* sie nachträglich anpassen kann, ohne dass jemand Code ändern muss.
*/
/** Rollen exakt wie im Prisma-Schema (enum RolleSchluessel). */
export const ROLLEN = [
"SUPERADMIN",
"KOMMANDANT",
"GERAETEWART",
"ATEMSCHUTZGERAETEWART",
"KASSIER",
"SCHRIFTFUEHRER",
"AUSBILDER",
"MITGLIED",
"KIOSK",
] as const;
export type Rolle = (typeof ROLLEN)[number];
/** Klartext für die Oberfläche. */
export const ROLLEN_BEZEICHNUNG: Record<Rolle, string> = {
SUPERADMIN: "Administrator",
KOMMANDANT: "Kommandant",
GERAETEWART: "Gerätewart",
ATEMSCHUTZGERAETEWART: "Atemschutzgerätewart",
KASSIER: "Kassier",
SCHRIFTFUEHRER: "Schriftführer",
AUSBILDER: "Ausbilder",
MITGLIED: "Mitglied",
KIOSK: "Terminal im Gerätehaus",
};
export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = {
SUPERADMIN:
"Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.",
KOMMANDANT:
"Führt die Wehr. Sieht und bearbeitet alles innerhalb der eigenen Organisation.",
GERAETEWART:
"Verantwortet Fahrzeuge, Geräte und deren Prüfungen nach DGUV.",
ATEMSCHUTZGERAETEWART:
"Verantwortet Atemschutzgeräte, Masken und die Überwachung nach FwDV 7.",
KASSIER: "Führt die Kasse: Buchungen, Beiträge, Rechnungen, Auswertungen.",
SCHRIFTFUEHRER:
"Führt Protokolle, Berichte und die Dokumentation der Wehr.",
AUSBILDER: "Plant Übungen und Lehrgänge, führt Teilnahmenachweise.",
MITGLIED: "Sieht die eigenen Daten, Termine und Qualifikationen.",
KIOSK:
"Terminal im Gerätehaus. Darf nur anmelden, quittieren und erfassen — nie verwalten.",
};
// -----------------------------------------------------------------------------
// Rechte
// -----------------------------------------------------------------------------
/**
* Alle Einzelrechte. Schema: <bereich>.<taetigkeit>
* Neue Rechte hier ergänzen, dann `npm run db:seed` — der Seed gleicht den
* Katalog in der Datenbank ab.
*/
export const RECHTE = {
// Organisation und System
"organisation.lesen": { bereich: "Organisation", bezeichnung: "Wehrdaten ansehen" },
"organisation.bearbeiten": { bereich: "Organisation", bezeichnung: "Wehrdaten bearbeiten" },
"organisation.verwalten": { bereich: "Organisation", bezeichnung: "Wehren anlegen und löschen" },
"system.einstellungen": { bereich: "System", bezeichnung: "Systemeinstellungen ändern" },
"system.auditlog": { bereich: "System", bezeichnung: "Audit-Log einsehen" },
// Personen
"person.lesen": { bereich: "Personen", bezeichnung: "Mitgliederliste ansehen" },
"person.eigene": { bereich: "Personen", bezeichnung: "Eigene Daten ansehen und pflegen" },
"person.bearbeiten": { bereich: "Personen", bezeichnung: "Mitglieder anlegen und bearbeiten" },
"person.loeschen": { bereich: "Personen", bezeichnung: "Mitglieder austragen" },
"person.rollen": { bereich: "Personen", bezeichnung: "Rollen zuweisen" },
"person.ausweis": { bereich: "Personen", bezeichnung: "PIN und RFID-Ausweise verwalten" },
"person.export": { bereich: "Personen", bezeichnung: "Personendaten exportieren (DSGVO)" },
// Fahrzeuge und Geräte
"fahrzeug.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge ansehen" },
"fahrzeug.bearbeiten": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge anlegen und bearbeiten" },
"geraet.lesen": { bereich: "Geräte", bezeichnung: "Geräte ansehen" },
"geraet.bearbeiten": { bereich: "Geräte", bezeichnung: "Geräte anlegen und bearbeiten" },
"geraet.aussondern": { bereich: "Geräte", bezeichnung: "Geräte aussondern oder sperren" },
// Prüfungen und Wartung (Phase 2)
"pruefung.lesen": { bereich: "Prüfungen", bezeichnung: "Prüfungen und Fristen ansehen" },
"pruefung.durchfuehren": { bereich: "Prüfungen", bezeichnung: "Prüfungen durchführen und dokumentieren" },
"pruefung.planen": { bereich: "Prüfungen", bezeichnung: "Prüftermine und Vorlagen planen" },
// Atemschutz nach FwDV 7 (Phase 5)
"atemschutz.lesen": { bereich: "Atemschutz", bezeichnung: "Atemschutzdaten ansehen" },
"atemschutz.bearbeiten": { bereich: "Atemschutz", bezeichnung: "Atemschutzgeräte und Träger verwalten" },
"atemschutz.ueberwachung": { bereich: "Atemschutz", bezeichnung: "Atemschutzüberwachung führen" },
// Einsätze und Übungen (Phase 3)
"einsatz.lesen": { bereich: "Einsätze", bezeichnung: "Einsätze ansehen" },
"einsatz.bearbeiten": { bereich: "Einsätze", bezeichnung: "Einsätze erfassen und bearbeiten" },
"einsatz.bericht": { bereich: "Einsätze", bezeichnung: "Einsatzberichte erstellen" },
// Ausbildung und Qualifikationen (Phase 1 und 3)
"ausbildung.lesen": { bereich: "Ausbildung", bezeichnung: "Übungen und Lehrgänge ansehen" },
"ausbildung.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Übungen planen und Teilnahme erfassen" },
"qualifikation.lesen": { bereich: "Ausbildung", bezeichnung: "Qualifikationen ansehen" },
"qualifikation.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Qualifikationen pflegen" },
// Kasse — die Rolle KASSIER ist in Phase 0 gefordert, ein Kassenmodul ist
// in Phase-0.md bis Phase-8.md aber nicht vorgesehen. Die Rechte stehen hier
// bereits bereit, damit die Rolle nicht leer bleibt. Siehe docs/ROADMAP.md.
"kasse.lesen": { bereich: "Kasse", bezeichnung: "Buchungen und Auswertungen ansehen" },
"kasse.bearbeiten": { bereich: "Kasse", bezeichnung: "Buchungen erfassen" },
"kasse.abschluss": { bereich: "Kasse", bezeichnung: "Kassenabschluss durchführen" },
// Dateien
"datei.lesen": { bereich: "Dateien", bezeichnung: "Dateien herunterladen" },
"datei.hochladen": { bereich: "Dateien", bezeichnung: "Dateien hochladen" },
"datei.loeschen": { bereich: "Dateien", bezeichnung: "Dateien löschen" },
// Terminal im Gerätehaus (Phase 4)
"kiosk.anmelden": { bereich: "Terminal", bezeichnung: "Am Terminal anmelden" },
"kiosk.erfassen": { bereich: "Terminal", bezeichnung: "Am Terminal Erfassungen vornehmen" },
} as const;
export type Recht = keyof typeof RECHTE;
export const ALLE_RECHTE = Object.keys(RECHTE) as Recht[];
// -----------------------------------------------------------------------------
// Rolle zu Recht
// -----------------------------------------------------------------------------
/** Rechte, die jede angemeldete Person hat. */
const GRUNDRECHTE: Recht[] = [
"person.eigene",
"organisation.lesen",
"person.lesen",
"fahrzeug.lesen",
"geraet.lesen",
"pruefung.lesen",
"einsatz.lesen",
"ausbildung.lesen",
"qualifikation.lesen",
"datei.lesen",
];
/**
* Vorgabe je Rolle. SUPERADMIN steht bewusst nicht drin — der bekommt in
* hatRecht() pauschal alles, damit ein neu ergänztes Recht nicht versehentlich
* am Administrator vorbeigeht.
*/
export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
KOMMANDANT: [
...GRUNDRECHTE,
"organisation.bearbeiten",
"system.auditlog",
"person.bearbeiten",
"person.loeschen",
"person.rollen",
"person.ausweis",
"person.export",
"fahrzeug.bearbeiten",
"geraet.bearbeiten",
"geraet.aussondern",
"pruefung.planen",
"pruefung.durchfuehren",
"atemschutz.lesen",
"atemschutz.bearbeiten",
"atemschutz.ueberwachung",
"einsatz.bearbeiten",
"einsatz.bericht",
"ausbildung.bearbeiten",
"qualifikation.bearbeiten",
"kasse.lesen",
"datei.hochladen",
"datei.loeschen",
],
GERAETEWART: [
...GRUNDRECHTE,
"fahrzeug.bearbeiten",
"geraet.bearbeiten",
"geraet.aussondern",
"pruefung.planen",
"pruefung.durchfuehren",
"datei.hochladen",
],
ATEMSCHUTZGERAETEWART: [
...GRUNDRECHTE,
"geraet.bearbeiten",
"pruefung.planen",
"pruefung.durchfuehren",
"atemschutz.lesen",
"atemschutz.bearbeiten",
"atemschutz.ueberwachung",
"datei.hochladen",
],
KASSIER: [...GRUNDRECHTE, "kasse.lesen", "kasse.bearbeiten", "kasse.abschluss", "datei.hochladen"],
SCHRIFTFUEHRER: [
...GRUNDRECHTE,
"person.bearbeiten",
"einsatz.bearbeiten",
"einsatz.bericht",
"ausbildung.bearbeiten",
"datei.hochladen",
],
AUSBILDER: [
...GRUNDRECHTE,
"ausbildung.bearbeiten",
"qualifikation.bearbeiten",
"datei.hochladen",
],
MITGLIED: [...GRUNDRECHTE],
// Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich
// zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person.
KIOSK: ["kiosk.anmelden", "kiosk.erfassen", "organisation.lesen"],
};
// -----------------------------------------------------------------------------
// Sitzung und Prüffunktionen
// -----------------------------------------------------------------------------
/** Was die Anwendung über den gerade Angemeldeten weiß. */
export interface Sitzung {
personId: string;
organisationId: string;
name: string;
rollen: Rolle[];
/** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */
anmeldeArt: "LOKAL" | "OIDC" | "KIOSK_PIN" | "KIOSK_RFID" | "SYSTEM";
}
/** Fehler, den die tRPC-Schicht in ein FORBIDDEN übersetzt. */
export class KeinZugriffFehler extends Error {
constructor(
public readonly benoetigtesRecht: Recht | Recht[],
nachricht?: string,
) {
const rechte = Array.isArray(benoetigtesRecht)
? benoetigtesRecht.join(", ")
: benoetigtesRecht;
super(nachricht ?? `Fehlende Berechtigung: ${rechte}`);
this.name = "KeinZugriffFehler";
}
}
/** Alle Rechte, die sich aus den Rollen einer Sitzung ergeben. */
export function rechteVon(rollen: readonly Rolle[]): Set<Recht> {
if (rollen.includes("SUPERADMIN")) return new Set(ALLE_RECHTE);
const gesammelt = new Set<Recht>();
for (const rolle of rollen) {
if (rolle === "SUPERADMIN") continue;
for (const recht of ROLLEN_RECHTE[rolle] ?? []) gesammelt.add(recht);
}
return gesammelt;
}
/** Hat die Sitzung das Recht? Der Administrator hat immer alles. */
export function hatRecht(sitzung: Sitzung | null, recht: Recht): boolean {
if (!sitzung) return false;
if (sitzung.rollen.includes("SUPERADMIN")) return true;
return rechteVon(sitzung.rollen).has(recht);
}
/** Mindestens eines der Rechte. */
export function hatEinesDerRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean {
return rechte.some((recht) => hatRecht(sitzung, recht));
}
/** Alle genannten Rechte. */
export function hatAlleRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean {
return rechte.every((recht) => hatRecht(sitzung, recht));
}
/** Wirft, wenn das Recht fehlt. Für Server-Actions und tRPC-Prozeduren. */
export function verlangeRecht(sitzung: Sitzung | null, recht: Recht): asserts sitzung is Sitzung {
if (!hatRecht(sitzung, recht)) throw new KeinZugriffFehler(recht);
}
/** Wirft, wenn keines der Rechte vorliegt. */
export function verlangeEinesDerRechte(
sitzung: Sitzung | null,
rechte: Recht[],
): asserts sitzung is Sitzung {
if (!hatEinesDerRechte(sitzung, rechte)) throw new KeinZugriffFehler(rechte);
}
/** Hat die Sitzung eine dieser Rollen? Nur für Anzeigezwecke gedacht. */
export function hatRolle(sitzung: Sitzung | null, ...rollen: Rolle[]): boolean {
if (!sitzung) return false;
return rollen.some((rolle) => sitzung.rollen.includes(rolle));
}
/**
* Darf die Sitzung auf diesen Datensatz zugreifen? Ergänzt die Mandanten-
* trennung der Datenbank um eine Prüfung im Code — doppelt hält besser, und
* die Fehlermeldung ist verständlicher als ein leeres Ergebnis.
*/
export function gehoertZurOrganisation(
sitzung: Sitzung | null,
datensatz: { organisationId: string } | null | undefined,
): boolean {
if (!sitzung || !datensatz) return false;
return sitzung.organisationId === datensatz.organisationId;
}
/**
* Die eigenen Daten darf jeder sehen, auch ohne person.lesen. Andere nur mit
* dem entsprechenden Recht.
*/
export function darfPersonSehen(sitzung: Sitzung | null, personId: string): boolean {
if (!sitzung) return false;
if (sitzung.personId === personId) return true;
return hatRecht(sitzung, "person.lesen");
}
/** Wandelt einen beliebigen Text in eine gültige Rolle um, sonst null. */
export function alsRolle(wert: string | null | undefined): Rolle | null {
if (!wert) return null;
const gross = wert.trim().toUpperCase();
return (ROLLEN as readonly string[]).includes(gross) ? (gross as Rolle) : null;
}
+146
View File
@@ -0,0 +1,146 @@
import { PrismaClient } from "@prisma/client";
import { env } from "@/lib/env";
/**
* Datenbankzugriff mit Mandantenkontext.
*
* Es gibt ZWEI Verbindungen, und der Unterschied ist der Kern der
* Mandantentrennung:
*
* prismaApp — verbindet sich als "florian_app". Für diese Rolle ist Row
* Level Security aktiv. Ohne gesetztes
* app.current_organisation liefert jede Abfrage null Zeilen.
* Das ist die Verbindung, über die die Anwendung arbeitet.
*
* prismaSystem — verbindet sich als Eigentümer (DIRECT_DATABASE_URL) und
* umgeht RLS. Das ist nötig für genau die Vorgänge, die VOR
* der Mandantenauflösung stattfinden: die Anmeldung muss eine
* E-Mail-Adresse über alle Wehren hinweg suchen können, weil
* zu dem Zeitpunkt noch niemand weiß, zu welcher Wehr sie
* gehört. Ebenso Wartungsjobs des Workers.
*
* Zugriff auf Fachdaten läuft ausschließlich über fuerOrganisation(). Wer
* prismaSystem in einem Router benutzt, umgeht die Trennung — deshalb heißt die
* Hilfsfunktion dafür ohneMandantenfilter() und verlangt eine Begründung.
*/
const globalerCache = globalThis as unknown as {
prismaApp?: PrismaClient;
prismaSystem?: PrismaClient;
};
function protokollStufen() {
return env.NODE_ENV === "development"
? ([
{ emit: "stdout", level: "warn" },
{ emit: "stdout", level: "error" },
] as const)
: ([{ emit: "stdout", level: "error" }] as const);
}
function clientAnlegen(url: string): PrismaClient {
return new PrismaClient({
datasources: { db: { url } },
log: [...protokollStufen()],
errorFormat: env.NODE_ENV === "development" ? "pretty" : "minimal",
});
}
/**
* Anwendungsverbindung mit aktiver Mandantentrennung.
* Nicht direkt verwenden — immer über fuerOrganisation().
*/
export const prismaApp: PrismaClient =
globalerCache.prismaApp ?? clientAnlegen(env.DATABASE_URL);
/**
* Eigentümerverbindung ohne Mandantentrennung.
* Nur für Anmeldung, Health-Checks und Wartungsjobs.
*/
export const prismaSystem: PrismaClient =
globalerCache.prismaSystem ??
clientAnlegen(env.DIRECT_DATABASE_URL ?? env.DATABASE_URL);
// In der Entwicklung zwischenlagern, damit Hot Reload keine neue
// Verbindungsschar aufmacht.
if (env.NODE_ENV !== "production") {
globalerCache.prismaApp = prismaApp;
globalerCache.prismaSystem = prismaSystem;
}
/** Der auf eine Wehr eingeschränkte Client, den fuerOrganisation übergibt. */
export type MandantenClient = Omit<
PrismaClient,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
>;
/** Prüft grob, ob es überhaupt ein UUID sein kann, bevor es ins SQL geht. */
const UUID_MUSTER =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Führt fn innerhalb einer Transaktion aus, in der app.current_organisation
* gesetzt ist. SET LOCAL gilt nur bis zum Ende der Transaktion — danach ist der
* Kontext wieder leer, auch wenn die Verbindung in den Pool zurückgeht.
*
* @example
* const personen = await fuerOrganisation(orgId, (db) =>
* db.person.findMany({ where: { aktiv: true } }),
* );
*/
export async function fuerOrganisation<T>(
organisationId: string,
fn: (db: MandantenClient) => Promise<T>,
optionen: { timeoutMs?: number; maxWaitMs?: number } = {},
): Promise<T> {
if (!UUID_MUSTER.test(organisationId)) {
throw new Error(
`Ungültige Organisations-Kennung: ${organisationId}. Erwartet wird ein UUID.`,
);
}
return prismaApp.$transaction(
async (tx) => {
// Parametrisiert, damit kein Wert ungeprüft ins SQL wandert.
// set_config mit is_local=true entspricht SET LOCAL.
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return fn(tx as unknown as MandantenClient);
},
{
maxWait: optionen.maxWaitMs ?? 5_000,
timeout: optionen.timeoutMs ?? 15_000,
},
);
}
/**
* Zugriff ohne Mandantenfilter — ausdrücklich benannt, damit man beim Lesen
* sofort sieht, dass hier die Trennung umgangen wird.
*
* Zulässig nur für: Anmeldung (E-Mail-Suche über alle Wehren), Health-Checks,
* Wartungsjobs des Workers. Jede Verwendung gehört begründet.
*/
export async function ohneMandantenfilter<T>(
grund: string,
fn: (db: PrismaClient) => Promise<T>,
): Promise<T> {
if (env.NODE_ENV === "development") {
console.warn(`[db] Zugriff ohne Mandantenfilter: ${grund}`);
}
return fn(prismaSystem);
}
/** Einfache Erreichbarkeitsprüfung für /api/health. */
export async function datenbankErreichbar(): Promise<boolean> {
try {
await prismaApp.$queryRaw`SELECT 1`;
return true;
} catch {
return false;
}
}
/** Verbindungen schließen — für den Worker-Abbruch und für Tests. */
export async function verbindungenTrennen(): Promise<void> {
await Promise.allSettled([prismaApp.$disconnect(), prismaSystem.$disconnect()]);
}
+173
View File
@@ -0,0 +1,173 @@
import { z } from "zod";
/**
* Zentrale Umgebungsvalidierung.
*
* Fehlt oder taugt eine Variable nicht, bricht der Start hier ab — mit einer
* Meldung, die sagt welche. Das ist deutlich angenehmer als ein Dienst, der
* anläuft und beim ersten Datei-Upload merkt, dass MinIO nie konfiguriert war.
*
* Beim Container-Build ist keine echte Umgebung vorhanden; dort setzt das
* Dockerfile SKIP_ENV_VALIDATION=1.
*/
/**
* Akzeptiert "true"/"1"/"ja" als wahr — Compose liefert alles als Text.
* Der Standardwert wird innerhalb der Umwandlung gesetzt, nicht per .default():
* .default() erwartet den Eingabetyp (string), nicht das Ergebnis (boolean).
*/
const boolAusText = (standard: boolean) =>
z
.string()
.optional()
.transform((wert) => {
if (wert === undefined || wert.trim() === "") return standard;
return ["true", "1", "ja", "yes", "on"].includes(wert.trim().toLowerCase());
});
const zahl = (standard: number) =>
z
.string()
.optional()
.transform((wert) => (wert === undefined || wert === "" ? standard : Number(wert)))
.pipe(z.number().int().positive());
const schema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
TZ: z.string().default("Europe/Berlin"),
APP_URL: z.string().url().default("http://localhost:3000"),
// Datenbank
DATABASE_URL: z.string().url(),
DIRECT_DATABASE_URL: z.string().url().optional(),
// Redis / Warteschlangen
REDIS_URL: z.string().url(),
WORKER_CONCURRENCY: zahl(5),
// Objektspeicher
S3_ENDPOINT: z.string().min(1),
S3_PORT: zahl(9000),
S3_USE_SSL: boolAusText(false),
S3_ACCESS_KEY: z.string().min(1),
S3_SECRET_KEY: z.string().min(1),
S3_REGION: z.string().default("eu-central-1"),
S3_BUCKET: z.string().min(1).default("florian"),
S3_PUBLIC_URL: z.string().url().optional(),
S3_URL_TTL: zahl(900),
// PDF-Erzeugung
GOTENBERG_URL: z.string().url().optional(),
GOTENBERG_TIMEOUT: zahl(30),
// Sitzungen
AUTH_SECRET: z.string().min(32, "AUTH_SECRET muss mindestens 32 Zeichen haben"),
AUTH_TRUST_HOST: boolAusText(true),
AUTH_SESSION_MAX_AGE: zahl(28_800),
// OIDC gegen authentik
AUTH_OIDC_ENABLED: boolAusText(false),
AUTH_OIDC_ISSUER: z.string().optional(),
AUTH_OIDC_CLIENT_ID: z.string().optional(),
AUTH_OIDC_CLIENT_SECRET: z.string().optional(),
AUTH_OIDC_NAME: z.string().default("authentik"),
AUTH_OIDC_GROUP_CLAIM: z.string().default("groups"),
AUTH_OIDC_GROUP_MAPPING: z.string().default(""),
AUTH_OIDC_AUTO_PROVISION: boolAusText(true),
AUTH_OIDC_STANDARD_ORGANISATION: z.string().optional(),
// Lokaler Login
AUTH_LOCAL_ENABLED: boolAusText(true),
AUTH_BCRYPT_ROUNDS: zahl(12),
// Kiosk
KIOSK_ENABLED: boolAusText(true),
KIOSK_DEVICE_SECRET: z.string().min(8),
KIOSK_PIN_LAENGE: zahl(6),
KIOSK_MAX_FEHLVERSUCHE: zahl(5),
KIOSK_SPERRDAUER: zahl(300),
KIOSK_IDLE_TIMEOUT: zahl(120),
// Integrationen (alle optional und abschaltbar)
MAIL_ENABLED: boolAusText(false),
MAIL_HOST: z.string().optional(),
MAIL_PORT: zahl(587),
MAIL_SECURE: boolAusText(false),
MAIL_USER: z.string().optional(),
MAIL_PASSWORD: z.string().optional(),
MAIL_FROM: z.string().optional(),
TELEGRAM_ENABLED: boolAusText(false),
TELEGRAM_BOT_TOKEN: z.string().optional(),
TELEGRAM_CHAT_ID: z.string().optional(),
LLM_ENABLED: boolAusText(false),
LLM_BASE_URL: z.string().optional(),
LLM_API_KEY: z.string().optional(),
LLM_MODELL: z.string().optional(),
// Aufbewahrung
AUFBEWAHRUNG_JAHRE: zahl(10),
AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650),
PERSON_ANONYMISIERUNG_TAGE: zahl(365),
});
export type Umgebung = z.infer<typeof schema>;
/** Ersatzwerte für Build und Tests — nie für den echten Betrieb. */
const platzhalter: Record<string, string> = {
DATABASE_URL: "postgresql://build:build@localhost:5432/build?schema=public",
REDIS_URL: "redis://localhost:6379",
S3_ENDPOINT: "localhost",
S3_ACCESS_KEY: "build",
S3_SECRET_KEY: "build",
AUTH_SECRET: "build_time_platzhalter_mindestens_32_zeichen",
KIOSK_DEVICE_SECRET: "build_time_platzhalter",
};
function lesen(): Umgebung {
const roh =
process.env.SKIP_ENV_VALIDATION === "1" || process.env.NODE_ENV === "test"
? { ...platzhalter, ...process.env }
: process.env;
const ergebnis = schema.safeParse(roh);
if (!ergebnis.success) {
const zeilen = ergebnis.error.issues.map(
(problem) => ` - ${problem.path.join(".")}: ${problem.message}`,
);
throw new Error(
[
"Die Umgebungskonfiguration ist unvollständig oder fehlerhaft:",
...zeilen,
"",
"Vorlage mit Erklärungen zu jeder Variable: .env.example",
].join("\n"),
);
}
return ergebnis.data;
}
export const env: Umgebung = lesen();
/** true, wenn die Anmeldung über authentik vollständig konfiguriert ist. */
export const oidcVerfuegbar =
env.AUTH_OIDC_ENABLED &&
Boolean(env.AUTH_OIDC_ISSUER) &&
Boolean(env.AUTH_OIDC_CLIENT_ID) &&
Boolean(env.AUTH_OIDC_CLIENT_SECRET);
/**
* Zuordnung authentik-Gruppe zu FLORIAN-Rolle aus AUTH_OIDC_GROUP_MAPPING.
* Format: "gruppe:ROLLE,gruppe2:ROLLE2".
*/
export function oidcGruppenZuordnung(): Map<string, string> {
const karte = new Map<string, string>();
for (const eintrag of env.AUTH_OIDC_GROUP_MAPPING.split(",")) {
const [gruppe, rolle] = eintrag.split(":").map((teil) => teil?.trim());
if (gruppe && rolle) karte.set(gruppe.toLowerCase(), rolle.toUpperCase());
}
return karte;
}
+106
View File
@@ -0,0 +1,106 @@
import "server-only";
import { env } from "@/lib/env";
/**
* Sprachmodell-Anbindung hinter einer Schnittstelle.
*
* Vorgesehen für: Einsatzberichte aus Stichpunkten formulieren, Prüfmängel
* zusammenfassen, Protokolle vorstrukturieren. Immer als Vorschlag, nie als
* Entscheidung — und immer mit einem Menschen, der gegenzeichnet.
*
* Es wird ausschließlich gegen einen OpenAI-kompatiblen Endpunkt gesprochen,
* der selbst betrieben werden kann (Ollama, vLLM, llama.cpp). Kein Cloud-Zwang,
* keine Daten, die das Gerätehaus verlassen müssen.
*/
export interface LlmAnfrage {
systemAnweisung: string;
benutzerEingabe: string;
maxToken?: number;
temperatur?: number;
}
export interface LlmAntwort {
erfolgreich: boolean;
text?: string;
fehler?: string;
}
export interface LlmDienst {
vervollstaendigen(anfrage: LlmAnfrage): Promise<LlmAntwort>;
verfuegbar(): boolean;
}
/** Standard, solange LLM_ENABLED=false: liefert eine klare Absage. */
class AbgeschaltetesLlm implements LlmDienst {
verfuegbar(): boolean {
return false;
}
async vervollstaendigen(): Promise<LlmAntwort> {
return {
erfolgreich: false,
fehler: "Die Textunterstützung ist in dieser Installation abgeschaltet (LLM_ENABLED=false).",
};
}
}
class OpenAiKompatiblesLlm implements LlmDienst {
verfuegbar(): boolean {
return Boolean(env.LLM_BASE_URL && env.LLM_MODELL);
}
async vervollstaendigen(anfrage: LlmAnfrage): Promise<LlmAntwort> {
if (!this.verfuegbar()) {
return { erfolgreich: false, fehler: "LLM ist nicht vollständig konfiguriert." };
}
try {
const antwort = await fetch(`${env.LLM_BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(env.LLM_API_KEY ? { Authorization: `Bearer ${env.LLM_API_KEY}` } : {}),
},
body: JSON.stringify({
model: env.LLM_MODELL,
messages: [
{ role: "system", content: anfrage.systemAnweisung },
{ role: "user", content: anfrage.benutzerEingabe },
],
max_tokens: anfrage.maxToken ?? 1024,
temperature: anfrage.temperatur ?? 0.3,
}),
});
if (!antwort.ok) {
return { erfolgreich: false, fehler: `Endpunkt antwortete mit ${antwort.status}` };
}
const daten = (await antwort.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const text = daten.choices?.[0]?.message?.content;
return text
? { erfolgreich: true, text }
: { erfolgreich: false, fehler: "Leere Antwort vom Endpunkt." };
} catch (fehler) {
return { erfolgreich: false, fehler: String(fehler) };
}
}
}
let cache: LlmDienst | null = null;
export function llm(): LlmDienst {
if (!cache) {
cache = env.LLM_ENABLED ? new OpenAiKompatiblesLlm() : new AbgeschaltetesLlm();
}
return cache;
}
/** Nur für Tests. */
export function _llmErsetzen(ersatz: LlmDienst | null): void {
cache = ersatz;
}
+75
View File
@@ -0,0 +1,75 @@
import "server-only";
import { env } from "@/lib/env";
/**
* E-Mail-Versand hinter einer Schnittstelle.
*
* Regel aus CLAUDE.md: externe Dienste hinter Interfaces in lib/integrations/,
* damit sie im Test mockbar und im Betrieb abschaltbar sind. Ist MAIL_ENABLED
* nicht gesetzt, landet die Nachricht im Log statt beim Empfänger — für
* Entwicklung und Testbetrieb genau richtig.
*
* Die tatsächliche SMTP-Anbindung kommt in Phase 7 (Benachrichtigungen). Bis
* dahin steht hier bewusst nur der Vertrag.
*/
export interface Nachricht {
an: string | string[];
betreff: string;
text: string;
html?: string;
antwortAn?: string;
anhaenge?: Array<{ dateiname: string; inhalt: Buffer; mimeTyp: string }>;
}
export interface MailVersand {
senden(nachricht: Nachricht): Promise<{ erfolgreich: boolean; kennung?: string; fehler?: string }>;
verfuegbar(): boolean;
}
/** Schreibt statt zu senden. Standard, solange MAIL_ENABLED=false. */
class ProtokollVersand implements MailVersand {
verfuegbar(): boolean {
return false;
}
async senden(nachricht: Nachricht) {
const empfaenger = Array.isArray(nachricht.an) ? nachricht.an.join(", ") : nachricht.an;
console.info(
`[mail] (nicht versendet, MAIL_ENABLED=false) an=${empfaenger} betreff="${nachricht.betreff}"`,
);
return { erfolgreich: true, kennung: "protokoll" };
}
}
/** Platzhalter für den echten SMTP-Versand aus Phase 7. */
class SmtpVersand implements MailVersand {
verfuegbar(): boolean {
return Boolean(env.MAIL_HOST && env.MAIL_FROM);
}
async senden(nachricht: Nachricht) {
const empfaenger = Array.isArray(nachricht.an) ? nachricht.an.join(", ") : nachricht.an;
console.warn(
`[mail] SMTP-Versand ist erst ab Phase 7 umgesetzt. an=${empfaenger} betreff="${nachricht.betreff}"`,
);
return {
erfolgreich: false,
fehler: "SMTP-Versand noch nicht umgesetzt (Phase 7).",
};
}
}
let versandCache: MailVersand | null = null;
export function mail(): MailVersand {
if (!versandCache) {
versandCache = env.MAIL_ENABLED ? new SmtpVersand() : new ProtokollVersand();
}
return versandCache;
}
/** Nur für Tests: eigene Umsetzung unterschieben. */
export function _mailErsetzen(ersatz: MailVersand | null): void {
versandCache = ersatz;
}
+77
View File
@@ -0,0 +1,77 @@
import "server-only";
import { env } from "@/lib/env";
/**
* Telegram-Benachrichtigungen hinter einer Schnittstelle — abschaltbar und
* mockbar. Gedacht für Erinnerungen an fällige Prüfungen und, ab Phase 4,
* optionale Alarmhinweise.
*
* ACHTUNG: Telegram ist ein externer Dienst. Es dürfen ausschließlich
* Hinweise ohne personenbezogene Daten hinausgehen ("3 Prüfungen fällig"),
* niemals Namen, Adressen oder Einsatzdetails.
*/
export interface TelegramVersand {
senden(text: string, chatId?: string): Promise<{ erfolgreich: boolean; fehler?: string }>;
verfuegbar(): boolean;
}
class ProtokollTelegram implements TelegramVersand {
verfuegbar(): boolean {
return false;
}
async senden(text: string) {
console.info(`[telegram] (nicht versendet, TELEGRAM_ENABLED=false) ${text}`);
return { erfolgreich: true };
}
}
class EchtesTelegram implements TelegramVersand {
verfuegbar(): boolean {
return Boolean(env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID);
}
async senden(text: string, chatId?: string) {
if (!this.verfuegbar()) {
return { erfolgreich: false, fehler: "Telegram ist nicht vollständig konfiguriert." };
}
try {
const antwort = await fetch(
`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId ?? env.TELEGRAM_CHAT_ID,
text,
parse_mode: "HTML",
disable_web_page_preview: true,
}),
},
);
if (!antwort.ok) {
return { erfolgreich: false, fehler: `Telegram antwortete mit ${antwort.status}` };
}
return { erfolgreich: true };
} catch (fehler) {
return { erfolgreich: false, fehler: String(fehler) };
}
}
}
let cache: TelegramVersand | null = null;
export function telegram(): TelegramVersand {
if (!cache) {
cache = env.TELEGRAM_ENABLED ? new EchtesTelegram() : new ProtokollTelegram();
}
return cache;
}
/** Nur für Tests. */
export function _telegramErsetzen(ersatz: TelegramVersand | null): void {
cache = ersatz;
}
+66
View File
@@ -0,0 +1,66 @@
import "server-only";
import bcrypt from "bcryptjs";
import { env } from "@/lib/env";
/**
* Alles, was gehasht wird: Passwörter, Terminal-PINs, RFID-Kennungen.
* Immer bcrypt, immer mit dem Kostenfaktor aus AUTH_BCRYPT_ROUNDS, und
* Vergleiche immer über bcrypt.compare — nie per Zeichenkettenvergleich.
*/
export async function hashen(klartext: string): Promise<string> {
return bcrypt.hash(klartext, env.AUTH_BCRYPT_ROUNDS);
}
export async function pruefen(klartext: string, hash: string | null | undefined): Promise<boolean> {
if (!hash) {
// Trotzdem einen Vergleich durchführen, damit ein fehlender Hash zeitlich
// nicht von einem falschen Passwort zu unterscheiden ist.
await bcrypt.compare(klartext, "$2a$12$ungueltigerhashfuerzeitgleichheit000000000000000000000");
return false;
}
return bcrypt.compare(klartext, hash);
}
/**
* Mindestanforderungen an ein Passwort. Bewusst schlicht gehalten: Länge
* schlägt Sonderzeichen, und eine Wehr soll sich nicht an Regeln abarbeiten.
*/
export function passwortPruefen(passwort: string): { gueltig: boolean; meldung?: string } {
if (passwort.length < 10) {
return { gueltig: false, meldung: "Das Passwort muss mindestens 10 Zeichen lang sein." };
}
if (passwort.length > 200) {
return { gueltig: false, meldung: "Das Passwort darf höchstens 200 Zeichen lang sein." };
}
if (/^\d+$/.test(passwort)) {
return { gueltig: false, meldung: "Das Passwort darf nicht nur aus Ziffern bestehen." };
}
return { gueltig: true };
}
/** Prüft eine Terminal-PIN gegen die konfigurierte Länge. */
export function pinPruefen(pin: string): { gueltig: boolean; meldung?: string } {
const laenge = env.KIOSK_PIN_LAENGE;
if (!new RegExp(`^\\d{${laenge}}$`).test(pin)) {
return { gueltig: false, meldung: `Die PIN muss aus genau ${laenge} Ziffern bestehen.` };
}
if (/^(\d)\1+$/.test(pin)) {
return { gueltig: false, meldung: "Die PIN darf nicht aus lauter gleichen Ziffern bestehen." };
}
const ziffern = pin.split("").map(Number);
const aufsteigend = ziffern.every((z, i) => i === 0 || z === (ziffern[i - 1] ?? 0) + 1);
const absteigend = ziffern.every((z, i) => i === 0 || z === (ziffern[i - 1] ?? 0) - 1);
if (aufsteigend || absteigend) {
return { gueltig: false, meldung: "Die PIN darf keine fortlaufende Ziffernfolge sein." };
}
return { gueltig: true };
}
/**
* Normalisiert eine RFID-/NFC-Kennung, bevor sie gehasht wird. Lesegeräte
* liefern die UID mal mit Doppelpunkten, mal klein-, mal großgeschrieben.
*/
export function rfidNormalisieren(uid: string): string {
return uid.replace(/[\s:-]/g, "").toUpperCase();
}
+209
View File
@@ -0,0 +1,209 @@
import "server-only";
import { createHash, randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { Client as MinioClient } from "minio";
import { env } from "@/lib/env";
/**
* Der einzige Weg zu Dateien.
*
* Regel aus CLAUDE.md: Dateizugriff ausschließlich über lib/storage.ts. Kein
* fs.readFile, kein Pfad im Dateisystem, keine Blobs in der Datenbank. Alles
* liegt in MinIO — dadurch überlebt eine Datei den Neuaufbau des Containers,
* lässt sich versionieren und getrennt sichern.
*
* Objektschlüssel folgen immer demselben Muster:
* <organisation-slug>/<entitaet>/<entitaetId>/<uuid>-<dateiname>
* Der Slug vorn macht Sicherungen und Löschungen je Wehr einfach.
*/
let clientCache: MinioClient | null = null;
function client(): MinioClient {
if (!clientCache) {
clientCache = new MinioClient({
endPoint: env.S3_ENDPOINT,
port: env.S3_PORT,
useSSL: env.S3_USE_SSL,
accessKey: env.S3_ACCESS_KEY,
secretKey: env.S3_SECRET_KEY,
region: env.S3_REGION,
});
}
return clientCache;
}
export const BUCKET = env.S3_BUCKET;
/** Zeichen, die in Objektschlüsseln Ärger machen, werden ersetzt. */
function dateinameBereinigen(name: string): string {
return name
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/ä/gi, "ae")
.replace(/ö/gi, "oe")
.replace(/ü/gi, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_{2,}/g, "_")
.slice(0, 180);
}
export interface SchluesselTeile {
organisationSlug: string;
/** Modellname, z. B. "Geraet". Wird kleingeschrieben. */
entitaet: string;
entitaetId?: string;
dateiname: string;
}
/** Baut einen eindeutigen Objektschlüssel nach dem oben beschriebenen Muster. */
export function objektSchluessel({
organisationSlug,
entitaet,
entitaetId,
dateiname,
}: SchluesselTeile): string {
const teile = [
organisationSlug,
entitaet.toLowerCase(),
entitaetId ?? "allgemein",
`${randomUUID()}-${dateinameBereinigen(dateiname)}`,
];
return teile.join("/");
}
export interface HochladenErgebnis {
bucket: string;
objektSchluessel: string;
groesseBytes: number;
/** SHA-256 des Inhalts — erkennt Dubletten, belegt Unverändertheit. */
pruefsumme: string;
etag: string;
}
/**
* Legt einen Puffer in MinIO ab und gibt zurück, was in die Datei-Tabelle
* gehört. Der Aufrufer schreibt den Datensatz und den Audit-Eintrag.
*/
export async function hochladen(
schluessel: string,
inhalt: Buffer,
mimeTyp: string,
metadaten: Record<string, string> = {},
): Promise<HochladenErgebnis> {
await bucketSicherstellen();
const pruefsumme = createHash("sha256").update(inhalt).digest("hex");
const ergebnis = await client().putObject(BUCKET, schluessel, inhalt, inhalt.length, {
"Content-Type": mimeTyp,
// MinIO nimmt eigene Angaben nur mit diesem Präfix an.
"x-amz-meta-pruefsumme": pruefsumme,
...Object.fromEntries(
Object.entries(metadaten).map(([schlssel, wert]) => [`x-amz-meta-${schlssel}`, wert]),
),
});
return {
bucket: BUCKET,
objektSchluessel: schluessel,
groesseBytes: inhalt.length,
pruefsumme,
etag: ergebnis.etag,
};
}
/** Lädt ein Objekt vollständig in den Speicher. Für große Dateien streamen. */
export async function herunterladen(schluessel: string): Promise<Buffer> {
const strom = await client().getObject(BUCKET, schluessel);
const stuecke: Buffer[] = [];
for await (const stueck of strom) {
stuecke.push(Buffer.isBuffer(stueck) ? stueck : Buffer.from(stueck));
}
return Buffer.concat(stuecke);
}
/** Objekt als Strom — für Downloads, die durch die Anwendung durchgereicht werden. */
export async function alsStrom(schluessel: string): Promise<Readable> {
return client().getObject(BUCKET, schluessel);
}
/**
* Zeitlich begrenzter Download-Link. Damit muss die Datei nicht durch die
* Anwendung laufen — der Browser holt sie direkt bei MinIO ab.
* Der Link gilt S3_URL_TTL Sekunden und ist danach wertlos.
*/
export async function downloadLink(
schluessel: string,
dateiname?: string,
gueltigkeitSekunden = env.S3_URL_TTL,
): Promise<string> {
const kopfzeilen: Record<string, string> = {};
if (dateiname) {
kopfzeilen["response-content-disposition"] =
`attachment; filename="${dateinameBereinigen(dateiname)}"`;
}
return client().presignedGetObject(BUCKET, schluessel, gueltigkeitSekunden, kopfzeilen);
}
/**
* Zeitlich begrenzter Upload-Link. Der Browser lädt direkt zu MinIO hoch,
* ohne dass große Dateien durch die Anwendung müssen.
*/
export async function uploadLink(
schluessel: string,
gueltigkeitSekunden = env.S3_URL_TTL,
): Promise<string> {
await bucketSicherstellen();
return client().presignedPutObject(BUCKET, schluessel, gueltigkeitSekunden);
}
/** Löscht ein Objekt endgültig. Nur für den Aufbewahrungs-Job des Workers. */
export async function endgueltigLoeschen(schluessel: string): Promise<void> {
await client().removeObject(BUCKET, schluessel);
}
/** Gibt es das Objekt und wie groß ist es? */
export async function objektInfo(schluessel: string) {
try {
const status = await client().statObject(BUCKET, schluessel);
return {
vorhanden: true as const,
groesseBytes: status.size,
geaendertAm: status.lastModified,
mimeTyp: status.metaData?.["content-type"] ?? "application/octet-stream",
etag: status.etag,
};
} catch {
return { vorhanden: false as const };
}
}
/** Kopiert ein Objekt, z. B. beim Duplizieren einer Prüfvorlage. */
export async function kopieren(vonSchluessel: string, nachSchluessel: string): Promise<void> {
await client().copyObject(BUCKET, nachSchluessel, `/${BUCKET}/${vonSchluessel}`);
}
/** Legt den Bucket an, falls er fehlt. Idempotent, deshalb überall aufrufbar. */
export async function bucketSicherstellen(): Promise<void> {
const vorhanden = await client().bucketExists(BUCKET).catch(() => false);
if (!vorhanden) {
await client().makeBucket(BUCKET, env.S3_REGION);
}
}
/** Erreichbarkeitsprüfung für /api/health. */
export async function speicherErreichbar(): Promise<boolean> {
try {
await client().bucketExists(BUCKET);
return true;
} catch {
return false;
}
}
/** Nur für Tests: setzt den zwischengespeicherten Client zurück. */
export function _clientZuruecksetzen(): void {
clientCache = null;
}
+104
View File
@@ -0,0 +1,104 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { formatDistanceToNow } from "date-fns";
import { de } from "date-fns/locale";
import { formatInTimeZone } from "date-fns-tz";
/** Tailwind-Klassen zusammenführen, spätere gewinnen. */
export function cn(...eingaben: ClassValue[]) {
return twMerge(clsx(eingaben));
}
/**
* Alle Anzeigen in Europe/Berlin. Gespeichert wird in UTC — die Umrechnung
* passiert ausschließlich hier, damit sie nirgends vergessen wird.
*/
export const ZEITZONE = "Europe/Berlin";
function alsDate(wert: Date | string | number): Date {
return wert instanceof Date ? wert : new Date(wert);
}
/** 02.09.2026 */
export function alsDatum(wert: Date | string | number | null | undefined): string {
if (!wert) return "—";
return formatInTimeZone(alsDate(wert), ZEITZONE, "dd.MM.yyyy", { locale: de });
}
/** 02.09.2026, 14:35 */
export function alsDatumZeit(wert: Date | string | number | null | undefined): string {
if (!wert) return "—";
return formatInTimeZone(alsDate(wert), ZEITZONE, "dd.MM.yyyy, HH:mm", { locale: de });
}
/** 14:35 */
export function alsUhrzeit(wert: Date | string | number | null | undefined): string {
if (!wert) return "—";
return formatInTimeZone(alsDate(wert), ZEITZONE, "HH:mm", { locale: de });
}
/** "vor 3 Tagen" */
export function alsRelativeZeit(wert: Date | string | number | null | undefined): string {
if (!wert) return "—";
return formatDistanceToNow(alsDate(wert), { addSuffix: true, locale: de });
}
/** Nachname, Vorname — so stehen Mitglieder in jeder Liste. */
export function personName(
person: { vorname: string; nachname: string } | null | undefined,
): string {
if (!person) return "—";
return `${person.nachname}, ${person.vorname}`;
}
/** Vorname Nachname — für Anreden und Überschriften. */
export function personAnrede(
person: { vorname: string; nachname: string } | null | undefined,
): string {
if (!person) return "—";
return `${person.vorname} ${person.nachname}`;
}
/** Initialen für den Platzhalter im Profilbild. */
export function initialen(
person: { vorname: string; nachname: string } | null | undefined,
): string {
if (!person) return "??";
return `${person.vorname.charAt(0)}${person.nachname.charAt(0)}`.toUpperCase();
}
/** 1,4 MB — deutsche Schreibweise mit Komma. */
export function alsDateigroesse(bytes: number | bigint | null | undefined): string {
if (bytes === null || bytes === undefined) return "—";
const zahl = typeof bytes === "bigint" ? Number(bytes) : bytes;
if (zahl < 1024) return `${zahl} B`;
const einheiten = ["kB", "MB", "GB", "TB"];
let wert = zahl / 1024;
let index = 0;
while (wert >= 1024 && index < einheiten.length - 1) {
wert /= 1024;
index += 1;
}
return `${wert.toLocaleString("de-DE", { maximumFractionDigits: 1 })} ${einheiten[index]}`;
}
/** 1.234,50 € */
export function alsEuro(betrag: number | null | undefined): string {
if (betrag === null || betrag === undefined) return "—";
return betrag.toLocaleString("de-DE", { style: "currency", currency: "EUR" });
}
/** Erzeugt aus einem Namen einen URL-tauglichen Slug. */
export function alsSlug(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
}
+109
View File
@@ -0,0 +1,109 @@
import "server-only";
import { Queue, type JobsOptions } from "bullmq";
import IORedis from "ioredis";
import { env } from "@/lib/env";
/**
* Warteschlangen für Hintergrundarbeit.
*
* Was hier hineingehört: alles, was länger dauert als eine Anfrage dauern darf
* oder nach Plan laufen soll — Fristenberechnung, Erinnerungen, PDF-Erzeugung,
* Exporte, Aufräumjobs. Der Worker in src/worker/index.ts arbeitet sie ab.
*
* Die Anwendung schreibt nur hinein, der Worker liest. So kann man den Worker
* neu starten, ohne dass die Oberfläche stehen bleibt.
*/
export const WARTESCHLANGE = "florian";
/** Bekannte Auftragsarten. Ab Phase 2 kommen die fachlichen Jobs dazu. */
export const AUFTRAG = {
/** Prüft alle Geräte auf fällige Fristen und setzt den Status. */
FRISTEN_PRUEFEN: "fristen.pruefen",
/** Verschickt Erinnerungen an fällige Prüfungen. */
ERINNERUNG_SENDEN: "erinnerung.senden",
/** Erzeugt ein PDF über Gotenberg. */
PDF_ERZEUGEN: "pdf.erzeugen",
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
} as const;
export type AuftragsArt = (typeof AUFTRAG)[keyof typeof AUFTRAG];
/** Jeder Auftrag trägt die Wehr mit, für die er läuft. */
export interface AuftragsDaten {
organisationId: string;
[feld: string]: unknown;
}
let verbindungCache: IORedis | null = null;
let warteschlangeCache: Queue | null = null;
export function redis(): IORedis {
if (!verbindungCache) {
verbindungCache = new IORedis(env.REDIS_URL, {
// BullMQ verlangt null, sonst bricht es Aufträge bei Netzhängern ab.
maxRetriesPerRequest: null,
enableReadyCheck: false,
});
verbindungCache.on("error", (fehler) => {
console.error("[redis] Verbindungsfehler:", fehler.message);
});
}
return verbindungCache;
}
export function warteschlange(): Queue {
if (!warteschlangeCache) {
warteschlangeCache = new Queue(WARTESCHLANGE, {
connection: redis(),
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 5_000 },
removeOnComplete: { age: 3600, count: 500 },
removeOnFail: { age: 7 * 24 * 3600 },
},
});
}
return warteschlangeCache;
}
/** Stellt einen Auftrag ein. */
export async function beauftrage(
art: AuftragsArt,
daten: AuftragsDaten,
optionen: JobsOptions = {},
): Promise<string | undefined> {
const auftrag = await warteschlange().add(art, daten, optionen);
return auftrag.id;
}
/** Wiederkehrender Auftrag nach Zeitplan (cron-Syntax, Europe/Berlin). */
export async function beauftrageWiederkehrend(
art: AuftragsArt,
daten: AuftragsDaten,
zeitplan: string,
): Promise<void> {
await warteschlange().add(art, daten, {
repeat: { pattern: zeitplan, tz: "Europe/Berlin" },
jobId: `${art}:${daten.organisationId}`,
});
}
/** Erreichbarkeitsprüfung für /api/health. */
export async function warteschlangeErreichbar(): Promise<boolean> {
try {
const antwort = await redis().ping();
return antwort === "PONG";
} catch {
return false;
}
}
/** Verbindungen sauber schließen — für Tests und den Worker-Abbruch. */
export async function verbindungenSchliessen(): Promise<void> {
await warteschlangeCache?.close();
verbindungCache?.disconnect();
warteschlangeCache = null;
verbindungCache = null;
}
+32
View File
@@ -0,0 +1,32 @@
import { aktuelleSitzung } from "@/lib/auth";
import type { Sitzung } from "@/lib/authz";
/**
* Kontext jeder tRPC-Anfrage. Enthält bewusst nur, was für JEDE Prozedur
* sinnvoll ist — die Sitzung und die Angaben zur Anfrage fürs Audit.
* Der mandantengebundene Datenbankzugriff kommt in der angemeldet-Middleware
* dazu, damit er ohne Sitzung gar nicht erst existiert.
*/
export interface Kontext {
sitzung: Sitzung | null;
ipAdresse: string | null;
userAgent: string | null;
}
/** Ermittelt die IP hinter Traefik aus den Weiterleitungs-Kopfzeilen. */
function ipErmitteln(kopfzeilen: Headers): string | null {
const weitergeleitet = kopfzeilen.get("x-forwarded-for");
if (weitergeleitet) {
const erste = weitergeleitet.split(",")[0]?.trim();
if (erste) return erste;
}
return kopfzeilen.get("x-real-ip");
}
export async function kontextErzeugen({ req }: { req: Request }): Promise<Kontext> {
return {
sitzung: await aktuelleSitzung(),
ipAdresse: ipErmitteln(req.headers),
userAgent: req.headers.get("user-agent"),
};
}
+106
View File
@@ -0,0 +1,106 @@
import { z } from "zod";
import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc";
import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz";
import { datenbankErreichbar } from "@/lib/db";
import { speicherErreichbar } from "@/lib/storage";
/**
* Wurzel-Router. Ab Phase 1 kommen hier die Fachbereiche als Unter-Router
* dazu (stammdaten, wartung, atemschutz, einsatz, ausbildung, kasse).
* Phase 0 liefert nur, was zum Prüfen des Fundaments nötig ist.
*/
const systemRouter = router({
/** Erreichbarkeit der Randsysteme. Ohne Anmeldung abrufbar. */
status: oeffentlich.query(async () => {
const [datenbank, speicher] = await Promise.all([
datenbankErreichbar(),
speicherErreichbar(),
]);
return {
datenbank,
speicher,
zeitpunkt: new Date(),
zeitzone: "Europe/Berlin",
};
}),
});
const sitzungRouter = router({
/** Wer bin ich, welcher Wehr gehöre ich an, was darf ich? */
ich: angemeldet.query(async ({ ctx }) => {
const person = await ctx.db((db) =>
db.person.findUnique({
where: { id: ctx.sitzung.personId },
select: {
id: true,
vorname: true,
nachname: true,
email: true,
dienstgrad: true,
mitgliedsnummer: true,
letzteAnmeldung: true,
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
},
}),
);
return {
person,
rollen: ctx.sitzung.rollen,
rollenBezeichnung: ctx.sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]),
rechte: [...rechteVon(ctx.sitzung.rollen)],
anmeldeArt: ctx.sitzung.anmeldeArt,
};
}),
});
const organisationRouter = router({
/** Stammdaten der eigenen Wehr. */
eigene: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.organisation.findUnique({
where: { id: ctx.sitzung.organisationId },
}),
),
),
/** Kennzahlen für die Startseite. */
kennzahlen: angemeldet.query(async ({ ctx }) =>
ctx.db(async (db) => {
const [mitglieder, fahrzeuge, geraete, pruefungFaellig] = await Promise.all([
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 } }),
]);
return { mitglieder, fahrzeuge, geraete, pruefungFaellig };
}),
),
});
const auditRouter = router({
/** Die letzten Einträge im Protokoll — nur mit dem entsprechenden Recht. */
letzte: mitRecht("system.auditlog")
.input(z.object({ anzahl: z.number().int().min(1).max(200).default(50) }).optional())
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.auditLog.findMany({
orderBy: { zeitpunkt: "desc" },
take: input?.anzahl ?? 50,
include: {
akteur: { select: { id: true, vorname: true, nachname: true } },
},
}),
),
),
});
export const appRouter = router({
system: systemRouter,
sitzung: sitzungRouter,
organisation: organisationRouter,
audit: auditRouter,
});
export type AppRouter = typeof appRouter;
+99
View File
@@ -0,0 +1,99 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import type { Kontext } from "@/server/context";
import { KeinZugriffFehler, hatRecht, type Recht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
/**
* tRPC-Grundgerüst.
*
* Drei Arten von Prozeduren:
* oeffentlich — ohne Anmeldung erreichbar (Health, Anmeldestatus)
* angemeldet — verlangt eine Sitzung; stellt db bereit, der bereits auf
* die Wehr des Angemeldeten eingeschränkt ist
* mitRecht(...) — zusätzlich ein bestimmtes Recht aus lib/authz.ts
*
* Wichtig: In den Prozeduren steht ctx.db für den mandantengebundenen Zugriff.
* Wer prismaSystem direkt benutzt, umgeht die Trennung — das soll auffallen.
*/
const t = initTRPC.context<Kontext>().create({
// superjson erhaelt Date, BigInt und Decimal auf dem Weg zum Browser.
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// Feldfehler aus Zod direkt an die Oberfläche durchreichen.
zodFehler:
error.cause instanceof ZodError ? error.cause.flatten().fieldErrors : null,
},
};
},
});
export const router = t.router;
export const middleware = t.middleware;
export const createCallerFactory = t.createCallerFactory;
/** Ohne Anmeldung erreichbar. */
export const oeffentlich = t.procedure;
/** Übersetzt KeinZugriffFehler in ein sauberes tRPC-FORBIDDEN. */
const fehlerUebersetzen = middleware(async ({ next }) => {
try {
return await next();
} catch (fehler) {
if (fehler instanceof KeinZugriffFehler) {
throw new TRPCError({ code: "FORBIDDEN", message: fehler.message, cause: fehler });
}
throw fehler;
}
});
/** Verlangt eine Sitzung und stellt den mandantengebundenen Zugriff bereit. */
const angemeldetMiddleware = middleware(async ({ ctx, next }) => {
if (!ctx.sitzung) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Bitte zuerst anmelden.",
});
}
const sitzung = ctx.sitzung;
return next({
ctx: {
...ctx,
sitzung,
/**
* Führt eine Abfrage im Mandantenkontext der Sitzung aus.
* @example const personen = await ctx.db((db) => db.person.findMany());
*/
db: <T>(fn: Parameters<typeof fuerOrganisation<T>>[1]) =>
fuerOrganisation(sitzung.organisationId, fn),
},
});
});
export const angemeldet = t.procedure.use(fehlerUebersetzen).use(angemeldetMiddleware);
/**
* Prozedur, die ein bestimmtes Recht verlangt.
* @example export const geraeteAendern = mitRecht("geraet.bearbeiten").mutation(...)
*/
export function mitRecht(recht: Recht) {
return angemeldet.use(
middleware(async ({ ctx, next }) => {
if (!hatRecht(ctx.sitzung, recht)) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Für diesen Vorgang fehlt die Berechtigung "${recht}".`,
});
}
return next({ ctx });
}),
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Rolle, Sitzung } from "@/lib/authz";
/**
* Erweitert die Typen von Auth.js um das, was FLORIAN zusätzlich in der
* Sitzung führt: Person, Wehr, Rollen und die Art der Anmeldung.
*/
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
organisationId: string;
rollen: Rolle[];
anmeldeArt: Sitzung["anmeldeArt"];
};
}
interface User {
organisationId?: string;
rollen?: Rolle[];
anmeldeArt?: Sitzung["anmeldeArt"];
}
}
declare module "next-auth/jwt" {
interface JWT {
personId?: string;
organisationId?: string;
rollen?: Rolle[];
anmeldeArt?: Sitzung["anmeldeArt"];
/** Zeitpunkt, zu dem die Rollen zuletzt aus der Datenbank kamen. */
geladenAm?: number;
}
}
export {};
+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"));