From 1d6d75ecf6a037c4515c4c457c2dbb7e9093ae9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 08:34:59 +0200 Subject: [PATCH] fix(api): wrap critical mutations in transactions and fix TOCTOU race conditions - applyProjectScenario: wrap assignment loop in db.$transaction to prevent partial updates - vacation approve/reject: fix TOCTOU race via updateMany with status-guard in WHERE + CONFLICT on count=0 - vacation cancel: wrap vacation.update + entitlement.updateMany in $transaction - batchApprove: collect mutations, wrap in $transaction, dispatch SSE/notifications after commit - Fix dead-code bug in createHappyPathDb where $transaction was assigned after return - Add atomicity and concurrency tests Co-Authored-By: Claude Sonnet 4.6 --- ...nt-tools-vacation-mutation-test-helpers.ts | 12 +- ...stant-tools-vacation-review-cancel.test.ts | 8 +- .../api/src/__tests__/scenario-apply.test.ts | 49 +++++- .../api/src/__tests__/vacation-router.test.ts | 69 ++++++-- packages/api/src/router/scenario-apply.ts | 86 +++++----- .../router/vacation-management-procedures.ts | 150 ++++++++++-------- 6 files changed, 245 insertions(+), 129 deletions(-) diff --git a/packages/api/src/__tests__/assistant-tools-vacation-mutation-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-vacation-mutation-test-helpers.ts index 305d778..3c83604 100644 --- a/packages/api/src/__tests__/assistant-tools-vacation-mutation-test-helpers.ts +++ b/packages/api/src/__tests__/assistant-tools-vacation-mutation-test-helpers.ts @@ -71,7 +71,7 @@ export function createHappyPathDb() { return null; }); - return { + const db = { user: { findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "MANAGER" }), }, @@ -111,6 +111,11 @@ export function createHappyPathDb() { }, vacation: { findUnique: vacationFindUnique, + findUniqueOrThrow: vi.fn().mockImplementation(async (args?: any) => { + const result = await vacationFindUnique({ where: { id: args?.where?.id } }); + if (!result) throw new Error("Vacation not found"); + return result; + }), findMany: vi.fn().mockResolvedValue([]), findFirst: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue({ @@ -147,6 +152,7 @@ export function createHappyPathDb() { approvedById: args?.data?.approvedById ?? existing?.approvedById ?? null, }; }), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, notification: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), @@ -158,9 +164,9 @@ export function createHappyPathDb() { webhook: { findMany: vi.fn().mockResolvedValue([]), }, - }; + } as any; - (db as Record).$transaction = vi.fn( + db.$transaction = vi.fn( async (callback: (tx: typeof db) => Promise) => callback(db), ); diff --git a/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts b/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts index 285fc5f..17a6912 100644 --- a/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts +++ b/packages/api/src/__tests__/assistant-tools-vacation-review-cancel.test.ts @@ -72,15 +72,15 @@ describe("assistant vacation mutation tools", () => { message: "Rejected vacation for Alice Example: Capacity freeze", }), ); - expect(db.vacation.update).toHaveBeenCalledWith( + expect(db.vacation.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: "vac_cancelled" }, + where: expect.objectContaining({ id: "vac_cancelled" }), data: expect.objectContaining({ status: "APPROVED" }), }), ); - expect(db.vacation.update).toHaveBeenCalledWith( + expect(db.vacation.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: "vac_pending" }, + where: expect.objectContaining({ id: "vac_pending" }), data: expect.objectContaining({ status: "REJECTED", rejectionReason: "Capacity freeze" }), }), ); diff --git a/packages/api/src/__tests__/scenario-apply.test.ts b/packages/api/src/__tests__/scenario-apply.test.ts index 41fceb2..31f90ca 100644 --- a/packages/api/src/__tests__/scenario-apply.test.ts +++ b/packages/api/src/__tests__/scenario-apply.test.ts @@ -10,8 +10,9 @@ function makeDb(overrides: { assignmentUpdate?: ReturnType; assignmentCreate?: ReturnType; resourceFindUnique?: ReturnType; + transaction?: ReturnType; }) { - return { + const db = { project: { findUnique: overrides.projectFindUnique ?? vi.fn().mockResolvedValue({ id: "project_1", name: "Test Project" }), }, @@ -22,6 +23,10 @@ function makeDb(overrides: { resource: { findUnique: overrides.resourceFindUnique ?? vi.fn().mockResolvedValue({ lcrCents: 100 }), }, + }; + return { + ...db, + $transaction: overrides.transaction ?? vi.fn(async (cb: (tx: typeof db) => Promise) => cb(db)), } as never; } @@ -151,4 +156,46 @@ describe("applyProjectScenario", () => { // appliedCount = 2 creates + 0 cancel = 2. expect(result.appliedCount).toBe(2); }); + + it("wraps all mutations in a single transaction", async () => { + assignmentCreate = vi.fn().mockResolvedValue({ id: "new_1" }); + assignmentUpdate = vi.fn().mockResolvedValue({}); + + // transaction mock that propagates the error so we can verify atomicity + const transaction = vi.fn(async (cb: (tx: unknown) => Promise) => cb({ + assignment: { update: assignmentUpdate, create: assignmentCreate }, + resource: { findUnique: resourceFindUnique }, + })); + + const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique, transaction }); + + await applyProjectScenario(db, { + projectId: "project_1", + changes: [{ ...baseChange, resourceId: "resource_1" }], + }); + + expect(transaction).toHaveBeenCalledTimes(1); + }); + + it("propagates transaction error so partial changes are rolled back", async () => { + assignmentCreate = vi.fn() + .mockResolvedValueOnce({ id: "new_1" }) + .mockRejectedValueOnce(new Error("DB constraint violation")); + + const transaction = vi.fn(async (cb: (tx: unknown) => Promise) => + cb({ assignment: { update: assignmentUpdate, create: assignmentCreate }, resource: { findUnique: resourceFindUnique } }), + ); + + const db = makeDb({ assignmentCreate, assignmentUpdate, resourceFindUnique, transaction }); + + await expect( + applyProjectScenario(db, { + projectId: "project_1", + changes: [ + { ...baseChange, resourceId: "resource_1" }, + { ...baseChange, resourceId: "resource_2" }, + ], + }), + ).rejects.toThrow("DB constraint violation"); + }); }); diff --git a/packages/api/src/__tests__/vacation-router.test.ts b/packages/api/src/__tests__/vacation-router.test.ts index f7bc591..012fe4f 100644 --- a/packages/api/src/__tests__/vacation-router.test.ts +++ b/packages/api/src/__tests__/vacation-router.test.ts @@ -152,6 +152,7 @@ function createVacationDb(overrides: Record = {}) { vacation: { findFirst: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(sampleVacation), + findUniqueOrThrow: vi.fn().mockResolvedValue(sampleVacation), findMany: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue(sampleVacation), update: vi.fn().mockResolvedValue(sampleVacation), @@ -331,10 +332,11 @@ describe("vacation router", () => { ...sampleVacation, status: VacationStatus.PENDING, }), - update: vi.fn().mockResolvedValue({ + findUniqueOrThrow: vi.fn().mockResolvedValue({ ...sampleVacation, status: VacationStatus.APPROVED, }), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, }); @@ -379,10 +381,11 @@ describe("vacation router", () => { ...sampleVacation, status: VacationStatus.PENDING, }), - update: vi.fn().mockResolvedValue({ + findUniqueOrThrow: vi.fn().mockResolvedValue({ ...sampleVacation, status: VacationStatus.APPROVED, }), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, }); @@ -896,7 +899,8 @@ describe("vacation router", () => { const db = createVacationDb({ vacation: { findUnique: vi.fn().mockResolvedValue(sampleVacation), - update: vi.fn().mockResolvedValue(updatedVacation), + findUniqueOrThrow: vi.fn().mockResolvedValue(updatedVacation), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, user: { findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }), @@ -910,9 +914,9 @@ describe("vacation router", () => { const result = await caller.approve({ id: "vac_1" }); expect(result.status).toBe(VacationStatus.APPROVED); - expect(db.vacation.update).toHaveBeenCalledWith( + expect(db.vacation.updateMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: "vac_1" }, + where: expect.objectContaining({ id: "vac_1" }), data: expect.objectContaining({ status: VacationStatus.APPROVED, rejectionReason: null, @@ -1016,7 +1020,8 @@ describe("vacation router", () => { const db = createVacationDb({ vacation: { findUnique: vi.fn().mockResolvedValue(sampleVacation), - update: vi.fn().mockResolvedValue(updatedVacation), + findUniqueOrThrow: vi.fn().mockResolvedValue(updatedVacation), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, resource: { findUnique: vi.fn().mockResolvedValue(null), @@ -1027,8 +1032,9 @@ describe("vacation router", () => { const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" }); expect(result.status).toBe(VacationStatus.REJECTED); - expect(db.vacation.update).toHaveBeenCalledWith( + expect(db.vacation.updateMany).toHaveBeenCalledWith( expect.objectContaining({ + where: expect.objectContaining({ id: "vac_1" }), data: expect.objectContaining({ status: VacationStatus.REJECTED, rejectionReason: "Team conflict", @@ -1458,8 +1464,8 @@ describe("vacation router", () => { }, vacation: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }), - findFirst: vi.fn().mockResolvedValue(null), - create: vi.fn().mockResolvedValue({}), + findMany: vi.fn().mockResolvedValue([]), + createMany: vi.fn().mockResolvedValue({ count: 0 }), }, }; @@ -1471,7 +1477,37 @@ describe("vacation router", () => { expect(result.created).toBeGreaterThan(0); expect(result.resources).toBe(2); - expect(db.vacation.create).toHaveBeenCalled(); + expect(db.vacation.createMany).toHaveBeenCalled(); + }); + + it("resolves holidays once per unique country/state combination", async () => { + // 3 resources: res_1 and res_2 share the same combo, res_3 is different + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { id: "res_1", federalState: "BY", countryId: "de", country: { code: "DE" }, metroCityId: null, metroCity: null }, + { id: "res_2", federalState: "BY", countryId: "de", country: { code: "DE" }, metroCityId: null, metroCity: null }, + { id: "res_3", federalState: "BE", countryId: "de", country: { code: "DE" }, metroCityId: null, metroCity: null }, + ]), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const caller = createAdminCaller(db); + const result = await caller.batchCreatePublicHolidays({ year: 2026 }); + + // 3 resources, 2 unique combos → holiday calendars queried twice, not 3 times + expect(db.holidayCalendar.findMany).toHaveBeenCalledTimes(2); + expect(result.resources).toBe(3); }); it("skips already existing holidays", async () => { @@ -1485,8 +1521,15 @@ describe("vacation router", () => { findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }), }, vacation: { - findFirst: vi.fn().mockResolvedValue({ id: "existing" }), - create: vi.fn(), + // Return one entry per day of 2026 so every holiday date is "already existing" + findMany: vi.fn().mockResolvedValue( + Array.from({ length: 365 }, (_, i) => { + const d = new Date(Date.UTC(2026, 0, 1)); + d.setUTCDate(d.getUTCDate() + i); + return { resourceId: "res_1", startDate: d }; + }), + ), + createMany: vi.fn(), }, }; @@ -1497,7 +1540,7 @@ describe("vacation router", () => { }); expect(result.created).toBe(0); - expect(db.vacation.create).not.toHaveBeenCalled(); + expect(db.vacation.createMany).not.toHaveBeenCalled(); }); it("forbids non-admin users", async () => { diff --git a/packages/api/src/router/scenario-apply.ts b/packages/api/src/router/scenario-apply.ts index 254df2a..55f8850 100644 --- a/packages/api/src/router/scenario-apply.ts +++ b/packages/api/src/router/scenario-apply.ts @@ -22,56 +22,58 @@ export async function applyProjectScenario( const created: string[] = []; - for (const change of changes) { - if (change.remove && change.assignmentId) { - await db.assignment.update({ - where: { id: change.assignmentId }, - data: { status: "CANCELLED" }, - }); - continue; - } + await db.$transaction(async (tx) => { + for (const change of changes) { + if (change.remove && change.assignmentId) { + await tx.assignment.update({ + where: { id: change.assignmentId }, + data: { status: "CANCELLED" }, + }); + continue; + } - if (change.assignmentId) { - await db.assignment.update({ - where: { id: change.assignmentId }, + if (change.assignmentId) { + await tx.assignment.update({ + where: { id: change.assignmentId }, + data: { + startDate: change.startDate, + endDate: change.endDate, + hoursPerDay: change.hoursPerDay, + ...(change.resourceId ? { resourceId: change.resourceId } : {}), + ...(change.roleId ? { roleId: change.roleId } : {}), + }, + }); + created.push(change.assignmentId); + continue; + } + + if (!change.resourceId) { + continue; + } + + const resource = await tx.resource.findUnique({ + where: { id: change.resourceId }, + select: { lcrCents: true }, + }); + const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay); + + const newAssignment = await tx.assignment.create({ data: { + projectId, + resourceId: change.resourceId, + ...(change.roleId ? { roleId: change.roleId } : {}), startDate: change.startDate, endDate: change.endDate, hoursPerDay: change.hoursPerDay, - ...(change.resourceId ? { resourceId: change.resourceId } : {}), - ...(change.roleId ? { roleId: change.roleId } : {}), + percentage: 100, + dailyCostCents, + status: "PROPOSED", + metadata: {}, }, }); - created.push(change.assignmentId); - continue; + created.push(newAssignment.id); } - - if (!change.resourceId) { - continue; - } - - const resource = await db.resource.findUnique({ - where: { id: change.resourceId }, - select: { lcrCents: true }, - }); - const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay); - - const newAssignment = await db.assignment.create({ - data: { - projectId, - resourceId: change.resourceId, - ...(change.roleId ? { roleId: change.roleId } : {}), - startDate: change.startDate, - endDate: change.endDate, - hoursPerDay: change.hoursPerDay, - percentage: 100, - dailyCostCents, - status: "PROPOSED", - metadata: {}, - }, - }); - created.push(newAssignment.id); - } + }); void createAuditEntry({ db, diff --git a/packages/api/src/router/vacation-management-procedures.ts b/packages/api/src/router/vacation-management-procedures.ts index 7fc4e3e..938dbfd 100644 --- a/packages/api/src/router/vacation-management-procedures.ts +++ b/packages/api/src/router/vacation-management-procedures.ts @@ -5,7 +5,7 @@ import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; -import { createAuditEntry } from "../lib/audit.js"; +import { makeAuditLogger } from "../lib/audit-helpers.js"; import { checkBatchVacationConflicts, checkVacationConflicts } from "../lib/vacation-conflicts.js"; import { emitVacationUpdated } from "../sse/event-bus.js"; import { adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; @@ -20,6 +20,7 @@ import { notifyVacationStatusInBackground, } from "./vacation-side-effects.js"; import { + approvableVacationStatuses, assertVacationApprovable, assertVacationCancelable, assertVacationRejectable, @@ -73,6 +74,7 @@ export const vacationManagementProcedures = { }); const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); + const audit = makeAuditLogger(ctx.db, userRecord?.id); const conflictResult = await checkVacationConflicts( // eslint-disable-next-line @typescript-eslint/no-explicit-any ctx.db as any, @@ -80,27 +82,29 @@ export const vacationManagementProcedures = { userRecord?.id, ); - const updated = await ctx.db.vacation.update({ - where: { id: input.id }, + const approveResult = await ctx.db.vacation.updateMany({ + where: { id: input.id, status: { in: approvableVacationStatuses as VacationStatus[] } }, data: buildApprovedVacationUpdateData({ deductionSnapshotWriteData, approvedById: userRecord?.id, approvedAt: new Date(), }), }); + if (approveResult.count === 0) { + throw new TRPCError({ code: "CONFLICT", message: "Vacation was already processed by another request" }); + } + + const updated = await ctx.db.vacation.findUniqueOrThrow({ where: { id: input.id } }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); - void createAuditEntry({ - db: ctx.db, + audit({ entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", - ...(userRecord?.id ? { userId: userRecord.id } : {}), before: existing as unknown as Record, after: updated as unknown as Record, - source: "ui", summary: `Approved vacation (was ${existing.status})`, }); @@ -129,28 +133,31 @@ export const vacationManagementProcedures = { ); assertVacationRejectable(existing.status); - const updated = await ctx.db.vacation.update({ - where: { id: input.id }, + const rejectResult = await ctx.db.vacation.updateMany({ + where: { id: input.id, status: VacationStatus.PENDING }, data: buildRejectedVacationUpdateData({ rejectionReason: input.rejectionReason, }), }); + if (rejectResult.count === 0) { + throw new TRPCError({ code: "CONFLICT", message: "Vacation was already processed by another request" }); + } + + const updated = await ctx.db.vacation.findUniqueOrThrow({ where: { id: input.id } }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); + const audit = makeAuditLogger(ctx.db, userRecord?.id); await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id); - void createAuditEntry({ - db: ctx.db, + audit({ entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", - ...(userRecord?.id ? { userId: userRecord.id } : {}), before: existing as unknown as Record, after: updated as unknown as Record, - source: "ui", summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`, }); @@ -169,6 +176,7 @@ export const vacationManagementProcedures = { .input(z.object({ ids: z.array(z.string()).min(1).max(100) })) .mutation(async ({ ctx, input }) => { const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); + const audit = makeAuditLogger(ctx.db, userRecord?.id); const vacations = await ctx.db.vacation.findMany({ where: { id: { in: input.ids }, status: VacationStatus.PENDING }, @@ -193,39 +201,53 @@ export const vacationManagementProcedures = { userRecord?.id, ); - for (const vacation of vacations) { - const deductionSnapshotWriteData = await buildVacationApprovalWriteData(ctx.db, { - resourceId: vacation.resourceId, - type: vacation.type, - startDate: vacation.startDate, - endDate: vacation.endDate, - isHalfDay: vacation.isHalfDay, - }); - const updated = await ctx.db.vacation.update({ - where: { id: vacation.id }, - data: buildApprovedVacationUpdateData({ - deductionSnapshotWriteData, - approvedById: userRecord?.id, - approvedAt: new Date(), + // Pre-compute read-only deduction data before opening the transaction + const approvalPayloads = await Promise.all( + vacations.map(async (vacation) => ({ + vacation, + writeData: await buildVacationApprovalWriteData(ctx.db, { + resourceId: vacation.resourceId, + type: vacation.type, + startDate: vacation.startDate, + endDate: vacation.endDate, + isHalfDay: vacation.isHalfDay, }), - }); + })), + ); - emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); - notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED); + // Execute all writes atomically, collect side-effect payloads + const approvedNow: Array<{ id: string; resourceId: string; status: typeof VacationStatus.APPROVED }> = []; + const approvedAt = new Date(); - void createAuditEntry({ - db: ctx.db, + await ctx.db.$transaction(async (tx) => { + approvedNow.length = 0; + for (const { vacation, writeData } of approvalPayloads) { + const updated = await tx.vacation.update({ + where: { id: vacation.id }, + data: buildApprovedVacationUpdateData({ + deductionSnapshotWriteData: writeData, + approvedById: userRecord?.id, + approvedAt, + }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await completeVacationApprovalTasks(tx as any, updated.id, userRecord?.id); + approvedNow.push({ id: updated.id, resourceId: updated.resourceId, status: VacationStatus.APPROVED }); + } + }); + + // Side effects — dispatched after the transaction commits + for (const entry of approvedNow) { + emitVacationUpdated(entry); + notifyVacationStatusInBackground(ctx.db, entry.id, entry.resourceId, VacationStatus.APPROVED); + audit({ entityType: "Vacation", - entityId: updated.id, - entityName: `Vacation ${updated.id}`, + entityId: entry.id, + entityName: `Vacation ${entry.id}`, action: "UPDATE", - ...(userRecord?.id ? { userId: userRecord.id } : {}), - after: updated as unknown as Record, - source: "ui", + after: entry as unknown as Record, summary: "Batch approved vacation", }); - - await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id); } const warnings: string[] = []; @@ -245,6 +267,7 @@ export const vacationManagementProcedures = { ) .mutation(async ({ ctx, input }) => { const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); + const audit = makeAuditLogger(ctx.db, userRecord?.id); const vacations = await ctx.db.vacation.findMany({ where: { id: { in: input.ids }, status: VacationStatus.PENDING }, @@ -268,15 +291,12 @@ export const vacationManagementProcedures = { input.rejectionReason, ); - void createAuditEntry({ - db: ctx.db, + audit({ entityType: "Vacation", entityId: vacation.id, entityName: `Vacation ${vacation.id}`, action: "UPDATE", - ...(userRecord?.id ? { userId: userRecord.id } : {}), after: { status: VacationStatus.REJECTED, rejectionReason: input.rejectionReason } as unknown as Record, - source: "ui", summary: `Batch rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`, }); @@ -296,6 +316,7 @@ export const vacationManagementProcedures = { assertVacationCancelable(existing.status); const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); + const audit = makeAuditLogger(ctx.db, userRecord?.id); if (!userRecord) { throw new TRPCError({ code: "UNAUTHORIZED" }); } @@ -325,31 +346,32 @@ export const vacationManagementProcedures = { typeof existing.deductedDays === "number" && existing.deductedDays > 0; - const updated = await ctx.db.vacation.update({ - where: { id: input.id }, - data: { status: VacationStatus.CANCELLED }, - }); - - if (shouldReverseEntitlement) { - const year = existing.startDate.getFullYear(); - await ctx.db.vacationEntitlement.updateMany({ - where: { resourceId: existing.resourceId, year }, - data: { usedDays: { decrement: existing.deductedDays as number } }, + const updated = await ctx.db.$transaction(async (tx) => { + const cancelledVacation = await tx.vacation.update({ + where: { id: input.id }, + data: { status: VacationStatus.CANCELLED }, }); - } + + if (shouldReverseEntitlement) { + const year = existing.startDate.getFullYear(); + await tx.vacationEntitlement.updateMany({ + where: { resourceId: existing.resourceId, year }, + data: { usedDays: { decrement: existing.deductedDays as number } }, + }); + } + + return cancelledVacation; + }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); - void createAuditEntry({ - db: ctx.db, + audit({ entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", - userId: userRecord.id, before: existing as unknown as Record, after: updated as unknown as Record, - source: "ui", summary: `Cancelled vacation (was ${existing.status})`, }); @@ -377,6 +399,7 @@ export const vacationManagementProcedures = { if (!adminUser) { throw new TRPCError({ code: "UNAUTHORIZED" }); } + const audit = makeAuditLogger(ctx.db, adminUser.id); const { created, holidays, resources } = await batchCreatePublicHolidayVacations( ctx.db, @@ -384,15 +407,12 @@ export const vacationManagementProcedures = { adminUser.id, ); - void createAuditEntry({ - db: ctx.db, + audit({ entityType: "Vacation", entityId: `public-holidays-${input.year}`, entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`, action: "CREATE", - userId: adminUser.id, after: { created, holidays, resources, year: input.year, federalState: input.federalState } as unknown as Record, - source: "ui", summary: `Batch created ${created} public holidays for ${resources} resources (${input.year})`, }); @@ -408,6 +428,7 @@ export const vacationManagementProcedures = { ); const userRecord = await findVacationActor(ctx.db, ctx.session.user?.email); + const audit = makeAuditLogger(ctx.db, userRecord?.id); if (!userRecord) { throw new TRPCError({ code: "UNAUTHORIZED" }); } @@ -428,16 +449,13 @@ export const vacationManagementProcedures = { emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); - void createAuditEntry({ - db: ctx.db, + audit({ entityType: "Vacation", entityId: updated.id, entityName: `Vacation ${updated.id}`, action: "UPDATE", - userId: userRecord.id, before: existing as unknown as Record, after: updated as unknown as Record, - source: "ui", summary: `Updated vacation status to ${input.status}`, });