From 109bf706998093d1a0da81e9de9e9ef7cd095bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 17:45:54 +0200 Subject: [PATCH] refactor(api): extract timeline allocation procedure support --- ...eline-allocation-procedure-support.test.ts | 180 ++++++++++++++++++ .../router/timeline-allocation-mutations.ts | 88 ++------- .../timeline-allocation-procedure-support.ts | 95 +++++++++ 3 files changed, 289 insertions(+), 74 deletions(-) create mode 100644 packages/api/src/__tests__/timeline-allocation-procedure-support.test.ts create mode 100644 packages/api/src/router/timeline-allocation-procedure-support.ts diff --git a/packages/api/src/__tests__/timeline-allocation-procedure-support.test.ts b/packages/api/src/__tests__/timeline-allocation-procedure-support.test.ts new file mode 100644 index 0000000..f710666 --- /dev/null +++ b/packages/api/src/__tests__/timeline-allocation-procedure-support.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@capakraken/application", () => ({ + buildSplitAllocationReadModel: vi.fn(), + createAssignment: vi.fn(), +})); + +vi.mock("../sse/event-bus.js", () => ({ + emitAllocationCreated: vi.fn(), + emitAllocationUpdated: vi.fn(), +})); + +vi.mock("../router/timeline-allocation-shift-support.js", () => ({ + applyTimelineBatchAllocationShift: vi.fn(), +})); + +import { + buildSplitAllocationReadModel, + createAssignment, +} from "@capakraken/application"; +import { + emitAllocationCreated, + emitAllocationUpdated, +} from "../sse/event-bus.js"; +import { + createTimelineBatchQuickAssignments, + createTimelineQuickAssignment, + shiftTimelineAllocations, +} from "../router/timeline-allocation-procedure-support.js"; +import { applyTimelineBatchAllocationShift } from "../router/timeline-allocation-shift-support.js"; + +const buildSplitAllocationReadModelMock = vi.mocked(buildSplitAllocationReadModel); +const createAssignmentMock = vi.mocked(createAssignment); +const emitAllocationCreatedMock = vi.mocked(emitAllocationCreated); +const emitAllocationUpdatedMock = vi.mocked(emitAllocationUpdated); +const applyTimelineBatchAllocationShiftMock = vi.mocked(applyTimelineBatchAllocationShift); + +describe("timeline allocation procedure support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a quick assignment and emits the allocation created event", async () => { + const db = { + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + } as never; + + createAssignmentMock.mockResolvedValueOnce({ id: "assignment_1" } as never); + buildSplitAllocationReadModelMock.mockReturnValueOnce({ + allocations: [ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + }, + ], + } as never); + + await expect( + createTimelineQuickAssignment(db, { + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + role: "Team Member", + status: "PROPOSED", + source: "quickAssign", + }), + ).resolves.toEqual({ + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + }); + + expect(createAssignmentMock).toHaveBeenCalledOnce(); + expect(emitAllocationCreatedMock).toHaveBeenCalledWith({ + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + }); + }); + + it("creates batch quick assignments and emits events for each result", async () => { + const db = { + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + } as never; + + createAssignmentMock + .mockResolvedValueOnce({ + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + } as never) + .mockResolvedValueOnce({ + id: "assignment_2", + projectId: "project_2", + resourceId: "resource_2", + } as never); + + await expect( + createTimelineBatchQuickAssignments(db, { + assignments: [ + { + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + hoursPerDay: 8, + role: "Team Member", + status: "PROPOSED", + source: "batchQuickAssign", + }, + { + resourceId: "resource_2", + projectId: "project_2", + startDate: new Date("2026-04-06T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + hoursPerDay: 6, + role: "Lead", + status: "PROPOSED", + source: "batchQuickAssign", + }, + ], + }), + ).resolves.toEqual({ count: 2 }); + + expect(createAssignmentMock).toHaveBeenCalledTimes(2); + expect(emitAllocationCreatedMock).toHaveBeenNthCalledWith(1, { + id: "assignment_1", + projectId: "project_1", + resourceId: "resource_1", + }); + expect(emitAllocationCreatedMock).toHaveBeenNthCalledWith(2, { + id: "assignment_2", + projectId: "project_2", + resourceId: "resource_2", + }); + }); + + it("applies allocation shifts and emits update events", async () => { + applyTimelineBatchAllocationShiftMock.mockResolvedValueOnce([ + { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + }, + { + id: "allocation_2", + projectId: "project_2", + resourceId: "resource_2", + }, + ] as never); + + await expect( + shiftTimelineAllocations({} as never, { + allocationIds: ["allocation_1", "allocation_2"], + daysDelta: 2, + mode: "move", + }), + ).resolves.toEqual({ count: 2 }); + + expect(applyTimelineBatchAllocationShiftMock).toHaveBeenCalledWith({ + db: {}, + allocationIds: ["allocation_1", "allocation_2"], + daysDelta: 2, + mode: "move", + }); + expect(emitAllocationUpdatedMock).toHaveBeenNthCalledWith(1, { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + }); + expect(emitAllocationUpdatedMock).toHaveBeenNthCalledWith(2, { + id: "allocation_2", + projectId: "project_2", + resourceId: "resource_2", + }); + }); +}); diff --git a/packages/api/src/router/timeline-allocation-mutations.ts b/packages/api/src/router/timeline-allocation-mutations.ts index ae8cfcf..603e745 100644 --- a/packages/api/src/router/timeline-allocation-mutations.ts +++ b/packages/api/src/router/timeline-allocation-mutations.ts @@ -1,7 +1,3 @@ -import { - buildSplitAllocationReadModel, - createAssignment, -} from "@capakraken/application"; import type { PrismaClient } from "@capakraken/db"; import { AllocationStatus, @@ -9,18 +5,14 @@ import { UpdateAllocationHoursSchema, } from "@capakraken/shared"; import { z } from "zod"; -import { - emitAllocationCreated, - emitAllocationUpdated, -} from "../sse/event-bus.js"; +import { emitAllocationUpdated } from "../sse/event-bus.js"; import { managerProcedure, requirePermission } from "../trpc.js"; import { - assertTimelineDateRangeValid, - buildTimelineQuickAssignAssignmentInput, - validateTimelineAllocationDateRanges, -} from "./timeline-allocation-mutation-support.js"; + createTimelineBatchQuickAssignments, + createTimelineQuickAssignment, + shiftTimelineAllocations, +} from "./timeline-allocation-procedure-support.js"; import { applyTimelineInlineAllocationUpdate } from "./timeline-allocation-inline-support.js"; -import { applyTimelineBatchAllocationShift } from "./timeline-allocation-shift-support.js"; export const timelineAllocationMutationProcedures = { updateAllocationInline: managerProcedure @@ -61,30 +53,10 @@ export const timelineAllocationMutationProcedures = { ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - assertTimelineDateRangeValid(input.startDate, input.endDate); - - const allocation = await ctx.db.$transaction(async (tx) => { - const assignment = await createAssignment( - tx as unknown as Parameters[0], - buildTimelineQuickAssignAssignmentInput({ - ...input, - source: "quickAssign", - }), - ); - - return buildSplitAllocationReadModel({ - demandRequirements: [], - assignments: [assignment], - }).allocations[0]!; + return createTimelineQuickAssignment(ctx.db as PrismaClient, { + ...input, + source: "quickAssign", }); - - emitAllocationCreated({ - id: allocation.id, - projectId: allocation.projectId, - resourceId: allocation.resourceId, - }); - - return allocation; }), batchQuickAssign: managerProcedure @@ -110,32 +82,12 @@ export const timelineAllocationMutationProcedures = { ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - validateTimelineAllocationDateRanges(input.assignments); - - const results = await ctx.db.$transaction(async (tx) => { - const created = []; - for (const assignment of input.assignments) { - const createdAssignment = await createAssignment( - tx as unknown as Parameters[0], - buildTimelineQuickAssignAssignmentInput({ - ...assignment, - source: "batchQuickAssign", - }), - ); - created.push(createdAssignment); - } - return created; + return createTimelineBatchQuickAssignments(ctx.db as PrismaClient, { + assignments: input.assignments.map((assignment) => ({ + ...assignment, + source: "batchQuickAssign" as const, + })), }); - - for (const assignment of results) { - emitAllocationCreated({ - id: assignment.id, - projectId: assignment.projectId, - resourceId: assignment.resourceId, - }); - } - - return { count: results.length }; }), batchShiftAllocations: managerProcedure @@ -148,22 +100,10 @@ export const timelineAllocationMutationProcedures = { ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - - const results = await applyTimelineBatchAllocationShift({ - db: ctx.db as PrismaClient, + return shiftTimelineAllocations(ctx.db as PrismaClient, { allocationIds: input.allocationIds, daysDelta: input.daysDelta, mode: input.mode, }); - - for (const allocation of results) { - emitAllocationUpdated({ - id: allocation.id, - projectId: allocation.projectId, - resourceId: allocation.resourceId, - }); - } - - return { count: results.length }; }), }; diff --git a/packages/api/src/router/timeline-allocation-procedure-support.ts b/packages/api/src/router/timeline-allocation-procedure-support.ts new file mode 100644 index 0000000..79a2ba4 --- /dev/null +++ b/packages/api/src/router/timeline-allocation-procedure-support.ts @@ -0,0 +1,95 @@ +import { + buildSplitAllocationReadModel, + createAssignment, +} from "@capakraken/application"; +import type { PrismaClient } from "@capakraken/db"; +import { + emitAllocationCreated, + emitAllocationUpdated, +} from "../sse/event-bus.js"; +import { + assertTimelineDateRangeValid, + buildTimelineQuickAssignAssignmentInput, + validateTimelineAllocationDateRanges, +} from "./timeline-allocation-mutation-support.js"; +import { applyTimelineBatchAllocationShift } from "./timeline-allocation-shift-support.js"; + +export async function createTimelineQuickAssignment( + db: PrismaClient, + input: Parameters[0], +) { + assertTimelineDateRangeValid(input.startDate, input.endDate); + + const allocation = await db.$transaction(async (tx) => { + const assignment = await createAssignment( + tx as unknown as Parameters[0], + buildTimelineQuickAssignAssignmentInput(input), + ); + + return buildSplitAllocationReadModel({ + demandRequirements: [], + assignments: [assignment], + }).allocations[0]!; + }); + + emitAllocationCreated({ + id: allocation.id, + projectId: allocation.projectId, + resourceId: allocation.resourceId, + }); + + return allocation; +} + +export async function createTimelineBatchQuickAssignments( + db: PrismaClient, + input: { + assignments: Array[0]>; + }, +) { + validateTimelineAllocationDateRanges(input.assignments); + + const results = await db.$transaction(async (tx) => { + const created = []; + for (const assignment of input.assignments) { + const createdAssignment = await createAssignment( + tx as unknown as Parameters[0], + buildTimelineQuickAssignAssignmentInput(assignment), + ); + created.push(createdAssignment); + } + return created; + }); + + for (const assignment of results) { + emitAllocationCreated({ + id: assignment.id, + projectId: assignment.projectId, + resourceId: assignment.resourceId, + }); + } + + return { count: results.length }; +} + +export async function shiftTimelineAllocations( + db: PrismaClient, + input: Omit[0], "db">, +) { + const results = await applyTimelineBatchAllocationShift({ + db, + allocationIds: input.allocationIds, + daysDelta: input.daysDelta, + mode: input.mode, + }); + + for (const allocation of results) { + emitAllocationUpdated({ + id: allocation.id, + projectId: allocation.projectId, + resourceId: allocation.resourceId, + }); + } + + return { count: results.length }; +}