diff --git a/packages/api/src/__tests__/assistant-tools-admin-crud-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-admin-crud-test-helpers.ts index 402d615..4e599d7 100644 --- a/packages/api/src/__tests__/assistant-tools-admin-crud-test-helpers.ts +++ b/packages/api/src/__tests__/assistant-tools-admin-crud-test-helpers.ts @@ -19,7 +19,7 @@ export function createToolContext( }, ): ToolContext { const userRole = options?.userRole ?? SystemRole.ADMIN; - const mergedDb = { + const mergedDb: Record = { ...defaultDbDefaults, ...db, blueprint: { @@ -27,6 +27,9 @@ export function createToolContext( ...(db.blueprint as Record | undefined), }, }; + if (!mergedDb["$transaction"]) { + mergedDb["$transaction"] = vi.fn(async (fn: (tx: unknown) => unknown) => fn(mergedDb)); + } return { db: mergedDb as ToolContext["db"], userId: "user_1", diff --git a/packages/api/src/__tests__/assistant-tools-estimate-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-estimate-test-helpers.ts index e3e3b68..02d0bba 100644 --- a/packages/api/src/__tests__/assistant-tools-estimate-test-helpers.ts +++ b/packages/api/src/__tests__/assistant-tools-estimate-test-helpers.ts @@ -21,8 +21,10 @@ export function createToolContext( }, ): ToolContext { const userRole = options?.userRole ?? SystemRole.ADMIN; + const mergedDb: Record = { ...db }; + mergedDb["$transaction"] = vi.fn(async (fn: (tx: unknown) => unknown) => fn(mergedDb)); return { - db: db as ToolContext["db"], + db: mergedDb as ToolContext["db"], userId: "user_1", userRole, permissions: new Set(options?.permissions ?? []), diff --git a/packages/api/src/__tests__/assistant-tools-master-data-mutation-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-master-data-mutation-test-helpers.ts index dceafe0..58d7a1f 100644 --- a/packages/api/src/__tests__/assistant-tools-master-data-mutation-test-helpers.ts +++ b/packages/api/src/__tests__/assistant-tools-master-data-mutation-test-helpers.ts @@ -1,4 +1,5 @@ import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { vi } from "vitest"; import type { ToolContext } from "../router/assistant-tools.js"; @@ -10,8 +11,12 @@ export function createToolContext( }, ): ToolContext { const userRole = options?.userRole ?? SystemRole.ADMIN; + const mergedDb: Record = { ...db }; + if (!mergedDb["$transaction"]) { + mergedDb["$transaction"] = vi.fn(async (fn: (tx: unknown) => unknown) => fn(mergedDb)); + } return { - db: db as ToolContext["db"], + db: mergedDb as ToolContext["db"], userId: "user_1", userRole, permissions: new Set(options?.permissions ?? []), diff --git a/packages/api/src/__tests__/effort-rule-procedure-support.test.ts b/packages/api/src/__tests__/effort-rule-procedure-support.test.ts index 126c893..70394d6 100644 --- a/packages/api/src/__tests__/effort-rule-procedure-support.test.ts +++ b/packages/api/src/__tests__/effort-rule-procedure-support.test.ts @@ -161,7 +161,7 @@ describe("effort rule procedure support", () => { const createMany = vi.fn().mockResolvedValue({ count: 1 }); const auditCreate = vi.fn().mockResolvedValue({}); - const result = await applyEffortRules(createManagerContext({ + const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue({ id: "est_1", @@ -195,7 +195,10 @@ describe("effort rule procedure support", () => { auditLog: { create: auditCreate, }, - }), { + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), + }; + + const result = await applyEffortRules(createManagerContext(db), { estimateId: "est_1", ruleSetId: "ers_1", mode: "replace", diff --git a/packages/api/src/__tests__/effort-rule-router.test.ts b/packages/api/src/__tests__/effort-rule-router.test.ts index 35d730f..1b88a15 100644 --- a/packages/api/src/__tests__/effort-rule-router.test.ts +++ b/packages/api/src/__tests__/effort-rule-router.test.ts @@ -416,7 +416,7 @@ describe("effortRule.applyRules", () => { it("replaces existing demand lines in replace mode", async () => { const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]); const ruleSet = sampleRuleSet(); - const db = { + const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, estimateDemandLine: { @@ -424,6 +424,7 @@ describe("effortRule.applyRules", () => { createMany: vi.fn().mockResolvedValue({ count: 1 }), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -444,7 +445,7 @@ describe("effortRule.applyRules", () => { it("does not delete existing lines in append mode", async () => { const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]); const ruleSet = sampleRuleSet(); - const db = { + const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, estimateDemandLine: { @@ -452,6 +453,7 @@ describe("effortRule.applyRules", () => { createMany: vi.fn().mockResolvedValue({ count: 1 }), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -513,7 +515,7 @@ describe("effortRule.applyRules", () => { it("creates demand lines with correct metadata shape", async () => { const estimate = makeEstimate("WORKING"); const ruleSet = sampleRuleSet(); - const db = { + const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, estimateDemandLine: { @@ -521,6 +523,7 @@ describe("effortRule.applyRules", () => { createMany: vi.fn().mockResolvedValue({ count: 1 }), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); diff --git a/packages/api/src/__tests__/estimate-router.test.ts b/packages/api/src/__tests__/estimate-router.test.ts index bbae498..eab696d 100644 --- a/packages/api/src/__tests__/estimate-router.test.ts +++ b/packages/api/src/__tests__/estimate-router.test.ts @@ -434,9 +434,10 @@ describe("estimate router", () => { const estimateCreate = vi.fn().mockResolvedValue(created); const auditLogCreate = vi.fn().mockResolvedValue({}); - const db = { + const db: Record = { estimate: { create: estimateCreate }, auditLog: { create: auditLogCreate }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), }; const caller = createManagerCaller(db); @@ -474,10 +475,11 @@ describe("estimate router", () => { }); const auditLogCreate = vi.fn().mockResolvedValue({}); - const db = { + const db: Record = { estimate: { create: estimateCreate }, project: { findUnique: projectFindUnique }, auditLog: { create: auditLogCreate }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), }; const caller = createManagerCaller(db); @@ -699,7 +701,7 @@ describe("estimate router", () => { const updateEstimate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({}); - const db = { + const db: Record = { estimate: { findUnique, update: updateEstimate, @@ -709,13 +711,8 @@ describe("estimate router", () => { update: updateVersion, }, auditLog: { create: auditLogCreate }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => - callback({ - estimateVersion: { updateMany, update: updateVersion }, - estimate: { update: updateEstimate }, - }), - ), }; + db["$transaction"] = vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)); const caller = createManagerCaller(db); const result = await caller.submitVersion({ estimateId: "est_1" }); @@ -803,7 +800,7 @@ describe("estimate router", () => { const updateEstimate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({}); - const db = { + const db: Record = { estimate: { findUnique, update: updateEstimate, @@ -813,13 +810,8 @@ describe("estimate router", () => { update: updateVersion, }, auditLog: { create: auditLogCreate }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => - callback({ - estimateVersion: { updateMany, update: updateVersion }, - estimate: { update: updateEstimate }, - }), - ), }; + db["$transaction"] = vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)); const caller = createManagerCaller(db); const result = await caller.approveVersion({ estimateId: "est_1" }); @@ -903,7 +895,7 @@ describe("estimate router", () => { const updateEstimate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({}); - const db = { + const db: Record = { estimate: { findUnique, update: updateEstimate, @@ -915,18 +907,8 @@ describe("estimate router", () => { resourceCostSnapshot: { createMany: createSnapshots }, estimateMetric: { createMany: createMetrics }, auditLog: { create: auditLogCreate }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => - callback({ - estimateVersion: { create: createVersion }, - estimateAssumption: { createMany: createAssumptions }, - scopeItem: { create: createScopeItem }, - estimateDemandLine: { createMany: createDemandLines }, - resourceCostSnapshot: { createMany: createSnapshots }, - estimateMetric: { createMany: createMetrics }, - estimate: { update: updateEstimate }, - }), - ), }; + db["$transaction"] = vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)); const caller = createManagerCaller(db); const result = await caller.createRevision({ estimateId: "est_1" }); @@ -1008,12 +990,13 @@ describe("estimate router", () => { const estimateCreate = vi.fn().mockResolvedValue(cloned); const auditLogCreate = vi.fn().mockResolvedValue({}); - const db = { + const db: Record = { estimate: { findUnique: estimateFindUnique, create: estimateCreate, }, auditLog: { create: auditLogCreate }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), }; const caller = createManagerCaller(db); @@ -1026,9 +1009,10 @@ describe("estimate router", () => { it("throws NOT_FOUND when source estimate does not exist", async () => { const findUnique = vi.fn().mockResolvedValue(null); - const db = { + const db: Record = { estimate: { findUnique }, auditLog: { create: vi.fn() }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), }; const caller = createManagerCaller(db); @@ -1130,10 +1114,11 @@ describe("estimate router", () => { const updateVersion = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({}); - const db = { + const db: Record = { estimate: { findUnique: estimateFindUnique }, estimateVersion: { update: updateVersion }, auditLog: { create: auditLogCreate }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), }; const caller = createManagerCaller(db); @@ -1279,10 +1264,11 @@ describe("estimate router", () => { const createExport = vi.fn().mockResolvedValue({ id: "exp_1" }); const auditLogCreate = vi.fn().mockResolvedValue({}); - const db = { + const db: Record = { estimate: { findUnique: estimateFindUnique }, estimateExport: { create: createExport }, auditLog: { create: auditLogCreate }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), }; const caller = createManagerCaller(db); @@ -1300,10 +1286,11 @@ describe("estimate router", () => { it("throws NOT_FOUND when estimate does not exist", async () => { const findUnique = vi.fn().mockResolvedValue(null); - const db = { + const db: Record = { estimate: { findUnique }, estimateExport: { create: vi.fn() }, auditLog: { create: vi.fn() }, + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), }; const caller = createManagerCaller(db); diff --git a/packages/api/src/__tests__/experience-multiplier-procedure-support.test.ts b/packages/api/src/__tests__/experience-multiplier-procedure-support.test.ts index 5c2e781..8e458c5 100644 --- a/packages/api/src/__tests__/experience-multiplier-procedure-support.test.ts +++ b/packages/api/src/__tests__/experience-multiplier-procedure-support.test.ts @@ -165,7 +165,7 @@ describe("experience multiplier procedure support", () => { const update = vi.fn().mockResolvedValue({}); const auditCreate = vi.fn().mockResolvedValue({}); - const result = await applyExperienceMultiplierRules(createManagerContext({ + const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue({ id: "est_1", @@ -199,7 +199,10 @@ describe("experience multiplier procedure support", () => { auditLog: { create: auditCreate, }, - }), { + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), + }; + + const result = await applyExperienceMultiplierRules(createManagerContext(db), { estimateId: "est_1", multiplierSetId: "ems_1", }); diff --git a/packages/api/src/__tests__/experience-multiplier-router.test.ts b/packages/api/src/__tests__/experience-multiplier-router.test.ts index e362114..63298d1 100644 --- a/packages/api/src/__tests__/experience-multiplier-router.test.ts +++ b/packages/api/src/__tests__/experience-multiplier-router.test.ts @@ -462,13 +462,14 @@ describe("experienceMultiplier.applyRules", () => { it("updates demand lines with adjusted rates and creates audit log", async () => { const estimate = makeEstimate("WORKING"); const multiplierSet = sampleMultiplierSet(); - const db = { + const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, estimateDemandLine: { update: vi.fn().mockResolvedValue({}), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -528,13 +529,14 @@ describe("experienceMultiplier.applyRules", () => { const estimate = makeEstimate("WORKING"); const multiplierSet = sampleMultiplierSet(); - const db = { + const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, estimateDemandLine: { update: vi.fn(), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -544,7 +546,7 @@ describe("experienceMultiplier.applyRules", () => { }); expect(result.linesUpdated).toBe(0); - expect(db.estimateDemandLine.update).not.toHaveBeenCalled(); + expect((db.estimateDemandLine as Record>).update).not.toHaveBeenCalled(); }); it("rejects applying to a non-WORKING version", async () => { @@ -613,13 +615,14 @@ describe("experienceMultiplier.applyRules", () => { }); const estimate = makeEstimate("WORKING", [lineWithMetadata]); const multiplierSet = sampleMultiplierSet(); - const db = { + const db: Record = { estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, estimateDemandLine: { update: vi.fn().mockResolvedValue({}), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); diff --git a/packages/api/src/__tests__/import-export-procedure-support.test.ts b/packages/api/src/__tests__/import-export-procedure-support.test.ts index 36a3dd5..8373476 100644 --- a/packages/api/src/__tests__/import-export-procedure-support.test.ts +++ b/packages/api/src/__tests__/import-export-procedure-support.test.ts @@ -133,20 +133,19 @@ describe("import-export procedure support", () => { .mockResolvedValueOnce(null); const resourceUpdate = vi.fn().mockResolvedValue({ id: "res_1" }); const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const db: Record = { + resource: { + findFirst: resourceFindFirst, + update: resourceUpdate, + }, + auditLog: { + create: auditCreate, + }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), + }; const result = await importCsv( - createContext( - { - resource: { - findFirst: resourceFindFirst, - update: resourceUpdate, - }, - auditLog: { - create: auditCreate, - }, - }, - [PermissionKey.IMPORT_DATA], - ), + createContext(db, [PermissionKey.IMPORT_DATA]), { entityType: "resources", rows: [ diff --git a/packages/api/src/__tests__/import-export-router.test.ts b/packages/api/src/__tests__/import-export-router.test.ts index 9c05209..e9c5805 100644 --- a/packages/api/src/__tests__/import-export-router.test.ts +++ b/packages/api/src/__tests__/import-export-router.test.ts @@ -73,17 +73,19 @@ describe("import-export router", () => { }); const resourceUpdate = vi.fn().mockResolvedValue({ id: "res_1" }); const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + const importDb: Record = { + resource: { + findFirst: resourceFindFirst, + update: resourceUpdate, + }, + auditLog: { + create: auditCreate, + }, + }; + importDb["$transaction"] = vi.fn(async (fn: (tx: unknown) => unknown) => fn(importDb)); const caller = createProtectedCaller( - { - resource: { - findFirst: resourceFindFirst, - update: resourceUpdate, - }, - auditLog: { - create: auditCreate, - }, - }, + importDb, { role: SystemRole.MANAGER, granted: [PermissionKey.IMPORT_DATA], diff --git a/packages/api/src/__tests__/project-router.test.ts b/packages/api/src/__tests__/project-router.test.ts index 96cd285..af12a5f 100644 --- a/packages/api/src/__tests__/project-router.test.ts +++ b/packages/api/src/__tests__/project-router.test.ts @@ -221,13 +221,14 @@ describe("project router", () => { describe("create", () => { it("creates a project and returns its id", async () => { const created = { ...sampleProject, id: "project_new" }; - const db = { + const db: Record = { project: { findUnique: vi.fn().mockResolvedValue(null), // no shortCode conflict create: vi.fn().mockResolvedValue(created), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, webhook: { findMany: vi.fn().mockResolvedValue([]) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -253,13 +254,14 @@ describe("project router", () => { vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable")); const created = { ...sampleProject, id: "project_safe_create" }; - const db = { + const db: Record = { project: { findUnique: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue(created), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, webhook: { findMany: vi.fn().mockResolvedValue([]) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -451,12 +453,13 @@ describe("project router", () => { describe("update", () => { it("updates project fields", async () => { const updated = { ...sampleProject, name: "Updated Name" }; - const db = { + const db: Record = { project: { findUnique: vi.fn().mockResolvedValue(sampleProject), update: vi.fn().mockResolvedValue(updated), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -558,14 +561,12 @@ describe("project router", () => { describe("batchUpdateStatus", () => { it("updates multiple projects and returns count", async () => { - const db = { + const db: Record = { project: { update: vi.fn().mockResolvedValue(sampleProject), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, - $transaction: vi.fn((calls: unknown[]) => - Promise.all((calls as Promise[]).map(() => sampleProject)), - ), + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -575,7 +576,7 @@ describe("project router", () => { }); expect(result.count).toBe(3); - expect(db.auditLog.create).toHaveBeenCalled(); + expect((db.auditLog as Record>).create).toHaveBeenCalled(); }); }); diff --git a/packages/api/src/__tests__/resource-router-crud.test.ts b/packages/api/src/__tests__/resource-router-crud.test.ts index 8c92409..163b132 100644 --- a/packages/api/src/__tests__/resource-router-crud.test.ts +++ b/packages/api/src/__tests__/resource-router-crud.test.ts @@ -328,12 +328,13 @@ describe("resource router CRUD", () => { describe("create", () => { it("creates a resource and returns it", async () => { const created = { ...sampleResource, id: "res_new", resourceRoles: [] }; - const db = { + const db: Record = { resource: { findFirst: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue(created), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -399,13 +400,14 @@ describe("resource router CRUD", () => { describe("update", () => { it("updates resource fields", async () => { const updated = { ...sampleResource, displayName: "Alice Updated" }; - const db = { + const db: Record = { resource: { findUnique: vi.fn().mockResolvedValue(sampleResource), update: vi.fn().mockResolvedValue(updated), }, resourceRole: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -443,11 +445,12 @@ describe("resource router CRUD", () => { describe("deactivate", () => { it("sets isActive to false", async () => { const deactivated = { ...sampleResource, isActive: false }; - const db = { + const db: Record = { resource: { update: vi.fn().mockResolvedValue(deactivated), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -469,12 +472,13 @@ describe("resource router CRUD", () => { .fn() .mockResolvedValueOnce({ ...sampleResource, id: "res_1", isActive: false }) .mockResolvedValueOnce({ ...sampleResource, id: "res_2", isActive: false }); - const db = { + const auditCreate = vi.fn().mockResolvedValue({}); + const db: Record = { resource: { update, }, - $transaction: vi.fn(async (operations: Promise[]) => Promise.all(operations)), - auditLog: { create: vi.fn().mockResolvedValue({}) }, + auditLog: { create: auditCreate }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const caller = createManagerCaller(db); @@ -482,7 +486,7 @@ describe("resource router CRUD", () => { expect(result).toEqual({ count: 2 }); expect(db.$transaction).toHaveBeenCalledTimes(1); - expect(db.auditLog.create).toHaveBeenCalledWith( + expect(auditCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ entityType: "Resource", diff --git a/packages/api/src/__tests__/role-procedure-support.test.ts b/packages/api/src/__tests__/role-procedure-support.test.ts index c1929ce..168b08b 100644 --- a/packages/api/src/__tests__/role-procedure-support.test.ts +++ b/packages/api/src/__tests__/role-procedure-support.test.ts @@ -222,7 +222,7 @@ describe("role procedure support", () => { color: "#111111", _count: { resourceRoles: 2 }, }; - const db = { + const db: Record = { role: { findUnique: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue(role), @@ -230,6 +230,7 @@ describe("role procedure support", () => { auditLog: { create: vi.fn().mockResolvedValue(undefined), }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const result = await createRole(createContext(db), { @@ -300,7 +301,7 @@ describe("role procedure support", () => { isActive: true, _count: { resourceRoles: 1 }, }; - const db = { + const db: Record = { role: { findUnique: vi.fn() .mockResolvedValueOnce(existing) @@ -312,6 +313,7 @@ describe("role procedure support", () => { auditLog: { create: vi.fn().mockResolvedValue(undefined), }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const result = await updateRole(createContext(db), UpdateRoleProcedureInputSchema.parse({ @@ -371,7 +373,7 @@ describe("role procedure support", () => { countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map([["role_fx", 4]]), }); - const db = { + const db: Record = { role: { update: vi.fn().mockResolvedValue({ id: "role_fx", @@ -385,6 +387,7 @@ describe("role procedure support", () => { auditLog: { create: vi.fn().mockResolvedValue(undefined), }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), }; const result = await deactivateRole(createContext(db), RoleIdInputSchema.parse({ id: "role_fx" })); diff --git a/packages/api/src/__tests__/role-router-auth.test.ts b/packages/api/src/__tests__/role-router-auth.test.ts index d6cc115..17ac2af 100644 --- a/packages/api/src/__tests__/role-router-auth.test.ts +++ b/packages/api/src/__tests__/role-router-auth.test.ts @@ -143,15 +143,14 @@ describe("role router authorization", () => { // planningEntry count queries (attachZeroAllocationCount path) const planningEntryFindMany = vi.fn().mockResolvedValue([]); + const db: Record = { + role: { create: roleCreate, findUnique: roleFindUnique }, + auditLog: { create: auditLogCreate }, + planningEntry: { findMany: planningEntryFindMany }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)), + }; const caller = createCaller( - createContext( - { - role: { create: roleCreate, findUnique: roleFindUnique }, - auditLog: { create: auditLogCreate }, - planningEntry: { findMany: planningEntryFindMany }, - }, - { role: SystemRole.MANAGER }, - ), + createContext(db, { role: SystemRole.MANAGER }), ); // Should not throw UNAUTHORIZED or FORBIDDEN @@ -180,19 +179,18 @@ describe("role router authorization", () => { const demandRequirementFindMany = vi.fn().mockResolvedValue([]); const assignmentFindMany = vi.fn().mockResolvedValue([]); + const adminDb: Record = { + role: { + findUnique: roleFindUnique, + delete: roleDelete, + }, + auditLog: { create: auditLogCreate }, + demandRequirement: { findMany: demandRequirementFindMany }, + assignment: { findMany: assignmentFindMany }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(adminDb)), + }; const caller = createCaller( - createContext( - { - role: { - findUnique: roleFindUnique, - delete: roleDelete, - }, - auditLog: { create: auditLogCreate }, - demandRequirement: { findMany: demandRequirementFindMany }, - assignment: { findMany: assignmentFindMany }, - }, - { role: SystemRole.ADMIN }, - ), + createContext(adminDb, { role: SystemRole.ADMIN }), ); const result = await caller.delete({ id: "role_1" }); diff --git a/packages/api/src/router/allocation/assignment-mutations.ts b/packages/api/src/router/allocation/assignment-mutations.ts index c92bbca..78a429f 100644 --- a/packages/api/src/router/allocation/assignment-mutations.ts +++ b/packages/api/src/router/allocation/assignment-mutations.ts @@ -302,16 +302,16 @@ export async function batchUpdateAllocationStatusWithAudit( ).allocation), ); - return updatedAllocations; - }); + await tx.auditLog.create({ + data: { + entityType: "Allocation", + entityId: input.ids.join(","), + action: "UPDATE", + changes: { after: { status: input.status, ids: input.ids } }, + }, + }); - await db.auditLog.create({ - data: { - entityType: "Allocation", - entityId: input.ids.join(","), - action: "UPDATE", - changes: { after: { status: input.status, ids: input.ids } }, - }, + return updatedAllocations; }); return updated; diff --git a/packages/api/src/router/effort-rule-procedure-support.ts b/packages/api/src/router/effort-rule-procedure-support.ts index 3baebef..bfabf35 100644 --- a/packages/api/src/router/effort-rule-procedure-support.ts +++ b/packages/api/src/router/effort-rule-procedure-support.ts @@ -238,41 +238,43 @@ export async function applyEffortRules( const rules = toEffortRuleEngineInputs(ruleSet.rules); const result = expandScopeToEffort(scopeItems, rules); - if (input.mode === "replace") { - await ctx.db.estimateDemandLine.deleteMany({ - where: { estimateVersionId: version.id }, - }); - } + await ctx.db.$transaction(async (tx) => { + if (input.mode === "replace") { + await tx.estimateDemandLine.deleteMany({ + where: { estimateVersionId: version.id }, + }); + } - if (result.lines.length > 0) { - await ctx.db.estimateDemandLine.createMany({ - data: buildEstimateDemandLineRows({ - estimateVersionId: version.id, - currency: estimate.baseCurrency, - ruleSet: { id: ruleSet.id, name: ruleSet.name }, - lines: result.lines, - }), - }); - } + if (result.lines.length > 0) { + await tx.estimateDemandLine.createMany({ + data: buildEstimateDemandLineRows({ + estimateVersionId: version.id, + currency: estimate.baseCurrency, + ruleSet: { id: ruleSet.id, name: ruleSet.name }, + lines: result.lines, + }), + }); + } - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "UPDATE", - ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), - changes: { - after: { - effortRulesApplied: { - ruleSetId: ruleSet.id, - ruleSetName: ruleSet.name, - mode: input.mode, - linesGenerated: result.lines.length, - warnings: result.warnings, + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: estimate.id, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + changes: { + after: { + effortRulesApplied: { + ruleSetId: ruleSet.id, + ruleSetName: ruleSet.name, + mode: input.mode, + linesGenerated: result.lines.length, + warnings: result.warnings, + }, }, }, }, - }, + }); }); return { diff --git a/packages/api/src/router/estimate-commercial.ts b/packages/api/src/router/estimate-commercial.ts index db88951..c241b63 100644 --- a/packages/api/src/router/estimate-commercial.ts +++ b/packages/api/src/router/estimate-commercial.ts @@ -65,22 +65,24 @@ export const estimateCommercialProcedures = { const validated = CommercialTermsSchema.parse(input.terms); - await ctx.db.estimateVersion.update({ - where: { id: version.id }, - data: { commercialTerms: validated as unknown as Prisma.InputJsonValue }, - }); + await ctx.db.$transaction(async (tx) => { + await tx.estimateVersion.update({ + where: { id: version.id }, + data: { commercialTerms: validated as unknown as Prisma.InputJsonValue }, + }); - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "UPDATE", - ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), - changes: { - field: "commercialTerms", - after: validated, - } as Prisma.InputJsonValue, - }, + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: estimate.id, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + changes: { + field: "commercialTerms", + after: validated, + } as Prisma.InputJsonValue, + }, + }); }); return { versionId: version.id, terms: validated }; diff --git a/packages/api/src/router/estimate-procedure-support.ts b/packages/api/src/router/estimate-procedure-support.ts index 885a5eb..3146888 100644 --- a/packages/api/src/router/estimate-procedure-support.ts +++ b/packages/api/src/router/estimate-procedure-support.ts @@ -100,28 +100,32 @@ export async function createEstimateRecord( await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId); const enrichedInput = { ...input, demandLines: enrichedLines }; - const estimate = await createEstimate( - ctx.db as unknown as Parameters[0], - withComputedMetrics(enrichedInput, input.baseCurrency), - ); + const estimate = await ctx.db.$transaction(async (tx) => { + const created = await createEstimate( + tx as unknown as Parameters[0], + withComputedMetrics(enrichedInput, input.baseCurrency), + ); - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "CREATE", - ...withAuditUser(ctx.dbUser?.id), - changes: { - after: { - id: estimate.id, - name: estimate.name, - status: estimate.status, - projectId: estimate.projectId, - latestVersionNumber: estimate.latestVersionNumber, - autoFilledRateCardLines: autoFilledIndices.length, - }, - } as Prisma.InputJsonValue, - }, + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: created.id, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + changes: { + after: { + id: created.id, + name: created.name, + status: created.status, + projectId: created.projectId, + latestVersionNumber: created.latestVersionNumber, + autoFilledRateCardLines: autoFilledIndices.length, + }, + } as Prisma.InputJsonValue, + }, + }); + + return created; }); return estimate; @@ -133,10 +137,30 @@ export async function cloneEstimateRecord( ) { let estimate; try { - estimate = await cloneEstimate( - ctx.db as unknown as Parameters[0], - input, - ); + estimate = await ctx.db.$transaction(async (tx) => { + const cloned = await cloneEstimate( + tx as unknown as Parameters[0], + input, + ); + + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: cloned.id, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + changes: { + after: { + id: cloned.id, + name: cloned.name, + clonedFrom: input.sourceEstimateId, + }, + } as Prisma.InputJsonValue, + }, + }); + + return cloned; + }); } catch (error) { rethrowEstimateRouterError(error, [ { @@ -146,22 +170,6 @@ export async function cloneEstimateRecord( ]); } - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "CREATE", - ...withAuditUser(ctx.dbUser?.id), - changes: { - after: { - id: estimate.id, - name: estimate.name, - clonedFrom: input.sourceEstimateId, - }, - } as Prisma.InputJsonValue, - }, - }); - return estimate; } @@ -194,10 +202,35 @@ export async function updateEstimateDraftRecord( let estimate; try { - estimate = await updateEstimateDraft( - ctx.db as unknown as Parameters[0], - withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"), - ); + estimate = await ctx.db.$transaction(async (tx) => { + const updated = await updateEstimateDraft( + tx as unknown as Parameters[0], + withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"), + ); + + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: updated.id, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + changes: { + after: { + id: updated.id, + name: updated.name, + status: updated.status, + latestVersionNumber: updated.latestVersionNumber, + workingVersionId: updated.versions.find( + (version) => version.status === "WORKING", + )?.id, + autoFilledRateCardLines: autoFilledIndices.length, + }, + } as Prisma.InputJsonValue, + }, + }); + + return updated; + }); } catch (error) { rethrowEstimateRouterError(error, [ { @@ -211,27 +244,6 @@ export async function updateEstimateDraftRecord( ]); } - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "UPDATE", - ...withAuditUser(ctx.dbUser?.id), - changes: { - after: { - id: estimate.id, - name: estimate.name, - status: estimate.status, - latestVersionNumber: estimate.latestVersionNumber, - workingVersionId: estimate.versions.find( - (version) => version.status === "WORKING", - )?.id, - autoFilledRateCardLines: autoFilledIndices.length, - }, - } as Prisma.InputJsonValue, - }, - }); - return estimate; } @@ -241,10 +253,35 @@ export async function createEstimateExportRecord( ) { let estimate; try { - estimate = await createEstimateExport( - ctx.db as unknown as Parameters[0], - input, - ); + estimate = await ctx.db.$transaction(async (tx) => { + const exported = await createEstimateExport( + tx as unknown as Parameters[0], + input, + ); + + const exportedVersion = input.versionId + ? exported.versions.find((version) => version.id === input.versionId) + : exported.versions[0]; + + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: exported.id, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + changes: { + after: { + id: exported.id, + exportFormat: input.format, + exportCount: exportedVersion?.exports.length ?? null, + versionId: exportedVersion?.id ?? null, + }, + } as Prisma.InputJsonValue, + }, + }); + + return exported; + }); } catch (error) { rethrowEstimateRouterError(error, [ { @@ -258,27 +295,6 @@ export async function createEstimateExportRecord( ]); } - const exportedVersion = input.versionId - ? estimate.versions.find((version) => version.id === input.versionId) - : estimate.versions[0]; - - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "UPDATE", - ...withAuditUser(ctx.dbUser?.id), - changes: { - after: { - id: estimate.id, - exportFormat: input.format, - exportCount: exportedVersion?.exports.length ?? null, - versionId: exportedVersion?.id ?? null, - }, - } as Prisma.InputJsonValue, - }, - }); - return estimate; } @@ -288,10 +304,36 @@ export async function createEstimatePlanningHandoffRecord( ) { let result; try { - result = await createEstimatePlanningHandoff( - ctx.db as unknown as Parameters[0], - input, - ); + result = await ctx.db.$transaction(async (tx) => { + const handoff = await createEstimatePlanningHandoff( + tx as unknown as Parameters[0], + input, + ); + + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: handoff.estimateId, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + changes: { + after: { + planningHandoff: { + versionId: handoff.estimateVersionId, + versionNumber: handoff.estimateVersionNumber, + projectId: handoff.projectId, + createdCount: handoff.createdCount, + assignedCount: handoff.assignedCount, + placeholderCount: handoff.placeholderCount, + fallbackPlaceholderCount: handoff.fallbackPlaceholderCount, + }, + }, + } as Prisma.InputJsonValue, + }, + }); + + return handoff; + }); } catch (error) { rethrowEstimateRouterError(error, [ { @@ -319,28 +361,6 @@ export async function createEstimatePlanningHandoffRecord( ]); } - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: result.estimateId, - action: "UPDATE", - ...withAuditUser(ctx.dbUser?.id), - changes: { - after: { - planningHandoff: { - versionId: result.estimateVersionId, - versionNumber: result.estimateVersionNumber, - projectId: result.projectId, - createdCount: result.createdCount, - assignedCount: result.assignedCount, - placeholderCount: result.placeholderCount, - fallbackPlaceholderCount: result.fallbackPlaceholderCount, - }, - }, - } as Prisma.InputJsonValue, - }, - }); - for (const allocation of result.allocations) { emitAllocationCreated({ id: allocation.id, diff --git a/packages/api/src/router/estimate-version-workflow.ts b/packages/api/src/router/estimate-version-workflow.ts index e4df7fc..210c649 100644 --- a/packages/api/src/router/estimate-version-workflow.ts +++ b/packages/api/src/router/estimate-version-workflow.ts @@ -21,10 +21,32 @@ export const estimateVersionWorkflowProcedures = { let estimate; try { - estimate = await submitEstimateVersion( - ctx.db as unknown as Parameters[0], - input, - ); + estimate = await ctx.db.$transaction(async (tx) => { + const submitted = await submitEstimateVersion( + tx as unknown as Parameters[0], + input, + ); + + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: submitted.id, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + changes: { + after: { + id: submitted.id, + status: submitted.status, + submittedVersionId: submitted.versions.find( + (version) => version.status === "SUBMITTED", + )?.id, + }, + } as Prisma.InputJsonValue, + }, + }); + + return submitted; + }); } catch (error) { rethrowEstimateRouterError(error, [ { @@ -41,24 +63,6 @@ export const estimateVersionWorkflowProcedures = { ]); } - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "UPDATE", - ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), - changes: { - after: { - id: estimate.id, - status: estimate.status, - submittedVersionId: estimate.versions.find( - (version) => version.status === "SUBMITTED", - )?.id, - }, - } as Prisma.InputJsonValue, - }, - }); - return estimate; }), @@ -69,10 +73,32 @@ export const estimateVersionWorkflowProcedures = { let estimate; try { - estimate = await approveEstimateVersion( - ctx.db as unknown as Parameters[0], - input, - ); + estimate = await ctx.db.$transaction(async (tx) => { + const approved = await approveEstimateVersion( + tx as unknown as Parameters[0], + input, + ); + + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: approved.id, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + changes: { + after: { + id: approved.id, + status: approved.status, + approvedVersionId: approved.versions.find( + (version) => version.status === "APPROVED", + )?.id, + }, + } as Prisma.InputJsonValue, + }, + }); + + return approved; + }); } catch (error) { rethrowEstimateRouterError(error, [ { @@ -89,24 +115,6 @@ export const estimateVersionWorkflowProcedures = { ]); } - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "UPDATE", - ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), - changes: { - after: { - id: estimate.id, - status: estimate.status, - approvedVersionId: estimate.versions.find( - (version) => version.status === "APPROVED", - )?.id, - }, - } as Prisma.InputJsonValue, - }, - }); - return estimate; }), @@ -117,10 +125,33 @@ export const estimateVersionWorkflowProcedures = { let estimate; try { - estimate = await createEstimateRevision( - ctx.db as unknown as Parameters[0], - input, - ); + estimate = await ctx.db.$transaction(async (tx) => { + const revision = await createEstimateRevision( + tx as unknown as Parameters[0], + input, + ); + + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: revision.id, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + changes: { + after: { + id: revision.id, + status: revision.status, + latestVersionNumber: revision.latestVersionNumber, + workingVersionId: revision.versions.find( + (version) => version.status === "WORKING", + )?.id, + }, + } as Prisma.InputJsonValue, + }, + }); + + return revision; + }); } catch (error) { rethrowEstimateRouterError(error, [ { @@ -138,25 +169,6 @@ export const estimateVersionWorkflowProcedures = { ]); } - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "UPDATE", - ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), - changes: { - after: { - id: estimate.id, - status: estimate.status, - latestVersionNumber: estimate.latestVersionNumber, - workingVersionId: estimate.versions.find( - (version) => version.status === "WORKING", - )?.id, - }, - } as Prisma.InputJsonValue, - }, - }); - return estimate; }), }; diff --git a/packages/api/src/router/experience-multiplier-procedure-support.ts b/packages/api/src/router/experience-multiplier-procedure-support.ts index 6f36ebc..1f24da7 100644 --- a/packages/api/src/router/experience-multiplier-procedure-support.ts +++ b/packages/api/src/router/experience-multiplier-procedure-support.ts @@ -275,42 +275,46 @@ export async function applyExperienceMultiplierRules( const inputs = demandLines.map((line) => buildExperienceMultiplierInput(line)); const batch = applyExperienceMultipliersBatch(inputs, engineRules); - let updatedCount = 0; - for (let i = 0; i < demandLines.length; i++) { - const line = demandLines[i]!; - const result = batch.results[i]!; + const updatedCount = await ctx.db.$transaction(async (tx) => { + let count = 0; + for (let i = 0; i < demandLines.length; i++) { + const line = demandLines[i]!; + const result = batch.results[i]!; - if (hasExperienceMultiplierChanges(line, result)) { - await ctx.db.estimateDemandLine.update({ - where: { id: line.id }, - data: buildExperienceMultiplierDemandLineUpdateData({ - line, - result, - multiplierSet, - }), - }); - updatedCount++; + if (hasExperienceMultiplierChanges(line, result)) { + await tx.estimateDemandLine.update({ + where: { id: line.id }, + data: buildExperienceMultiplierDemandLineUpdateData({ + line, + result, + multiplierSet, + }), + }); + count++; + } } - } - await ctx.db.auditLog.create({ - data: { - entityType: "Estimate", - entityId: estimate.id, - action: "UPDATE", - ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), - changes: { - after: { - experienceMultipliersApplied: { - setId: multiplierSet.id, - setName: multiplierSet.name, - linesUpdated: updatedCount, - totalOriginalHours: batch.totalOriginalHours, - totalAdjustedHours: batch.totalAdjustedHours, + await tx.auditLog.create({ + data: { + entityType: "Estimate", + entityId: estimate.id, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + changes: { + after: { + experienceMultipliersApplied: { + setId: multiplierSet.id, + setName: multiplierSet.name, + linesUpdated: count, + totalOriginalHours: batch.totalOriginalHours, + totalAdjustedHours: batch.totalAdjustedHours, + }, }, }, }, - }, + }); + + return count; }); return { diff --git a/packages/api/src/router/import-export-procedure-support.ts b/packages/api/src/router/import-export-procedure-support.ts index e2f7941..6e83c2b 100644 --- a/packages/api/src/router/import-export-procedure-support.ts +++ b/packages/api/src/router/import-export-procedure-support.ts @@ -159,36 +159,38 @@ export async function importCsv(ctx: ImportExportMutationContext, input: ImportC return { ...results, message: `Dry run: ${input.rows.length} rows validated` }; } - for (let index = 0; index < input.rows.length; index += 1) { - const row = input.rows[index]; - if (!row) { - continue; - } - - try { - if (input.entityType === "resources") { - const outcome = await importResourceRow(ctx, row); - if (outcome.updated) { - results.updated += 1; - } else if (outcome.error) { - results.errors.push({ row: index + 1, message: outcome.error }); - } + await ctx.db.$transaction(async (tx) => { + for (let index = 0; index < input.rows.length; index += 1) { + const row = input.rows[index]; + if (!row) { + continue; } - } catch (error) { - results.errors.push({ - row: index + 1, - message: error instanceof Error ? error.message : "Unknown error", - }); - } - } - await ctx.db.auditLog.create({ - data: { - entityType: input.entityType, - entityId: "bulk-import", - action: "IMPORT", - changes: { summary: results }, - }, + try { + if (input.entityType === "resources") { + const outcome = await importResourceRow({ ...ctx, db: tx as unknown as typeof ctx.db }, row); + if (outcome.updated) { + results.updated += 1; + } else if (outcome.error) { + results.errors.push({ row: index + 1, message: outcome.error }); + } + } + } catch (error) { + results.errors.push({ + row: index + 1, + message: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + await tx.auditLog.create({ + data: { + entityType: input.entityType, + entityId: "bulk-import", + action: "IMPORT", + changes: { summary: results }, + }, + }); }); return results; diff --git a/packages/api/src/router/project-lifecycle.ts b/packages/api/src/router/project-lifecycle.ts index 313facb..15cd5e0 100644 --- a/packages/api/src/router/project-lifecycle.ts +++ b/packages/api/src/router/project-lifecycle.ts @@ -123,19 +123,23 @@ export function createProjectLifecycleProcedures( ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - const updated = await ctx.db.$transaction( - input.ids.map((id) => - ctx.db.project.update({ where: { id }, data: { status: input.status } }), - ), - ); + const updated = await ctx.db.$transaction(async (tx) => { + const results = await Promise.all( + input.ids.map((id) => + tx.project.update({ where: { id }, data: { status: input.status } }), + ), + ); - await ctx.db.auditLog.create({ - data: { - entityType: "Project", - entityId: input.ids.join(","), - action: "UPDATE", - changes: { after: { status: input.status, ids: input.ids } }, - }, + await tx.auditLog.create({ + data: { + entityType: "Project", + entityId: input.ids.join(","), + action: "UPDATE", + changes: { after: { status: input.status, ids: input.ids } }, + }, + }); + + return results; }); dependencies.invalidateDashboardCacheInBackground(); diff --git a/packages/api/src/router/project-mutations.ts b/packages/api/src/router/project-mutations.ts index 5025958..82daf94 100644 --- a/packages/api/src/router/project-mutations.ts +++ b/packages/api/src/router/project-mutations.ts @@ -80,17 +80,21 @@ export function createProjectMutationProcedures( target: BlueprintTarget.PROJECT, }); - const project = await ctx.db.project.create({ - data: buildProjectCreateData(input), - }); + const project = await ctx.db.$transaction(async (tx) => { + const created = await tx.project.create({ + data: buildProjectCreateData(input), + }); - await ctx.db.auditLog.create({ - data: { - entityType: "Project", - entityId: project.id, - action: "CREATE", - changes: { after: project }, - }, + await tx.auditLog.create({ + data: { + entityType: "Project", + entityId: created.id, + action: "CREATE", + changes: { after: created }, + }, + }); + + return created; }); backgroundEffects.invalidateDashboardCacheInBackground(); @@ -124,18 +128,22 @@ export function createProjectMutationProcedures( target: BlueprintTarget.PROJECT, }); - const updated = await ctx.db.project.update({ - where: { id: input.id }, - data: buildProjectUpdateData(input.data), - }); + const updated = await ctx.db.$transaction(async (tx) => { + const result = await tx.project.update({ + where: { id: input.id }, + data: buildProjectUpdateData(input.data), + }); - await ctx.db.auditLog.create({ - data: { - entityType: "Project", - entityId: input.id, - action: "UPDATE", - changes: { before: existing, after: updated }, - }, + await tx.auditLog.create({ + data: { + entityType: "Project", + entityId: input.id, + action: "UPDATE", + changes: { before: existing, after: result }, + }, + }); + + return result; }); backgroundEffects.invalidateDashboardCacheInBackground(); diff --git a/packages/api/src/router/resource-mutations.ts b/packages/api/src/router/resource-mutations.ts index 432d8c4..ad710e7 100644 --- a/packages/api/src/router/resource-mutations.ts +++ b/packages/api/src/router/resource-mutations.ts @@ -34,62 +34,66 @@ export const resourceMutationProcedures = { throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" }); } - const resource = await ctx.db.resource.create({ - data: { - eid: input.eid, - displayName: input.displayName, - email: input.email, - chapter: input.chapter, - lcrCents: input.lcrCents, - ucrCents: input.ucrCents, - currency: input.currency, - chargeabilityTarget: input.chargeabilityTarget, - availability: input.availability, - skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue, - dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue, - blueprintId: input.blueprintId, - portfolioUrl: input.portfolioUrl || undefined, - roleId: input.roleId || undefined, - ...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}), - ...(input.postalCode && !input.federalState - ? { federalState: inferStateFromPostalCode(input.postalCode) } - : input.federalState !== undefined - ? { federalState: input.federalState } - : {}), - ...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}), - ...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}), - ...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}), - ...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}), - ...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}), - ...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}), - ...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}), - ...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}), - ...(input.departed !== undefined ? { departed: input.departed } : {}), - ...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}), - ...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}), - ...(input.fte !== undefined ? { fte: input.fte } : {}), - resourceRoles: input.roles?.length - ? { - create: input.roles.map((role) => ({ - roleId: role.roleId, - isPrimary: role.isPrimary, - })), - } - : undefined, - } as unknown as Parameters[0]["data"], - include: { - resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } }, - }, - }); + const resource = await ctx.db.$transaction(async (tx) => { + const created = await tx.resource.create({ + data: { + eid: input.eid, + displayName: input.displayName, + email: input.email, + chapter: input.chapter, + lcrCents: input.lcrCents, + ucrCents: input.ucrCents, + currency: input.currency, + chargeabilityTarget: input.chargeabilityTarget, + availability: input.availability, + skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue, + dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue, + blueprintId: input.blueprintId, + portfolioUrl: input.portfolioUrl || undefined, + roleId: input.roleId || undefined, + ...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}), + ...(input.postalCode && !input.federalState + ? { federalState: inferStateFromPostalCode(input.postalCode) } + : input.federalState !== undefined + ? { federalState: input.federalState } + : {}), + ...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}), + ...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}), + ...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}), + ...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}), + ...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}), + ...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}), + ...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}), + ...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}), + ...(input.departed !== undefined ? { departed: input.departed } : {}), + ...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}), + ...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}), + ...(input.fte !== undefined ? { fte: input.fte } : {}), + resourceRoles: input.roles?.length + ? { + create: input.roles.map((role) => ({ + roleId: role.roleId, + isPrimary: role.isPrimary, + })), + } + : undefined, + } as unknown as Parameters[0]["data"], + include: { + resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } }, + }, + }); - await ctx.db.auditLog.create({ - data: { - entityType: "Resource", - entityId: resource.id, - action: "CREATE", - userId: ctx.dbUser?.id, - changes: { after: resource }, - } as unknown as Parameters[0]["data"], + await tx.auditLog.create({ + data: { + entityType: "Resource", + entityId: created.id, + action: "CREATE", + userId: ctx.dbUser?.id, + changes: { after: created }, + } as unknown as Parameters[0]["data"], + }); + + return created; }); return resource; @@ -121,67 +125,71 @@ export const resourceMutationProcedures = { } } - const updated = await ctx.db.resource.update({ - where: { id: input.id }, - data: { - ...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}), - ...(input.data.email !== undefined ? { email: input.data.email } : {}), - ...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}), - ...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}), - ...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}), - ...(input.data.currency !== undefined ? { currency: input.data.currency } : {}), - ...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}), - ...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), - ...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), - ...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), - ...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}), - ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), - ...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}), - ...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}), - ...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}), - ...(input.data.postalCode && !input.data.federalState - ? { federalState: inferStateFromPostalCode(input.data.postalCode) } - : input.data.federalState !== undefined - ? { federalState: input.data.federalState } - : {}), - ...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}), - ...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}), - ...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}), - ...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}), - ...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}), - ...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}), - ...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}), - ...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}), - ...(input.data.departed !== undefined ? { departed: input.data.departed } : {}), - ...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}), - ...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}), - ...(input.data.fte !== undefined ? { fte: input.data.fte } : {}), - } as unknown as Parameters[0]["data"], - include: { - resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } }, - }, - }); + const updated = await ctx.db.$transaction(async (tx) => { + const result = await tx.resource.update({ + where: { id: input.id }, + data: { + ...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}), + ...(input.data.email !== undefined ? { email: input.data.email } : {}), + ...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}), + ...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}), + ...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}), + ...(input.data.currency !== undefined ? { currency: input.data.currency } : {}), + ...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}), + ...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), + ...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), + ...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), + ...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}), + ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), + ...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}), + ...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}), + ...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}), + ...(input.data.postalCode && !input.data.federalState + ? { federalState: inferStateFromPostalCode(input.data.postalCode) } + : input.data.federalState !== undefined + ? { federalState: input.data.federalState } + : {}), + ...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}), + ...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}), + ...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}), + ...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}), + ...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}), + ...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}), + ...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}), + ...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}), + ...(input.data.departed !== undefined ? { departed: input.data.departed } : {}), + ...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}), + ...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}), + ...(input.data.fte !== undefined ? { fte: input.data.fte } : {}), + } as unknown as Parameters[0]["data"], + include: { + resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } }, + }, + }); - if (input.data.roles !== undefined) { - await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } }); - if (input.data.roles.length > 0) { - await ctx.db.resourceRole.createMany({ - data: input.data.roles.map((role) => ({ - resourceId: input.id, - roleId: role.roleId, - isPrimary: role.isPrimary, - })), - }); + if (input.data.roles !== undefined) { + await tx.resourceRole.deleteMany({ where: { resourceId: input.id } }); + if (input.data.roles.length > 0) { + await tx.resourceRole.createMany({ + data: input.data.roles.map((role) => ({ + resourceId: input.id, + roleId: role.roleId, + isPrimary: role.isPrimary, + })), + }); + } } - } - await ctx.db.auditLog.create({ - data: { - entityType: "Resource", - entityId: input.id, - action: "UPDATE", - changes: { before: existing, after: updated }, - }, + await tx.auditLog.create({ + data: { + entityType: "Resource", + entityId: input.id, + action: "UPDATE", + changes: { before: existing, after: result }, + }, + }); + + return result; }); return updated; @@ -191,18 +199,22 @@ export const resourceMutationProcedures = { .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); - const resource = await ctx.db.resource.update({ - where: { id: input.id }, - data: { isActive: false }, - }); + const resource = await ctx.db.$transaction(async (tx) => { + const result = await tx.resource.update({ + where: { id: input.id }, + data: { isActive: false }, + }); - await ctx.db.auditLog.create({ - data: { - entityType: "Resource", - entityId: input.id, - action: "UPDATE", - changes: { after: { isActive: false } }, - }, + await tx.auditLog.create({ + data: { + entityType: "Resource", + entityId: input.id, + action: "UPDATE", + changes: { after: { isActive: false } }, + }, + }); + + return result; }); return resource; @@ -212,19 +224,23 @@ export const resourceMutationProcedures = { .input(z.object({ ids: z.array(z.string()).min(1).max(100) })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); - const updated = await ctx.db.$transaction( - input.ids.map((id) => - ctx.db.resource.update({ where: { id }, data: { isActive: false } }), - ), - ); + const updated = await ctx.db.$transaction(async (tx) => { + const results = await Promise.all( + input.ids.map((id) => + tx.resource.update({ where: { id }, data: { isActive: false } }), + ), + ); - await ctx.db.auditLog.create({ - data: { - entityType: "Resource", - entityId: input.ids.join(","), - action: "UPDATE", - changes: { after: { isActive: false, ids: input.ids } }, - }, + await tx.auditLog.create({ + data: { + entityType: "Resource", + entityId: input.ids.join(","), + action: "UPDATE", + changes: { after: { isActive: false, ids: input.ids } }, + }, + }); + + return results; }); return { count: updated.length }; @@ -238,23 +254,25 @@ export const resourceMutationProcedures = { .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); - await ctx.db.$transaction( - input.ids.map((id) => - ctx.db.$executeRaw` - UPDATE "Resource" - SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb - WHERE id = ${id} - `, - ), - ); + await ctx.db.$transaction(async (tx) => { + await Promise.all( + input.ids.map((id) => + tx.$executeRaw` + UPDATE "Resource" + SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb + WHERE id = ${id} + `, + ), + ); - await ctx.db.auditLog.create({ - data: { - entityType: "Resource", - entityId: input.ids.join(","), - action: "UPDATE", - changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue, - }, + await tx.auditLog.create({ + data: { + entityType: "Resource", + entityId: input.ids.join(","), + action: "UPDATE", + changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + }, + }); }); return { updated: input.ids.length }; @@ -271,20 +289,20 @@ export const resourceMutationProcedures = { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } - await ctx.db.$transaction([ - ctx.db.assignment.deleteMany({ where: { resourceId: input.id } }), - ctx.db.vacation.deleteMany({ where: { resourceId: input.id } }), - ctx.db.resource.delete({ where: { id: input.id } }), - ]); + await ctx.db.$transaction(async (tx) => { + await tx.assignment.deleteMany({ where: { resourceId: input.id } }); + await tx.vacation.deleteMany({ where: { resourceId: input.id } }); + await tx.resource.delete({ where: { id: input.id } }); - await ctx.db.auditLog.create({ - data: { - entityType: "Resource", - entityId: input.id, - action: "DELETE", - userId: ctx.dbUser?.id, - changes: { before: resource } as unknown as import("@capakraken/db").Prisma.InputJsonValue, - }, + await tx.auditLog.create({ + data: { + entityType: "Resource", + entityId: input.id, + action: "DELETE", + userId: ctx.dbUser?.id, + changes: { before: resource } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + }, + }); }); return { deleted: true }; @@ -298,20 +316,20 @@ export const resourceMutationProcedures = { select: { id: true, displayName: true, eid: true }, }); - await ctx.db.$transaction([ - ctx.db.assignment.deleteMany({ where: { resourceId: { in: input.ids } } }), - ctx.db.vacation.deleteMany({ where: { resourceId: { in: input.ids } } }), - ctx.db.resource.deleteMany({ where: { id: { in: input.ids } } }), - ]); + await ctx.db.$transaction(async (tx) => { + await tx.assignment.deleteMany({ where: { resourceId: { in: input.ids } } }); + await tx.vacation.deleteMany({ where: { resourceId: { in: input.ids } } }); + await tx.resource.deleteMany({ where: { id: { in: input.ids } } }); - await ctx.db.auditLog.createMany({ - data: resources.map((r) => ({ - entityType: "Resource", - entityId: r.id, - action: "DELETE", - userId: ctx.dbUser?.id, - changes: { before: r } as unknown as import("@capakraken/db").Prisma.InputJsonValue, - })), + await tx.auditLog.createMany({ + data: resources.map((r) => ({ + entityType: "Resource", + entityId: r.id, + action: "DELETE", + userId: ctx.dbUser?.id, + changes: { before: r } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + })), + }); }); return { deleted: resources.length }; diff --git a/packages/api/src/router/role-procedure-support.ts b/packages/api/src/router/role-procedure-support.ts index 6093151..170bd3e 100644 --- a/packages/api/src/router/role-procedure-support.ts +++ b/packages/api/src/router/role-procedure-support.ts @@ -135,18 +135,22 @@ export async function createRole( requirePermission(ctx, PermissionKey.MANAGE_ROLES); await assertRoleNameAvailable(ctx.db, input.name); - const role = await ctx.db.role.create({ - data: buildRoleCreateData(input), - include: { _count: { select: { resourceRoles: true } } }, - }); + const role = await ctx.db.$transaction(async (tx) => { + const created = await tx.role.create({ + data: buildRoleCreateData(input), + include: { _count: { select: { resourceRoles: true } } }, + }); - await ctx.db.auditLog.create({ - data: { - entityType: "Role", - entityId: role.id, - action: "CREATE", - changes: { after: role }, - }, + await tx.auditLog.create({ + data: { + entityType: "Role", + entityId: created.id, + action: "CREATE", + changes: { after: created }, + }, + }); + + return created; }); emitRoleCreated({ id: role.id, name: role.name }); @@ -168,19 +172,23 @@ export async function updateRole( await assertRoleNameAvailable(ctx.db, input.data.name, input.id); } - const updated = await ctx.db.role.update({ - where: { id: input.id }, - data: buildRoleUpdateData(input.data), - include: { _count: { select: { resourceRoles: true } } }, - }); + const updated = await ctx.db.$transaction(async (tx) => { + const result = await tx.role.update({ + where: { id: input.id }, + data: buildRoleUpdateData(input.data), + include: { _count: { select: { resourceRoles: true } } }, + }); - await ctx.db.auditLog.create({ - data: { - entityType: "Role", - entityId: input.id, - action: "UPDATE", - changes: { before: existing, after: updated }, - }, + await tx.auditLog.create({ + data: { + entityType: "Role", + entityId: input.id, + action: "UPDATE", + changes: { before: existing, after: result }, + }, + }); + + return result; }); emitRoleUpdated({ id: updated.id, name: updated.name }); @@ -213,15 +221,17 @@ export async function deleteRole( }); } - await ctx.db.role.delete({ where: { id: input.id } }); + await ctx.db.$transaction(async (tx) => { + await tx.role.delete({ where: { id: input.id } }); - await ctx.db.auditLog.create({ - data: { - entityType: "Role", - entityId: input.id, - action: "DELETE", - changes: { before: role }, - }, + await tx.auditLog.create({ + data: { + entityType: "Role", + entityId: input.id, + action: "DELETE", + changes: { before: role }, + }, + }); }); emitRoleDeleted(input.id); @@ -234,19 +244,23 @@ export async function deactivateRole( input: z.infer, ) { requirePermission(ctx, PermissionKey.MANAGE_ROLES); - const role = await ctx.db.role.update({ - where: { id: input.id }, - data: { isActive: false }, - include: { _count: { select: { resourceRoles: true } } }, - }); + const role = await ctx.db.$transaction(async (tx) => { + const result = await tx.role.update({ + where: { id: input.id }, + data: { isActive: false }, + include: { _count: { select: { resourceRoles: true } } }, + }); - await ctx.db.auditLog.create({ - data: { - entityType: "Role", - entityId: input.id, - action: "UPDATE", - changes: { after: { isActive: false } }, - }, + await tx.auditLog.create({ + data: { + entityType: "Role", + entityId: input.id, + action: "UPDATE", + changes: { after: { isActive: false } }, + }, + }); + + return result; }); emitRoleUpdated({ id: role.id, isActive: false });