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