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 { 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.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" }, }), ]); return buildSplitAllocationReadModel({ demandRequirements, assignments }); } 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 }) => { return 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" }, }); }), 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 }) => { return 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" }, }); }), 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 }; }), });