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:
@@ -101,6 +101,13 @@ Konfiguration per Env (`AI_EXTRACTION_PROVIDER=anthropic`, `ANTHROPIC_API_KEY`,
|
|||||||
### 4.7 Berichtsinhalt
|
### 4.7 Berichtsinhalt
|
||||||
`src/lib/reports/content.ts#ReportContent` (Zod): Snapshot aller Berichtsdaten (Spec §16.2/§17.2) – wird beim Erstellen/Freigeben aus DB gebaut (`services/reports/build-content.ts`) und ist nach `approved` unveränderlich. Änderungen nach Freigabe = neue Version (`lineageId`, `version+1`, alte → `superseded`).
|
`src/lib/reports/content.ts#ReportContent` (Zod): Snapshot aller Berichtsdaten (Spec §16.2/§17.2) – wird beim Erstellen/Freigeben aus DB gebaut (`services/reports/build-content.ts`) und ist nach `approved` unveränderlich. Änderungen nach Freigabe = neue Version (`lineageId`, `version+1`, alte → `superseded`).
|
||||||
|
|
||||||
|
### 4.8 Transaktionen, Header, Uploads (Fundament-Nachträge)
|
||||||
|
- **Mehrschritt-Schreibvorgänge nur über `inTransaction(ctx, fn)`** (`src/server/services/context.ts`, basiert auf `tenantTransaction` in `db.ts`). Direktes `ctx.db.$transaction(...)` ist bei `RLS_ENFORCED=true` nicht atomar.
|
||||||
|
- Datei-Routen, die im eigenen iframe angezeigt werden dürfen, stehen in `EMBEDDABLE_FILE_ROUTES` (`next.config.ts`, `frame-ancestors 'self'` / `SAMEORIGIN`); alles andere bleibt `DENY`.
|
||||||
|
- Request-Bodies über den Proxy: `experimental.proxyClientMaxBodySize = 26mb` (größter Upload 25 MB).
|
||||||
|
- Berichtsversionen (§4.7): Eine freigegebene Version wird erst `superseded`, wenn die NEUE Version freigegeben wird – so existiert immer ein gültiges freigegebenes PDF.
|
||||||
|
- Neue Personenreferenz-Felder (`…ById`, `userId`) in `src/server/dsgvo/pii-fields.ts` eintragen.
|
||||||
|
|
||||||
## 5. Routen
|
## 5. Routen
|
||||||
|
|
||||||
| Bereich | Route | Modul |
|
| Bereich | Route | Modul |
|
||||||
|
|||||||
+21
-4
@@ -31,6 +31,13 @@ const csp = [
|
|||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
].join("; ");
|
].join("; ");
|
||||||
|
|
||||||
|
// Same-origin embedding for inline document previews (PDF viewer in the import review
|
||||||
|
// mask, document previews). Everything else stays frame-ancestors 'none' / DENY.
|
||||||
|
const cspEmbeddable = csp.replace("frame-ancestors 'none'", "frame-ancestors 'self'");
|
||||||
|
|
||||||
|
// Routes that stream stored files and may be shown in a same-origin <iframe>.
|
||||||
|
const EMBEDDABLE_FILE_ROUTES = ["/files/:path*", "/imports/:id/file"];
|
||||||
|
|
||||||
const securityHeaders = [
|
const securityHeaders = [
|
||||||
{ key: "Content-Security-Policy", value: csp },
|
{ key: "Content-Security-Policy", value: csp },
|
||||||
// HSTS bewusst zusätzlich in der App (neben dem Coolify-/Traefik-Proxy).
|
// HSTS bewusst zusätzlich in der App (neben dem Coolify-/Traefik-Proxy).
|
||||||
@@ -46,17 +53,27 @@ const securityHeaders = [
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Standalone-Output für den Docker-Multi-Stage-Build (siehe Dockerfile)
|
// Standalone-Output für den Docker-Multi-Stage-Build (siehe Dockerfile)
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
experimental: {
|
||||||
|
// Proxy (src/proxy.ts) buffers request bodies; the 10 MB default truncates uploads silently.
|
||||||
|
// Largest allowed upload is 25 MB (PDF import) plus multipart overhead.
|
||||||
|
proxyClientMaxBodySize: "26mb",
|
||||||
|
},
|
||||||
// i18n-Kataloge werden zur Laufzeit per fs geladen (src/i18n/request.ts) — für den
|
// i18n-Kataloge werden zur Laufzeit per fs geladen (src/i18n/request.ts) — für den
|
||||||
// standalone-Output explizit mitkopieren.
|
// standalone-Output explizit mitkopieren.
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
"/*": ["./messages/**/*.json"],
|
"/*": ["./messages/**/*.json"],
|
||||||
},
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
|
// Later entries override same-named headers of earlier matches (Next.js header semantics).
|
||||||
return [
|
return [
|
||||||
{
|
{ source: "/:path*", headers: securityHeaders },
|
||||||
source: "/:path*",
|
...EMBEDDABLE_FILE_ROUTES.map((source) => ({
|
||||||
headers: securityHeaders,
|
source,
|
||||||
},
|
headers: [
|
||||||
|
{ key: "Content-Security-Policy", value: cspEmbeddable },
|
||||||
|
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||||
|
],
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,9 +42,17 @@ async function expectThrow(fn: () => Promise<unknown>, msg: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const RLS_URL =
|
/**
|
||||||
process.env.RLS_DATABASE_URL ??
|
* Default: same host/port/database as DATABASE_URL, but as the restricted role craftvia_app.
|
||||||
"postgresql://craftvia_app:craftvia_app_local@localhost:5432/craftvia?schema=public";
|
* Keeps the test pointed at the database that was actually migrated/seeded (e.g. lane DBs).
|
||||||
|
*/
|
||||||
|
function defaultRlsUrl(): string {
|
||||||
|
const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public");
|
||||||
|
base.username = "craftvia_app";
|
||||||
|
base.password = "craftvia_app_local";
|
||||||
|
return base.toString();
|
||||||
|
}
|
||||||
|
const RLS_URL = process.env.RLS_DATABASE_URL ?? defaultRlsUrl();
|
||||||
|
|
||||||
const SLUG_A = "zz-rls-test-a";
|
const SLUG_A = "zz-rls-test-a";
|
||||||
const SLUG_B = "zz-rls-test-b";
|
const SLUG_B = "zz-rls-test-b";
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tenantTransaction(): atomic multi-step writes for one tenant.
|
||||||
|
* Runs in the mode given by RLS_ENFORCED (owner path by default). The runner executes this
|
||||||
|
* script once as-is; to verify the RLS path run: RLS_ENFORCED=true npx tsx scripts/test-tenant-transaction.ts
|
||||||
|
* (requires craftvia_app LOGIN, see test-rls-enforcement.ts — otherwise that mode is skipped).
|
||||||
|
* (1) commit: two writes inside → both visible afterwards
|
||||||
|
* (2) rollback: two writes, then throw → nothing visible
|
||||||
|
* (3) a write for another tenant inside the transaction → rejected, whole tx rolled back
|
||||||
|
* (4) nested tenantTransaction joins the outer transaction (rollback covers both)
|
||||||
|
*/
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(name: string, cond: boolean, detail?: unknown) {
|
||||||
|
if (cond) console.log(` ✓ ${name}`);
|
||||||
|
else {
|
||||||
|
failures++;
|
||||||
|
console.log(` ✗ ${name}`, detail ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { prisma, dbForTenant, tenantTransaction, RLS_ENFORCED } = await import("../src/server/db");
|
||||||
|
console.log(`Modus: ${RLS_ENFORCED ? "RLS (craftvia_app)" : "Owner"}`);
|
||||||
|
|
||||||
|
const stamp = Date.now();
|
||||||
|
const a = await prisma.tenant.create({ data: { name: "ZZ Tx A", slug: `zz-tx-a-${stamp}` } });
|
||||||
|
const b = await prisma.tenant.create({ data: { name: "ZZ Tx B", slug: `zz-tx-b-${stamp}` } });
|
||||||
|
const dbA = dbForTenant(a.id);
|
||||||
|
|
||||||
|
const countA = () => prisma.numberSequence.count({ where: { tenantId: a.id } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// (1) commit
|
||||||
|
await tenantTransaction(dbA, a.id, async (tx) => {
|
||||||
|
await tx.numberSequence.create({ data: { tenantId: a.id, key: "customer", prefix: "K-" } });
|
||||||
|
await tx.numberSequence.create({ data: { tenantId: a.id, key: "work_order", prefix: "A-" } });
|
||||||
|
});
|
||||||
|
check("(1) commit: both rows persisted", (await countA()) === 2, await countA());
|
||||||
|
|
||||||
|
// (2) rollback
|
||||||
|
try {
|
||||||
|
await tenantTransaction(dbA, a.id, async (tx) => {
|
||||||
|
await tx.numberSequence.create({ data: { tenantId: a.id, key: "report", prefix: "B-" } });
|
||||||
|
await tx.numberSequence.create({ data: { tenantId: a.id, key: "emergency", prefix: "N-" } });
|
||||||
|
throw new Error("boom");
|
||||||
|
});
|
||||||
|
check("(2) rollback: error propagated", false);
|
||||||
|
} catch (err) {
|
||||||
|
check("(2) rollback: error propagated", (err as Error).message === "boom", (err as Error).message);
|
||||||
|
}
|
||||||
|
check("(2) rollback: no rows from failed tx", (await countA()) === 2, await countA());
|
||||||
|
|
||||||
|
// (3) foreign tenant inside the tx
|
||||||
|
try {
|
||||||
|
await tenantTransaction(dbA, a.id, async (tx) => {
|
||||||
|
await tx.numberSequence.create({ data: { tenantId: a.id, key: "report", prefix: "B-" } });
|
||||||
|
const foreign = await prisma.numberSequence.create({ data: { tenantId: b.id, key: "customer", prefix: "K-" } });
|
||||||
|
await tx.numberSequence.update({ where: { id: foreign.id }, data: { prefix: "HACK-" } });
|
||||||
|
});
|
||||||
|
check("(3) foreign update rejected", false);
|
||||||
|
} catch {
|
||||||
|
check("(3) foreign update rejected", true);
|
||||||
|
}
|
||||||
|
const foreignRow = await prisma.numberSequence.findFirst({ where: { tenantId: b.id } });
|
||||||
|
check("(3) foreign row unchanged", foreignRow?.prefix === "K-", foreignRow?.prefix);
|
||||||
|
check("(3) own write of failed tx rolled back", (await countA()) === 2, await countA());
|
||||||
|
|
||||||
|
// (4) nested join
|
||||||
|
try {
|
||||||
|
await tenantTransaction(dbA, a.id, async (tx) => {
|
||||||
|
await tx.numberSequence.create({ data: { tenantId: a.id, key: "report", prefix: "B-" } });
|
||||||
|
await tenantTransaction(tx, a.id, async (inner) => {
|
||||||
|
await inner.numberSequence.create({ data: { tenantId: a.id, key: "emergency", prefix: "N-" } });
|
||||||
|
});
|
||||||
|
throw new Error("outer boom");
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* expected */
|
||||||
|
}
|
||||||
|
check("(4) nested writes rolled back with outer tx", (await countA()) === 2, await countA());
|
||||||
|
} finally {
|
||||||
|
await prisma.numberSequence.deleteMany({ where: { tenantId: { in: [a.id, b.id] } } });
|
||||||
|
await prisma.auditLog.deleteMany({ where: { tenantId: { in: [a.id, b.id] } } });
|
||||||
|
await prisma.tenant.deleteMany({ where: { id: { in: [a.id, b.id] } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failures ? `\n${failures} Fehler` : "\nOK");
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
const msg = String((err as Error)?.message ?? err);
|
||||||
|
if (/craftvia_app|password authentication|permission denied for role/i.test(msg)) {
|
||||||
|
console.log(`⚠ ÜBERSPRUNGEN: RLS-Verbindung nicht möglich (${msg.split("\n")[0]})`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { PrismaPg } from "@prisma/adapter-pg";
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
|
||||||
@@ -329,6 +330,11 @@ async function applyTenantGuard(
|
|||||||
* `app.tenant_id` transaktionslokal gesetzt wird (verworfen bei Commit/Rollback
|
* `app.tenant_id` transaktionslokal gesetzt wird (verworfen bei Commit/Rollback
|
||||||
* → kein Leak über den Pool). Die Guard-Logik ist in beiden Pfaden identisch.
|
* → kein Leak über den Pool). Die Guard-Logik ist in beiden Pfaden identisch.
|
||||||
*/
|
*/
|
||||||
|
type TxClient = Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0];
|
||||||
|
|
||||||
|
/** Active tenant transaction for the current async call chain (RLS path only). */
|
||||||
|
const tenantTx = new AsyncLocalStorage<{ tenantId: string; tx: TxClient }>();
|
||||||
|
|
||||||
export function dbForTenant(tenantId: string) {
|
export function dbForTenant(tenantId: string) {
|
||||||
if (!tenantId) throw new Error("dbForTenant: tenantId is required");
|
if (!tenantId) throw new Error("dbForTenant: tenantId is required");
|
||||||
|
|
||||||
@@ -353,6 +359,26 @@ export function dbForTenant(tenantId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inside tenantTransaction(): run on the shared transaction (atomic, context set once).
|
||||||
|
const active = tenantTx.getStore();
|
||||||
|
if (active) {
|
||||||
|
if (active.tenantId !== tenantId) {
|
||||||
|
throw new Error("Tenant isolation violation: nested transaction for another tenant");
|
||||||
|
}
|
||||||
|
const txDelegate = (active.tx as unknown as Record<
|
||||||
|
string,
|
||||||
|
Record<string, (a: unknown) => Promise<unknown>>
|
||||||
|
>)[model.charAt(0).toLowerCase() + model.slice(1)];
|
||||||
|
return applyTenantGuard(
|
||||||
|
active.tx as unknown as DelegateSource,
|
||||||
|
tenantId,
|
||||||
|
model,
|
||||||
|
operation,
|
||||||
|
args,
|
||||||
|
(a) => txDelegate[operation](a),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// RLS-Pfad: alles in EINER Transaktion des Basisclients, damit
|
// RLS-Pfad: alles in EINER Transaktion des Basisclients, damit
|
||||||
// Kontextsetzung und Ausführung garantiert auf derselben Connection
|
// Kontextsetzung und Ausführung garantiert auf derselben Connection
|
||||||
// liegen. `tx` ist uneextendiert → keine Rekursion in die Extension.
|
// liegen. `tx` ist uneextendiert → keine Rekursion in die Extension.
|
||||||
@@ -381,3 +407,37 @@ export function dbForTenant(tenantId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TenantDb = ReturnType<typeof dbForTenant>;
|
export type TenantDb = ReturnType<typeof dbForTenant>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` atomically for one tenant. ALWAYS use this (or `inTransaction` in services)
|
||||||
|
* instead of `db.$transaction(...)` for multi-step writes — atomic in both modes:
|
||||||
|
* - Owner mode: interactive transaction of the guarded client (the extension applies to tx).
|
||||||
|
* - RLS mode: one craftvia_app transaction with app.tenant_id set once; every operation of the
|
||||||
|
* guarded client inside `fn` (same async call chain) runs on that transaction.
|
||||||
|
* Nested calls join the outer transaction.
|
||||||
|
*/
|
||||||
|
export async function tenantTransaction<T>(
|
||||||
|
db: TenantDb,
|
||||||
|
tenantId: string,
|
||||||
|
fn: (tx: TenantDb) => Promise<T>,
|
||||||
|
options?: { timeout?: number; maxWait?: number },
|
||||||
|
): Promise<T> {
|
||||||
|
if (!RLS_ENFORCED) {
|
||||||
|
if (isTransactionClient(db)) return fn(db);
|
||||||
|
return db.$transaction((tx) => fn(tx as unknown as TenantDb), options) as Promise<T>;
|
||||||
|
}
|
||||||
|
const active = tenantTx.getStore();
|
||||||
|
if (active) {
|
||||||
|
if (active.tenantId !== tenantId) throw new Error("Tenant isolation violation: nested transaction for another tenant");
|
||||||
|
return fn(db);
|
||||||
|
}
|
||||||
|
return appBase!.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
|
||||||
|
return tenantTx.run({ tenantId, tx }, () => fn(db));
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interactive transaction clients have no $transaction method. */
|
||||||
|
function isTransactionClient(db: TenantDb): boolean {
|
||||||
|
return typeof (db as unknown as { $transaction?: unknown }).$transaction !== "function";
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,4 +20,33 @@ export interface PiiReference {
|
|||||||
export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
||||||
{ model: "AuditLog", field: "actorId" },
|
{ model: "AuditLog", field: "actorId" },
|
||||||
{ model: "NotificationPreference", field: "userId" },
|
{ model: "NotificationPreference", field: "userId" },
|
||||||
|
// ── Craftvia domain (0002_craftvia_domain) ──
|
||||||
|
{ model: "Customer", field: "createdById" },
|
||||||
|
{ model: "Team", field: "leaderUserId" },
|
||||||
|
{ model: "TeamMember", field: "userId" },
|
||||||
|
{ model: "WorkOrder", field: "createdById" },
|
||||||
|
{ model: "WorkOrder", field: "teamLeadUserId" },
|
||||||
|
{ model: "WorkOrderAssignee", field: "userId" },
|
||||||
|
{ model: "WorkOrderStatusChange", field: "actorId" },
|
||||||
|
{ model: "ChecklistItem", field: "checkedById" },
|
||||||
|
{ model: "MaterialUsage", field: "recordedById" },
|
||||||
|
{ model: "WorkSession", field: "userId" },
|
||||||
|
{ model: "TimeEntry", field: "userId" },
|
||||||
|
{ model: "TimeEntry", field: "correctedById" },
|
||||||
|
{ model: "ActivityNote", field: "authorId" },
|
||||||
|
{ model: "Document", field: "uploadedById" },
|
||||||
|
{ model: "Photo", field: "takenById" },
|
||||||
|
{ model: "VoiceNote", field: "recordedById" },
|
||||||
|
{ model: "Report", field: "createdById" },
|
||||||
|
{ model: "Report", field: "teamApprovedById" },
|
||||||
|
{ model: "Report", field: "approvedById" },
|
||||||
|
{ model: "Signature", field: "capturedById" },
|
||||||
|
{ model: "ImportJob", field: "importedById" },
|
||||||
|
{ model: "ImportJob", field: "confirmedById" },
|
||||||
|
{ model: "Notification", field: "userId" },
|
||||||
|
{ model: "SyncOperation", field: "userId" },
|
||||||
|
{ model: "SyncOperation", field: "resolvedById" },
|
||||||
|
{ model: "AiGeneration", field: "createdById" },
|
||||||
|
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
|
||||||
|
// tenant business data under data processing — not part of the employee subject export.
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Session } from "next-auth";
|
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)
|
* 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 {
|
export function can(ctx: ServiceCtx, permission: string): boolean {
|
||||||
return ctx.permissions.has(permission);
|
return ctx.permissions.has(permission);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import type { TenantDb } from "@/server/db";
|
|
||||||
import { writeAuditLog } from "@/server/audit";
|
import { writeAuditLog } from "@/server/audit";
|
||||||
import { nextNumber } from "@/server/services/numbering";
|
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 { readStoredExtraction } from "@/lib/imports/extraction";
|
||||||
import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imports/review";
|
import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imports/review";
|
||||||
// TODO(L3→L2): replace with the L2 work order service after merge (same input type).
|
// 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 corrections = computeCorrections(stored.fields, form);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const result = await ctx.db.$transaction(async (tx) => {
|
const result = await inTransaction(ctx, async (txCtx) => {
|
||||||
const db = tx as unknown as TenantDb;
|
const db = txCtx.db;
|
||||||
const txCtx: ServiceCtx = { ...ctx, db };
|
|
||||||
|
|
||||||
const switched = await db.importJob.updateMany({
|
const switched = await db.importJob.updateMany({
|
||||||
where: { id: job.id, status: "review_required" },
|
where: { id: job.id, status: "review_required" },
|
||||||
|
|||||||
Reference in New Issue
Block a user