import { buildSplitAllocationReadModel, createAssignment, createDemandRequirement, deleteAssignment, deleteAllocationEntry, deleteDemandRequirement, fillDemandRequirement, fillOpenDemand, loadAllocationEntry, updateAllocationEntry, updateAssignment, updateDemandRequirement, } from "@capakraken/application"; import { AllocationStatus, buildTaskAction, CreateAllocationSchema, CreateAssignmentSchema, CreateDemandRequirementSchema, FillDemandRequirementSchema, FillOpenDemandByAllocationSchema, PermissionKey, type WeekdayAvailability, UpdateAssignmentSchema, UpdateAllocationSchema, UpdateDemandRequirementSchema, } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkBudgetThresholds } from "../lib/budget-alerts.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js"; import { generateAutoSuggestions } from "../lib/auto-staffing.js"; import { invalidateDashboardCache } from "../lib/cache.js"; import { logger } from "../lib/logger.js"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, calculateEffectiveDayAvailability, countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; import { createTRPCRouter, managerProcedure, planningReadProcedure, requirePermission } from "../trpc.js"; import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js"; const DEMAND_INCLUDE = { project: { select: PROJECT_BRIEF_SELECT }, roleEntity: { select: ROLE_BRIEF_SELECT }, assignments: { include: { resource: { select: RESOURCE_BRIEF_SELECT }, project: { select: PROJECT_BRIEF_SELECT }, roleEntity: { select: ROLE_BRIEF_SELECT }, }, }, } as const; const ASSIGNMENT_INCLUDE = { resource: { select: RESOURCE_BRIEF_SELECT }, project: { select: PROJECT_BRIEF_SELECT }, roleEntity: { select: ROLE_BRIEF_SELECT }, demandRequirement: { select: { id: true, projectId: true, startDate: true, endDate: true, hoursPerDay: true, percentage: true, role: true, roleId: true, headcount: true, status: true, }, }, } as const; type AllocationListFilters = { projectId?: string | undefined; resourceId?: string | undefined; status?: AllocationStatus | undefined; }; type AllocationEntryUpdateInput = z.infer; type AssignmentResolutionInput = { assignmentId?: string | undefined; resourceId?: string | undefined; projectId?: string | undefined; startDate?: Date | undefined; endDate?: Date | undefined; selectionMode?: "WINDOW" | "EXACT_START" | undefined; excludeCancelled?: boolean | undefined; }; type CreateDemandDraftInput = { projectId: string; role?: string | undefined; roleId?: string | undefined; headcount?: number | undefined; hoursPerDay: number; startDate: Date; endDate: Date; budgetCents?: number | undefined; metadata?: Record | undefined; }; function runAllocationBackgroundEffect( effectName: string, execute: () => unknown, metadata: Record = {}, ): void { void Promise.resolve() .then(execute) .catch((error) => { logger.error( { err: error, effectName, ...metadata }, "Allocation background side effect failed", ); }); } function invalidateDashboardCacheInBackground(): void { runAllocationBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache()); } function checkBudgetThresholdsInBackground( db: import("@capakraken/db").PrismaClient, projectId: string, ): void { runAllocationBackgroundEffect( "checkBudgetThresholds", // eslint-disable-next-line @typescript-eslint/no-explicit-any () => checkBudgetThresholds(db as any, projectId), { projectId }, ); } function dispatchAllocationWebhookInBackground( db: import("@capakraken/db").PrismaClient, event: string, payload: Record, ): void { runAllocationBackgroundEffect( "dispatchWebhooks", () => dispatchWebhooks(db, event, payload), { event }, ); } function generateAutoSuggestionsInBackground( db: import("@capakraken/db").PrismaClient, demandRequirementId: string, ): void { runAllocationBackgroundEffect( "generateAutoSuggestions", // eslint-disable-next-line @typescript-eslint/no-explicit-any () => generateAutoSuggestions(db as any, demandRequirementId), { demandRequirementId }, ); } function toDemandRequirementUpdateInput(input: AllocationEntryUpdateInput) { return { ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), ...(input.startDate !== undefined ? { startDate: input.startDate } : {}), ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), ...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}), ...(input.percentage !== undefined ? { percentage: input.percentage } : {}), ...(input.role !== undefined ? { role: input.role } : {}), ...(input.roleId !== undefined ? { roleId: input.roleId } : {}), ...(input.headcount !== undefined ? { headcount: input.headcount } : {}), ...(input.budgetCents !== undefined ? { budgetCents: input.budgetCents } : {}), ...(input.status !== undefined ? { status: input.status } : {}), ...(input.metadata !== undefined ? { metadata: input.metadata } : {}), }; } function toAssignmentUpdateInput(input: AllocationEntryUpdateInput) { return { ...(input.resourceId !== undefined ? { resourceId: input.resourceId } : {}), ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), ...(input.startDate !== undefined ? { startDate: input.startDate } : {}), ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), ...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}), ...(input.percentage !== undefined ? { percentage: input.percentage } : {}), ...(input.role !== undefined ? { role: input.role } : {}), ...(input.roleId !== undefined ? { roleId: input.roleId } : {}), ...(input.status !== undefined ? { status: input.status } : {}), ...(input.metadata !== undefined ? { metadata: input.metadata } : {}), }; } async function loadAllocationReadModel( db: Pick, input: AllocationListFilters, ) { const [demandRequirements, assignments] = await Promise.all([ input.resourceId ? Promise.resolve([]) : db.demandRequirement.findMany({ where: { ...(input.projectId ? { projectId: input.projectId } : {}), ...(input.status ? { status: input.status } : {}), }, include: DEMAND_INCLUDE, orderBy: { startDate: "asc" }, }), db.assignment.findMany({ where: { ...(input.projectId ? { projectId: input.projectId } : {}), ...(input.resourceId ? { resourceId: input.resourceId } : {}), ...(input.status ? { status: input.status } : {}), }, include: ASSIGNMENT_INCLUDE, orderBy: { startDate: "asc" }, }), ]); const readModel = buildSplitAllocationReadModel({ demandRequirements, assignments }); const directory = await getAnonymizationDirectory(db as import("@capakraken/db").PrismaClient); if (!directory) return readModel; function anonymizeAllocation(alloc: T): T { if (!alloc.resource) return alloc; return { ...alloc, resource: anonymizeResource(alloc.resource, directory) }; } return { ...readModel, allocations: readModel.allocations.map(anonymizeAllocation), demands: readModel.demands.map(anonymizeAllocation), assignments: readModel.assignments.map(anonymizeAllocation), }; } async function findAllocationEntryOrNull( db: Pick, id: string, ) { try { return await loadAllocationEntry(db, id); } catch (error) { if (error instanceof TRPCError && error.code === "NOT_FOUND") { return null; } throw error; } } function toIsoDate(value: Date) { return value.toISOString().slice(0, 10); } function round1(value: number) { return Math.round(value * 10) / 10; } function averagePerWorkingDay(totalHours: number, workingDays: number) { if (workingDays <= 0) { return 0; } return round1(totalHours / workingDays); } function buildCreateDemandRequirementInput(input: CreateDemandDraftInput): z.infer { return { projectId: input.projectId, startDate: input.startDate, endDate: input.endDate, hoursPerDay: input.hoursPerDay, percentage: (input.hoursPerDay / 8) * 100, status: AllocationStatus.PROPOSED, headcount: input.headcount ?? 1, budgetCents: input.budgetCents ?? 0, metadata: input.metadata ?? {}, ...(input.role ? { role: input.role } : {}), ...(input.roleId ? { roleId: input.roleId } : {}), }; } async function getDemandRequirementByIdOrThrow( db: Pick, id: string, ) { return findUniqueOrThrow( db.demandRequirement.findUnique({ where: { id }, include: DEMAND_INCLUDE, }), "Demand requirement", ); } async function createDemandRequirementWithEffects( db: import("@capakraken/db").PrismaClient, input: z.infer, ) { const demandRequirement = await db.$transaction(async (tx) => { return createDemandRequirement( tx as unknown as Parameters[0], input, ); }); emitAllocationCreated({ id: demandRequirement.id, projectId: demandRequirement.projectId, resourceId: null, }); invalidateDashboardCacheInBackground(); const [project, roleEntity, managers] = await Promise.all([ db.project.findUnique({ where: { id: demandRequirement.projectId }, select: { name: true }, }), demandRequirement.roleId ? db.role.findUnique({ where: { id: demandRequirement.roleId }, select: { name: true }, }) : Promise.resolve(null), db.user.findMany({ where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, select: { id: true }, }), ]); const roleName = roleEntity?.name ?? demandRequirement.role ?? "Unspecified role"; const projectName = project?.name ?? "Unknown project"; const headcount = demandRequirement.headcount ?? 1; for (const manager of managers) { const task = await db.notification.create({ data: { userId: manager.id, category: "TASK", type: "DEMAND_FILL", priority: "NORMAL", title: `Staff demand: ${roleName} for ${projectName}`, body: `${headcount} ${roleName} needed for project ${projectName}`, taskStatus: "OPEN", taskAction: buildTaskAction("fill_demand", demandRequirement.id), entityId: demandRequirement.id, entityType: "demand", link: `/projects/${demandRequirement.projectId}`, channel: "in_app", }, }); emitNotificationCreated(manager.id, task.id); } checkBudgetThresholdsInBackground(db, demandRequirement.projectId); generateAutoSuggestionsInBackground(db, demandRequirement.id); return demandRequirement; } async function fillDemandRequirementWithEffects( db: import("@capakraken/db").PrismaClient, input: z.infer, ) { const result = await fillDemandRequirement(db, input); emitAllocationCreated({ id: result.assignment.id, projectId: result.assignment.projectId, resourceId: result.assignment.resourceId, }); emitAllocationUpdated({ id: result.updatedDemandRequirement.id, projectId: result.updatedDemandRequirement.projectId, resourceId: null, }); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(db, result.assignment.projectId); if (result.updatedDemandRequirement.headcount > 0 && result.updatedDemandRequirement.status !== "COMPLETED") { generateAutoSuggestionsInBackground(db, result.updatedDemandRequirement.id); } return result; } async function resolveAssignmentBySelection( db: Pick, input: AssignmentResolutionInput, ) { if (input.assignmentId) { return findUniqueOrThrow( db.assignment.findUnique({ where: { id: input.assignmentId }, include: ASSIGNMENT_INCLUDE, }), "Assignment", ); } if (!input.resourceId || !input.projectId) { throw new TRPCError({ code: "BAD_REQUEST", message: "resourceId and projectId are required when assignmentId is not provided", }); } const assignments = await db.assignment.findMany({ where: { resourceId: input.resourceId, projectId: input.projectId, ...(input.excludeCancelled ? { status: { not: AllocationStatus.CANCELLED } } : {}), }, include: ASSIGNMENT_INCLUDE, orderBy: { startDate: "asc" }, }); const matchingAssignment = assignments .filter((assignment) => { if (input.selectionMode === "WINDOW") { return (!input.startDate || assignment.startDate >= input.startDate) && (!input.endDate || assignment.endDate <= input.endDate); } return !input.startDate || toIsoDate(assignment.startDate) === toIsoDate(input.startDate); }) .sort((left, right) => right.startDate.getTime() - left.startDate.getTime())[0] ?? null; if (!matchingAssignment) { throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }); } return matchingAssignment; } async function buildResourceAvailabilityView( db: Pick, input: { resourceId: string; startDate: Date; endDate: Date; hoursPerDay: number; }, ) { const resource = await db.resource.findUnique({ where: { id: input.resourceId }, select: { id: true, displayName: true, eid: true, fte: true, availability: true, countryId: true, federalState: true, metroCityId: true, country: { select: { dailyWorkingHours: true, code: true } }, metroCity: { select: { name: true } }, }, }); if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1); const availability = (resource.availability as WeekdayAvailability | null) ?? { monday: fallbackDailyHours, tuesday: fallbackDailyHours, wednesday: fallbackDailyHours, thursday: fallbackDailyHours, friday: fallbackDailyHours, saturday: 0, sunday: 0, }; const [existingAssignments, vacations] = await Promise.all([ db.assignment.findMany({ where: { resourceId: input.resourceId, status: { not: "CANCELLED" }, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, }, select: { id: true, startDate: true, endDate: true, hoursPerDay: true, status: true, project: { select: { name: true, shortCode: true } }, }, orderBy: { startDate: "asc" }, }), db.vacation.findMany({ where: { resourceId: input.resourceId, status: { in: ["APPROVED", "PENDING"] }, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, }, select: { id: true, type: true, startDate: true, endDate: true, isHalfDay: true, halfDayPart: true, status: true, }, orderBy: { startDate: "asc" }, }), ]); const contexts = await loadResourceDailyAvailabilityContexts( db, [{ id: resource.id, availability, countryId: resource.countryId, countryCode: resource.country?.code, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, }], input.startDate, input.endDate, ); const context = contexts.get(resource.id); const totalWorkingDays = countEffectiveWorkingDays({ availability, periodStart: input.startDate, periodEnd: input.endDate, context, }); let availableDays = 0; let conflictDays = 0; let partialDays = 0; let totalAvailableHours = 0; const requestedHpd = input.hoursPerDay; const d = new Date(input.startDate); const end = new Date(input.endDate); while (d <= end) { const effectiveDayCapacity = calculateEffectiveDayAvailability({ availability, date: d, context, }); if (effectiveDayCapacity > 0) { let bookedHours = 0; for (const assignment of existingAssignments) { bookedHours += calculateEffectiveBookedHours({ availability, startDate: assignment.startDate, endDate: assignment.endDate, hoursPerDay: assignment.hoursPerDay, periodStart: d, periodEnd: d, context, }); } const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours); if (remainingCapacity >= requestedHpd) { availableDays++; totalAvailableHours += requestedHpd; } else if (remainingCapacity > 0) { partialDays++; totalAvailableHours += remainingCapacity; } else { conflictDays++; } } d.setDate(d.getDate() + 1); } const totalRequestedHours = totalWorkingDays * requestedHpd; const totalPeriodCapacity = calculateEffectiveAvailableHours({ availability, periodStart: input.startDate, periodEnd: input.endDate, context, }); const dailyCapacity = totalWorkingDays > 0 ? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10 : 0; return { resource: { id: resource.id, name: resource.displayName, eid: resource.eid }, dailyCapacity, totalWorkingDays, availableDays, partialDays, conflictDays, totalAvailableHours: Math.round(totalAvailableHours * 10) / 10, totalRequestedHours, coveragePercent: totalRequestedHours > 0 ? Math.round((totalAvailableHours / totalRequestedHours) * 100) : 0, existingAssignments: existingAssignments.map((assignment) => ({ project: assignment.project.name, code: assignment.project.shortCode, hoursPerDay: assignment.hoursPerDay, start: assignment.startDate.toISOString().slice(0, 10), end: assignment.endDate.toISOString().slice(0, 10), status: assignment.status, })), vacations: vacations.map((vacation) => ({ id: vacation.id, type: vacation.type, status: vacation.status, start: vacation.startDate.toISOString().slice(0, 10), end: vacation.endDate.toISOString().slice(0, 10), isHalfDay: vacation.isHalfDay, halfDayPart: vacation.halfDayPart, })), }; } function buildResourceAvailabilitySummary( availability: Awaited>, period: { startDate: Date; endDate: Date }, ) { const periodAvailableHours = availability.totalRequestedHours > 0 ? round1(availability.dailyCapacity * availability.totalWorkingDays) : 0; const periodRemainingHours = round1(availability.totalAvailableHours); const periodBookedHours = round1(Math.max(0, periodAvailableHours - periodRemainingHours)); return { resource: availability.resource.name, period: `${toIsoDate(period.startDate)} to ${toIsoDate(period.endDate)}`, fte: null, workingDays: availability.totalWorkingDays, periodAvailableHours, periodBookedHours, periodRemainingHours, maxHoursPerDay: availability.dailyCapacity, currentBookedHoursPerDay: round1( Math.max( 0, availability.dailyCapacity - availability.totalAvailableHours / Math.max(availability.totalWorkingDays, 1), ), ), availableHoursPerDay: averagePerWorkingDay(availability.totalAvailableHours, availability.totalWorkingDays), isFullyAvailable: availability.existingAssignments.length === 0 && availability.vacations.length === 0, existingAllocations: availability.existingAssignments.map((assignment) => ({ project: `${assignment.project} (${assignment.code})`, hoursPerDay: assignment.hoursPerDay, status: assignment.status, start: assignment.start, end: assignment.end, })), vacations: availability.vacations.map((vacation) => ({ type: vacation.type, start: vacation.start, end: vacation.end, isHalfDay: vacation.isHalfDay, })), }; } export const allocationRouter = createTRPCRouter({ list: planningReadProcedure .input( z.object({ projectId: z.string().optional(), resourceId: z.string().optional(), status: z.nativeEnum(AllocationStatus).optional(), }), ) .query(async ({ ctx, input }) => { const readModel = await loadAllocationReadModel(ctx.db, input); return readModel.allocations; }), listView: planningReadProcedure .input( z.object({ projectId: z.string().optional(), resourceId: z.string().optional(), status: z.nativeEnum(AllocationStatus).optional(), }), ) .query(async ({ ctx, input }) => loadAllocationReadModel(ctx.db, input)), create: managerProcedure .input(CreateAllocationSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const allocation = await ctx.db.$transaction(async (tx) => { if (!input.resourceId) { const demandRequirement = await createDemandRequirement( tx as unknown as Parameters[0], { projectId: input.projectId, startDate: input.startDate, endDate: input.endDate, hoursPerDay: input.hoursPerDay, percentage: input.percentage, role: input.role, roleId: input.roleId, headcount: input.headcount, status: input.status, metadata: input.metadata, }, ); return buildSplitAllocationReadModel({ demandRequirements: [demandRequirement], assignments: [], }).allocations[0]!; } 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: input.percentage, role: input.role, roleId: input.roleId, status: input.status, metadata: input.metadata, }, ); return buildSplitAllocationReadModel({ demandRequirements: [], assignments: [assignment], }).allocations[0]!; }); emitAllocationCreated({ id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId, }); dispatchAllocationWebhookInBackground(ctx.db, "allocation.created", { id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId, }); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, allocation.projectId); return allocation; }), listDemands: planningReadProcedure .input( z.object({ projectId: z.string().optional(), status: z.nativeEnum(AllocationStatus).optional(), roleId: z.string().optional(), }), ) .query(async ({ ctx, input }) => { const demands = await ctx.db.demandRequirement.findMany({ where: { ...(input.projectId ? { projectId: input.projectId } : {}), ...(input.status ? { status: input.status } : {}), ...(input.roleId ? { roleId: input.roleId } : {}), }, include: DEMAND_INCLUDE, orderBy: { startDate: "asc" }, }); const dir = await getAnonymizationDirectory(ctx.db); if (!dir) return demands; return demands.map((d) => ({ ...d, assignments: d.assignments.map((a) => a.resource ? { ...a, resource: anonymizeResource(a.resource, dir) } : a, ), })); }), listAssignments: planningReadProcedure .input( z.object({ projectId: z.string().optional(), resourceId: z.string().optional(), status: z.nativeEnum(AllocationStatus).optional(), demandRequirementId: z.string().optional(), }), ) .query(async ({ ctx, input }) => { const assignments = await ctx.db.assignment.findMany({ where: { ...(input.projectId ? { projectId: input.projectId } : {}), ...(input.resourceId ? { resourceId: input.resourceId } : {}), ...(input.status ? { status: input.status } : {}), ...(input.demandRequirementId ? { demandRequirementId: input.demandRequirementId } : {}), }, include: ASSIGNMENT_INCLUDE, orderBy: { startDate: "asc" }, }); const dir = await getAnonymizationDirectory(ctx.db); if (!dir) return assignments; return assignments.map((a) => a.resource ? { ...a, resource: anonymizeResource(a.resource, dir) } : a, ); }), getAssignmentById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const assignment = await findUniqueOrThrow( ctx.db.assignment.findUnique({ where: { id: input.id }, include: ASSIGNMENT_INCLUDE, }), "Assignment", ); const dir = await getAnonymizationDirectory(ctx.db); if (!dir || !assignment.resource) { return assignment; } return { ...assignment, resource: anonymizeResource(assignment.resource, dir), }; }), resolveAssignment: planningReadProcedure .input(z.object({ assignmentId: z.string().optional(), resourceId: z.string().optional(), projectId: z.string().optional(), startDate: z.coerce.date().optional(), endDate: z.coerce.date().optional(), selectionMode: z.enum(["WINDOW", "EXACT_START"]).default("EXACT_START"), excludeCancelled: z.boolean().default(false), })) .query(async ({ ctx, input }) => resolveAssignmentBySelection(ctx.db, input)), getDemandRequirementById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => getDemandRequirementByIdOrThrow(ctx.db, input.id)), /** * Check a resource's availability for a date range. * Returns working days, existing allocations, conflict days, and available capacity. */ checkResourceAvailability: planningReadProcedure .input(z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0.5).max(24).default(8), })) .query(async ({ ctx, input }) => { const { vacations: _vacations, ...availability } = await buildResourceAvailabilityView(ctx.db, input); return availability; }), getResourceAvailabilityView: planningReadProcedure .input(z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0.5).max(24).default(8), })) .query(async ({ ctx, input }) => buildResourceAvailabilityView(ctx.db, input)), getResourceAvailabilitySummary: planningReadProcedure .input(z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0.5).max(24).default(8), })) .query(async ({ ctx, input }) => { const availability = await buildResourceAvailabilityView(ctx.db, input); return buildResourceAvailabilitySummary(availability, input); }), createDemandRequirement: managerProcedure .input(CreateDemandRequirementSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); return createDemandRequirementWithEffects(ctx.db, input); }), createDemand: managerProcedure .input(z.object({ projectId: z.string(), role: z.string().optional(), roleId: z.string().optional(), headcount: z.number().int().positive().default(1), hoursPerDay: z.number().min(0.5).max(24), startDate: z.coerce.date(), endDate: z.coerce.date(), budgetCents: z.number().int().min(0).optional(), metadata: z.record(z.string(), z.unknown()).optional(), })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); return createDemandRequirementWithEffects( ctx.db, buildCreateDemandRequirementInput(input), ); }), updateDemandRequirement: managerProcedure .input(z.object({ id: z.string(), data: UpdateDemandRequirementSchema })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const updated = await ctx.db.$transaction(async (tx) => { return updateDemandRequirement( tx as unknown as Parameters[0], input.id, input.data, ); }); emitAllocationUpdated({ id: updated.id, projectId: updated.projectId, resourceId: null, }); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, updated.projectId); return updated; }), createAssignment: managerProcedure .input(CreateAssignmentSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const assignment = await ctx.db.$transaction(async (tx) => { return createAssignment( tx as unknown as Parameters[0], input, ); }); emitAllocationCreated({ id: assignment.id, projectId: assignment.projectId, resourceId: assignment.resourceId, }); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, assignment.projectId); return assignment; }), ensureAssignment: 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), role: z.string().optional(), })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const existing = (await ctx.db.assignment.findMany({ where: { resourceId: input.resourceId, projectId: input.projectId, }, include: ASSIGNMENT_INCLUDE, orderBy: { startDate: "asc" }, })).find((assignment) => ( toIsoDate(assignment.startDate) === toIsoDate(input.startDate) && toIsoDate(assignment.endDate) === toIsoDate(input.endDate) )); if (existing) { if (existing.status !== AllocationStatus.CANCELLED) { throw new TRPCError({ code: "CONFLICT", message: `An allocation already exists for this resource/project/dates with status ${existing.status}.`, }); } const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { lcrCents: true }, }); if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } const updated = await ctx.db.$transaction(async (tx) => updateAssignment( tx as unknown as Parameters[0], existing.id, { status: AllocationStatus.PROPOSED, hoursPerDay: input.hoursPerDay, percentage: (input.hoursPerDay / 8) * 100, dailyCostCents: Math.round(resource.lcrCents * input.hoursPerDay), ...(input.role ? { role: input.role } : {}), }, )); emitAllocationUpdated({ id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, }); dispatchAllocationWebhookInBackground(ctx.db, "allocation.updated", { id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, }); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, updated.projectId); return { assignment: updated, action: "reactivated" as const }; } const assignment = await ctx.db.$transaction(async (tx) => createAssignment( tx as unknown as Parameters[0], { resourceId: input.resourceId, projectId: input.projectId, startDate: input.startDate, endDate: input.endDate, hoursPerDay: input.hoursPerDay, percentage: (input.hoursPerDay / 8) * 100, status: AllocationStatus.PROPOSED, metadata: {}, ...(input.role ? { role: input.role } : {}), }, )); emitAllocationCreated({ id: assignment.id, projectId: assignment.projectId, resourceId: assignment.resourceId, }); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, assignment.projectId); return { assignment, action: "created" as const }; }), updateAssignment: managerProcedure .input(z.object({ id: z.string(), data: UpdateAssignmentSchema })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const existing = await findUniqueOrThrow( ctx.db.assignment.findUnique({ where: { id: input.id }, select: { resourceId: true }, }), "Assignment", ); const updated = await ctx.db.$transaction(async (tx) => { return updateAssignment( tx as unknown as Parameters[0], input.id, input.data, ); }); emitAllocationUpdated({ id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, resourceIds: [existing.resourceId, updated.resourceId], }); dispatchAllocationWebhookInBackground(ctx.db, "allocation.updated", { id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, }); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, updated.projectId); return updated; }), deleteDemandRequirement: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const existing = await findUniqueOrThrow( ctx.db.demandRequirement.findUnique({ where: { id: input.id }, include: DEMAND_INCLUDE, }), "Demand requirement", ); await ctx.db.$transaction(async (tx) => { await deleteDemandRequirement( tx as unknown as Parameters[0], input.id, ); await tx.auditLog.create({ data: { entityType: "DemandRequirement", entityId: input.id, action: "DELETE", changes: { before: existing } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); }); emitAllocationDeleted(existing.id, existing.projectId); dispatchAllocationWebhookInBackground(ctx.db, "allocation.deleted", { id: existing.id, projectId: existing.projectId, }); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, existing.projectId); return { success: true }; }), fillDemandRequirement: managerProcedure .input(FillDemandRequirementSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); return fillDemandRequirementWithEffects(ctx.db, input); }), assignResourceToDemand: managerProcedure .input(z.object({ demandRequirementId: z.string(), resourceId: z.string(), })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const result = await fillDemandRequirementWithEffects(ctx.db, input); const demandRequirement = await getDemandRequirementByIdOrThrow( ctx.db, input.demandRequirementId, ); return { ...result, demandRequirement, }; }), fillOpenDemandByAllocation: managerProcedure .input(FillOpenDemandByAllocationSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const result = await fillOpenDemand(ctx.db, input); emitAllocationCreated(result.createdAllocation); if (result.updatedAllocation) { emitAllocationUpdated(result.updatedAllocation); } invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, result.createdAllocation.projectId as string); return result; }), update: managerProcedure .input(z.object({ id: z.string(), data: UpdateAllocationSchema })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const existing = await loadAllocationEntry(ctx.db, input.id); const updated = await ctx.db.$transaction(async (tx) => { const { allocation: updatedAllocation } = await updateAllocationEntry( tx as unknown as Parameters[0], { id: input.id, demandRequirementUpdate: existing.kind === "assignment" ? {} : toDemandRequirementUpdateInput(input.data), assignmentUpdate: existing.kind === "demand" ? {} : toAssignmentUpdateInput(input.data), }, ); await tx.auditLog.create({ data: { entityType: "Allocation", entityId: input.id, action: "UPDATE", changes: { before: existing.entry, after: updatedAllocation, } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); return updatedAllocation; }); emitAllocationUpdated({ id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, resourceIds: [existing.entry.resourceId, updated.resourceId], }); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, updated.projectId); return updated; }), deleteAssignment: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const existing = await findUniqueOrThrow( ctx.db.assignment.findUnique({ where: { id: input.id }, include: ASSIGNMENT_INCLUDE, }), "Assignment", ); await ctx.db.$transaction(async (tx) => { await deleteAssignment( tx as unknown as Parameters[0], input.id, ); await tx.auditLog.create({ data: { entityType: "Assignment", entityId: input.id, action: "DELETE", changes: { before: existing } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); }); emitAllocationDeleted(existing.id, existing.projectId, existing.resourceId); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, existing.projectId); return { success: true }; }), delete: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const existing = await loadAllocationEntry(ctx.db, input.id); await ctx.db.$transaction(async (tx) => { await deleteAllocationEntry( tx as unknown as Parameters[0], existing, ); await tx.auditLog.create({ data: { entityType: "Allocation", entityId: input.id, action: "DELETE", changes: { before: existing.entry } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); }); emitAllocationDeleted(existing.entry.id, existing.projectId, existing.entry.resourceId); invalidateDashboardCacheInBackground(); checkBudgetThresholdsInBackground(ctx.db, existing.projectId); return { success: true }; }), batchDelete: managerProcedure .input(z.object({ ids: z.array(z.string()).min(1).max(100) })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const existing = ( await Promise.all(input.ids.map(async (id) => findAllocationEntryOrNull(ctx.db, id))) ).filter((entry): entry is NonNullable => Boolean(entry)); await ctx.db.$transaction(async (tx) => { for (const allocation of existing) { await deleteAllocationEntry( tx as unknown as Parameters[0], allocation, ); } await tx.auditLog.create({ data: { entityType: "Allocation", entityId: input.ids.join(","), action: "DELETE", changes: { before: existing.map((a) => ({ id: a.entry.id, projectId: a.projectId })), } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); }); for (const a of existing) { emitAllocationDeleted(a.entry.id, a.projectId, a.entry.resourceId); } invalidateDashboardCacheInBackground(); // Check budget thresholds for each affected project const affectedProjectIds = [...new Set(existing.map((a) => a.projectId))]; for (const pid of affectedProjectIds) { checkBudgetThresholdsInBackground(ctx.db, pid); } return { count: existing.length }; }), batchUpdateStatus: managerProcedure .input( z.object({ ids: z.array(z.string()).min(1).max(100), status: z.nativeEnum(AllocationStatus), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const updated = await ctx.db.$transaction(async (tx) => { const updatedAllocations = await Promise.all( input.ids.map(async (id) => ( await updateAllocationEntry( tx as unknown as Parameters[0], { id, demandRequirementUpdate: { status: input.status }, assignmentUpdate: { status: input.status }, }, ) ).allocation, ), ); return updatedAllocations; }); await ctx.db.auditLog.create({ data: { entityType: "Allocation", entityId: input.ids.join(","), action: "UPDATE", changes: { after: { status: input.status, ids: input.ids } }, }, }); for (const a of updated) { emitAllocationUpdated({ id: a.id, projectId: a.projectId, resourceId: a.resourceId }); } invalidateDashboardCacheInBackground(); // Check budget thresholds for each affected project const affectedProjectIds = [...new Set(updated.map((a) => a.projectId))]; for (const pid of affectedProjectIds) { checkBudgetThresholdsInBackground(ctx.db, pid); } return { count: updated.length }; }), });