From 9553aa0544cacbf8579eecc4506d8f9f14086d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 23:46:23 +0200 Subject: [PATCH] feat(api): add timeline allocation fragment support --- ...meline-allocation-fragment-support.test.ts | 175 +++++++++++++++++ .../timeline-allocation-fragment-support.ts | 185 ++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 packages/api/src/__tests__/timeline-allocation-fragment-support.test.ts create mode 100644 packages/api/src/router/timeline-allocation-fragment-support.ts diff --git a/packages/api/src/__tests__/timeline-allocation-fragment-support.test.ts b/packages/api/src/__tests__/timeline-allocation-fragment-support.test.ts new file mode 100644 index 0000000..12edff6 --- /dev/null +++ b/packages/api/src/__tests__/timeline-allocation-fragment-support.test.ts @@ -0,0 +1,175 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + loadAllocationEntryMock, + updateAssignmentMock, + createAssignmentMock, + deleteAllocationEntryMock, +} = vi.hoisted(() => ({ + loadAllocationEntryMock: vi.fn(), + updateAssignmentMock: vi.fn(), + createAssignmentMock: vi.fn(), + deleteAllocationEntryMock: vi.fn(), +})); + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadAllocationEntry: loadAllocationEntryMock, + updateAssignment: updateAssignmentMock, + createAssignment: createAssignmentMock, + deleteAllocationEntry: deleteAllocationEntryMock, + }; +}); + +import { carveTimelineAllocationRange } from "../router/timeline-allocation-fragment-support.js"; + +function createResolvedAssignment() { + return { + kind: "assignment" as const, + entry: { + id: "assignment_1", + startDate: new Date("2026-04-06T00:00:00.000Z"), + endDate: new Date("2026-04-17T00:00:00.000Z"), + hoursPerDay: 8, + metadata: {}, + }, + assignment: { + id: "assignment_1", + demandRequirementId: null, + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-04-06T00:00:00.000Z"), + endDate: new Date("2026-04-17T00:00:00.000Z"), + hoursPerDay: 8, + percentage: 100, + role: "Artist", + roleId: "role_1", + dailyCostCents: 80000, + status: "ACTIVE", + metadata: {}, + }, + projectId: "project_1", + resourceId: "resource_1", + }; +} + +describe("timeline allocation fragment support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("splits an assignment into left and right fragments around the carved range", async () => { + loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment()); + updateAssignmentMock.mockResolvedValue({ id: "assignment_1" }); + createAssignmentMock.mockResolvedValue({ id: "assignment_2" }); + + const db = { + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + }; + + const result = await carveTimelineAllocationRange({ + db: db as never, + allocationId: "assignment_1", + startDate: new Date("2026-04-09T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }); + + expect(result).toEqual({ + action: "split", + allocationGroupId: expect.any(String), + updatedAllocationIds: ["assignment_1"], + createdAllocationIds: ["assignment_2"], + deletedAllocationIds: [], + projectId: "project_1", + resourceId: "resource_1", + }); + expect(updateAssignmentMock).toHaveBeenCalledWith( + db, + "assignment_1", + expect.objectContaining({ + startDate: new Date("2026-04-06T00:00:00.000Z"), + endDate: new Date("2026-04-08T00:00:00.000Z"), + }), + ); + expect(createAssignmentMock).toHaveBeenCalledWith( + db, + expect.objectContaining({ + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-04-11T00:00:00.000Z"), + endDate: new Date("2026-04-17T00:00:00.000Z"), + }), + ); + }); + + it("shrinks the existing assignment when carving from the start edge", async () => { + loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment()); + updateAssignmentMock.mockResolvedValue({ id: "assignment_1" }); + + const db = { + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + }; + + const result = await carveTimelineAllocationRange({ + db: db as never, + allocationId: "assignment_1", + startDate: new Date("2026-04-06T00:00:00.000Z"), + endDate: new Date("2026-04-08T00:00:00.000Z"), + }); + + expect(result.action).toBe("updated"); + expect(createAssignmentMock).not.toHaveBeenCalled(); + expect(updateAssignmentMock).toHaveBeenCalledWith( + db, + "assignment_1", + expect.objectContaining({ + startDate: new Date("2026-04-09T00:00:00.000Z"), + endDate: new Date("2026-04-17T00:00:00.000Z"), + }), + ); + }); + + it("deletes the assignment when the carved range covers the full interval", async () => { + loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment()); + + const db = { + $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), + }; + + const result = await carveTimelineAllocationRange({ + db: db as never, + allocationId: "assignment_1", + startDate: new Date("2026-04-06T00:00:00.000Z"), + endDate: new Date("2026-04-17T00:00:00.000Z"), + }); + + expect(result.action).toBe("deleted"); + expect(deleteAllocationEntryMock).toHaveBeenCalledWith( + db, + expect.objectContaining({ + assignment: expect.objectContaining({ id: "assignment_1" }), + }), + ); + }); + + it("rejects carve ranges outside the allocation interval", async () => { + loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment()); + + await expect( + carveTimelineAllocationRange({ + db: { $transaction: vi.fn() } as never, + allocationId: "assignment_1", + startDate: new Date("2026-04-05T00:00:00.000Z"), + endDate: new Date("2026-04-06T00:00:00.000Z"), + }), + ).rejects.toThrowError( + new TRPCError({ + code: "BAD_REQUEST", + message: "The requested carve range must be fully inside the existing allocation.", + }), + ); + }); +}); diff --git a/packages/api/src/router/timeline-allocation-fragment-support.ts b/packages/api/src/router/timeline-allocation-fragment-support.ts new file mode 100644 index 0000000..6c21c63 --- /dev/null +++ b/packages/api/src/router/timeline-allocation-fragment-support.ts @@ -0,0 +1,185 @@ +import { + createAssignment, + deleteAllocationEntry, + loadAllocationEntry, + updateAssignment, +} from "@capakraken/application"; +import type { PrismaClient } from "@capakraken/db"; +import { AllocationStatus } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; + +const ALLOCATION_GROUP_ID_KEY = "allocationGroupId"; +const ALLOCATION_ORIGIN_ID_KEY = "allocationOriginId"; +const ALLOCATION_FRAGMENTED_KEY = "isFragmentedAllocation"; + +function toUtcCalendarDate(value: Date): Date { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); +} + +function addDays(date: Date, days: number): Date { + const next = toUtcCalendarDate(date); + next.setUTCDate(next.getUTCDate() + days); + return next; +} + +function toSharedAllocationStatus(status: unknown): AllocationStatus { + switch (status) { + case "PROPOSED": + return AllocationStatus.PROPOSED; + case "CONFIRMED": + return AllocationStatus.CONFIRMED; + case "ACTIVE": + return AllocationStatus.ACTIVE; + case "COMPLETED": + return AllocationStatus.COMPLETED; + case "CANCELLED": + return AllocationStatus.CANCELLED; + default: + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unsupported allocation status "${String(status)}" for fragmented assignment creation.`, + }); + } +} + +function readFragmentMetadata(metadata: unknown, allocationId: string) { + const record = + metadata && typeof metadata === "object" && !Array.isArray(metadata) + ? (metadata as Record) + : {}; + const groupId = + typeof record[ALLOCATION_GROUP_ID_KEY] === "string" && record[ALLOCATION_GROUP_ID_KEY].length > 0 + ? (record[ALLOCATION_GROUP_ID_KEY] as string) + : crypto.randomUUID(); + const originId = + typeof record[ALLOCATION_ORIGIN_ID_KEY] === "string" && record[ALLOCATION_ORIGIN_ID_KEY].length > 0 + ? (record[ALLOCATION_ORIGIN_ID_KEY] as string) + : allocationId; + + return { + groupId, + metadata: { + ...record, + [ALLOCATION_GROUP_ID_KEY]: groupId, + [ALLOCATION_ORIGIN_ID_KEY]: originId, + [ALLOCATION_FRAGMENTED_KEY]: true, + }, + }; +} + +export interface TimelineAllocationCarveResult { + action: "updated" | "split" | "deleted"; + allocationGroupId: string; + updatedAllocationIds: string[]; + createdAllocationIds: string[]; + deletedAllocationIds: string[]; + projectId: string; + resourceId: string | null; +} + +export async function carveTimelineAllocationRange(input: { + db: PrismaClient; + allocationId: string; + startDate: Date; + endDate: Date; +}): Promise { + const resolved = await loadAllocationEntry(input.db, input.allocationId); + if (resolved.kind !== "assignment") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only staffed assignments can currently be split into fragments.", + }); + } + + const carveStart = toUtcCalendarDate(input.startDate); + const carveEnd = toUtcCalendarDate(input.endDate); + if (carveEnd < carveStart) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Carve end date must be on or after the carve start date.", + }); + } + + const assignment = resolved.assignment; + const assignmentStart = toUtcCalendarDate(assignment.startDate); + const assignmentEnd = toUtcCalendarDate(assignment.endDate); + if (carveStart < assignmentStart || carveEnd > assignmentEnd) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The requested carve range must be fully inside the existing allocation.", + }); + } + + const { groupId, metadata } = readFragmentMetadata(assignment.metadata, assignment.id); + const leftEnd = addDays(carveStart, -1); + const rightStart = addDays(carveEnd, 1); + const hasLeftFragment = leftEnd >= assignmentStart; + const hasRightFragment = rightStart <= assignmentEnd; + + return input.db.$transaction(async (tx) => { + if (!hasLeftFragment && !hasRightFragment) { + await deleteAllocationEntry( + tx as unknown as Parameters[0], + resolved, + ); + + return { + action: "deleted" as const, + allocationGroupId: groupId, + updatedAllocationIds: [], + createdAllocationIds: [], + deletedAllocationIds: [assignment.id], + projectId: assignment.projectId, + resourceId: assignment.resourceId, + }; + } + + const updatedAllocationIds: string[] = []; + const createdAllocationIds: string[] = []; + + const keptStart = hasLeftFragment ? assignmentStart : rightStart; + const keptEnd = hasLeftFragment ? leftEnd : assignmentEnd; + + const updated = await updateAssignment( + tx as unknown as Parameters[0], + assignment.id, + { + startDate: keptStart, + endDate: keptEnd, + metadata, + }, + ); + updatedAllocationIds.push(updated.id); + + if (hasLeftFragment && hasRightFragment) { + const created = await createAssignment( + tx as unknown as Parameters[0], + { + demandRequirementId: assignment.demandRequirementId ?? undefined, + resourceId: assignment.resourceId, + projectId: assignment.projectId, + startDate: rightStart, + endDate: assignmentEnd, + hoursPerDay: assignment.hoursPerDay, + percentage: assignment.percentage, + role: assignment.role ?? undefined, + roleId: assignment.roleId ?? undefined, + dailyCostCents: assignment.dailyCostCents, + status: toSharedAllocationStatus(assignment.status), + metadata, + }, + ); + createdAllocationIds.push(created.id); + } + + return { + action: hasLeftFragment && hasRightFragment ? "split" as const : "updated" as const, + allocationGroupId: groupId, + updatedAllocationIds, + createdAllocationIds, + deletedAllocationIds: [], + projectId: assignment.projectId, + resourceId: assignment.resourceId, + }; + }); +}