import { buildSplitAllocationReadModel, createAssignment, createDemandRequirement, deleteAssignment, deleteAllocationEntry, deleteDemandRequirement, fillDemandRequirement, fillOpenDemand, loadAllocationEntry, updateAllocationEntry, updateAssignment, updateDemandRequirement, } from "@planarchy/application"; import { AllocationStatus, CreateAllocationSchema, CreateAssignmentSchema, CreateDemandRequirementSchema, FillDemandRequirementSchema, FillOpenDemandByAllocationSchema, PermissionKey, UpdateAssignmentSchema, UpdateAllocationSchema, UpdateDemandRequirementSchema, } from "@planarchy/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated } from "../sse/event-bus.js"; import { createTRPCRouter, managerProcedure, protectedProcedure, 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; 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("@planarchy/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; } } export const allocationRouter = createTRPCRouter({ list: protectedProcedure .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: protectedProcedure .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, }); return allocation; }), listDemands: protectedProcedure .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: protectedProcedure .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, ); }), /** * Check a resource's availability for a date range. * Returns working days, existing allocations, conflict days, and available capacity. */ checkResourceAvailability: protectedProcedure .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 resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { id: true, displayName: true, eid: true, fte: true, country: { select: { dailyWorkingHours: true } }, }, }); if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); const dailyCapacity = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1); // Get existing assignments in the date range const existingAssignments = await ctx.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" }, }); // Get vacations in the date range const vacations = await ctx.db.vacation.findMany({ where: { resourceId: input.resourceId, status: "APPROVED", startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, }, select: { startDate: true, endDate: true, isHalfDay: true }, }); // Calculate day-by-day availability let totalWorkingDays = 0; 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 dow = d.getDay(); if (dow !== 0 && dow !== 6) { totalWorkingDays++; // Check vacation const isVacation = vacations.some((v) => { const vs = new Date(v.startDate); vs.setHours(0, 0, 0, 0); const ve = new Date(v.endDate); ve.setHours(0, 0, 0, 0); const dc = new Date(d); dc.setHours(0, 0, 0, 0); return dc >= vs && dc <= ve; }); if (isVacation) { conflictDays++; d.setDate(d.getDate() + 1); continue; } // Sum existing hours on this day let bookedHours = 0; for (const a of existingAssignments) { const as2 = new Date(a.startDate); as2.setHours(0, 0, 0, 0); const ae = new Date(a.endDate); ae.setHours(0, 0, 0, 0); const dc = new Date(d); dc.setHours(0, 0, 0, 0); if (dc >= as2 && dc <= ae) { bookedHours += a.hoursPerDay; } } const remainingCapacity = Math.max(0, dailyCapacity - 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; 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((a) => ({ project: a.project.name, code: a.project.shortCode, hoursPerDay: a.hoursPerDay, start: a.startDate.toISOString().slice(0, 10), end: a.endDate.toISOString().slice(0, 10), status: a.status, })), }; }), createDemandRequirement: managerProcedure .input(CreateDemandRequirementSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const demandRequirement = await ctx.db.$transaction(async (tx) => { return createDemandRequirement( tx as unknown as Parameters[0], input, ); }); emitAllocationCreated({ id: demandRequirement.id, projectId: demandRequirement.projectId, resourceId: null, }); return demandRequirement; }), 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, }); 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, }); return assignment; }), updateAssignment: managerProcedure .input(z.object({ id: z.string(), data: UpdateAssignmentSchema })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); 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, }); 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("@planarchy/db").Prisma.InputJsonValue, }, }); }); emitAllocationDeleted(existing.id, existing.projectId); return { success: true }; }), fillDemandRequirement: managerProcedure .input(FillDemandRequirementSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const result = await fillDemandRequirement(ctx.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, }); return result; }), 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); } 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("@planarchy/db").Prisma.InputJsonValue, }, }); return updatedAllocation; }); emitAllocationUpdated({ id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, }); 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("@planarchy/db").Prisma.InputJsonValue, }, }); }); emitAllocationDeleted(existing.id, 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("@planarchy/db").Prisma.InputJsonValue, }, }); }); emitAllocationDeleted(existing.entry.id, 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("@planarchy/db").Prisma.InputJsonValue, }, }); }); for (const a of existing) { emitAllocationDeleted(a.entry.id, a.projectId); } 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 }); } return { count: updated.length }; }), });