import { buildSplitAllocationReadModel, createAssignment, createDemandRequirement, deleteAllocationEntry, deleteAssignment, loadAllocationEntry, updateAllocationEntry, updateAssignment, } from "@capakraken/application"; import { AllocationStatus, CreateAllocationSchema, CreateAssignmentSchema, PermissionKey, UpdateAllocationSchema, UpdateAssignmentSchema, } from "@capakraken/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 { checkBudgetThresholdsInBackground, dispatchAllocationWebhookInBackground, invalidateDashboardCacheInBackground, } from "./allocation-effects.js"; import { ASSIGNMENT_INCLUDE, toIsoDate, } from "./allocation-shared.js"; import { findAllocationEntryOrNull, toAssignmentUpdateInput, toDemandRequirementUpdateInput, } from "./allocation-support.js"; import { managerProcedure, requirePermission, } from "../trpc.js"; export const allocationAssignmentProcedures = { 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; }), 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; }), 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((allocation) => ({ id: allocation.entry.id, projectId: allocation.projectId, })), } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); }); for (const allocation of existing) { emitAllocationDeleted( allocation.entry.id, allocation.projectId, allocation.entry.resourceId, ); } invalidateDashboardCacheInBackground(); const affectedProjectIds = [...new Set(existing.map((allocation) => allocation.projectId))]; for (const projectId of affectedProjectIds) { checkBudgetThresholdsInBackground(ctx.db, 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 allocation of updated) { emitAllocationUpdated({ id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId, }); } invalidateDashboardCacheInBackground(); const affectedProjectIds = [...new Set(updated.map((allocation) => allocation.projectId))]; for (const projectId of affectedProjectIds) { checkBudgetThresholdsInBackground(ctx.db, projectId); } return { count: updated.length }; }), };