diff --git a/packages/api/src/__tests__/timeline-allocation-mutation-support.test.ts b/packages/api/src/__tests__/timeline-allocation-mutation-support.test.ts index fd778b5..097b215 100644 --- a/packages/api/src/__tests__/timeline-allocation-mutation-support.test.ts +++ b/packages/api/src/__tests__/timeline-allocation-mutation-support.test.ts @@ -2,11 +2,14 @@ import { TRPCError } from "@trpc/server"; import { describe, expect, it } from "vitest"; import { assertTimelineDateRangeValid, + buildTimelineAllocationEntryUpdate, buildTimelineAllocationMetadata, buildTimelineBatchShiftAuditChanges, + buildTimelineQuickAssignAssignmentInput, buildTimelineQuickAssignMetadata, calculateTimelineAllocationPercentage, shiftTimelineAllocationWindow, + validateTimelineAllocationDateRanges, } from "../router/timeline-allocation-mutation-support.js"; describe("timeline allocation mutation support", () => { @@ -46,6 +49,33 @@ describe("timeline allocation mutation support", () => { expect(buildTimelineQuickAssignMetadata("batchQuickAssign")).toEqual({ source: "batchQuickAssign" }); }); + it("builds assignment create payloads for quick assign flows", () => { + expect( + buildTimelineQuickAssignAssignmentInput({ + 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: 6, + role: "Engineer", + roleId: "role_1", + status: "PROPOSED", + source: "quickAssign", + }), + ).toEqual({ + 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: 6, + percentage: 75, + role: "Engineer", + roleId: "role_1", + status: "PROPOSED", + metadata: { source: "quickAssign" }, + }); + }); + it("shifts and clamps allocation windows for each batch-shift mode", () => { expect( shiftTimelineAllocationWindow({ @@ -98,4 +128,47 @@ describe("timeline allocation mutation support", () => { count: 2, }); }); + + it("validates date ranges in bulk", () => { + expect(() => + validateTimelineAllocationDateRanges([ + { + startDate: new Date("2026-04-10T00:00:00.000Z"), + endDate: new Date("2026-04-11T00:00:00.000Z"), + }, + { + startDate: new Date("2026-04-12T00:00:00.000Z"), + endDate: new Date("2026-04-09T00:00:00.000Z"), + }, + ])).toThrowError(TRPCError); + }); + + it("builds shared update payloads for demand and assignment changes", () => { + expect( + buildTimelineAllocationEntryUpdate({ + hoursPerDay: 7.5, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + metadata: { includeSaturday: true }, + dailyCostCents: 48000, + role: "Architect", + }), + ).toEqual({ + demandRequirementUpdate: { + hoursPerDay: 7.5, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + metadata: { includeSaturday: true }, + role: "Architect", + }, + assignmentUpdate: { + hoursPerDay: 7.5, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + dailyCostCents: 48000, + metadata: { includeSaturday: true }, + role: "Architect", + }, + }); + }); }); diff --git a/packages/api/src/router/timeline-allocation-mutation-support.ts b/packages/api/src/router/timeline-allocation-mutation-support.ts index 6713752..81713e1 100644 --- a/packages/api/src/router/timeline-allocation-mutation-support.ts +++ b/packages/api/src/router/timeline-allocation-mutation-support.ts @@ -1,4 +1,5 @@ import { Prisma } from "@capakraken/db"; +import { AllocationStatus } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; export type TimelineBatchShiftMode = "move" | "resize-start" | "resize-end"; @@ -39,6 +40,39 @@ export function buildTimelineQuickAssignMetadata(source: "quickAssign" | "batchQ return { source } satisfies Record; } +export function buildTimelineQuickAssignAssignmentInput(input: { + resourceId: string; + projectId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + role: string; + roleId?: string | undefined; + status: AllocationStatus; + source: "quickAssign" | "batchQuickAssign"; +}) { + return { + resourceId: input.resourceId, + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + hoursPerDay: input.hoursPerDay, + percentage: calculateTimelineAllocationPercentage(input.hoursPerDay), + role: input.role, + ...(input.roleId !== undefined ? { roleId: input.roleId } : {}), + status: input.status, + metadata: buildTimelineQuickAssignMetadata(input.source), + }; +} + +export function validateTimelineAllocationDateRanges( + ranges: Array<{ startDate: Date; endDate: Date }>, +): void { + for (const range of ranges) { + assertTimelineDateRangeValid(range.startDate, range.endDate); + } +} + export function shiftTimelineAllocationWindow(input: { startDate: Date; endDate: Date; @@ -98,6 +132,33 @@ export function buildTimelineAllocationUpdateAuditChanges(input: { } as unknown as Prisma.InputJsonValue; } +export function buildTimelineAllocationEntryUpdate(input: { + hoursPerDay: number; + startDate: Date; + endDate: Date; + metadata: Record; + dailyCostCents: number; + role?: string | undefined; +}) { + return { + demandRequirementUpdate: { + hoursPerDay: input.hoursPerDay, + startDate: input.startDate, + endDate: input.endDate, + metadata: input.metadata, + ...(input.role !== undefined ? { role: input.role } : {}), + }, + assignmentUpdate: { + hoursPerDay: input.hoursPerDay, + startDate: input.startDate, + endDate: input.endDate, + dailyCostCents: input.dailyCostCents, + metadata: input.metadata, + ...(input.role !== undefined ? { role: input.role } : {}), + }, + }; +} + export function buildTimelineBatchShiftAuditChanges(input: { mode: TimelineBatchShiftMode; daysDelta: number; diff --git a/packages/api/src/router/timeline-allocation-mutations.ts b/packages/api/src/router/timeline-allocation-mutations.ts index 1c63f7f..12e4e92 100644 --- a/packages/api/src/router/timeline-allocation-mutations.ts +++ b/packages/api/src/router/timeline-allocation-mutations.ts @@ -22,12 +22,13 @@ import { import { managerProcedure, requirePermission } from "../trpc.js"; import { assertTimelineDateRangeValid, + buildTimelineAllocationEntryUpdate, buildTimelineAllocationMetadata, buildTimelineAllocationUpdateAuditChanges, buildTimelineBatchShiftAuditChanges, - buildTimelineQuickAssignMetadata, - calculateTimelineAllocationPercentage, + buildTimelineQuickAssignAssignmentInput, shiftTimelineAllocationWindow, + validateTimelineAllocationDateRanges, } from "./timeline-allocation-mutation-support.js"; import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; @@ -81,21 +82,14 @@ export const timelineAllocationMutationProcedures = { tx as unknown as Parameters[0], { id: input.allocationId, - demandRequirementUpdate: { + ...buildTimelineAllocationEntryUpdate({ hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, metadata: newMeta, - ...(input.role !== undefined ? { role: input.role } : {}), - }, - assignmentUpdate: { - hoursPerDay: newHoursPerDay, - startDate: newStartDate, - endDate: newEndDate, dailyCostCents: newDailyCostCents, - metadata: newMeta, - ...(input.role !== undefined ? { role: input.role } : {}), - }, + role: input.role, + }), }, ); @@ -147,24 +141,13 @@ export const timelineAllocationMutationProcedures = { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); assertTimelineDateRangeValid(input.startDate, input.endDate); - const percentage = calculateTimelineAllocationPercentage(input.hoursPerDay); - const metadata = buildTimelineQuickAssignMetadata("quickAssign"); - const allocation = await ctx.db.$transaction(async (tx) => { const assignment = await createAssignment( tx as unknown as Parameters[0], - { - resourceId: input.resourceId, - projectId: input.projectId, - startDate: input.startDate, - endDate: input.endDate, - hoursPerDay: input.hoursPerDay, - percentage, - role: input.role, - roleId: input.roleId ?? undefined, - status: input.status, - metadata, - }, + buildTimelineQuickAssignAssignmentInput({ + ...input, + source: "quickAssign", + }), ); return buildSplitAllocationReadModel({ @@ -205,30 +188,17 @@ export const timelineAllocationMutationProcedures = { ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - - for (const assignment of input.assignments) { - assertTimelineDateRangeValid(assignment.startDate, assignment.endDate); - } + validateTimelineAllocationDateRanges(input.assignments); const results = await ctx.db.$transaction(async (tx) => { const created = []; for (const assignment of input.assignments) { - const percentage = calculateTimelineAllocationPercentage(assignment.hoursPerDay); - const metadata = buildTimelineQuickAssignMetadata("batchQuickAssign"); - const createdAssignment = await createAssignment( tx as unknown as Parameters[0], - { - resourceId: assignment.resourceId, - projectId: assignment.projectId, - startDate: assignment.startDate, - endDate: assignment.endDate, - hoursPerDay: assignment.hoursPerDay, - percentage, - role: assignment.role, - status: assignment.status, - metadata, - }, + buildTimelineQuickAssignAssignmentInput({ + ...assignment, + source: "batchQuickAssign", + }), ); created.push(createdAssignment); }