import { createAssignmentFragment, 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 interface TimelineAllocationExtractResult { action: "unchanged" | "extracted"; allocationGroupId: string; extractedAllocationId: string; updatedAllocationIds: string[]; createdAllocationIds: 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 createAssignmentFragment( 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, }; }); } export async function extractTimelineAllocationFragment(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 extracted into fragments.", }); } const extractStart = toUtcCalendarDate(input.startDate); const extractEnd = toUtcCalendarDate(input.endDate); if (extractEnd < extractStart) { throw new TRPCError({ code: "BAD_REQUEST", message: "Extract end date must be on or after the extract start date.", }); } const assignment = resolved.assignment; const assignmentStart = toUtcCalendarDate(assignment.startDate); const assignmentEnd = toUtcCalendarDate(assignment.endDate); if (extractStart < assignmentStart || extractEnd > assignmentEnd) { throw new TRPCError({ code: "BAD_REQUEST", message: "The requested extract range must be fully inside the existing allocation.", }); } const { groupId, metadata } = readFragmentMetadata(assignment.metadata, assignment.id); const hasLeftFragment = extractStart > assignmentStart; const hasRightFragment = extractEnd < assignmentEnd; if (!hasLeftFragment && !hasRightFragment) { return { action: "unchanged", allocationGroupId: groupId, extractedAllocationId: assignment.id, updatedAllocationIds: [], createdAllocationIds: [], projectId: assignment.projectId, resourceId: assignment.resourceId, }; } return input.db.$transaction(async (tx) => { const createdAllocationIds: string[] = []; const updated = await updateAssignment( tx as unknown as Parameters[0], assignment.id, { startDate: extractStart, endDate: extractEnd, metadata, }, ); const fragmentBase = { demandRequirementId: assignment.demandRequirementId ?? undefined, resourceId: assignment.resourceId, projectId: assignment.projectId, hoursPerDay: assignment.hoursPerDay, percentage: assignment.percentage, role: assignment.role ?? undefined, roleId: assignment.roleId ?? undefined, dailyCostCents: assignment.dailyCostCents, status: toSharedAllocationStatus(assignment.status), metadata, }; const txClient = tx as unknown as Parameters[0]; const fragments = await Promise.all([ hasLeftFragment ? createAssignmentFragment(txClient, { ...fragmentBase, startDate: assignmentStart, endDate: addDays(extractStart, -1) }) : null, hasRightFragment ? createAssignmentFragment(txClient, { ...fragmentBase, startDate: addDays(extractEnd, 1), endDate: assignmentEnd }) : null, ]); for (const frag of fragments) { if (frag) createdAllocationIds.push(frag.id); } return { action: "extracted" as const, allocationGroupId: groupId, extractedAllocationId: updated.id, updatedAllocationIds: [updated.id], createdAllocationIds, projectId: assignment.projectId, resourceId: assignment.resourceId, }; }); }