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( organisationId: string, fn: (db: MandantenClient) => Promise, optionen: { timeoutMs?: number; maxWaitMs?: number } = {}, ): Promise { 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( grund: string, fn: (db: PrismaClient) => Promise, ): Promise { 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 { 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 { await Promise.allSettled([prismaApp.$disconnect(), prismaSystem.$disconnect()]); }