diff --git a/packages/api/src/__tests__/timeline-allocation-mutation-support.test.ts b/packages/api/src/__tests__/timeline-allocation-mutation-support.test.ts new file mode 100644 index 0000000..fd778b5 --- /dev/null +++ b/packages/api/src/__tests__/timeline-allocation-mutation-support.test.ts @@ -0,0 +1,101 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it } from "vitest"; +import { + assertTimelineDateRangeValid, + buildTimelineAllocationMetadata, + buildTimelineBatchShiftAuditChanges, + buildTimelineQuickAssignMetadata, + calculateTimelineAllocationPercentage, + shiftTimelineAllocationWindow, +} from "../router/timeline-allocation-mutation-support.js"; + +describe("timeline allocation mutation support", () => { + it("preserves existing metadata while updating includeSaturday", () => { + const result = buildTimelineAllocationMetadata({ + existingMetadata: { + recurrence: { frequency: "weekly", interval: 2 }, + includeSaturday: false, + }, + includeSaturday: true, + }); + + expect(result).toEqual({ + metadata: { + recurrence: { frequency: "weekly", interval: 2 }, + includeSaturday: true, + }, + includeSaturday: true, + }); + }); + + it("rejects inverted date ranges", () => { + expect(() => + assertTimelineDateRangeValid( + new Date("2026-04-10T00:00:00.000Z"), + new Date("2026-04-09T00:00:00.000Z"), + )).toThrowError(TRPCError); + }); + + it("rounds allocation percentages and clamps them to 100", () => { + expect(calculateTimelineAllocationPercentage(3.9)).toBe(49); + expect(calculateTimelineAllocationPercentage(9)).toBe(100); + }); + + it("builds source metadata for quick assign operations", () => { + expect(buildTimelineQuickAssignMetadata("quickAssign")).toEqual({ source: "quickAssign" }); + expect(buildTimelineQuickAssignMetadata("batchQuickAssign")).toEqual({ source: "batchQuickAssign" }); + }); + + it("shifts and clamps allocation windows for each batch-shift mode", () => { + expect( + shiftTimelineAllocationWindow({ + startDate: new Date("2026-04-10T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + daysDelta: 2, + mode: "move", + }), + ).toEqual({ + startDate: new Date("2026-04-12T00:00:00.000Z"), + endDate: new Date("2026-04-14T00:00:00.000Z"), + }); + + expect( + shiftTimelineAllocationWindow({ + startDate: new Date("2026-04-10T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + daysDelta: 5, + mode: "resize-start", + }), + ).toEqual({ + startDate: new Date("2026-04-12T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + }); + + expect( + shiftTimelineAllocationWindow({ + startDate: new Date("2026-04-10T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + daysDelta: -5, + mode: "resize-end", + }), + ).toEqual({ + startDate: new Date("2026-04-10T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }); + }); + + it("builds batch-shift audit payloads", () => { + expect( + buildTimelineBatchShiftAuditChanges({ + mode: "move", + daysDelta: 3, + count: 2, + }), + ).toEqual({ + operation: "batchShift", + mode: "move", + daysDelta: 3, + count: 2, + }); + }); +}); diff --git a/packages/api/src/router/timeline-allocation-mutation-support.ts b/packages/api/src/router/timeline-allocation-mutation-support.ts new file mode 100644 index 0000000..6713752 --- /dev/null +++ b/packages/api/src/router/timeline-allocation-mutation-support.ts @@ -0,0 +1,112 @@ +import { Prisma } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; + +export type TimelineBatchShiftMode = "move" | "resize-start" | "resize-end"; + +export function assertTimelineDateRangeValid(startDate: Date, endDate: Date): void { + if (endDate >= startDate) { + return; + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: "End date must be after start date", + }); +} + +export function buildTimelineAllocationMetadata(input: { + existingMetadata: Record | null | undefined; + includeSaturday: boolean | undefined; +}): { metadata: Record; includeSaturday: boolean } { + const existingMetadata = input.existingMetadata ?? {}; + const metadata: Record = { + ...existingMetadata, + ...(input.includeSaturday !== undefined + ? { includeSaturday: input.includeSaturday } + : {}), + }; + const includeSaturday = + input.includeSaturday ?? (existingMetadata.includeSaturday as boolean | undefined) ?? false; + + return { metadata, includeSaturday }; +} + +export function calculateTimelineAllocationPercentage(hoursPerDay: number): number { + return Math.min(100, Math.round((hoursPerDay / 8) * 100)); +} + +export function buildTimelineQuickAssignMetadata(source: "quickAssign" | "batchQuickAssign") { + return { source } satisfies Record; +} + +export function shiftTimelineAllocationWindow(input: { + startDate: Date; + endDate: Date; + daysDelta: number; + mode: TimelineBatchShiftMode; +}): { startDate: Date; endDate: Date } { + const startDate = new Date(input.startDate); + const endDate = new Date(input.endDate); + + if (input.mode === "move") { + startDate.setDate(startDate.getDate() + input.daysDelta); + endDate.setDate(endDate.getDate() + input.daysDelta); + return { startDate, endDate }; + } + + if (input.mode === "resize-start") { + startDate.setDate(startDate.getDate() + input.daysDelta); + if (startDate > endDate) { + startDate.setTime(endDate.getTime()); + } + return { startDate, endDate }; + } + + endDate.setDate(endDate.getDate() + input.daysDelta); + if (endDate < startDate) { + endDate.setTime(startDate.getTime()); + } + + return { startDate, endDate }; +} + +export function buildTimelineAllocationUpdateAuditChanges(input: { + allocationId: string; + previousHoursPerDay: number; + previousStartDate: Date; + previousEndDate: Date; + nextAllocationId: string; + nextHoursPerDay: number; + nextStartDate: Date; + nextEndDate: Date; + includeSaturday: boolean; +}): Prisma.InputJsonValue { + return { + before: { + id: input.allocationId, + hoursPerDay: input.previousHoursPerDay, + startDate: input.previousStartDate, + endDate: input.previousEndDate, + }, + after: { + id: input.nextAllocationId, + hoursPerDay: input.nextHoursPerDay, + startDate: input.nextStartDate, + endDate: input.nextEndDate, + includeSaturday: input.includeSaturday, + }, + } as unknown as Prisma.InputJsonValue; +} + +export function buildTimelineBatchShiftAuditChanges(input: { + mode: TimelineBatchShiftMode; + daysDelta: number; + count: number; +}): Prisma.InputJsonValue { + return { + operation: "batchShift", + mode: input.mode, + daysDelta: input.daysDelta, + count: input.count, + } as unknown as Prisma.InputJsonValue; +} diff --git a/packages/api/src/router/timeline-allocation-mutations.ts b/packages/api/src/router/timeline-allocation-mutations.ts index f040962..1c63f7f 100644 --- a/packages/api/src/router/timeline-allocation-mutations.ts +++ b/packages/api/src/router/timeline-allocation-mutations.ts @@ -6,7 +6,13 @@ import { updateAllocationEntry, } from "@capakraken/application"; import type { PrismaClient } from "@capakraken/db"; -import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema } from "@capakraken/shared"; +import { + AllocationStatus, + PermissionKey, + UpdateAllocationHoursSchema, + type RecurrencePattern, + type WeekdayAvailability, +} from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { @@ -14,6 +20,15 @@ import { emitAllocationUpdated, } from "../sse/event-bus.js"; import { managerProcedure, requirePermission } from "../trpc.js"; +import { + assertTimelineDateRangeValid, + buildTimelineAllocationMetadata, + buildTimelineAllocationUpdateAuditChanges, + buildTimelineBatchShiftAuditChanges, + buildTimelineQuickAssignMetadata, + calculateTimelineAllocationPercentage, + shiftTimelineAllocationWindow, +} from "./timeline-allocation-mutation-support.js"; import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; export const timelineAllocationMutationProcedures = { @@ -34,22 +49,11 @@ export const timelineAllocationMutationProcedures = { const newStartDate = input.startDate ?? existing.startDate; const newEndDate = input.endDate ?? existing.endDate; - if (newEndDate < newStartDate) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "End date must be after start date", - }); - } - - const existingMeta = (existing.metadata as Record) ?? {}; - const newMeta: Record = { - ...existingMeta, - ...(input.includeSaturday !== undefined - ? { includeSaturday: input.includeSaturday } - : {}), - }; - const includeSaturday = - input.includeSaturday ?? (existingMeta.includeSaturday as boolean | undefined) ?? false; + assertTimelineDateRangeValid(newStartDate, newEndDate); + const { metadata: newMeta, includeSaturday } = buildTimelineAllocationMetadata({ + existingMetadata: existing.metadata as Record | null | undefined, + includeSaturday: input.includeSaturday, + }); let newDailyCostCents = 0; if (resolved.resourceId) { @@ -57,9 +61,8 @@ export const timelineAllocationMutationProcedures = { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } - const availability = - existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability; - const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined; + 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, @@ -101,21 +104,17 @@ export const timelineAllocationMutationProcedures = { entityType: "Allocation", entityId: input.allocationId, action: "UPDATE", - changes: { - before: { - id: resolved.entry.id, - hoursPerDay: existing.hoursPerDay, - startDate: existing.startDate, - endDate: existing.endDate, - }, - after: { - id: updatedAllocation.id, - hoursPerDay: newHoursPerDay, - startDate: newStartDate, - endDate: newEndDate, - includeSaturday, - }, - }, + 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, + }), }, }); @@ -146,12 +145,10 @@ export const timelineAllocationMutationProcedures = { ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); - if (input.endDate < input.startDate) { - throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date" }); - } + assertTimelineDateRangeValid(input.startDate, input.endDate); - const percentage = Math.min(100, Math.round((input.hoursPerDay / 8) * 100)); - const metadata = { source: "quickAssign" } satisfies Record; + const percentage = calculateTimelineAllocationPercentage(input.hoursPerDay); + const metadata = buildTimelineQuickAssignMetadata("quickAssign"); const allocation = await ctx.db.$transaction(async (tx) => { const assignment = await createAssignment( @@ -210,24 +207,14 @@ export const timelineAllocationMutationProcedures = { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); for (const assignment of input.assignments) { - if (assignment.endDate < assignment.startDate) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "End date must be after start date", - }); - } + assertTimelineDateRangeValid(assignment.startDate, assignment.endDate); } const results = await ctx.db.$transaction(async (tx) => { const created = []; for (const assignment of input.assignments) { - const percentage = Math.min( - 100, - Math.round((assignment.hoursPerDay / 8) * 100), - ); - const metadata = { - source: "batchQuickAssign", - } satisfies Record; + const percentage = calculateTimelineAllocationPercentage(assignment.hoursPerDay); + const metadata = buildTimelineQuickAssignMetadata("batchQuickAssign"); const createdAssignment = await createAssignment( tx as unknown as Parameters[0], @@ -289,35 +276,24 @@ export const timelineAllocationMutationProcedures = { const updated = []; for (const entry of resolved) { const existing = entry.entry; - const newStart = new Date(existing.startDate); - const newEnd = new Date(existing.endDate); - - if (input.mode === "move") { - newStart.setDate(newStart.getDate() + input.daysDelta); - newEnd.setDate(newEnd.getDate() + input.daysDelta); - } else if (input.mode === "resize-start") { - newStart.setDate(newStart.getDate() + input.daysDelta); - if (newStart > newEnd) { - newStart.setTime(newEnd.getTime()); - } - } else { - newEnd.setDate(newEnd.getDate() + input.daysDelta); - if (newEnd < newStart) { - newEnd.setTime(newStart.getTime()); - } - } + const shiftedWindow = shiftTimelineAllocationWindow({ + startDate: existing.startDate, + endDate: existing.endDate, + daysDelta: input.daysDelta, + mode: input.mode, + }); const result = await updateAllocationEntry( tx as unknown as Parameters[0], { id: existing.id, demandRequirementUpdate: { - startDate: newStart, - endDate: newEnd, + startDate: shiftedWindow.startDate, + endDate: shiftedWindow.endDate, }, assignmentUpdate: { - startDate: newStart, - endDate: newEnd, + startDate: shiftedWindow.startDate, + endDate: shiftedWindow.endDate, }, }, ); @@ -329,12 +305,11 @@ export const timelineAllocationMutationProcedures = { entityType: "Allocation", entityId: input.allocationIds.join(","), action: "UPDATE", - changes: { - operation: "batchShift", + changes: buildTimelineBatchShiftAuditChanges({ mode: input.mode, daysDelta: input.daysDelta, count: resolved.length, - } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + }), }, });