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