From b669de54e17d908dbe83897ede008f35a0ed8f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 14:52:34 +0200 Subject: [PATCH] refactor(api): extract timeline shift support --- .../__tests__/timeline-shift-support.test.ts | 287 ++++++++++++++++++ .../api/src/router/timeline-shift-support.ts | 224 ++++++++++++++ packages/api/src/router/timeline.ts | 114 +------ 3 files changed, 522 insertions(+), 103 deletions(-) create mode 100644 packages/api/src/__tests__/timeline-shift-support.test.ts create mode 100644 packages/api/src/router/timeline-shift-support.ts diff --git a/packages/api/src/__tests__/timeline-shift-support.test.ts b/packages/api/src/__tests__/timeline-shift-support.test.ts new file mode 100644 index 0000000..012e6b7 --- /dev/null +++ b/packages/api/src/__tests__/timeline-shift-support.test.ts @@ -0,0 +1,287 @@ +import type { ShiftValidationResult } from "@capakraken/shared"; +import { AllocationStatus } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; + +const { + calculateAllocationMock, + updateAssignmentMock, + updateDemandRequirementMock, + validateShiftMock, +} = vi.hoisted(() => ({ + validateShiftMock: vi.fn(), + calculateAllocationMock: vi.fn(), + updateAssignmentMock: vi.fn(), + updateDemandRequirementMock: vi.fn(), +})); + +vi.mock("@capakraken/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateShift: validateShiftMock, + calculateAllocation: calculateAllocationMock, + }; +}); + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateAssignment: updateAssignmentMock, + updateDemandRequirement: updateDemandRequirementMock, + }; +}); + +import { + applyTimelineProjectShift, + assertTimelineProjectShiftValid, + buildTimelineProjectShiftEventPayload, + buildTimelineProjectShiftValidation, +} from "../router/timeline-shift-support.js"; + +function createValidValidationResult( + overrides: Partial = {}, +): ShiftValidationResult { + return { + valid: true, + errors: [], + warnings: [], + conflictDetails: [], + costImpact: { + currentTotalCents: 100_000, + newTotalCents: 112_000, + deltaCents: 12_000, + budgetCents: 200_000, + budgetUtilizationBefore: 50, + budgetUtilizationAfter: 56, + wouldExceedBudget: false, + }, + ...overrides, + }; +} + +describe("timeline shift support", () => { + it("builds validation requests from loaded shift context", () => { + const expectedValidation = createValidValidationResult(); + validateShiftMock.mockReturnValue(expectedValidation); + + const validationAllocations = [ + { + id: "assignment_1", + resourceId: "resource_1", + startDate: new Date("2026-04-01"), + endDate: new Date("2026-04-05"), + hoursPerDay: 8, + percentage: 100, + role: "Compositing", + dailyCostCents: 40_000, + status: AllocationStatus.ACTIVE, + resource: { + id: "resource_1", + displayName: "Alice", + lcrCents: 5_000, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + }, + allAllocationsForResource: [], + }, + ]; + + const result = buildTimelineProjectShiftValidation({ + context: { + project: { + id: "project_1", + budgetCents: 200_000, + winProbability: 80, + startDate: new Date("2026-04-01"), + endDate: new Date("2026-04-10"), + }, + demandRequirements: [], + assignments: [], + shiftPlan: { + validationAllocations, + }, + }, + newStartDate: new Date("2026-04-03"), + newEndDate: new Date("2026-04-12"), + }); + + expect(result).toBe(expectedValidation); + expect(validateShiftMock).toHaveBeenCalledWith({ + project: { + id: "project_1", + budgetCents: 200_000, + winProbability: 80, + startDate: new Date("2026-04-01"), + endDate: new Date("2026-04-10"), + }, + newStartDate: new Date("2026-04-03"), + newEndDate: new Date("2026-04-12"), + allocations: validationAllocations, + }); + }); + + it("throws a BAD_REQUEST error for invalid shift validations", () => { + expect(() => + assertTimelineProjectShiftValid(createValidValidationResult({ + valid: false, + errors: [ + { + code: "AVAILABILITY_CONFLICT", + message: "Alice exceeds capacity", + }, + ], + })), + ).toThrowError(new TRPCError({ + code: "BAD_REQUEST", + message: "Shift validation failed: Alice exceeds capacity", + })); + }); + + it("applies project shift updates and recalculates only staffed assignments", async () => { + const validation = createValidValidationResult(); + validateShiftMock.mockReturnValue(validation); + calculateAllocationMock.mockReturnValue({ dailyCostCents: 54_321 }); + updateDemandRequirementMock.mockResolvedValue({}); + updateAssignmentMock.mockResolvedValue({}); + + const updatedProject = { + id: "project_1", + startDate: new Date("2026-04-03"), + endDate: new Date("2026-04-12"), + }; + const db = { + project: { + update: vi.fn().mockResolvedValue(updatedProject), + }, + auditLog: { + create: vi.fn().mockResolvedValue({}), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + calculationRule: { + findMany: vi.fn().mockResolvedValue([{ id: "rule_1", priority: 1 }]), + }, + $transaction: vi.fn(async (callback: (tx: typeof db) => unknown) => callback(db)), + }; + + const context = { + project: { + id: "project_1", + budgetCents: 200_000, + winProbability: 80, + startDate: new Date("2026-04-01"), + endDate: new Date("2026-04-10"), + }, + demandRequirements: [ + { + id: "demand_1", + }, + ], + assignments: [ + { + id: "assignment_staffed", + resourceId: "resource_1", + hoursPerDay: 8, + metadata: { includeSaturday: true }, + resource: { + lcrCents: 5_000, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + }, + }, + }, + { + id: "assignment_placeholder", + resourceId: null, + hoursPerDay: 4, + metadata: {}, + resource: null, + }, + ], + shiftPlan: { + validationAllocations: [], + }, + }; + + const result = await applyTimelineProjectShift({ + db: db as never, + projectId: "project_1", + newStartDate: new Date("2026-04-03"), + newEndDate: new Date("2026-04-12"), + context: context as never, + }); + + expect(db.project.update).toHaveBeenCalledWith({ + where: { id: "project_1" }, + data: { + startDate: new Date("2026-04-03"), + endDate: new Date("2026-04-12"), + }, + }); + expect(updateDemandRequirementMock).toHaveBeenCalledWith( + db, + "demand_1", + { + startDate: new Date("2026-04-03"), + endDate: new Date("2026-04-12"), + }, + ); + expect(db.vacation.findMany).toHaveBeenCalledTimes(1); + expect(db.calculationRule.findMany).toHaveBeenCalledTimes(1); + expect(calculateAllocationMock).toHaveBeenCalledTimes(1); + expect(updateAssignmentMock).toHaveBeenNthCalledWith( + 1, + db, + "assignment_staffed", + { + startDate: new Date("2026-04-03"), + endDate: new Date("2026-04-12"), + dailyCostCents: 54_321, + }, + ); + expect(updateAssignmentMock).toHaveBeenNthCalledWith( + 2, + db, + "assignment_placeholder", + { + startDate: new Date("2026-04-03"), + endDate: new Date("2026-04-12"), + }, + ); + expect(db.auditLog.create).toHaveBeenCalledWith({ + data: { + entityType: "Project", + entityId: "project_1", + action: "SHIFT", + changes: expect.objectContaining({ + before: { + startDate: new Date("2026-04-01"), + endDate: new Date("2026-04-10"), + }, + after: { + startDate: new Date("2026-04-03"), + endDate: new Date("2026-04-12"), + }, + costImpact: validation.costImpact, + }), + }, + }); + expect(result).toEqual({ + project: updatedProject, + validation, + event: buildTimelineProjectShiftEventPayload({ + projectId: "project_1", + newStartDate: new Date("2026-04-03"), + newEndDate: new Date("2026-04-12"), + validation, + assignments: context.assignments as never, + }), + }); + }); +}); diff --git a/packages/api/src/router/timeline-shift-support.ts b/packages/api/src/router/timeline-shift-support.ts new file mode 100644 index 0000000..3b61a34 --- /dev/null +++ b/packages/api/src/router/timeline-shift-support.ts @@ -0,0 +1,224 @@ +import { + updateAssignment, + updateDemandRequirement, + type SplitAssignmentRecord, + type SplitDemandRequirementRecord, +} from "@capakraken/application"; +import { Prisma, type PrismaClient } from "@capakraken/db"; +import { calculateAllocation, validateShift } from "@capakraken/engine"; +import type { ShiftValidationResult, WeekdayAvailability } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { buildAbsenceDays, loadCalculationRules } from "./timeline-allocation-mutations.js"; +import type { TimelineShiftPlan } from "./timeline-shift-planning.js"; + +export interface TimelineShiftProjectRecord { + id: string; + budgetCents: number; + winProbability: number; + startDate: Date; + endDate: Date; +} + +export interface LoadedTimelineShiftContext { + project: TimelineShiftProjectRecord; + demandRequirements: SplitDemandRequirementRecord[]; + assignments: SplitAssignmentRecord[]; + shiftPlan: TimelineShiftPlan; +} + +export interface ApplyTimelineProjectShiftInput { + db: PrismaClient; + projectId: string; + newStartDate: Date; + newEndDate: Date; + context: LoadedTimelineShiftContext; +} + +export interface TimelineProjectShiftEventPayload extends Record { + projectId: string; + newStartDate: string; + newEndDate: string; + costDeltaCents: number; + resourceIds: Array; +} + +export function buildTimelineProjectShiftValidation(input: { + context: LoadedTimelineShiftContext; + newStartDate: Date; + newEndDate: Date; +}) { + const { context, newStartDate, newEndDate } = input; + + return validateShift({ + project: { + id: context.project.id, + budgetCents: context.project.budgetCents, + winProbability: context.project.winProbability, + startDate: context.project.startDate, + endDate: context.project.endDate, + }, + newStartDate, + newEndDate, + allocations: context.shiftPlan.validationAllocations, + }); +} + +export function assertTimelineProjectShiftValid( + validation: ShiftValidationResult, +): void { + if (validation.valid) { + return; + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Shift validation failed: ${validation.errors.map((error) => error.message).join(", ")}`, + }); +} + +export function buildTimelineProjectShiftEventPayload(input: { + projectId: string; + newStartDate: Date; + newEndDate: Date; + validation: ShiftValidationResult; + assignments: SplitAssignmentRecord[]; +}): TimelineProjectShiftEventPayload { + return { + projectId: input.projectId, + newStartDate: input.newStartDate.toISOString(), + newEndDate: input.newEndDate.toISOString(), + costDeltaCents: input.validation.costImpact.deltaCents, + resourceIds: input.assignments.map((assignment) => assignment.resourceId), + }; +} + +function buildTimelineProjectShiftAuditChanges(input: { + project: TimelineShiftProjectRecord; + newStartDate: Date; + newEndDate: Date; + validation: ShiftValidationResult; +}): Prisma.InputJsonValue { + return { + before: { + startDate: input.project.startDate, + endDate: input.project.endDate, + }, + after: { + startDate: input.newStartDate, + endDate: input.newEndDate, + }, + costImpact: input.validation.costImpact, + } as unknown as Prisma.InputJsonValue; +} + +async function recalculateShiftedAssignmentDailyCost(input: { + db: PrismaClient; + assignment: SplitAssignmentRecord; + newStartDate: Date; + newEndDate: Date; +}): Promise { + if (!input.assignment.resourceId || !input.assignment.resource) { + return undefined; + } + + const metadata = (input.assignment.metadata as Record | null | undefined) ?? {}; + const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false; + const [shiftAbsenceData, shiftRules] = await Promise.all([ + buildAbsenceDays( + input.db, + input.assignment.resourceId, + input.newStartDate, + input.newEndDate, + ), + loadCalculationRules(input.db), + ]); + + return calculateAllocation({ + lcrCents: input.assignment.resource.lcrCents, + hoursPerDay: input.assignment.hoursPerDay, + startDate: input.newStartDate, + endDate: input.newEndDate, + availability: input.assignment.resource.availability as WeekdayAvailability, + includeSaturday, + vacationDates: shiftAbsenceData.legacyVacationDates, + absenceDays: shiftAbsenceData.absenceDays, + calculationRules: shiftRules, + }).dailyCostCents; +} + +export async function applyTimelineProjectShift(input: ApplyTimelineProjectShiftInput) { + const validation = buildTimelineProjectShiftValidation({ + context: input.context, + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, + }); + assertTimelineProjectShiftValid(validation); + + const updatedProject = await input.db.$transaction(async (tx) => { + const projectRecord = await tx.project.update({ + where: { id: input.projectId }, + data: { + startDate: input.newStartDate, + endDate: input.newEndDate, + }, + }); + + for (const demandRequirement of input.context.demandRequirements) { + await updateDemandRequirement( + tx as unknown as Parameters[0], + demandRequirement.id, + { + startDate: input.newStartDate, + endDate: input.newEndDate, + }, + ); + } + + for (const assignment of input.context.assignments) { + const dailyCostCents = await recalculateShiftedAssignmentDailyCost({ + db: input.db, + assignment, + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, + }); + + await updateAssignment( + tx as unknown as Parameters[0], + assignment.id, + { + startDate: input.newStartDate, + endDate: input.newEndDate, + ...(dailyCostCents !== undefined ? { dailyCostCents } : {}), + }, + ); + } + + await tx.auditLog.create({ + data: { + entityType: "Project", + entityId: input.projectId, + action: "SHIFT", + changes: buildTimelineProjectShiftAuditChanges({ + project: input.context.project, + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, + validation, + }), + }, + }); + + return projectRecord; + }); + + return { + project: updatedProject, + validation, + event: buildTimelineProjectShiftEventPayload({ + projectId: input.projectId, + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, + validation, + assignments: input.context.assignments, + }), + }; +} diff --git a/packages/api/src/router/timeline.ts b/packages/api/src/router/timeline.ts index 799327f..d983eae 100644 --- a/packages/api/src/router/timeline.ts +++ b/packages/api/src/router/timeline.ts @@ -1,13 +1,10 @@ -import { updateAssignment, updateDemandRequirement } from "@capakraken/application"; -import type { PrismaClient } from "@capakraken/db"; -import { calculateAllocation, validateShift } from "@capakraken/engine"; import { PermissionKey, ShiftProjectSchema } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; import { emitProjectShifted } from "../sse/event-bus.js"; import { createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; -import { buildAbsenceDays, loadCalculationRules, timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js"; +import { timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js"; import { timelineReadProcedures } from "./timeline-read.js"; import { loadProjectShiftContext } from "./timeline-project-read.js"; +import { applyTimelineProjectShift } from "./timeline-shift-support.js"; export const timelineRouter = createTRPCRouter({ ...timelineReadProcedures, @@ -22,109 +19,20 @@ export const timelineRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const { projectId, newStartDate, newEndDate } = input; - const { project, demandRequirements, assignments, shiftPlan } = await loadProjectShiftContext( - ctx.db, + const context = await loadProjectShiftContext(ctx.db, projectId); + const result = await applyTimelineProjectShift({ + db: ctx.db, projectId, - ); - - const validation = validateShift({ - project: { - id: project.id, - budgetCents: project.budgetCents, - winProbability: project.winProbability, - startDate: project.startDate, - endDate: project.endDate, - }, newStartDate, newEndDate, - allocations: shiftPlan.validationAllocations, + context, }); - if (!validation.valid) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Shift validation failed: ${validation.errors.map((error) => error.message).join(", ")}`, - }); - } + emitProjectShifted(result.event); - const shiftRules = await loadCalculationRules(ctx.db as PrismaClient); - - const updatedProject = await ctx.db.$transaction(async (tx) => { - const projectRecord = await tx.project.update({ - where: { id: projectId }, - data: { startDate: newStartDate, endDate: newEndDate }, - }); - - for (const demandRequirement of demandRequirements) { - await updateDemandRequirement( - tx as unknown as Parameters[0], - demandRequirement.id, - { - startDate: newStartDate, - endDate: newEndDate, - }, - ); - } - - for (const assignment of assignments) { - const metadata = (assignment.metadata as Record | null | undefined) ?? {}; - const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false; - - const shiftAbsenceData = await buildAbsenceDays( - ctx.db as PrismaClient, - assignment.resourceId!, - newStartDate, - newEndDate, - ); - - const newDailyCost = calculateAllocation({ - lcrCents: assignment.resource!.lcrCents, - hoursPerDay: assignment.hoursPerDay, - startDate: newStartDate, - endDate: newEndDate, - availability: - assignment.resource!.availability as unknown as import("@capakraken/shared").WeekdayAvailability, - includeSaturday, - vacationDates: shiftAbsenceData.legacyVacationDates, - absenceDays: shiftAbsenceData.absenceDays, - calculationRules: shiftRules, - }).dailyCostCents; - - await updateAssignment( - tx as unknown as Parameters[0], - assignment.id, - { - startDate: newStartDate, - endDate: newEndDate, - dailyCostCents: newDailyCost, - }, - ); - } - - await tx.auditLog.create({ - data: { - entityType: "Project", - entityId: projectId, - action: "SHIFT", - changes: { - before: { startDate: project.startDate, endDate: project.endDate }, - after: { startDate: newStartDate, endDate: newEndDate }, - costImpact: validation.costImpact, - } as unknown as import("@capakraken/db").Prisma.InputJsonValue, - }, - }); - - return projectRecord; - }); - - emitProjectShifted({ - projectId, - newStartDate: newStartDate.toISOString(), - newEndDate: newEndDate.toISOString(), - costDeltaCents: validation.costImpact.deltaCents, - resourceIds: assignments.map((assignment) => assignment.resourceId), - }); - - return { project: updatedProject, validation }; + return { + project: result.project, + validation: result.validation, + }; }), });