From c2d027599aed1bdf3bdfeec54cae535522b7af5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 11:17:27 +0200 Subject: [PATCH] refactor(api): extract allocation management procedures --- .../allocation-assignment-procedures.ts | 476 ++++++++++++++ .../router/allocation-demand-procedures.ts | 172 +++++ packages/api/src/router/allocation.ts | 604 +----------------- 3 files changed, 653 insertions(+), 599 deletions(-) create mode 100644 packages/api/src/router/allocation-assignment-procedures.ts create mode 100644 packages/api/src/router/allocation-demand-procedures.ts diff --git a/packages/api/src/router/allocation-assignment-procedures.ts b/packages/api/src/router/allocation-assignment-procedures.ts new file mode 100644 index 0000000..50df9a2 --- /dev/null +++ b/packages/api/src/router/allocation-assignment-procedures.ts @@ -0,0 +1,476 @@ +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 }; + }), +}; diff --git a/packages/api/src/router/allocation-demand-procedures.ts b/packages/api/src/router/allocation-demand-procedures.ts new file mode 100644 index 0000000..32f9150 --- /dev/null +++ b/packages/api/src/router/allocation-demand-procedures.ts @@ -0,0 +1,172 @@ +import { + deleteDemandRequirement, + fillOpenDemand, + updateDemandRequirement, +} from "@capakraken/application"; +import { + CreateDemandRequirementSchema, + FillDemandRequirementSchema, + FillOpenDemandByAllocationSchema, + PermissionKey, + UpdateDemandRequirementSchema, +} from "@capakraken/shared"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { + emitAllocationCreated, + emitAllocationDeleted, + emitAllocationUpdated, +} from "../sse/event-bus.js"; +import { + checkBudgetThresholdsInBackground, + createDemandRequirementWithEffects, + dispatchAllocationWebhookInBackground, + fillDemandRequirementWithEffects, + invalidateDashboardCacheInBackground, +} from "./allocation-effects.js"; +import { DEMAND_INCLUDE } from "./allocation-shared.js"; +import { + buildCreateDemandRequirementInput, + getDemandRequirementByIdOrThrow, +} from "./allocation-support.js"; +import { + managerProcedure, + requirePermission, +} from "../trpc.js"; + +export const allocationDemandProcedures = { + 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; + }), + + 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; + }), +}; diff --git a/packages/api/src/router/allocation.ts b/packages/api/src/router/allocation.ts index 1613671..d3f361b 100644 --- a/packages/api/src/router/allocation.ts +++ b/packages/api/src/router/allocation.ts @@ -1,604 +1,10 @@ -import { - buildSplitAllocationReadModel, - createAssignment, - createDemandRequirement, - deleteAssignment, - deleteAllocationEntry, - deleteDemandRequirement, - fillOpenDemand, - loadAllocationEntry, - updateAllocationEntry, - updateAssignment, - updateDemandRequirement, -} from "@capakraken/application"; -import { - AllocationStatus, - CreateAllocationSchema, - CreateAssignmentSchema, - CreateDemandRequirementSchema, - FillDemandRequirementSchema, - FillOpenDemandByAllocationSchema, - PermissionKey, - UpdateAssignmentSchema, - UpdateAllocationSchema, - UpdateDemandRequirementSchema, -} 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, - createDemandRequirementWithEffects, - dispatchAllocationWebhookInBackground, - fillDemandRequirementWithEffects, - invalidateDashboardCacheInBackground, -} from "./allocation-effects.js"; +import { allocationAssignmentProcedures } from "./allocation-assignment-procedures.js"; +import { allocationDemandProcedures } from "./allocation-demand-procedures.js"; import { allocationReadProcedures } from "./allocation-read-procedures.js"; -import { ASSIGNMENT_INCLUDE, DEMAND_INCLUDE, toIsoDate } from "./allocation-shared.js"; -import { - buildCreateDemandRequirementInput, - findAllocationEntryOrNull, - getDemandRequirementByIdOrThrow, - toAssignmentUpdateInput, - toDemandRequirementUpdateInput, -} from "./allocation-support.js"; -import { createTRPCRouter, managerProcedure, planningReadProcedure, requirePermission } from "../trpc.js"; +import { createTRPCRouter } from "../trpc.js"; export const allocationRouter = createTRPCRouter({ ...allocationReadProcedures, - - 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; - }), - - 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 }; - }), + ...allocationDemandProcedures, + ...allocationAssignmentProcedures, });