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>
147 lines
5.0 KiB
TypeScript
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()]);
|
|
}
|