Fundament: atomare Mandanten-Transaktionen, iframe-Vorschau, Uploads bis 25 MB, DSGVO-Felder

- db.ts: tenantTransaction() – atomar auch bei RLS_ENFORCED=true (AsyncLocalStorage
  bindet Operationen an eine craftvia_app-Transaktion, Kontext einmal gesetzt,
  verschachtelte Aufrufe treten bei, fremder Mandant wird abgewiesen)
- services/context.ts: inTransaction(ctx, fn); imports/confirm.ts umgestellt
- next.config.ts: EMBEDDABLE_FILE_ROUTES mit frame-ancestors 'self'/SAMEORIGIN
  (PDF-Vorschau Prüfmaske), proxyClientMaxBodySize 26mb (Import bis 25 MB)
- test-rls-enforcement: RLS-URL-Default aus DATABASE_URL (Lane-DBs)
- dsgvo/pii-fields: 26 Personenreferenzen des Craftvia-Domänenmodells
- ARCHITEKTUR §4.8: Transaktions-, Header-, Upload-, Versions- und PII-Regeln
- Test test-tenant-transaction (Commit/Rollback/Fremdmandant/Verschachtelung),
  grün im Owner- und im RLS-Modus

Gate: tsc, lint, build, 31/31 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:29:57 +02:00
co-authored by Claude Opus 5
parent 3e16689b2f
commit 4a25f2cc3b
8 changed files with 243 additions and 13 deletions
+10 -1
View File
@@ -1,5 +1,5 @@
import type { Session } from "next-auth";
import type { TenantDb } from "@/server/db";
import { tenantTransaction, type TenantDb } from "@/server/db";
/**
* Context passed to every domain service. Created by server actions (from moduleGuard)
@@ -23,6 +23,15 @@ export function ctxFromGuard(g: { session: Session; db: TenantDb; permissions: R
};
}
/**
* Run a multi-step write atomically (ARCHITEKTUR §4.8). `fn` receives a ctx whose `db`
* is bound to the transaction; nested calls join the outer transaction.
* Never use `ctx.db.$transaction(...)` directly — it is not atomic with RLS_ENFORCED=true.
*/
export function inTransaction<T>(ctx: ServiceCtx, fn: (ctx: ServiceCtx) => Promise<T>): Promise<T> {
return tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx }));
}
export function can(ctx: ServiceCtx, permission: string): boolean {
return ctx.permissions.has(permission);
}
+3 -5
View File
@@ -1,8 +1,7 @@
import type { Prisma } from "@prisma/client";
import type { TenantDb } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { nextNumber } from "@/server/services/numbering";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { readStoredExtraction } from "@/lib/imports/extraction";
import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imports/review";
// TODO(L3→L2): replace with the L2 work order service after merge (same input type).
@@ -65,9 +64,8 @@ export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm:
const corrections = computeCorrections(stored.fields, form);
const now = new Date();
const result = await ctx.db.$transaction(async (tx) => {
const db = tx as unknown as TenantDb;
const txCtx: ServiceCtx = { ...ctx, db };
const result = await inTransaction(ctx, async (txCtx) => {
const db = txCtx.db;
const switched = await db.importJob.updateMany({
where: { id: job.id, status: "review_required" },