import { buildSplitAllocationReadModel, createAssignment, findAllocationEntry, loadAllocationEntry, listAssignmentBookings, updateAssignment, updateDemandRequirement, updateAllocationEntry, } from "@capakraken/application"; import { Prisma, VacationType } from "@capakraken/db"; import type { PrismaClient } from "@capakraken/db"; import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine"; import type { CalculationRule, AbsenceDay } from "@capakraken/shared"; import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { loadProjectPlanningReadModel, TIMELINE_ASSIGNMENT_INCLUDE, PROJECT_PLANNING_DEMAND_INCLUDE, } from "./project-planning-read-model.js"; import { emitAllocationCreated, emitAllocationUpdated, emitProjectShifted, } from "../sse/event-bus.js"; import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; import { logger } from "../lib/logger.js"; import type { TRPCContext } from "../trpc.js"; type ShiftDbClient = Pick< PrismaClient, "project" | "demandRequirement" | "assignment" >; export type TimelineEntriesDbClient = Pick< PrismaClient, "demandRequirement" | "assignment" | "resource" | "project" | "holidayCalendar" | "country" | "metroCity" >; export type TimelineEntriesFilters = { startDate: Date; endDate: Date; resourceIds?: string[] | undefined; projectIds?: string[] | undefined; clientIds?: string[] | undefined; chapters?: string[] | undefined; eids?: string[] | undefined; countryCodes?: string[] | undefined; }; const TimelineWindowFiltersSchema = z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(), resourceIds: z.array(z.string()).optional(), projectIds: z.array(z.string()).optional(), clientIds: z.array(z.string()).optional(), chapters: z.array(z.string()).optional(), eids: z.array(z.string()).optional(), countryCodes: z.array(z.string()).optional(), }); type TimelineWindowFiltersInput = z.infer; type TimelineSelfServiceContext = Pick; export function getAssignmentResourceIds( readModel: ReturnType, ): string[] { return [ ...new Set( readModel.assignments .map((assignment) => assignment.resourceId) .filter((resourceId): resourceId is string => resourceId !== null), ), ]; } function fmtDate(value: Date | null | undefined): string | null { if (!value) { return null; } return value.toISOString().slice(0, 10); } function createUtcDate(year: number, month: number, day: number): Date { return new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); } function createTimelineDateRange(input: { startDate?: string | undefined; endDate?: string | undefined; durationDays?: number | undefined; }): { startDate: Date; endDate: Date } { const now = new Date(); const startDate = input.startDate ? new Date(`${input.startDate}T00:00:00.000Z`) : createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); if (Number.isNaN(startDate.getTime())) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid startDate: ${input.startDate}`, }); } const endDate = input.endDate ? new Date(`${input.endDate}T00:00:00.000Z`) : createUtcDate( startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0), ); if (Number.isNaN(endDate.getTime())) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid endDate: ${input.endDate}`, }); } if (endDate < startDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "endDate must be on or after startDate.", }); } return { startDate, endDate }; } function normalizeStringList(values?: string[] | undefined): string[] | undefined { const normalized = values ?.map((value) => value.trim()) .filter((value) => value.length > 0); return normalized && normalized.length > 0 ? normalized : undefined; } function createTimelineFilters(input: { resourceIds?: string[] | undefined; projectIds?: string[] | undefined; clientIds?: string[] | undefined; chapters?: string[] | undefined; eids?: string[] | undefined; countryCodes?: string[] | undefined; }): Omit { return { resourceIds: normalizeStringList(input.resourceIds), projectIds: normalizeStringList(input.projectIds), clientIds: normalizeStringList(input.clientIds), chapters: normalizeStringList(input.chapters), eids: normalizeStringList(input.eids), countryCodes: normalizeStringList(input.countryCodes), }; } function createEmptyTimelineEntriesView() { return buildSplitAllocationReadModel({ demandRequirements: [], assignments: [], }); } async function findOwnedTimelineResourceId( ctx: TimelineSelfServiceContext, ): Promise { if (!ctx.dbUser?.id) { return null; } if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { return null; } const resource = await ctx.db.resource.findFirst({ where: { userId: ctx.dbUser.id }, select: { id: true }, }); return resource?.id ?? null; } async function buildSelfServiceTimelineInput( ctx: TimelineSelfServiceContext, input: TimelineWindowFiltersInput, ): Promise { const ownedResourceId = await findOwnedTimelineResourceId(ctx); if (!ownedResourceId) { return null; } return { startDate: input.startDate, endDate: input.endDate, resourceIds: [ownedResourceId], projectIds: normalizeStringList(input.projectIds), clientIds: normalizeStringList(input.clientIds), }; } function summarizeTimelineEntries(readModel: { allocations: Array<{ projectId: string | null; resourceId: string | null }>; demands: Array<{ projectId: string | null }>; assignments: Array<{ projectId: string | null; resourceId: string | null }>; }) { const projectIds = new Set(); const resourceIds = new Set(); for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) { if (entry.projectId) { projectIds.add(entry.projectId); } } for (const assignment of [...readModel.allocations, ...readModel.assignments]) { if (assignment.resourceId) { resourceIds.add(assignment.resourceId); } } return { allocationCount: readModel.allocations.length, demandCount: readModel.demands.length, assignmentCount: readModel.assignments.length, projectCount: projectIds.size, resourceCount: resourceIds.size, }; } function formatHolidayOverlays( overlays: Array<{ id: string; resourceId: string; startDate: Date; endDate: Date; note?: string | null; scope?: string | null; calendarName?: string | null; sourceType?: string | null; }>, ) { return overlays.map((overlay) => ({ id: overlay.id, resourceId: overlay.resourceId, startDate: fmtDate(overlay.startDate), endDate: fmtDate(overlay.endDate), note: overlay.note ?? null, scope: overlay.scope ?? null, calendarName: overlay.calendarName ?? null, sourceType: overlay.sourceType ?? null, })); } function summarizeHolidayOverlays( overlays: ReturnType, ) { const resourceIds = new Set(); const byScope = new Map(); for (const overlay of overlays) { resourceIds.add(overlay.resourceId); const scope = overlay.scope ?? "UNKNOWN"; byScope.set(scope, (byScope.get(scope) ?? 0) + 1); } return { overlayCount: overlays.length, holidayResourceCount: resourceIds.size, byScope: [...byScope.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([scope, count]) => ({ scope, count })), }; } function rangesOverlap( leftStart: Date, leftEnd: Date, rightStart: Date, rightEnd: Date, ): boolean { return leftStart <= rightEnd && rightStart <= leftEnd; } function toDate(value: Date | string): Date { return value instanceof Date ? value : new Date(value); } export async function loadTimelineEntriesReadModel( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, ) { const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids, countryCodes } = input; // When resource-level filters are active (resourceIds, chapters, eids, or countryCodes), // resolve matching resource IDs so we can push the filter to the DB query. const effectiveResourceIds = await (async () => { if (resourceIds && resourceIds.length > 0) return resourceIds; const hasChapters = chapters && chapters.length > 0; const hasEids = eids && eids.length > 0; const hasCountry = countryCodes && countryCodes.length > 0; if (!hasChapters && !hasEids && !hasCountry) return undefined; const andConditions: Record[] = []; if (hasChapters) andConditions.push({ chapter: { in: chapters } }); if (hasEids) andConditions.push({ eid: { in: eids } }); if (hasCountry) andConditions.push({ country: { code: { in: countryCodes } } }); const matching = await db.resource.findMany({ // eslint-disable-next-line @typescript-eslint/no-explicit-any where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any, select: { id: true }, }); return matching.map((r) => r.id); })(); const effectiveProjectIds = await (async () => { if (!clientIds || clientIds.length === 0) return projectIds; const matchingProjects = await db.project.findMany({ where: { clientId: { in: clientIds } }, select: { id: true }, }); const clientProjectIds = matchingProjects.map((project) => project.id); if (!projectIds || projectIds.length === 0) { return clientProjectIds; } const allowedIds = new Set(clientProjectIds); return projectIds.filter((projectId) => allowedIds.has(projectId)); })(); // When filtering by resource (either explicit resourceIds or derived from chapters), // demands without a resource are excluded. const excludeDemands = effectiveResourceIds !== undefined; const [demandRequirements, assignments] = await Promise.all([ excludeDemands ? Promise.resolve([]) : db.demandRequirement.findMany({ where: { status: { not: "CANCELLED" }, startDate: { lte: endDate }, endDate: { gte: startDate }, ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}), }, include: PROJECT_PLANNING_DEMAND_INCLUDE, orderBy: [{ startDate: "asc" }, { projectId: "asc" }], }), db.assignment.findMany({ where: { status: { not: "CANCELLED" }, startDate: { lte: endDate }, endDate: { gte: startDate }, ...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}), ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}), }, include: TIMELINE_ASSIGNMENT_INCLUDE, orderBy: [{ startDate: "asc" }, { resourceId: "asc" }], }), ]); return buildSplitAllocationReadModel({ demandRequirements, assignments }); } export async function loadTimelineHolidayOverlays( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, ) { const readModel = await loadTimelineEntriesReadModel(db, input); return loadTimelineHolidayOverlaysForReadModel(db, input, readModel); } async function loadTimelineHolidayOverlaysForReadModel( db: TimelineEntriesDbClient, input: TimelineEntriesFilters, readModel: ReturnType, ) { const resourceIds = [...new Set( readModel.assignments .map((assignment) => assignment.resourceId) .filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0), )]; if (input.resourceIds && input.resourceIds.length > 0) { for (const resourceId of input.resourceIds) { if (resourceId && !resourceIds.includes(resourceId)) { resourceIds.push(resourceId); } } } const hasResourceFilters = (input.chapters?.length ?? 0) > 0 || (input.eids?.length ?? 0) > 0 || (input.countryCodes?.length ?? 0) > 0; if (hasResourceFilters) { const andConditions: Record[] = []; if (input.chapters && input.chapters.length > 0) { andConditions.push({ chapter: { in: input.chapters } }); } if (input.eids && input.eids.length > 0) { andConditions.push({ eid: { in: input.eids } }); } if (input.countryCodes && input.countryCodes.length > 0) { andConditions.push({ country: { code: { in: input.countryCodes } } }); } const matchingResources = await db.resource.findMany({ // eslint-disable-next-line @typescript-eslint/no-explicit-any where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any, select: { id: true }, }); for (const resource of matchingResources) { if (!resourceIds.includes(resource.id)) { resourceIds.push(resource.id); } } } if (resourceIds.length === 0) { return []; } const resources = await db.resource.findMany({ where: { id: { in: resourceIds } }, select: { id: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, }, }); const overlays = await Promise.all( resources.map(async (resource) => { const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), { periodStart: input.startDate, periodEnd: input.endDate, countryId: resource.countryId, countryCode: resource.country?.code ?? null, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name ?? null, }); return holidays.map((holiday) => { const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`); return { id: `calendar-holiday:${resource.id}:${holiday.date}`, resourceId: resource.id, type: VacationType.PUBLIC_HOLIDAY, status: "APPROVED" as const, startDate: holidayDate, endDate: holidayDate, note: holiday.name, scope: holiday.scope, calendarName: holiday.calendarName, sourceType: holiday.sourceType, }; }); }), ); return overlays.flat().sort((left, right) => { if (left.resourceId !== right.resourceId) { return left.resourceId.localeCompare(right.resourceId); } return left.startDate.getTime() - right.startDate.getTime(); }); } async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { const [project, planningRead] = await Promise.all([ findUniqueOrThrow( db.project.findUnique({ where: { id: projectId }, select: { id: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, }, }), "Project", ), loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), ]); const { demandRequirements, assignments, readModel: projectReadModel } = planningRead; const resourceIds = getAssignmentResourceIds(projectReadModel); const allAssignmentWindows = resourceIds.length === 0 ? [] : ( await listAssignmentBookings(db, { resourceIds, }) ).map((booking) => ({ id: booking.id, resourceId: booking.resourceId!, projectId: booking.projectId, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, status: booking.status, })); const shiftPlan = buildTimelineShiftPlan({ demandRequirements, assignments, allAssignmentWindows, }); return { project, demandRequirements, assignments, shiftPlan, }; } export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) { const [project, planningRead] = await Promise.all([ findUniqueOrThrow( db.project.findUnique({ where: { id: projectId }, select: { id: true, name: true, shortCode: true, orderType: true, budgetCents: true, winProbability: true, status: true, startDate: true, endDate: true, staffingReqs: true, }, }), "Project", ), loadProjectPlanningReadModel(db, { projectId, activeOnly: true, }), ]); const resourceIds = getAssignmentResourceIds(planningRead.readModel); const allResourceAllocations = resourceIds.length === 0 ? [] : await listAssignmentBookings(db, { resourceIds, }); return { project, allocations: planningRead.readModel.allocations, demands: planningRead.readModel.demands, assignments: planningRead.readModel.assignments, allResourceAllocations, resourceIds, }; } export async function previewTimelineProjectShift( db: ShiftDbClient, input: { projectId: string; newStartDate: Date; newEndDate: Date; }, ) { const { project, shiftPlan } = await loadProjectShiftContext(db, input.projectId); return validateShift({ project: { id: project.id, budgetCents: project.budgetCents, winProbability: project.winProbability, startDate: project.startDate, endDate: project.endDate, }, newStartDate: input.newStartDate, newEndDate: input.newEndDate, allocations: shiftPlan.validationAllocations, }); } function anonymizeResourceOnEntry( entry: T, directory: Awaited>, ): T { if (!entry.resource) { return entry; } return { ...entry, resource: anonymizeResource(entry.resource, directory), }; } /** Load active calculation rules from DB, falling back to defaults if none configured. */ function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean { if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error.code !== "P2021") { return false; } const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : ""; const message = error.message.toLowerCase(); return tableHints.some((hint) => table.includes(hint) || message.includes(hint)); } if (typeof error !== "object" || error === null || !("code" in error)) { return false; } const candidate = error as { code?: unknown; message?: unknown; meta?: { table?: unknown }; }; const code = typeof candidate.code === "string" ? candidate.code : ""; if (code !== "P2021") { return false; } const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : ""; const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : ""; return tableHints.some((hint) => table.includes(hint) || message.includes(hint)); } async function loadCalculationRules(db: PrismaClient): Promise { const calculationRuleModel = (db as PrismaClient & { calculationRule?: { findMany?: (args: unknown) => Promise }; }).calculationRule; if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") { return DEFAULT_CALCULATION_RULES; } try { const rules = await calculationRuleModel.findMany({ where: { isActive: true }, orderBy: [{ priority: "desc" }], }); if (rules.length > 0) { return rules as unknown as CalculationRule[]; } } catch (error) { if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) { logger.error({ err: error }, "Failed to load active calculation rules for timeline"); throw error; } } return DEFAULT_CALCULATION_RULES; } /** Build typed absence days from vacations for a resource in a date range. */ async function buildAbsenceDays( db: PrismaClient, resourceId: string, startDate: Date, endDate: Date, ): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> { const absenceDays: AbsenceDay[] = []; const legacyVacationDates: Date[] = []; try { const vacations = await db.vacation.findMany({ where: { resourceId, status: "APPROVED", startDate: { lte: endDate }, endDate: { gte: startDate }, }, select: { startDate: true, endDate: true, type: true, isHalfDay: true }, }); for (const v of vacations) { const cur = new Date(v.startDate); cur.setHours(0, 0, 0, 0); const vEnd = new Date(v.endDate); vEnd.setHours(0, 0, 0, 0); // Map Prisma VacationType to AbsenceTrigger const triggerType = v.type === VacationType.SICK ? "SICK" as const : v.type === VacationType.PUBLIC_HOLIDAY ? "PUBLIC_HOLIDAY" as const : "VACATION" as const; while (cur <= vEnd) { absenceDays.push({ date: new Date(cur), type: triggerType, ...(v.isHalfDay ? { isHalfDay: true } : {}), }); // Also populate legacy vacation dates for backward compat if (triggerType === "VACATION") { legacyVacationDates.push(new Date(cur)); } cur.setDate(cur.getDate() + 1); } } } catch (error) { if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) { logger.error( { err: error, resourceId, startDate, endDate }, "Failed to load timeline absence days", ); throw error; } } return { absenceDays, legacyVacationDates }; } export const timelineRouter = createTRPCRouter({ /** * Get all timeline entries (projects + allocations) for a date range. * Includes project startDate, endDate, staffingReqs for demand overlay. */ getEntries: controllerProcedure .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => { const readModel = await loadTimelineEntriesReadModel(ctx.db, input); const directory = await getAnonymizationDirectory(ctx.db); return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)); }), getEntriesView: controllerProcedure .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => { const [readModel, directory] = await Promise.all([ loadTimelineEntriesReadModel(ctx.db, input), getAnonymizationDirectory(ctx.db), ]); return { ...readModel, allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), }; }), getMyEntriesView: protectedProcedure .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => { const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); if (!selfServiceInput) { return createEmptyTimelineEntriesView(); } const [readModel, directory] = await Promise.all([ loadTimelineEntriesReadModel(ctx.db, selfServiceInput), getAnonymizationDirectory(ctx.db), ]); return { ...readModel, allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), }; }), getHolidayOverlays: controllerProcedure .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)), getMyHolidayOverlays: protectedProcedure .input(TimelineWindowFiltersSchema) .query(async ({ ctx, input }) => { const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input); if (!selfServiceInput) { return []; } return loadTimelineHolidayOverlays(ctx.db, selfServiceInput); }), getEntriesDetail: controllerProcedure .input( z.object({ startDate: z.string().optional(), endDate: z.string().optional(), durationDays: z.number().int().min(1).max(366).optional(), resourceIds: z.array(z.string()).optional(), projectIds: z.array(z.string()).optional(), clientIds: z.array(z.string()).optional(), chapters: z.array(z.string()).optional(), eids: z.array(z.string()).optional(), countryCodes: z.array(z.string()).optional(), }), ) .query(async ({ ctx, input }) => { const { startDate, endDate } = createTimelineDateRange(input); const filters = createTimelineFilters(input); const timelineInput = { ...filters, startDate, endDate }; const [readModel, directory] = await Promise.all([ loadTimelineEntriesReadModel(ctx.db, timelineInput), getAnonymizationDirectory(ctx.db), ]); const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel( ctx.db, timelineInput, readModel, ); const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); return { period: { startDate: fmtDate(startDate), endDate: fmtDate(endDate), }, filters, summary: { ...summarizeTimelineEntries(readModel), ...summarizeHolidayOverlays(formattedHolidayOverlays), }, allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)), demands: readModel.demands, assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)), holidayOverlays: formattedHolidayOverlays, }; }), getHolidayOverlayDetail: controllerProcedure .input( z.object({ startDate: z.string().optional(), endDate: z.string().optional(), durationDays: z.number().int().min(1).max(366).optional(), resourceIds: z.array(z.string()).optional(), projectIds: z.array(z.string()).optional(), clientIds: z.array(z.string()).optional(), chapters: z.array(z.string()).optional(), eids: z.array(z.string()).optional(), countryCodes: z.array(z.string()).optional(), }), ) .query(async ({ ctx, input }) => { const { startDate, endDate } = createTimelineDateRange(input); const filters = createTimelineFilters(input); const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, { ...filters, startDate, endDate, }); const formattedOverlays = formatHolidayOverlays(holidayOverlays); return { period: { startDate: fmtDate(startDate), endDate: fmtDate(endDate), }, filters, summary: summarizeHolidayOverlays(formattedOverlays), overlays: formattedOverlays, }; }), /** * Get full project context for a project: * - project with staffingReqs and budget * - all active planning entries on this project * - all assignment bookings for the same resources (for cross-project overlap display) * Used when: drag starts or project panel opens. */ getProjectContext: controllerProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const { project, allocations, demands, assignments, allResourceAllocations, resourceIds, } = await loadTimelineProjectContext(ctx.db, input.projectId); const directory = await getAnonymizationDirectory(ctx.db); return { project, allocations: allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory), ), demands, assignments: assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory), ), allResourceAllocations: allResourceAllocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory), ), resourceIds, }; }), getProjectContextDetail: controllerProcedure .input( z.object({ projectId: z.string(), startDate: z.string().optional(), endDate: z.string().optional(), durationDays: z.number().int().min(1).max(366).optional(), }), ) .query(async ({ ctx, input }) => { const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId); const directory = await getAnonymizationDirectory(ctx.db); const derivedStartDate = input.startDate ? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate : projectContext.project.startDate ?? projectContext.assignments[0]?.startDate ?? projectContext.demands[0]?.startDate ?? createTimelineDateRange({ durationDays: 1 }).startDate; const derivedEndDate = input.endDate ? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate : projectContext.project.endDate ?? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, durationDays: input.durationDays ?? 21, }).endDate; if (derivedEndDate < derivedStartDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "endDate must be on or after startDate.", }); } const holidayOverlays = projectContext.resourceIds.length > 0 ? await loadTimelineHolidayOverlays(ctx.db, { startDate: derivedStartDate, endDate: derivedEndDate, resourceIds: projectContext.resourceIds, projectIds: [input.projectId], }) : []; const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays); const assignmentConflicts = projectContext.assignments .filter((assignment) => assignment.resourceId && assignment.resource) .map((assignment) => { const overlaps = projectContext.allResourceAllocations .filter((booking) => ( booking.resourceId === assignment.resourceId && booking.id !== assignment.id && rangesOverlap( toDate(booking.startDate), toDate(booking.endDate), toDate(assignment.startDate), toDate(assignment.endDate), ) )) .map((booking) => ({ id: booking.id, projectId: booking.projectId, projectName: booking.project?.name ?? null, projectShortCode: booking.project?.shortCode ?? null, startDate: fmtDate(toDate(booking.startDate)), endDate: fmtDate(toDate(booking.endDate)), hoursPerDay: booking.hoursPerDay, status: booking.status, sameProject: booking.projectId === input.projectId, })); return { assignmentId: assignment.id, resourceId: assignment.resourceId!, resourceName: assignment.resource?.displayName ?? null, startDate: fmtDate(toDate(assignment.startDate)), endDate: fmtDate(toDate(assignment.endDate)), hoursPerDay: assignment.hoursPerDay, overlapCount: overlaps.length, crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length, overlaps, }; }); return { project: projectContext.project, period: { startDate: fmtDate(derivedStartDate), endDate: fmtDate(derivedEndDate), }, summary: { ...summarizeTimelineEntries({ allocations: projectContext.allocations, demands: projectContext.demands, assignments: projectContext.assignments, }), resourceIds: projectContext.resourceIds.length, allResourceAllocationCount: projectContext.allResourceAllocations.length, conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length, ...summarizeHolidayOverlays(formattedHolidayOverlays), }, allocations: projectContext.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory), ), demands: projectContext.demands, assignments: projectContext.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory), ), allResourceAllocations: projectContext.allResourceAllocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory), ), assignmentConflicts, holidayOverlays: formattedHolidayOverlays, resourceIds: projectContext.resourceIds, }; }), /** * Inline update of an allocation's hours, dates, includeSaturday, or role. * Recalculates dailyCostCents and emits SSE. */ updateAllocationInline: managerProcedure .input(UpdateAllocationHoursSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const resolved = await loadAllocationEntry(ctx.db, input.allocationId); const existing = resolved.entry; const existingResource = resolved.resourceId ? await ctx.db.resource.findUnique({ where: { id: resolved.resourceId }, select: { id: true, lcrCents: true, availability: true }, }) : null; const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay; 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", }); } // Merge includeSaturday into metadata 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; // For placeholder allocations (no resource), dailyCostCents stays 0 let newDailyCostCents = 0; if (resolved.resourceId) { if (!existingResource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } const availability = existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability; // Load recurrence from merged metadata const recurrence = (newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined); // Load typed absences + calculation rules for rules-aware cost computation const [absenceData, calculationRules] = await Promise.all([ buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate), loadCalculationRules(ctx.db as PrismaClient), ]); newDailyCostCents = calculateAllocation({ lcrCents: existingResource.lcrCents, hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, availability, includeSaturday, ...(recurrence ? { recurrence } : {}), vacationDates: absenceData.legacyVacationDates, absenceDays: absenceData.absenceDays, calculationRules, }).dailyCostCents; } const updated = await ctx.db.$transaction(async (tx) => { const { allocation: updatedAllocation } = await updateAllocationEntry( tx as unknown as Parameters[0], { id: input.allocationId, demandRequirementUpdate: { 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 } : {}), }, }, ); await tx.auditLog.create({ data: { 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, }, }, }, }); return updatedAllocation; }); emitAllocationUpdated({ id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, }); return updated; }), /** * Preview a project shift — validate without committing. * Returns cost impact, conflicts, warnings. */ previewShift: controllerProcedure .input(ShiftProjectSchema) .query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)), getShiftPreviewDetail: controllerProcedure .input(ShiftProjectSchema) .query(async ({ ctx, input }) => { const [project, preview] = await Promise.all([ findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true, name: true, shortCode: true, status: true, responsiblePerson: true, startDate: true, endDate: true, }, }), "Project", ), previewTimelineProjectShift(ctx.db, input), ]); return { project: { id: project.id, name: project.name, shortCode: project.shortCode, status: project.status, responsiblePerson: project.responsiblePerson, startDate: fmtDate(project.startDate), endDate: fmtDate(project.endDate), }, requestedShift: { newStartDate: fmtDate(input.newStartDate), newEndDate: fmtDate(input.newEndDate), }, preview, }; }), /** * 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, ); // Re-validate before committing 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((e) => e.message).join(", ")}`, }); } // Pre-load calculation rules for cost recalculation const shiftRules = await loadCalculationRules(ctx.db as PrismaClient); // Apply shift in a transaction const updatedProject = await ctx.db.$transaction(async (tx) => { // Update project dates const proj = 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, }, ); } // Write audit log 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 proj; }); // Emit SSE event for live updates emitProjectShifted({ projectId, newStartDate: newStartDate.toISOString(), newEndDate: newEndDate.toISOString(), costDeltaCents: validation.costImpact.deltaCents, resourceIds: assignments.map((assignment) => assignment.resourceId), }); return { project: updatedProject, validation }; }), /** * Quick-assign a resource to a project for a date range. * Overbooking is intentionally allowed — no availability throw. * For use from the timeline drag-to-assign UI. */ quickAssign: managerProcedure .input( z.object({ resourceId: z.string(), projectId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0.5).max(24).default(8), role: z.string().min(1).max(200).default("Team Member"), roleId: z.string().optional(), status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), }), ) .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" }); } const percentage = Math.min(100, Math.round((input.hoursPerDay / 8) * 100)); const metadata = { source: "quickAssign" } satisfies Record; 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, }, ); return buildSplitAllocationReadModel({ demandRequirements: [], assignments: [assignment], }).allocations[0]!; }); emitAllocationCreated({ id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId, }); return allocation; }), /** * Batch quick-assign multiple resources to a project for a date range. * Used by the multi-selection floating action bar. */ batchQuickAssign: managerProcedure .input( z.object({ assignments: z .array( z.object({ resourceId: z.string(), projectId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0.5).max(24).default(8), role: z.string().min(1).max(200).default("Team Member"), status: z .nativeEnum(AllocationStatus) .default(AllocationStatus.PROPOSED), }), ) .min(1) .max(50), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); // Validate all date ranges for (const a of input.assignments) { if (a.endDate < a.startDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date", }); } } const results = await ctx.db.$transaction(async (tx) => { const created = []; for (const a of input.assignments) { const percentage = Math.min( 100, Math.round((a.hoursPerDay / 8) * 100), ); const metadata = { source: "batchQuickAssign", } satisfies Record; const assignment = await createAssignment( tx as unknown as Parameters[0], { resourceId: a.resourceId, projectId: a.projectId, startDate: a.startDate, endDate: a.endDate, hoursPerDay: a.hoursPerDay, percentage, role: a.role, status: a.status, metadata, }, ); created.push(assignment); } return created; }); // Fire SSE events for (const assignment of results) { emitAllocationCreated({ id: assignment.id, projectId: assignment.projectId, resourceId: assignment.resourceId, }); } return { count: results.length }; }), /** * Batch-shift multiple allocations by the same number of days. * Used by multi-select drag on the timeline. */ batchShiftAllocations: managerProcedure .input( z.object({ allocationIds: z.array(z.string()).min(1).max(100), daysDelta: z.number().int().min(-3650).max(3650), mode: z.enum(["move", "resize-start", "resize-end"]).default("move"), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); if (input.daysDelta === 0) return { count: 0 }; // Load all allocations const entries = await Promise.all( input.allocationIds.map((id) => findAllocationEntry(ctx.db, id)), ); const resolved = entries.filter( (e): e is NonNullable => e !== null, ); if (resolved.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" }); } const results = await ctx.db.$transaction(async (tx) => { 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); // Clamp: start must not exceed end if (newStart > newEnd) newStart.setTime(newEnd.getTime()); } else { // resize-end newEnd.setDate(newEnd.getDate() + input.daysDelta); // Clamp: end must not precede start if (newEnd < newStart) newEnd.setTime(newStart.getTime()); } const result = await updateAllocationEntry( tx as unknown as Parameters[0], { id: existing.id, demandRequirementUpdate: { startDate: newStart, endDate: newEnd, }, assignmentUpdate: { startDate: newStart, endDate: newEnd, }, }, ); updated.push(result.allocation); } await tx.auditLog.create({ data: { entityType: "Allocation", entityId: input.allocationIds.join(","), action: "UPDATE", changes: { operation: "batchShift", mode: input.mode, daysDelta: input.daysDelta, count: resolved.length, } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); return updated; }); // Fire SSE events for (const alloc of results) { emitAllocationUpdated({ id: alloc.id, projectId: alloc.projectId, resourceId: alloc.resourceId, }); } return { count: results.length }; }), /** * Get budget status for a project. */ getBudgetStatus: controllerProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const project = await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true, name: true, shortCode: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, }, }), "Project", ); // Use wide date range to catch all assignments (including those extending beyond project dates) const bookings = await listAssignmentBookings(ctx.db, { projectIds: [project.id], }); const budgetStatus = computeBudgetStatus( project.budgetCents, project.winProbability, bookings.map((booking) => ({ status: booking.status, dailyCostCents: booking.dailyCostCents, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, })) as unknown as Pick[], project.startDate, project.endDate, ); return { ...budgetStatus, projectName: project.name, projectCode: project.shortCode, totalAllocations: bookings.length, budgetCents: project.budgetCents, }; }), });