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 { timelineReadProcedures } from "./timeline-read.js"; import { loadProjectShiftContext } from "./timeline-project-read.js"; export const timelineRouter = createTRPCRouter({ ...timelineReadProcedures, ...timelineAllocationMutationProcedures, /** * Apply a project shift: validate, then commit all allocation date changes. * Reads includeSaturday from each allocation's metadata. */ applyShift: managerProcedure .input(ShiftProjectSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const { projectId, newStartDate, newEndDate } = input; const { project, demandRequirements, assignments, shiftPlan } = await loadProjectShiftContext( 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, }); if (!validation.valid) { throw new TRPCError({ code: "BAD_REQUEST", message: `Shift validation failed: ${validation.errors.map((error) => error.message).join(", ")}`, }); } 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 }; }), });