L10b Betrieb & Aufräumen: Audit nach Commit, mergeCustomers atomar, Audit-Aktion read

- Aufräumpunkt h: writeAuditLog puffert innerhalb von inTransaction (AsyncLocalStorage) und
  schreibt nach dem Commit; bei Rollback werden die Einträge verworfen, nur „denied" bleibt.
  Verschachtelte Transaktionen nutzen den äußeren Puffer.
- Aufräumpunkt d: mergeCustomers läuft über inTransaction (sequenziell, geschützter Statuswechsel)
  statt ctx.db.$transaction([...]) und ist damit auch bei RLS_ENFORCED=true atomar und in äußere
  Transaktionen einbettbar.
- Aufräumpunkt e: AuditAction „read" (+ Label im Audit-Viewer de/en); Notdienst-Kunden- und
  Objektsuche protokollieren als „read" statt „export".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:19:19 +02:00
co-authored by Claude Opus 5
parent 85bae832d0
commit 8aedc642ca
6 changed files with 129 additions and 57 deletions
+4 -1
View File
@@ -1,5 +1,6 @@
import type { Session } from "next-auth";
import { tenantTransaction, type TenantDb } from "@/server/db";
import { withDeferredAudit } from "@/server/audit";
/**
* Context passed to every domain service. Created by server actions (from moduleGuard)
@@ -27,9 +28,11 @@ 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.
* Audit entries written inside `fn` are flushed after the commit and dropped on rollback
* (except `denied`), see audit.ts#withDeferredAudit.
*/
export function inTransaction<T>(ctx: ServiceCtx, fn: (ctx: ServiceCtx) => Promise<T>): Promise<T> {
return tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx }));
return withDeferredAudit(() => tenantTransaction(ctx.db, ctx.tenantId, (tx) => fn({ ...ctx, db: tx })));
}
export function can(ctx: ServiceCtx, permission: string): boolean {
+45 -38
View File
@@ -1,5 +1,5 @@
import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { mergeSchema, type MergeInput } from "@/server/services/customers/schemas";
/**
@@ -7,51 +7,58 @@ import { mergeSchema, type MergeInput } from "@/server/services/customers/schema
* Contacts, sites, work orders and documents of the source are moved to the target; the source
* becomes status `merged` with `mergedIntoId`. Both records must belong to the caller's tenant
* (dbForTenant) — ids of another tenant are "not found". Never triggered automatically.
* Runs in `inTransaction` (L10b): atomic with RLS_ENFORCED=true and joinable by callers that
* already hold a transaction (e.g. the emergency review).
*/
export async function mergeCustomers(ctx: ServiceCtx, input: MergeInput) {
assertCan(ctx, "customer:merge");
const { sourceId, targetId } = mergeSchema.parse(input);
const [source, target] = await Promise.all([
ctx.db.customer.findFirst({ where: { id: sourceId, deletedAt: null } }),
ctx.db.customer.findFirst({ where: { id: targetId, deletedAt: null } }),
]);
if (!source) throw new ServiceError("not_found", "source customer not found", { field: "sourceId", reason: "not_found" });
if (!target) throw new ServiceError("not_found", "target customer not found", { field: "targetId", reason: "not_found" });
if (source.status === "merged" || target.status === "merged") {
throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" });
}
return inTransaction(ctx, async (tx) => {
const [source, target] = await Promise.all([
tx.db.customer.findFirst({ where: { id: sourceId, deletedAt: null } }),
tx.db.customer.findFirst({ where: { id: targetId, deletedAt: null } }),
]);
if (!source) throw new ServiceError("not_found", "source customer not found", { field: "sourceId", reason: "not_found" });
if (!target) throw new ServiceError("not_found", "target customer not found", { field: "targetId", reason: "not_found" });
if (source.status === "merged" || target.status === "merged") {
throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" });
}
const [contacts, sites, workOrders, documents, mergedSource] = await ctx.db.$transaction([
ctx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }),
ctx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }),
// sequential on purpose: one interactive transaction, no parallel queries on the tx client
const contacts = await tx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } });
const sites = await tx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } });
// version bump: offline clients must not overwrite the re-parented order with stale data
ctx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId, version: { increment: 1 } } }),
ctx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }),
ctx.db.customer.update({
where: { id: sourceId },
const workOrders = await tx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId, version: { increment: 1 } } });
const documents = await tx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } });
// guarded update: a concurrent merge of the same source loses instead of merging twice
const flipped = await tx.db.customer.updateMany({
where: { id: sourceId, status: { not: "merged" } },
data: { status: "merged", mergedIntoId: targetId, isProvisional: false },
}),
]);
});
if (flipped.count !== 1) throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" });
const mergedSource = await tx.db.customer.findFirstOrThrow({ where: { id: sourceId } });
const moved = { contacts: contacts.count, sites: sites.count, workOrders: workOrders.count, documents: documents.count };
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "update",
entity: "customer",
entityId: sourceId,
before: source,
after: { ...mergedSource, merge: { role: "source", targetId, moved } },
const moved = { contacts: contacts.count, sites: sites.count, workOrders: workOrders.count, documents: documents.count };
// deferred until commit by inTransaction (audit.ts#withDeferredAudit)
await writeAuditLog({
tenantId: tx.tenantId,
actorId: tx.userId,
action: "update",
entity: "customer",
entityId: sourceId,
before: source,
after: { ...mergedSource, merge: { role: "source", targetId, moved } },
});
await writeAuditLog({
tenantId: tx.tenantId,
actorId: tx.userId,
action: "update",
entity: "customer",
entityId: targetId,
before: target,
after: { merge: { role: "target", sourceId, moved } },
});
return { sourceId, targetId, moved };
});
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "update",
entity: "customer",
entityId: targetId,
before: target,
after: { merge: { role: "target", sourceId, moved } },
});
return { sourceId, targetId, moved };
}
+2 -2
View File
@@ -35,7 +35,7 @@ export async function searchCustomersForEmergency(ctx: ServiceCtx, rawQuery: str
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "export",
action: "read",
entity: "emergency_customer_search",
after: { query: q, resultCount: rows.length, customerIds: rows.map((r) => r.id) },
});
@@ -64,7 +64,7 @@ export async function listSitesForEmergency(ctx: ServiceCtx, customerId: string)
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "export",
action: "read",
entity: "emergency_site_lookup",
entityId: customerId,
after: { resultCount: sites.length, siteIds: sites.map((s) => s.id) },