fix(api): wrap audit log writes inside their parent transactions

Prevents mutations from committing without an audit trail if the
auditLog.create call fails after the main write already succeeded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:40:10 +02:00
parent a01f99561d
commit 3c0179fcec
25 changed files with 758 additions and 656 deletions
@@ -19,7 +19,7 @@ export function createToolContext(
}, },
): ToolContext { ): ToolContext {
const userRole = options?.userRole ?? SystemRole.ADMIN; const userRole = options?.userRole ?? SystemRole.ADMIN;
const mergedDb = { const mergedDb: Record<string, unknown> = {
...defaultDbDefaults, ...defaultDbDefaults,
...db, ...db,
blueprint: { blueprint: {
@@ -27,6 +27,9 @@ export function createToolContext(
...(db.blueprint as Record<string, unknown> | undefined), ...(db.blueprint as Record<string, unknown> | undefined),
}, },
}; };
if (!mergedDb["$transaction"]) {
mergedDb["$transaction"] = vi.fn(async (fn: (tx: unknown) => unknown) => fn(mergedDb));
}
return { return {
db: mergedDb as ToolContext["db"], db: mergedDb as ToolContext["db"],
userId: "user_1", userId: "user_1",
@@ -21,8 +21,10 @@ export function createToolContext(
}, },
): ToolContext { ): ToolContext {
const userRole = options?.userRole ?? SystemRole.ADMIN; const userRole = options?.userRole ?? SystemRole.ADMIN;
const mergedDb: Record<string, unknown> = { ...db };
mergedDb["$transaction"] = vi.fn(async (fn: (tx: unknown) => unknown) => fn(mergedDb));
return { return {
db: db as ToolContext["db"], db: mergedDb as ToolContext["db"],
userId: "user_1", userId: "user_1",
userRole, userRole,
permissions: new Set(options?.permissions ?? []), permissions: new Set(options?.permissions ?? []),
@@ -1,4 +1,5 @@
import { PermissionKey, SystemRole } from "@capakraken/shared"; import { PermissionKey, SystemRole } from "@capakraken/shared";
import { vi } from "vitest";
import type { ToolContext } from "../router/assistant-tools.js"; import type { ToolContext } from "../router/assistant-tools.js";
@@ -10,8 +11,12 @@ export function createToolContext(
}, },
): ToolContext { ): ToolContext {
const userRole = options?.userRole ?? SystemRole.ADMIN; const userRole = options?.userRole ?? SystemRole.ADMIN;
const mergedDb: Record<string, unknown> = { ...db };
if (!mergedDb["$transaction"]) {
mergedDb["$transaction"] = vi.fn(async (fn: (tx: unknown) => unknown) => fn(mergedDb));
}
return { return {
db: db as ToolContext["db"], db: mergedDb as ToolContext["db"],
userId: "user_1", userId: "user_1",
userRole, userRole,
permissions: new Set(options?.permissions ?? []), permissions: new Set(options?.permissions ?? []),
@@ -161,7 +161,7 @@ describe("effort rule procedure support", () => {
const createMany = vi.fn().mockResolvedValue({ count: 1 }); const createMany = vi.fn().mockResolvedValue({ count: 1 });
const auditCreate = vi.fn().mockResolvedValue({}); const auditCreate = vi.fn().mockResolvedValue({});
const result = await applyEffortRules(createManagerContext({ const db: Record<string, unknown> = {
estimate: { estimate: {
findUnique: vi.fn().mockResolvedValue({ findUnique: vi.fn().mockResolvedValue({
id: "est_1", id: "est_1",
@@ -195,7 +195,10 @@ describe("effort rule procedure support", () => {
auditLog: { auditLog: {
create: auditCreate, create: auditCreate,
}, },
}), { $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
};
const result = await applyEffortRules(createManagerContext(db), {
estimateId: "est_1", estimateId: "est_1",
ruleSetId: "ers_1", ruleSetId: "ers_1",
mode: "replace", mode: "replace",
@@ -416,7 +416,7 @@ describe("effortRule.applyRules", () => {
it("replaces existing demand lines in replace mode", async () => { it("replaces existing demand lines in replace mode", async () => {
const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]); const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]);
const ruleSet = sampleRuleSet(); const ruleSet = sampleRuleSet();
const db = { const db: Record<string, unknown> = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: { estimateDemandLine: {
@@ -424,6 +424,7 @@ describe("effortRule.applyRules", () => {
createMany: vi.fn().mockResolvedValue({ count: 1 }), createMany: vi.fn().mockResolvedValue({ count: 1 }),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -444,7 +445,7 @@ describe("effortRule.applyRules", () => {
it("does not delete existing lines in append mode", async () => { it("does not delete existing lines in append mode", async () => {
const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]); const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]);
const ruleSet = sampleRuleSet(); const ruleSet = sampleRuleSet();
const db = { const db: Record<string, unknown> = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: { estimateDemandLine: {
@@ -452,6 +453,7 @@ describe("effortRule.applyRules", () => {
createMany: vi.fn().mockResolvedValue({ count: 1 }), createMany: vi.fn().mockResolvedValue({ count: 1 }),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -513,7 +515,7 @@ describe("effortRule.applyRules", () => {
it("creates demand lines with correct metadata shape", async () => { it("creates demand lines with correct metadata shape", async () => {
const estimate = makeEstimate("WORKING"); const estimate = makeEstimate("WORKING");
const ruleSet = sampleRuleSet(); const ruleSet = sampleRuleSet();
const db = { const db: Record<string, unknown> = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) }, effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: { estimateDemandLine: {
@@ -521,6 +523,7 @@ describe("effortRule.applyRules", () => {
createMany: vi.fn().mockResolvedValue({ count: 1 }), createMany: vi.fn().mockResolvedValue({ count: 1 }),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -434,9 +434,10 @@ describe("estimate router", () => {
const estimateCreate = vi.fn().mockResolvedValue(created); const estimateCreate = vi.fn().mockResolvedValue(created);
const auditLogCreate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({});
const db = { const db: Record<string, unknown> = {
estimate: { create: estimateCreate }, estimate: { create: estimateCreate },
auditLog: { create: auditLogCreate }, auditLog: { create: auditLogCreate },
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -474,10 +475,11 @@ describe("estimate router", () => {
}); });
const auditLogCreate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({});
const db = { const db: Record<string, unknown> = {
estimate: { create: estimateCreate }, estimate: { create: estimateCreate },
project: { findUnique: projectFindUnique }, project: { findUnique: projectFindUnique },
auditLog: { create: auditLogCreate }, auditLog: { create: auditLogCreate },
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -699,7 +701,7 @@ describe("estimate router", () => {
const updateEstimate = vi.fn().mockResolvedValue({}); const updateEstimate = vi.fn().mockResolvedValue({});
const auditLogCreate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({});
const db = { const db: Record<string, unknown> = {
estimate: { estimate: {
findUnique, findUnique,
update: updateEstimate, update: updateEstimate,
@@ -709,13 +711,8 @@ describe("estimate router", () => {
update: updateVersion, update: updateVersion,
}, },
auditLog: { create: auditLogCreate }, 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 caller = createManagerCaller(db);
const result = await caller.submitVersion({ estimateId: "est_1" }); const result = await caller.submitVersion({ estimateId: "est_1" });
@@ -803,7 +800,7 @@ describe("estimate router", () => {
const updateEstimate = vi.fn().mockResolvedValue({}); const updateEstimate = vi.fn().mockResolvedValue({});
const auditLogCreate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({});
const db = { const db: Record<string, unknown> = {
estimate: { estimate: {
findUnique, findUnique,
update: updateEstimate, update: updateEstimate,
@@ -813,13 +810,8 @@ describe("estimate router", () => {
update: updateVersion, update: updateVersion,
}, },
auditLog: { create: auditLogCreate }, 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 caller = createManagerCaller(db);
const result = await caller.approveVersion({ estimateId: "est_1" }); const result = await caller.approveVersion({ estimateId: "est_1" });
@@ -903,7 +895,7 @@ describe("estimate router", () => {
const updateEstimate = vi.fn().mockResolvedValue({}); const updateEstimate = vi.fn().mockResolvedValue({});
const auditLogCreate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({});
const db = { const db: Record<string, unknown> = {
estimate: { estimate: {
findUnique, findUnique,
update: updateEstimate, update: updateEstimate,
@@ -915,18 +907,8 @@ describe("estimate router", () => {
resourceCostSnapshot: { createMany: createSnapshots }, resourceCostSnapshot: { createMany: createSnapshots },
estimateMetric: { createMany: createMetrics }, estimateMetric: { createMany: createMetrics },
auditLog: { create: auditLogCreate }, 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 caller = createManagerCaller(db);
const result = await caller.createRevision({ estimateId: "est_1" }); const result = await caller.createRevision({ estimateId: "est_1" });
@@ -1008,12 +990,13 @@ describe("estimate router", () => {
const estimateCreate = vi.fn().mockResolvedValue(cloned); const estimateCreate = vi.fn().mockResolvedValue(cloned);
const auditLogCreate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({});
const db = { const db: Record<string, unknown> = {
estimate: { estimate: {
findUnique: estimateFindUnique, findUnique: estimateFindUnique,
create: estimateCreate, create: estimateCreate,
}, },
auditLog: { create: auditLogCreate }, auditLog: { create: auditLogCreate },
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -1026,9 +1009,10 @@ describe("estimate router", () => {
it("throws NOT_FOUND when source estimate does not exist", async () => { it("throws NOT_FOUND when source estimate does not exist", async () => {
const findUnique = vi.fn().mockResolvedValue(null); const findUnique = vi.fn().mockResolvedValue(null);
const db = { const db: Record<string, unknown> = {
estimate: { findUnique }, estimate: { findUnique },
auditLog: { create: vi.fn() }, auditLog: { create: vi.fn() },
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -1130,10 +1114,11 @@ describe("estimate router", () => {
const updateVersion = vi.fn().mockResolvedValue({}); const updateVersion = vi.fn().mockResolvedValue({});
const auditLogCreate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({});
const db = { const db: Record<string, unknown> = {
estimate: { findUnique: estimateFindUnique }, estimate: { findUnique: estimateFindUnique },
estimateVersion: { update: updateVersion }, estimateVersion: { update: updateVersion },
auditLog: { create: auditLogCreate }, auditLog: { create: auditLogCreate },
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -1279,10 +1264,11 @@ describe("estimate router", () => {
const createExport = vi.fn().mockResolvedValue({ id: "exp_1" }); const createExport = vi.fn().mockResolvedValue({ id: "exp_1" });
const auditLogCreate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({});
const db = { const db: Record<string, unknown> = {
estimate: { findUnique: estimateFindUnique }, estimate: { findUnique: estimateFindUnique },
estimateExport: { create: createExport }, estimateExport: { create: createExport },
auditLog: { create: auditLogCreate }, auditLog: { create: auditLogCreate },
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -1300,10 +1286,11 @@ describe("estimate router", () => {
it("throws NOT_FOUND when estimate does not exist", async () => { it("throws NOT_FOUND when estimate does not exist", async () => {
const findUnique = vi.fn().mockResolvedValue(null); const findUnique = vi.fn().mockResolvedValue(null);
const db = { const db: Record<string, unknown> = {
estimate: { findUnique }, estimate: { findUnique },
estimateExport: { create: vi.fn() }, estimateExport: { create: vi.fn() },
auditLog: { create: vi.fn() }, auditLog: { create: vi.fn() },
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -165,7 +165,7 @@ describe("experience multiplier procedure support", () => {
const update = vi.fn().mockResolvedValue({}); const update = vi.fn().mockResolvedValue({});
const auditCreate = vi.fn().mockResolvedValue({}); const auditCreate = vi.fn().mockResolvedValue({});
const result = await applyExperienceMultiplierRules(createManagerContext({ const db: Record<string, unknown> = {
estimate: { estimate: {
findUnique: vi.fn().mockResolvedValue({ findUnique: vi.fn().mockResolvedValue({
id: "est_1", id: "est_1",
@@ -199,7 +199,10 @@ describe("experience multiplier procedure support", () => {
auditLog: { auditLog: {
create: auditCreate, create: auditCreate,
}, },
}), { $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
};
const result = await applyExperienceMultiplierRules(createManagerContext(db), {
estimateId: "est_1", estimateId: "est_1",
multiplierSetId: "ems_1", multiplierSetId: "ems_1",
}); });
@@ -462,13 +462,14 @@ describe("experienceMultiplier.applyRules", () => {
it("updates demand lines with adjusted rates and creates audit log", async () => { it("updates demand lines with adjusted rates and creates audit log", async () => {
const estimate = makeEstimate("WORKING"); const estimate = makeEstimate("WORKING");
const multiplierSet = sampleMultiplierSet(); const multiplierSet = sampleMultiplierSet();
const db = { const db: Record<string, unknown> = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: { estimateDemandLine: {
update: vi.fn().mockResolvedValue({}), update: vi.fn().mockResolvedValue({}),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -528,13 +529,14 @@ describe("experienceMultiplier.applyRules", () => {
const estimate = makeEstimate("WORKING"); const estimate = makeEstimate("WORKING");
const multiplierSet = sampleMultiplierSet(); const multiplierSet = sampleMultiplierSet();
const db = { const db: Record<string, unknown> = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: { estimateDemandLine: {
update: vi.fn(), update: vi.fn(),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -544,7 +546,7 @@ describe("experienceMultiplier.applyRules", () => {
}); });
expect(result.linesUpdated).toBe(0); expect(result.linesUpdated).toBe(0);
expect(db.estimateDemandLine.update).not.toHaveBeenCalled(); expect((db.estimateDemandLine as Record<string, ReturnType<typeof vi.fn>>).update).not.toHaveBeenCalled();
}); });
it("rejects applying to a non-WORKING version", async () => { it("rejects applying to a non-WORKING version", async () => {
@@ -613,13 +615,14 @@ describe("experienceMultiplier.applyRules", () => {
}); });
const estimate = makeEstimate("WORKING", [lineWithMetadata]); const estimate = makeEstimate("WORKING", [lineWithMetadata]);
const multiplierSet = sampleMultiplierSet(); const multiplierSet = sampleMultiplierSet();
const db = { const db: Record<string, unknown> = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) }, estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) }, experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: { estimateDemandLine: {
update: vi.fn().mockResolvedValue({}), update: vi.fn().mockResolvedValue({}),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -133,10 +133,7 @@ describe("import-export procedure support", () => {
.mockResolvedValueOnce(null); .mockResolvedValueOnce(null);
const resourceUpdate = vi.fn().mockResolvedValue({ id: "res_1" }); const resourceUpdate = vi.fn().mockResolvedValue({ id: "res_1" });
const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" });
const db: Record<string, unknown> = {
const result = await importCsv(
createContext(
{
resource: { resource: {
findFirst: resourceFindFirst, findFirst: resourceFindFirst,
update: resourceUpdate, update: resourceUpdate,
@@ -144,9 +141,11 @@ describe("import-export procedure support", () => {
auditLog: { auditLog: {
create: auditCreate, create: auditCreate,
}, },
}, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
[PermissionKey.IMPORT_DATA], };
),
const result = await importCsv(
createContext(db, [PermissionKey.IMPORT_DATA]),
{ {
entityType: "resources", entityType: "resources",
rows: [ rows: [
@@ -73,9 +73,7 @@ describe("import-export router", () => {
}); });
const resourceUpdate = vi.fn().mockResolvedValue({ id: "res_1" }); const resourceUpdate = vi.fn().mockResolvedValue({ id: "res_1" });
const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" });
const importDb: Record<string, unknown> = {
const caller = createProtectedCaller(
{
resource: { resource: {
findFirst: resourceFindFirst, findFirst: resourceFindFirst,
update: resourceUpdate, update: resourceUpdate,
@@ -83,7 +81,11 @@ describe("import-export router", () => {
auditLog: { auditLog: {
create: auditCreate, create: auditCreate,
}, },
}, };
importDb["$transaction"] = vi.fn(async (fn: (tx: unknown) => unknown) => fn(importDb));
const caller = createProtectedCaller(
importDb,
{ {
role: SystemRole.MANAGER, role: SystemRole.MANAGER,
granted: [PermissionKey.IMPORT_DATA], granted: [PermissionKey.IMPORT_DATA],
@@ -221,13 +221,14 @@ describe("project router", () => {
describe("create", () => { describe("create", () => {
it("creates a project and returns its id", async () => { it("creates a project and returns its id", async () => {
const created = { ...sampleProject, id: "project_new" }; const created = { ...sampleProject, id: "project_new" };
const db = { const db: Record<string, unknown> = {
project: { project: {
findUnique: vi.fn().mockResolvedValue(null), // no shortCode conflict findUnique: vi.fn().mockResolvedValue(null), // no shortCode conflict
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
webhook: { findMany: vi.fn().mockResolvedValue([]) }, webhook: { findMany: vi.fn().mockResolvedValue([]) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -253,13 +254,14 @@ describe("project router", () => {
vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable")); vi.mocked(dispatchWebhooks).mockRejectedValueOnce(new Error("webhook unavailable"));
const created = { ...sampleProject, id: "project_safe_create" }; const created = { ...sampleProject, id: "project_safe_create" };
const db = { const db: Record<string, unknown> = {
project: { project: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
webhook: { findMany: vi.fn().mockResolvedValue([]) }, webhook: { findMany: vi.fn().mockResolvedValue([]) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -451,12 +453,13 @@ describe("project router", () => {
describe("update", () => { describe("update", () => {
it("updates project fields", async () => { it("updates project fields", async () => {
const updated = { ...sampleProject, name: "Updated Name" }; const updated = { ...sampleProject, name: "Updated Name" };
const db = { const db: Record<string, unknown> = {
project: { project: {
findUnique: vi.fn().mockResolvedValue(sampleProject), findUnique: vi.fn().mockResolvedValue(sampleProject),
update: vi.fn().mockResolvedValue(updated), update: vi.fn().mockResolvedValue(updated),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -558,14 +561,12 @@ describe("project router", () => {
describe("batchUpdateStatus", () => { describe("batchUpdateStatus", () => {
it("updates multiple projects and returns count", async () => { it("updates multiple projects and returns count", async () => {
const db = { const db: Record<string, unknown> = {
project: { project: {
update: vi.fn().mockResolvedValue(sampleProject), update: vi.fn().mockResolvedValue(sampleProject),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn((calls: unknown[]) => $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
Promise.all((calls as Promise<unknown>[]).map(() => sampleProject)),
),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -575,7 +576,7 @@ describe("project router", () => {
}); });
expect(result.count).toBe(3); expect(result.count).toBe(3);
expect(db.auditLog.create).toHaveBeenCalled(); expect((db.auditLog as Record<string, ReturnType<typeof vi.fn>>).create).toHaveBeenCalled();
}); });
}); });
@@ -328,12 +328,13 @@ describe("resource router CRUD", () => {
describe("create", () => { describe("create", () => {
it("creates a resource and returns it", async () => { it("creates a resource and returns it", async () => {
const created = { ...sampleResource, id: "res_new", resourceRoles: [] }; const created = { ...sampleResource, id: "res_new", resourceRoles: [] };
const db = { const db: Record<string, unknown> = {
resource: { resource: {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -399,13 +400,14 @@ describe("resource router CRUD", () => {
describe("update", () => { describe("update", () => {
it("updates resource fields", async () => { it("updates resource fields", async () => {
const updated = { ...sampleResource, displayName: "Alice Updated" }; const updated = { ...sampleResource, displayName: "Alice Updated" };
const db = { const db: Record<string, unknown> = {
resource: { resource: {
findUnique: vi.fn().mockResolvedValue(sampleResource), findUnique: vi.fn().mockResolvedValue(sampleResource),
update: vi.fn().mockResolvedValue(updated), update: vi.fn().mockResolvedValue(updated),
}, },
resourceRole: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) }, resourceRole: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -443,11 +445,12 @@ describe("resource router CRUD", () => {
describe("deactivate", () => { describe("deactivate", () => {
it("sets isActive to false", async () => { it("sets isActive to false", async () => {
const deactivated = { ...sampleResource, isActive: false }; const deactivated = { ...sampleResource, isActive: false };
const db = { const db: Record<string, unknown> = {
resource: { resource: {
update: vi.fn().mockResolvedValue(deactivated), update: vi.fn().mockResolvedValue(deactivated),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -469,12 +472,13 @@ describe("resource router CRUD", () => {
.fn() .fn()
.mockResolvedValueOnce({ ...sampleResource, id: "res_1", isActive: false }) .mockResolvedValueOnce({ ...sampleResource, id: "res_1", isActive: false })
.mockResolvedValueOnce({ ...sampleResource, id: "res_2", isActive: false }); .mockResolvedValueOnce({ ...sampleResource, id: "res_2", isActive: false });
const db = { const auditCreate = vi.fn().mockResolvedValue({});
const db: Record<string, unknown> = {
resource: { resource: {
update, update,
}, },
$transaction: vi.fn(async (operations: Promise<unknown>[]) => Promise.all(operations)), auditLog: { create: auditCreate },
auditLog: { create: vi.fn().mockResolvedValue({}) }, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -482,7 +486,7 @@ describe("resource router CRUD", () => {
expect(result).toEqual({ count: 2 }); expect(result).toEqual({ count: 2 });
expect(db.$transaction).toHaveBeenCalledTimes(1); expect(db.$transaction).toHaveBeenCalledTimes(1);
expect(db.auditLog.create).toHaveBeenCalledWith( expect(auditCreate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: expect.objectContaining({ data: expect.objectContaining({
entityType: "Resource", entityType: "Resource",
@@ -222,7 +222,7 @@ describe("role procedure support", () => {
color: "#111111", color: "#111111",
_count: { resourceRoles: 2 }, _count: { resourceRoles: 2 },
}; };
const db = { const db: Record<string, unknown> = {
role: { role: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(role), create: vi.fn().mockResolvedValue(role),
@@ -230,6 +230,7 @@ describe("role procedure support", () => {
auditLog: { auditLog: {
create: vi.fn().mockResolvedValue(undefined), create: vi.fn().mockResolvedValue(undefined),
}, },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const result = await createRole(createContext(db), { const result = await createRole(createContext(db), {
@@ -300,7 +301,7 @@ describe("role procedure support", () => {
isActive: true, isActive: true,
_count: { resourceRoles: 1 }, _count: { resourceRoles: 1 },
}; };
const db = { const db: Record<string, unknown> = {
role: { role: {
findUnique: vi.fn() findUnique: vi.fn()
.mockResolvedValueOnce(existing) .mockResolvedValueOnce(existing)
@@ -312,6 +313,7 @@ describe("role procedure support", () => {
auditLog: { auditLog: {
create: vi.fn().mockResolvedValue(undefined), create: vi.fn().mockResolvedValue(undefined),
}, },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
}; };
const result = await updateRole(createContext(db), UpdateRoleProcedureInputSchema.parse({ const result = await updateRole(createContext(db), UpdateRoleProcedureInputSchema.parse({
@@ -371,7 +373,7 @@ describe("role procedure support", () => {
countPlanningEntries.mockResolvedValue({ countPlanningEntries.mockResolvedValue({
countsByRoleId: new Map([["role_fx", 4]]), countsByRoleId: new Map([["role_fx", 4]]),
}); });
const db = { const db: Record<string, unknown> = {
role: { role: {
update: vi.fn().mockResolvedValue({ update: vi.fn().mockResolvedValue({
id: "role_fx", id: "role_fx",
@@ -385,6 +387,7 @@ describe("role procedure support", () => {
auditLog: { auditLog: {
create: vi.fn().mockResolvedValue(undefined), 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" })); const result = await deactivateRole(createContext(db), RoleIdInputSchema.parse({ id: "role_fx" }));
@@ -143,15 +143,14 @@ describe("role router authorization", () => {
// planningEntry count queries (attachZeroAllocationCount path) // planningEntry count queries (attachZeroAllocationCount path)
const planningEntryFindMany = vi.fn().mockResolvedValue([]); const planningEntryFindMany = vi.fn().mockResolvedValue([]);
const caller = createCaller( const db: Record<string, unknown> = {
createContext(
{
role: { create: roleCreate, findUnique: roleFindUnique }, role: { create: roleCreate, findUnique: roleFindUnique },
auditLog: { create: auditLogCreate }, auditLog: { create: auditLogCreate },
planningEntry: { findMany: planningEntryFindMany }, planningEntry: { findMany: planningEntryFindMany },
}, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(db)),
{ role: SystemRole.MANAGER }, };
), const caller = createCaller(
createContext(db, { role: SystemRole.MANAGER }),
); );
// Should not throw UNAUTHORIZED or FORBIDDEN // Should not throw UNAUTHORIZED or FORBIDDEN
@@ -180,9 +179,7 @@ describe("role router authorization", () => {
const demandRequirementFindMany = vi.fn().mockResolvedValue([]); const demandRequirementFindMany = vi.fn().mockResolvedValue([]);
const assignmentFindMany = vi.fn().mockResolvedValue([]); const assignmentFindMany = vi.fn().mockResolvedValue([]);
const caller = createCaller( const adminDb: Record<string, unknown> = {
createContext(
{
role: { role: {
findUnique: roleFindUnique, findUnique: roleFindUnique,
delete: roleDelete, delete: roleDelete,
@@ -190,9 +187,10 @@ describe("role router authorization", () => {
auditLog: { create: auditLogCreate }, auditLog: { create: auditLogCreate },
demandRequirement: { findMany: demandRequirementFindMany }, demandRequirement: { findMany: demandRequirementFindMany },
assignment: { findMany: assignmentFindMany }, assignment: { findMany: assignmentFindMany },
}, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(adminDb)),
{ role: SystemRole.ADMIN }, };
), const caller = createCaller(
createContext(adminDb, { role: SystemRole.ADMIN }),
); );
const result = await caller.delete({ id: "role_1" }); const result = await caller.delete({ id: "role_1" });
@@ -302,10 +302,7 @@ export async function batchUpdateAllocationStatusWithAudit(
).allocation), ).allocation),
); );
return updatedAllocations; await tx.auditLog.create({
});
await db.auditLog.create({
data: { data: {
entityType: "Allocation", entityType: "Allocation",
entityId: input.ids.join(","), entityId: input.ids.join(","),
@@ -314,5 +311,8 @@ export async function batchUpdateAllocationStatusWithAudit(
}, },
}); });
return updatedAllocations;
});
return updated; return updated;
} }
@@ -238,14 +238,15 @@ export async function applyEffortRules(
const rules = toEffortRuleEngineInputs(ruleSet.rules); const rules = toEffortRuleEngineInputs(ruleSet.rules);
const result = expandScopeToEffort(scopeItems, rules); const result = expandScopeToEffort(scopeItems, rules);
await ctx.db.$transaction(async (tx) => {
if (input.mode === "replace") { if (input.mode === "replace") {
await ctx.db.estimateDemandLine.deleteMany({ await tx.estimateDemandLine.deleteMany({
where: { estimateVersionId: version.id }, where: { estimateVersionId: version.id },
}); });
} }
if (result.lines.length > 0) { if (result.lines.length > 0) {
await ctx.db.estimateDemandLine.createMany({ await tx.estimateDemandLine.createMany({
data: buildEstimateDemandLineRows({ data: buildEstimateDemandLineRows({
estimateVersionId: version.id, estimateVersionId: version.id,
currency: estimate.baseCurrency, currency: estimate.baseCurrency,
@@ -255,7 +256,7 @@ export async function applyEffortRules(
}); });
} }
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Estimate", entityType: "Estimate",
entityId: estimate.id, entityId: estimate.id,
@@ -274,6 +275,7 @@ export async function applyEffortRules(
}, },
}, },
}); });
});
return { return {
linesGenerated: result.lines.length, linesGenerated: result.lines.length,
@@ -65,12 +65,13 @@ export const estimateCommercialProcedures = {
const validated = CommercialTermsSchema.parse(input.terms); const validated = CommercialTermsSchema.parse(input.terms);
await ctx.db.estimateVersion.update({ await ctx.db.$transaction(async (tx) => {
await tx.estimateVersion.update({
where: { id: version.id }, where: { id: version.id },
data: { commercialTerms: validated as unknown as Prisma.InputJsonValue }, data: { commercialTerms: validated as unknown as Prisma.InputJsonValue },
}); });
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Estimate", entityType: "Estimate",
entityId: estimate.id, entityId: estimate.id,
@@ -82,6 +83,7 @@ export const estimateCommercialProcedures = {
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
}, },
}); });
});
return { versionId: version.id, terms: validated }; return { versionId: version.id, terms: validated };
}), }),
@@ -100,30 +100,34 @@ export async function createEstimateRecord(
await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId); await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId);
const enrichedInput = { ...input, demandLines: enrichedLines }; const enrichedInput = { ...input, demandLines: enrichedLines };
const estimate = await createEstimate( const estimate = await ctx.db.$transaction(async (tx) => {
ctx.db as unknown as Parameters<typeof createEstimate>[0], const created = await createEstimate(
tx as unknown as Parameters<typeof createEstimate>[0],
withComputedMetrics(enrichedInput, input.baseCurrency), withComputedMetrics(enrichedInput, input.baseCurrency),
); );
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Estimate", entityType: "Estimate",
entityId: estimate.id, entityId: created.id,
action: "CREATE", action: "CREATE",
...withAuditUser(ctx.dbUser?.id), ...withAuditUser(ctx.dbUser?.id),
changes: { changes: {
after: { after: {
id: estimate.id, id: created.id,
name: estimate.name, name: created.name,
status: estimate.status, status: created.status,
projectId: estimate.projectId, projectId: created.projectId,
latestVersionNumber: estimate.latestVersionNumber, latestVersionNumber: created.latestVersionNumber,
autoFilledRateCardLines: autoFilledIndices.length, autoFilledRateCardLines: autoFilledIndices.length,
}, },
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
}, },
}); });
return created;
});
return estimate; return estimate;
} }
@@ -133,10 +137,30 @@ export async function cloneEstimateRecord(
) { ) {
let estimate; let estimate;
try { try {
estimate = await cloneEstimate( estimate = await ctx.db.$transaction(async (tx) => {
ctx.db as unknown as Parameters<typeof cloneEstimate>[0], const cloned = await cloneEstimate(
tx as unknown as Parameters<typeof cloneEstimate>[0],
input, 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) { } catch (error) {
rethrowEstimateRouterError(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; return estimate;
} }
@@ -194,10 +202,35 @@ export async function updateEstimateDraftRecord(
let estimate; let estimate;
try { try {
estimate = await updateEstimateDraft( estimate = await ctx.db.$transaction(async (tx) => {
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0], const updated = await updateEstimateDraft(
tx as unknown as Parameters<typeof updateEstimateDraft>[0],
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"), 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) { } catch (error) {
rethrowEstimateRouterError(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; return estimate;
} }
@@ -241,10 +253,35 @@ export async function createEstimateExportRecord(
) { ) {
let estimate; let estimate;
try { try {
estimate = await createEstimateExport( estimate = await ctx.db.$transaction(async (tx) => {
ctx.db as unknown as Parameters<typeof createEstimateExport>[0], const exported = await createEstimateExport(
tx as unknown as Parameters<typeof createEstimateExport>[0],
input, 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) { } catch (error) {
rethrowEstimateRouterError(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; return estimate;
} }
@@ -288,10 +304,36 @@ export async function createEstimatePlanningHandoffRecord(
) { ) {
let result; let result;
try { try {
result = await createEstimatePlanningHandoff( result = await ctx.db.$transaction(async (tx) => {
ctx.db as unknown as Parameters<typeof createEstimatePlanningHandoff>[0], const handoff = await createEstimatePlanningHandoff(
tx as unknown as Parameters<typeof createEstimatePlanningHandoff>[0],
input, 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) { } catch (error) {
rethrowEstimateRouterError(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) { for (const allocation of result.allocations) {
emitAllocationCreated({ emitAllocationCreated({
id: allocation.id, id: allocation.id,
@@ -21,10 +21,32 @@ export const estimateVersionWorkflowProcedures = {
let estimate; let estimate;
try { try {
estimate = await submitEstimateVersion( estimate = await ctx.db.$transaction(async (tx) => {
ctx.db as unknown as Parameters<typeof submitEstimateVersion>[0], const submitted = await submitEstimateVersion(
tx as unknown as Parameters<typeof submitEstimateVersion>[0],
input, 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) { } catch (error) {
rethrowEstimateRouterError(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; return estimate;
}), }),
@@ -69,10 +73,32 @@ export const estimateVersionWorkflowProcedures = {
let estimate; let estimate;
try { try {
estimate = await approveEstimateVersion( estimate = await ctx.db.$transaction(async (tx) => {
ctx.db as unknown as Parameters<typeof approveEstimateVersion>[0], const approved = await approveEstimateVersion(
tx as unknown as Parameters<typeof approveEstimateVersion>[0],
input, 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) { } catch (error) {
rethrowEstimateRouterError(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; return estimate;
}), }),
@@ -117,10 +125,33 @@ export const estimateVersionWorkflowProcedures = {
let estimate; let estimate;
try { try {
estimate = await createEstimateRevision( estimate = await ctx.db.$transaction(async (tx) => {
ctx.db as unknown as Parameters<typeof createEstimateRevision>[0], const revision = await createEstimateRevision(
tx as unknown as Parameters<typeof createEstimateRevision>[0],
input, 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) { } catch (error) {
rethrowEstimateRouterError(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; return estimate;
}), }),
}; };
@@ -275,13 +275,14 @@ export async function applyExperienceMultiplierRules(
const inputs = demandLines.map((line) => buildExperienceMultiplierInput(line)); const inputs = demandLines.map((line) => buildExperienceMultiplierInput(line));
const batch = applyExperienceMultipliersBatch(inputs, engineRules); const batch = applyExperienceMultipliersBatch(inputs, engineRules);
let updatedCount = 0; const updatedCount = await ctx.db.$transaction(async (tx) => {
let count = 0;
for (let i = 0; i < demandLines.length; i++) { for (let i = 0; i < demandLines.length; i++) {
const line = demandLines[i]!; const line = demandLines[i]!;
const result = batch.results[i]!; const result = batch.results[i]!;
if (hasExperienceMultiplierChanges(line, result)) { if (hasExperienceMultiplierChanges(line, result)) {
await ctx.db.estimateDemandLine.update({ await tx.estimateDemandLine.update({
where: { id: line.id }, where: { id: line.id },
data: buildExperienceMultiplierDemandLineUpdateData({ data: buildExperienceMultiplierDemandLineUpdateData({
line, line,
@@ -289,11 +290,11 @@ export async function applyExperienceMultiplierRules(
multiplierSet, multiplierSet,
}), }),
}); });
updatedCount++; count++;
} }
} }
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Estimate", entityType: "Estimate",
entityId: estimate.id, entityId: estimate.id,
@@ -304,7 +305,7 @@ export async function applyExperienceMultiplierRules(
experienceMultipliersApplied: { experienceMultipliersApplied: {
setId: multiplierSet.id, setId: multiplierSet.id,
setName: multiplierSet.name, setName: multiplierSet.name,
linesUpdated: updatedCount, linesUpdated: count,
totalOriginalHours: batch.totalOriginalHours, totalOriginalHours: batch.totalOriginalHours,
totalAdjustedHours: batch.totalAdjustedHours, totalAdjustedHours: batch.totalAdjustedHours,
}, },
@@ -313,6 +314,9 @@ export async function applyExperienceMultiplierRules(
}, },
}); });
return count;
});
return { return {
linesUpdated: updatedCount, linesUpdated: updatedCount,
totalOriginalHours: batch.totalOriginalHours, totalOriginalHours: batch.totalOriginalHours,
@@ -159,6 +159,7 @@ export async function importCsv(ctx: ImportExportMutationContext, input: ImportC
return { ...results, message: `Dry run: ${input.rows.length} rows validated` }; return { ...results, message: `Dry run: ${input.rows.length} rows validated` };
} }
await ctx.db.$transaction(async (tx) => {
for (let index = 0; index < input.rows.length; index += 1) { for (let index = 0; index < input.rows.length; index += 1) {
const row = input.rows[index]; const row = input.rows[index];
if (!row) { if (!row) {
@@ -167,7 +168,7 @@ export async function importCsv(ctx: ImportExportMutationContext, input: ImportC
try { try {
if (input.entityType === "resources") { if (input.entityType === "resources") {
const outcome = await importResourceRow(ctx, row); const outcome = await importResourceRow({ ...ctx, db: tx as unknown as typeof ctx.db }, row);
if (outcome.updated) { if (outcome.updated) {
results.updated += 1; results.updated += 1;
} else if (outcome.error) { } else if (outcome.error) {
@@ -182,7 +183,7 @@ export async function importCsv(ctx: ImportExportMutationContext, input: ImportC
} }
} }
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: input.entityType, entityType: input.entityType,
entityId: "bulk-import", entityId: "bulk-import",
@@ -190,6 +191,7 @@ export async function importCsv(ctx: ImportExportMutationContext, input: ImportC
changes: { summary: results }, changes: { summary: results },
}, },
}); });
});
return results; return results;
} }
+7 -3
View File
@@ -123,13 +123,14 @@ export function createProjectLifecycleProcedures(
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const updated = await ctx.db.$transaction( const updated = await ctx.db.$transaction(async (tx) => {
const results = await Promise.all(
input.ids.map((id) => input.ids.map((id) =>
ctx.db.project.update({ where: { id }, data: { status: input.status } }), tx.project.update({ where: { id }, data: { status: input.status } }),
), ),
); );
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Project", entityType: "Project",
entityId: input.ids.join(","), entityId: input.ids.join(","),
@@ -138,6 +139,9 @@ export function createProjectLifecycleProcedures(
}, },
}); });
return results;
});
dependencies.invalidateDashboardCacheInBackground(); dependencies.invalidateDashboardCacheInBackground();
return { count: updated.length }; return { count: updated.length };
}), }),
+15 -7
View File
@@ -80,19 +80,23 @@ export function createProjectMutationProcedures(
target: BlueprintTarget.PROJECT, target: BlueprintTarget.PROJECT,
}); });
const project = await ctx.db.project.create({ const project = await ctx.db.$transaction(async (tx) => {
const created = await tx.project.create({
data: buildProjectCreateData(input), data: buildProjectCreateData(input),
}); });
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Project", entityType: "Project",
entityId: project.id, entityId: created.id,
action: "CREATE", action: "CREATE",
changes: { after: project }, changes: { after: created },
}, },
}); });
return created;
});
backgroundEffects.invalidateDashboardCacheInBackground(); backgroundEffects.invalidateDashboardCacheInBackground();
backgroundEffects.dispatchProjectWebhookInBackground(ctx.db, "project.created", { backgroundEffects.dispatchProjectWebhookInBackground(ctx.db, "project.created", {
id: project.id, id: project.id,
@@ -124,20 +128,24 @@ export function createProjectMutationProcedures(
target: BlueprintTarget.PROJECT, target: BlueprintTarget.PROJECT,
}); });
const updated = await ctx.db.project.update({ const updated = await ctx.db.$transaction(async (tx) => {
const result = await tx.project.update({
where: { id: input.id }, where: { id: input.id },
data: buildProjectUpdateData(input.data), data: buildProjectUpdateData(input.data),
}); });
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Project", entityType: "Project",
entityId: input.id, entityId: input.id,
action: "UPDATE", action: "UPDATE",
changes: { before: existing, after: updated }, changes: { before: existing, after: result },
}, },
}); });
return result;
});
backgroundEffects.invalidateDashboardCacheInBackground(); backgroundEffects.invalidateDashboardCacheInBackground();
return updated; return updated;
}), }),
+50 -32
View File
@@ -34,7 +34,8 @@ export const resourceMutationProcedures = {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" }); throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
} }
const resource = await ctx.db.resource.create({ const resource = await ctx.db.$transaction(async (tx) => {
const created = await tx.resource.create({
data: { data: {
eid: input.eid, eid: input.eid,
displayName: input.displayName, displayName: input.displayName,
@@ -76,20 +77,23 @@ export const resourceMutationProcedures = {
})), })),
} }
: undefined, : undefined,
} as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"], } as unknown as Parameters<typeof tx.resource.create>[0]["data"],
include: { include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } }, resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
}, },
}); });
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Resource", entityType: "Resource",
entityId: resource.id, entityId: created.id,
action: "CREATE", action: "CREATE",
userId: ctx.dbUser?.id, userId: ctx.dbUser?.id,
changes: { after: resource }, changes: { after: created },
} as unknown as Parameters<typeof ctx.db.auditLog.create>[0]["data"], } as unknown as Parameters<typeof tx.auditLog.create>[0]["data"],
});
return created;
}); });
return resource; return resource;
@@ -121,7 +125,8 @@ export const resourceMutationProcedures = {
} }
} }
const updated = await ctx.db.resource.update({ const updated = await ctx.db.$transaction(async (tx) => {
const result = await tx.resource.update({
where: { id: input.id }, where: { id: input.id },
data: { data: {
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}), ...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
@@ -156,16 +161,16 @@ export const resourceMutationProcedures = {
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}), ...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}), ...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}), ...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
} as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"], } as unknown as Parameters<typeof tx.resource.update>[0]["data"],
include: { include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } }, resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
}, },
}); });
if (input.data.roles !== undefined) { if (input.data.roles !== undefined) {
await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } }); await tx.resourceRole.deleteMany({ where: { resourceId: input.id } });
if (input.data.roles.length > 0) { if (input.data.roles.length > 0) {
await ctx.db.resourceRole.createMany({ await tx.resourceRole.createMany({
data: input.data.roles.map((role) => ({ data: input.data.roles.map((role) => ({
resourceId: input.id, resourceId: input.id,
roleId: role.roleId, roleId: role.roleId,
@@ -175,15 +180,18 @@ export const resourceMutationProcedures = {
} }
} }
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Resource", entityType: "Resource",
entityId: input.id, entityId: input.id,
action: "UPDATE", action: "UPDATE",
changes: { before: existing, after: updated }, changes: { before: existing, after: result },
}, },
}); });
return result;
});
return updated; return updated;
}), }),
@@ -191,12 +199,13 @@ export const resourceMutationProcedures = {
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await ctx.db.resource.update({ const resource = await ctx.db.$transaction(async (tx) => {
const result = await tx.resource.update({
where: { id: input.id }, where: { id: input.id },
data: { isActive: false }, data: { isActive: false },
}); });
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Resource", entityType: "Resource",
entityId: input.id, entityId: input.id,
@@ -205,6 +214,9 @@ export const resourceMutationProcedures = {
}, },
}); });
return result;
});
return resource; return resource;
}), }),
@@ -212,13 +224,14 @@ export const resourceMutationProcedures = {
.input(z.object({ ids: z.array(z.string()).min(1).max(100) })) .input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const updated = await ctx.db.$transaction( const updated = await ctx.db.$transaction(async (tx) => {
const results = await Promise.all(
input.ids.map((id) => input.ids.map((id) =>
ctx.db.resource.update({ where: { id }, data: { isActive: false } }), tx.resource.update({ where: { id }, data: { isActive: false } }),
), ),
); );
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Resource", entityType: "Resource",
entityId: input.ids.join(","), entityId: input.ids.join(","),
@@ -227,6 +240,9 @@ export const resourceMutationProcedures = {
}, },
}); });
return results;
});
return { count: updated.length }; return { count: updated.length };
}), }),
@@ -238,9 +254,10 @@ export const resourceMutationProcedures = {
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
await ctx.db.$transaction( await ctx.db.$transaction(async (tx) => {
await Promise.all(
input.ids.map((id) => input.ids.map((id) =>
ctx.db.$executeRaw` tx.$executeRaw`
UPDATE "Resource" UPDATE "Resource"
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
WHERE id = ${id} WHERE id = ${id}
@@ -248,7 +265,7 @@ export const resourceMutationProcedures = {
), ),
); );
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Resource", entityType: "Resource",
entityId: input.ids.join(","), entityId: input.ids.join(","),
@@ -256,6 +273,7 @@ export const resourceMutationProcedures = {
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue, changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}, },
}); });
});
return { updated: input.ids.length }; return { updated: input.ids.length };
}), }),
@@ -271,13 +289,12 @@ export const resourceMutationProcedures = {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
} }
await ctx.db.$transaction([ await ctx.db.$transaction(async (tx) => {
ctx.db.assignment.deleteMany({ where: { resourceId: input.id } }), await tx.assignment.deleteMany({ where: { resourceId: input.id } });
ctx.db.vacation.deleteMany({ where: { resourceId: input.id } }), await tx.vacation.deleteMany({ where: { resourceId: input.id } });
ctx.db.resource.delete({ where: { id: input.id } }), await tx.resource.delete({ where: { id: input.id } });
]);
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Resource", entityType: "Resource",
entityId: input.id, entityId: input.id,
@@ -286,6 +303,7 @@ export const resourceMutationProcedures = {
changes: { before: resource } as unknown as import("@capakraken/db").Prisma.InputJsonValue, changes: { before: resource } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}, },
}); });
});
return { deleted: true }; return { deleted: true };
}), }),
@@ -298,13 +316,12 @@ export const resourceMutationProcedures = {
select: { id: true, displayName: true, eid: true }, select: { id: true, displayName: true, eid: true },
}); });
await ctx.db.$transaction([ await ctx.db.$transaction(async (tx) => {
ctx.db.assignment.deleteMany({ where: { resourceId: { in: input.ids } } }), await tx.assignment.deleteMany({ where: { resourceId: { in: input.ids } } });
ctx.db.vacation.deleteMany({ where: { resourceId: { in: input.ids } } }), await tx.vacation.deleteMany({ where: { resourceId: { in: input.ids } } });
ctx.db.resource.deleteMany({ where: { id: { in: input.ids } } }), await tx.resource.deleteMany({ where: { id: { in: input.ids } } });
]);
await ctx.db.auditLog.createMany({ await tx.auditLog.createMany({
data: resources.map((r) => ({ data: resources.map((r) => ({
entityType: "Resource", entityType: "Resource",
entityId: r.id, entityId: r.id,
@@ -313,6 +330,7 @@ export const resourceMutationProcedures = {
changes: { before: r } as unknown as import("@capakraken/db").Prisma.InputJsonValue, changes: { before: r } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
})), })),
}); });
});
return { deleted: resources.length }; return { deleted: resources.length };
}), }),
@@ -135,20 +135,24 @@ export async function createRole(
requirePermission(ctx, PermissionKey.MANAGE_ROLES); requirePermission(ctx, PermissionKey.MANAGE_ROLES);
await assertRoleNameAvailable(ctx.db, input.name); await assertRoleNameAvailable(ctx.db, input.name);
const role = await ctx.db.role.create({ const role = await ctx.db.$transaction(async (tx) => {
const created = await tx.role.create({
data: buildRoleCreateData(input), data: buildRoleCreateData(input),
include: { _count: { select: { resourceRoles: true } } }, include: { _count: { select: { resourceRoles: true } } },
}); });
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Role", entityType: "Role",
entityId: role.id, entityId: created.id,
action: "CREATE", action: "CREATE",
changes: { after: role }, changes: { after: created },
}, },
}); });
return created;
});
emitRoleCreated({ id: role.id, name: role.name }); emitRoleCreated({ id: role.id, name: role.name });
return appendZeroAllocationCount(role); return appendZeroAllocationCount(role);
@@ -168,21 +172,25 @@ export async function updateRole(
await assertRoleNameAvailable(ctx.db, input.data.name, input.id); await assertRoleNameAvailable(ctx.db, input.data.name, input.id);
} }
const updated = await ctx.db.role.update({ const updated = await ctx.db.$transaction(async (tx) => {
const result = await tx.role.update({
where: { id: input.id }, where: { id: input.id },
data: buildRoleUpdateData(input.data), data: buildRoleUpdateData(input.data),
include: { _count: { select: { resourceRoles: true } } }, include: { _count: { select: { resourceRoles: true } } },
}); });
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Role", entityType: "Role",
entityId: input.id, entityId: input.id,
action: "UPDATE", action: "UPDATE",
changes: { before: existing, after: updated }, changes: { before: existing, after: result },
}, },
}); });
return result;
});
emitRoleUpdated({ id: updated.id, name: updated.name }); emitRoleUpdated({ id: updated.id, name: updated.name });
return attachSingleRolePlanningEntryCount(ctx.db, updated); return attachSingleRolePlanningEntryCount(ctx.db, updated);
@@ -213,9 +221,10 @@ 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({ await tx.auditLog.create({
data: { data: {
entityType: "Role", entityType: "Role",
entityId: input.id, entityId: input.id,
@@ -223,6 +232,7 @@ export async function deleteRole(
changes: { before: role }, changes: { before: role },
}, },
}); });
});
emitRoleDeleted(input.id); emitRoleDeleted(input.id);
@@ -234,13 +244,14 @@ export async function deactivateRole(
input: z.infer<typeof RoleIdInputSchema>, input: z.infer<typeof RoleIdInputSchema>,
) { ) {
requirePermission(ctx, PermissionKey.MANAGE_ROLES); requirePermission(ctx, PermissionKey.MANAGE_ROLES);
const role = await ctx.db.role.update({ const role = await ctx.db.$transaction(async (tx) => {
const result = await tx.role.update({
where: { id: input.id }, where: { id: input.id },
data: { isActive: false }, data: { isActive: false },
include: { _count: { select: { resourceRoles: true } } }, include: { _count: { select: { resourceRoles: true } } },
}); });
await ctx.db.auditLog.create({ await tx.auditLog.create({
data: { data: {
entityType: "Role", entityType: "Role",
entityId: input.id, entityId: input.id,
@@ -249,6 +260,9 @@ export async function deactivateRole(
}, },
}); });
return result;
});
emitRoleUpdated({ id: role.id, isActive: false }); emitRoleUpdated({ id: role.id, isActive: false });
return attachSingleRolePlanningEntryCount(ctx.db, role); return attachSingleRolePlanningEntryCount(ctx.db, role);