diff --git a/packages/api/src/__tests__/timeline-allocation-shift-support.test.ts b/packages/api/src/__tests__/timeline-allocation-shift-support.test.ts new file mode 100644 index 0000000..77bf7e3 --- /dev/null +++ b/packages/api/src/__tests__/timeline-allocation-shift-support.test.ts @@ -0,0 +1,146 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; + +const { + findAllocationEntryMock, + updateAllocationEntryMock, +} = vi.hoisted(() => ({ + findAllocationEntryMock: vi.fn(), + updateAllocationEntryMock: vi.fn(), +})); + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findAllocationEntry: findAllocationEntryMock, + updateAllocationEntry: updateAllocationEntryMock, + }; +}); + +import { applyTimelineBatchAllocationShift } from "../router/timeline-allocation-shift-support.js"; + +describe("timeline allocation shift support", () => { + it("returns an empty result when the shift delta is zero", async () => { + const db = { + $transaction: vi.fn(), + }; + + await expect(applyTimelineBatchAllocationShift({ + db: db as never, + allocationIds: ["allocation_1"], + daysDelta: 0, + mode: "move", + })).resolves.toEqual([]); + + expect(findAllocationEntryMock).not.toHaveBeenCalled(); + expect(db.$transaction).not.toHaveBeenCalled(); + }); + + it("throws when no allocations can be resolved", async () => { + findAllocationEntryMock.mockResolvedValue(null); + + await expect(applyTimelineBatchAllocationShift({ + db: { + $transaction: vi.fn(), + } as never, + allocationIds: ["missing_1", "missing_2"], + daysDelta: 2, + mode: "move", + })).rejects.toThrowError(new TRPCError({ + code: "NOT_FOUND", + message: "No allocations found", + })); + }); + + it("updates resolved allocations and writes a batch audit record", async () => { + findAllocationEntryMock + .mockResolvedValueOnce({ + entry: { + id: "allocation_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + }, + }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + entry: { + id: "allocation_3", + startDate: new Date("2026-04-10T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + }, + }); + + updateAllocationEntryMock + .mockResolvedValueOnce({ + allocation: { id: "allocation_1", projectId: "project_1", resourceId: "resource_1" }, + }) + .mockResolvedValueOnce({ + allocation: { id: "allocation_3", projectId: "project_2", resourceId: "resource_2" }, + }); + + const tx = { + auditLog: { + create: vi.fn().mockResolvedValue({}), + }, + }; + const db = { + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + }; + + const result = await applyTimelineBatchAllocationShift({ + db: db as never, + allocationIds: ["allocation_1", "missing_2", "allocation_3"], + daysDelta: 2, + mode: "move", + }); + + expect(result).toEqual([ + { id: "allocation_1", projectId: "project_1", resourceId: "resource_1" }, + { id: "allocation_3", projectId: "project_2", resourceId: "resource_2" }, + ]); + expect(updateAllocationEntryMock).toHaveBeenNthCalledWith( + 1, + tx, + { + id: "allocation_1", + demandRequirementUpdate: { + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + }, + assignmentUpdate: { + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-07T00:00:00.000Z"), + }, + }, + ); + expect(updateAllocationEntryMock).toHaveBeenNthCalledWith( + 2, + tx, + { + id: "allocation_3", + demandRequirementUpdate: { + startDate: new Date("2026-04-12T00:00:00.000Z"), + endDate: new Date("2026-04-14T00:00:00.000Z"), + }, + assignmentUpdate: { + startDate: new Date("2026-04-12T00:00:00.000Z"), + endDate: new Date("2026-04-14T00:00:00.000Z"), + }, + }, + ); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: { + entityType: "Allocation", + entityId: "allocation_1,missing_2,allocation_3", + action: "UPDATE", + changes: { + operation: "batchShift", + mode: "move", + daysDelta: 2, + count: 2, + }, + }, + }); + }); +}); diff --git a/packages/api/src/router/timeline-allocation-mutations.ts b/packages/api/src/router/timeline-allocation-mutations.ts index 12e4e92..d8e3a54 100644 --- a/packages/api/src/router/timeline-allocation-mutations.ts +++ b/packages/api/src/router/timeline-allocation-mutations.ts @@ -1,7 +1,6 @@ import { buildSplitAllocationReadModel, createAssignment, - findAllocationEntry, loadAllocationEntry, updateAllocationEntry, } from "@capakraken/application"; @@ -25,11 +24,10 @@ import { buildTimelineAllocationEntryUpdate, buildTimelineAllocationMetadata, buildTimelineAllocationUpdateAuditChanges, - buildTimelineBatchShiftAuditChanges, buildTimelineQuickAssignAssignmentInput, - shiftTimelineAllocationWindow, validateTimelineAllocationDateRanges, } from "./timeline-allocation-mutation-support.js"; +import { applyTimelineBatchAllocationShift } from "./timeline-allocation-shift-support.js"; import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; export const timelineAllocationMutationProcedures = { @@ -227,63 +225,11 @@ export const timelineAllocationMutationProcedures = { .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - if (input.daysDelta === 0) { - return { count: 0 }; - } - - const entries = await Promise.all( - input.allocationIds.map((allocationId) => findAllocationEntry(ctx.db, allocationId)), - ); - const resolved = entries.filter( - (entry): entry is NonNullable => entry !== null, - ); - - if (resolved.length === 0) { - throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" }); - } - - const results = await ctx.db.$transaction(async (tx) => { - const updated = []; - for (const entry of resolved) { - const existing = entry.entry; - const shiftedWindow = shiftTimelineAllocationWindow({ - startDate: existing.startDate, - endDate: existing.endDate, - daysDelta: input.daysDelta, - mode: input.mode, - }); - - const result = await updateAllocationEntry( - tx as unknown as Parameters[0], - { - id: existing.id, - demandRequirementUpdate: { - startDate: shiftedWindow.startDate, - endDate: shiftedWindow.endDate, - }, - assignmentUpdate: { - startDate: shiftedWindow.startDate, - endDate: shiftedWindow.endDate, - }, - }, - ); - updated.push(result.allocation); - } - - await tx.auditLog.create({ - data: { - entityType: "Allocation", - entityId: input.allocationIds.join(","), - action: "UPDATE", - changes: buildTimelineBatchShiftAuditChanges({ - mode: input.mode, - daysDelta: input.daysDelta, - count: resolved.length, - }), - }, - }); - - return updated; + const results = await applyTimelineBatchAllocationShift({ + db: ctx.db as PrismaClient, + allocationIds: input.allocationIds, + daysDelta: input.daysDelta, + mode: input.mode, }); for (const allocation of results) { diff --git a/packages/api/src/router/timeline-allocation-shift-support.ts b/packages/api/src/router/timeline-allocation-shift-support.ts new file mode 100644 index 0000000..14a67af --- /dev/null +++ b/packages/api/src/router/timeline-allocation-shift-support.ts @@ -0,0 +1,74 @@ +import { findAllocationEntry, updateAllocationEntry } from "@capakraken/application"; +import type { PrismaClient } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; +import { + buildTimelineBatchShiftAuditChanges, + shiftTimelineAllocationWindow, + type TimelineBatchShiftMode, +} from "./timeline-allocation-mutation-support.js"; + +export async function applyTimelineBatchAllocationShift(input: { + db: PrismaClient; + allocationIds: string[]; + daysDelta: number; + mode: TimelineBatchShiftMode; +}) { + if (input.daysDelta === 0) { + return []; + } + + const entries = await Promise.all( + input.allocationIds.map((allocationId) => findAllocationEntry(input.db, allocationId)), + ); + const resolved = entries.filter( + (entry): entry is NonNullable => entry !== null, + ); + + if (resolved.length === 0) { + throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" }); + } + + return input.db.$transaction(async (tx) => { + const updated = []; + for (const entry of resolved) { + const existing = entry.entry; + const shiftedWindow = shiftTimelineAllocationWindow({ + startDate: existing.startDate, + endDate: existing.endDate, + daysDelta: input.daysDelta, + mode: input.mode, + }); + + const result = await updateAllocationEntry( + tx as unknown as Parameters[0], + { + id: existing.id, + demandRequirementUpdate: { + startDate: shiftedWindow.startDate, + endDate: shiftedWindow.endDate, + }, + assignmentUpdate: { + startDate: shiftedWindow.startDate, + endDate: shiftedWindow.endDate, + }, + }, + ); + updated.push(result.allocation); + } + + await tx.auditLog.create({ + data: { + entityType: "Allocation", + entityId: input.allocationIds.join(","), + action: "UPDATE", + changes: buildTimelineBatchShiftAuditChanges({ + mode: input.mode, + daysDelta: input.daysDelta, + count: resolved.length, + }), + }, + }); + + return updated; + }); +}