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:
@@ -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
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user