From ad4b334f20d2b7564e56fae071347d9de17e4e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 16:49:36 +0200 Subject: [PATCH] refactor(api): extract timeline allocation inline support --- ...timeline-allocation-inline-support.test.ts | 193 ++++++++++++++++++ .../timeline-allocation-inline-support.ts | 99 +++++++++ .../router/timeline-allocation-mutations.ts | 92 +-------- 3 files changed, 300 insertions(+), 84 deletions(-) create mode 100644 packages/api/src/__tests__/timeline-allocation-inline-support.test.ts create mode 100644 packages/api/src/router/timeline-allocation-inline-support.ts diff --git a/packages/api/src/__tests__/timeline-allocation-inline-support.test.ts b/packages/api/src/__tests__/timeline-allocation-inline-support.test.ts new file mode 100644 index 0000000..8cf7358 --- /dev/null +++ b/packages/api/src/__tests__/timeline-allocation-inline-support.test.ts @@ -0,0 +1,193 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + loadAllocationEntryMock, + updateAllocationEntryMock, + calculateTimelineAllocationDailyCostMock, +} = vi.hoisted(() => ({ + loadAllocationEntryMock: vi.fn(), + updateAllocationEntryMock: vi.fn(), + calculateTimelineAllocationDailyCostMock: vi.fn(), +})); + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadAllocationEntry: loadAllocationEntryMock, + updateAllocationEntry: updateAllocationEntryMock, + }; +}); + +vi.mock("../router/timeline-cost-support.js", () => ({ + calculateTimelineAllocationDailyCost: calculateTimelineAllocationDailyCostMock, +})); + +import { applyTimelineInlineAllocationUpdate } from "../router/timeline-allocation-inline-support.js"; + +describe("timeline allocation inline support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("updates staffed allocations with recalculated daily cost and audit changes", async () => { + loadAllocationEntryMock.mockResolvedValue({ + entry: { + id: "allocation_1", + hoursPerDay: 4, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + metadata: {}, + }, + resourceId: "resource_1", + }); + calculateTimelineAllocationDailyCostMock.mockResolvedValue(54000); + updateAllocationEntryMock.mockResolvedValue({ + allocation: { + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + }, + }); + + const tx = { + auditLog: { + create: vi.fn().mockResolvedValue({}), + }, + }; + const db = { + resource: { + findUnique: vi.fn().mockResolvedValue({ + id: "resource_1", + lcrCents: 5000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + }), + }, + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + }; + + const result = await applyTimelineInlineAllocationUpdate({ + db: db as never, + allocationId: "allocation_1", + hoursPerDay: 6, + endDate: new Date("2026-04-06T00:00:00.000Z"), + includeSaturday: true, + role: "Architect", + }); + + expect(result).toEqual({ + id: "allocation_1", + projectId: "project_1", + resourceId: "resource_1", + }); + expect(calculateTimelineAllocationDailyCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + db, + resourceId: "resource_1", + lcrCents: 5000, + hoursPerDay: 6, + includeSaturday: true, + }), + ); + expect(updateAllocationEntryMock).toHaveBeenCalledWith( + tx, + { + id: "allocation_1", + demandRequirementUpdate: { + hoursPerDay: 6, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-06T00:00:00.000Z"), + metadata: { includeSaturday: true }, + role: "Architect", + }, + assignmentUpdate: { + hoursPerDay: 6, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-06T00:00:00.000Z"), + dailyCostCents: 54000, + metadata: { includeSaturday: true }, + role: "Architect", + }, + }, + ); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + entityType: "Allocation", + entityId: "allocation_1", + action: "UPDATE", + }), + }); + }); + + it("updates placeholder allocations without recalculating cost", async () => { + loadAllocationEntryMock.mockResolvedValue({ + entry: { + id: "demand_1", + hoursPerDay: 4, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + metadata: {}, + }, + resourceId: null, + }); + updateAllocationEntryMock.mockResolvedValue({ + allocation: { + id: "demand_1", + projectId: "project_1", + resourceId: null, + }, + }); + + const tx = { + auditLog: { + create: vi.fn().mockResolvedValue({}), + }, + }; + const db = { + resource: { + findUnique: vi.fn(), + }, + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + }; + + await expect(applyTimelineInlineAllocationUpdate({ + db: db as never, + allocationId: "demand_1", + hoursPerDay: 5, + })).resolves.toEqual({ + id: "demand_1", + projectId: "project_1", + resourceId: null, + }); + + expect(db.resource.findUnique).not.toHaveBeenCalled(); + expect(calculateTimelineAllocationDailyCostMock).not.toHaveBeenCalled(); + }); + + it("throws when a staffed allocation references a missing resource", async () => { + loadAllocationEntryMock.mockResolvedValue({ + entry: { + id: "allocation_1", + hoursPerDay: 4, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + metadata: {}, + }, + resourceId: "resource_1", + }); + + await expect(applyTimelineInlineAllocationUpdate({ + db: { + resource: { + findUnique: vi.fn().mockResolvedValue(null), + }, + $transaction: vi.fn(), + } as never, + allocationId: "allocation_1", + })).rejects.toThrowError(new TRPCError({ + code: "NOT_FOUND", + message: "Resource not found", + })); + }); +}); diff --git a/packages/api/src/router/timeline-allocation-inline-support.ts b/packages/api/src/router/timeline-allocation-inline-support.ts new file mode 100644 index 0000000..17fb4e6 --- /dev/null +++ b/packages/api/src/router/timeline-allocation-inline-support.ts @@ -0,0 +1,99 @@ +import { loadAllocationEntry, updateAllocationEntry } from "@capakraken/application"; +import type { PrismaClient } from "@capakraken/db"; +import type { RecurrencePattern, WeekdayAvailability } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { + assertTimelineDateRangeValid, + buildTimelineAllocationEntryUpdate, + buildTimelineAllocationMetadata, + buildTimelineAllocationUpdateAuditChanges, +} from "./timeline-allocation-mutation-support.js"; +import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; + +export async function applyTimelineInlineAllocationUpdate(input: { + db: PrismaClient; + allocationId: string; + hoursPerDay?: number | undefined; + startDate?: Date | undefined; + endDate?: Date | undefined; + includeSaturday?: boolean | undefined; + role?: string | undefined; +}) { + const resolved = await loadAllocationEntry(input.db, input.allocationId); + const existing = resolved.entry; + const existingResource = resolved.resourceId + ? await input.db.resource.findUnique({ + where: { id: resolved.resourceId }, + select: { id: true, lcrCents: true, availability: true }, + }) + : null; + + const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay; + const newStartDate = input.startDate ?? existing.startDate; + const newEndDate = input.endDate ?? existing.endDate; + + assertTimelineDateRangeValid(newStartDate, newEndDate); + const { metadata: newMeta, includeSaturday } = buildTimelineAllocationMetadata({ + existingMetadata: existing.metadata as Record | null | undefined, + includeSaturday: input.includeSaturday, + }); + + let newDailyCostCents = 0; + if (resolved.resourceId) { + if (!existingResource) { + throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + } + + const availability = existingResource.availability as unknown as WeekdayAvailability; + const recurrence = newMeta.recurrence as RecurrencePattern | undefined; + newDailyCostCents = await calculateTimelineAllocationDailyCost({ + db: input.db, + resourceId: resolved.resourceId, + lcrCents: existingResource.lcrCents, + hoursPerDay: newHoursPerDay, + startDate: newStartDate, + endDate: newEndDate, + availability, + includeSaturday, + ...(recurrence ? { recurrence } : {}), + }); + } + + return input.db.$transaction(async (tx) => { + const { allocation: updatedAllocation } = await updateAllocationEntry( + tx as unknown as Parameters[0], + { + id: input.allocationId, + ...buildTimelineAllocationEntryUpdate({ + hoursPerDay: newHoursPerDay, + startDate: newStartDate, + endDate: newEndDate, + metadata: newMeta, + dailyCostCents: newDailyCostCents, + role: input.role, + }), + }, + ); + + await tx.auditLog.create({ + data: { + entityType: "Allocation", + entityId: input.allocationId, + action: "UPDATE", + changes: buildTimelineAllocationUpdateAuditChanges({ + allocationId: resolved.entry.id, + previousHoursPerDay: existing.hoursPerDay, + previousStartDate: existing.startDate, + previousEndDate: existing.endDate, + nextAllocationId: updatedAllocation.id, + nextHoursPerDay: newHoursPerDay, + nextStartDate: newStartDate, + nextEndDate: newEndDate, + includeSaturday, + }), + }, + }); + + return updatedAllocation; + }); +} diff --git a/packages/api/src/router/timeline-allocation-mutations.ts b/packages/api/src/router/timeline-allocation-mutations.ts index d8e3a54..ae8cfcf 100644 --- a/packages/api/src/router/timeline-allocation-mutations.ts +++ b/packages/api/src/router/timeline-allocation-mutations.ts @@ -1,18 +1,13 @@ import { buildSplitAllocationReadModel, createAssignment, - loadAllocationEntry, - updateAllocationEntry, } from "@capakraken/application"; import type { PrismaClient } from "@capakraken/db"; import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema, - type RecurrencePattern, - type WeekdayAvailability, } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { emitAllocationCreated, @@ -21,96 +16,25 @@ import { import { managerProcedure, requirePermission } from "../trpc.js"; import { assertTimelineDateRangeValid, - buildTimelineAllocationEntryUpdate, - buildTimelineAllocationMetadata, - buildTimelineAllocationUpdateAuditChanges, buildTimelineQuickAssignAssignmentInput, validateTimelineAllocationDateRanges, } from "./timeline-allocation-mutation-support.js"; +import { applyTimelineInlineAllocationUpdate } from "./timeline-allocation-inline-support.js"; import { applyTimelineBatchAllocationShift } from "./timeline-allocation-shift-support.js"; -import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; export const timelineAllocationMutationProcedures = { updateAllocationInline: managerProcedure .input(UpdateAllocationHoursSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - const resolved = await loadAllocationEntry(ctx.db, input.allocationId); - const existing = resolved.entry; - const existingResource = resolved.resourceId - ? await ctx.db.resource.findUnique({ - where: { id: resolved.resourceId }, - select: { id: true, lcrCents: true, availability: true }, - }) - : null; - - const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay; - const newStartDate = input.startDate ?? existing.startDate; - const newEndDate = input.endDate ?? existing.endDate; - - assertTimelineDateRangeValid(newStartDate, newEndDate); - const { metadata: newMeta, includeSaturday } = buildTimelineAllocationMetadata({ - existingMetadata: existing.metadata as Record | null | undefined, + const updated = await applyTimelineInlineAllocationUpdate({ + db: ctx.db as PrismaClient, + allocationId: input.allocationId, + hoursPerDay: input.hoursPerDay, + startDate: input.startDate, + endDate: input.endDate, includeSaturday: input.includeSaturday, - }); - - let newDailyCostCents = 0; - if (resolved.resourceId) { - if (!existingResource) { - throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); - } - - const availability = existingResource.availability as unknown as WeekdayAvailability; - const recurrence = newMeta.recurrence as RecurrencePattern | undefined; - newDailyCostCents = await calculateTimelineAllocationDailyCost({ - db: ctx.db as PrismaClient, - resourceId: resolved.resourceId, - lcrCents: existingResource.lcrCents, - hoursPerDay: newHoursPerDay, - startDate: newStartDate, - endDate: newEndDate, - availability, - includeSaturday, - ...(recurrence ? { recurrence } : {}), - }); - } - - const updated = await ctx.db.$transaction(async (tx) => { - const { allocation: updatedAllocation } = await updateAllocationEntry( - tx as unknown as Parameters[0], - { - id: input.allocationId, - ...buildTimelineAllocationEntryUpdate({ - hoursPerDay: newHoursPerDay, - startDate: newStartDate, - endDate: newEndDate, - metadata: newMeta, - dailyCostCents: newDailyCostCents, - role: input.role, - }), - }, - ); - - await tx.auditLog.create({ - data: { - entityType: "Allocation", - entityId: input.allocationId, - action: "UPDATE", - changes: buildTimelineAllocationUpdateAuditChanges({ - allocationId: resolved.entry.id, - previousHoursPerDay: existing.hoursPerDay, - previousStartDate: existing.startDate, - previousEndDate: existing.endDate, - nextAllocationId: updatedAllocation.id, - nextHoursPerDay: newHoursPerDay, - nextStartDate: newStartDate, - nextEndDate: newEndDate, - includeSaturday, - }), - }, - }); - - return updatedAllocation; + role: input.role, }); emitAllocationUpdated({