Files
FireAdministration/src/lib/db.ts
T
Florian WildeandClaude Opus 5 845942f36c 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>
2026-09-03 00:13:53 +02:00

147 lines
5.0 KiB
TypeScript

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()]);
}