diff --git a/packages/api/src/router/allocation-assignment-mutations.ts b/packages/api/src/router/allocation-assignment-mutations.ts new file mode 100644 index 0000000..96c6d06 --- /dev/null +++ b/packages/api/src/router/allocation-assignment-mutations.ts @@ -0,0 +1,318 @@ +import { + buildSplitAllocationReadModel, + createAssignment, + createDemandRequirement, + deleteAllocationEntry, + deleteAssignment, + loadAllocationEntry, + updateAllocationEntry, + updateAssignment, +} from "@capakraken/application"; +import type { PrismaClient } from "@capakraken/db"; +import { + AllocationStatus, + CreateAllocationSchema, + UpdateAllocationSchema, +} from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { ASSIGNMENT_INCLUDE, toIsoDate } from "./allocation-shared.js"; +import { + findAllocationEntryOrNull, + toAssignmentUpdateInput, + toDemandRequirementUpdateInput, +} from "./allocation-support.js"; + +type AllocationMutationDb = Pick< + PrismaClient, + "$transaction" | "assignment" | "demandRequirement" | "resource" | "auditLog" +>; + +type CreateAllocationInput = z.infer; +type UpdateAllocationInput = z.infer; + +type EnsureAssignmentInput = { + resourceId: string; + projectId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + role?: string | undefined; +}; + +export async function createAllocationReadModelEntry( + tx: Parameters[0], + input: CreateAllocationInput, +) { + if (!input.resourceId) { + const demandRequirement = await createDemandRequirement( + tx 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, + { + 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]!; +} + +export async function ensureAssignmentRecord( + db: Pick, + input: EnsureAssignmentInput, +) { + const existing = (await 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 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 db.$transaction(async (tx) => updateAssignment( + tx 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 } : {}), + }, + )); + + return { assignment: updated, action: "reactivated" as const }; + } + + const assignment = await db.$transaction(async (tx) => createAssignment( + tx 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 } : {}), + }, + )); + + return { assignment, action: "created" as const }; +} + +export async function updateAllocationWithAudit( + db: AllocationMutationDb, + id: string, + data: UpdateAllocationInput, +) { + const existing = await loadAllocationEntry(db, id); + const updated = await db.$transaction(async (tx) => { + const { allocation: updatedAllocation } = await updateAllocationEntry( + tx as Parameters[0], + { + id, + demandRequirementUpdate: + existing.kind === "assignment" ? {} : toDemandRequirementUpdateInput(data), + assignmentUpdate: + existing.kind === "demand" ? {} : toAssignmentUpdateInput(data), + }, + ); + + await tx.auditLog.create({ + data: { + entityType: "Allocation", + entityId: id, + action: "UPDATE", + changes: { + before: existing.entry, + after: updatedAllocation, + } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + }, + }); + + return updatedAllocation; + }); + + return { existing, updated }; +} + +export async function deleteAssignmentWithAudit( + db: AllocationMutationDb, + id: string, +) { + const existing = await findUniqueOrThrow( + db.assignment.findUnique({ + where: { id }, + include: ASSIGNMENT_INCLUDE, + }), + "Assignment", + ); + + await db.$transaction(async (tx) => { + await deleteAssignment( + tx as Parameters[0], + id, + ); + + await tx.auditLog.create({ + data: { + entityType: "Assignment", + entityId: id, + action: "DELETE", + changes: { before: existing } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + }, + }); + }); + + return existing; +} + +export async function deleteAllocationWithAudit( + db: AllocationMutationDb, + id: string, +) { + const existing = await loadAllocationEntry(db, id); + + await db.$transaction(async (tx) => { + await deleteAllocationEntry( + tx as Parameters[0], + existing, + ); + + await tx.auditLog.create({ + data: { + entityType: "Allocation", + entityId: id, + action: "DELETE", + changes: { before: existing.entry } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + }, + }); + }); + + return existing; +} + +export async function batchDeleteAllocationsWithAudit( + db: AllocationMutationDb, + ids: string[], +) { + const existing = ( + await Promise.all(ids.map(async (id) => findAllocationEntryOrNull(db, id))) + ).filter((entry): entry is NonNullable => Boolean(entry)); + + await db.$transaction(async (tx) => { + for (const allocation of existing) { + await deleteAllocationEntry( + tx as Parameters[0], + allocation, + ); + } + + await tx.auditLog.create({ + data: { + entityType: "Allocation", + entityId: ids.join(","), + action: "DELETE", + changes: { + before: existing.map((allocation) => ({ + id: allocation.entry.id, + projectId: allocation.projectId, + })), + } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + }, + }); + }); + + return existing; +} + +export async function batchUpdateAllocationStatusWithAudit( + db: AllocationMutationDb, + input: { + ids: string[]; + status: AllocationStatus; + }, +) { + const updated = await db.$transaction(async (tx) => { + const updatedAllocations = await Promise.all( + input.ids.map(async (id) => ( + await updateAllocationEntry( + tx as Parameters[0], + { + id, + demandRequirementUpdate: { status: input.status }, + assignmentUpdate: { status: input.status }, + }, + ) + ).allocation), + ); + + return updatedAllocations; + }); + + await db.auditLog.create({ + data: { + entityType: "Allocation", + entityId: input.ids.join(","), + action: "UPDATE", + changes: { after: { status: input.status, ids: input.ids } }, + }, + }); + + return updated; +} diff --git a/packages/api/src/router/allocation-assignment-procedures.ts b/packages/api/src/router/allocation-assignment-procedures.ts index 932574f..76ab59f 100644 --- a/packages/api/src/router/allocation-assignment-procedures.ts +++ b/packages/api/src/router/allocation-assignment-procedures.ts @@ -1,11 +1,5 @@ import { - buildSplitAllocationReadModel, createAssignment, - createDemandRequirement, - deleteAllocationEntry, - deleteAssignment, - loadAllocationEntry, - updateAllocationEntry, updateAssignment, } from "@capakraken/application"; import { @@ -16,7 +10,6 @@ import { UpdateAllocationSchema, UpdateAssignmentSchema, } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { @@ -27,14 +20,14 @@ import { publishBatchAllocationStatusUpdates, } from "./allocation-assignment-effects.js"; import { - ASSIGNMENT_INCLUDE, - toIsoDate, -} from "./allocation-shared.js"; -import { - findAllocationEntryOrNull, - toAssignmentUpdateInput, - toDemandRequirementUpdateInput, -} from "./allocation-support.js"; + batchDeleteAllocationsWithAudit, + batchUpdateAllocationStatusWithAudit, + createAllocationReadModelEntry, + deleteAllocationWithAudit, + deleteAssignmentWithAudit, + ensureAssignmentRecord, + updateAllocationWithAudit, +} from "./allocation-assignment-mutations.js"; import { managerProcedure, requirePermission, @@ -45,51 +38,11 @@ export const allocationAssignmentProcedures = { .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]!; - }); + const allocation = await ctx.db.$transaction(async (tx) => + createAllocationReadModelEntry( + tx as Parameters[0], + input, + )); publishAllocationCreated(ctx.db, { id: allocation.id, @@ -131,78 +84,25 @@ export const allocationAssignmentProcedures = { })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); + const result = await ensureAssignmentRecord(ctx.db, input); - 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 } : {}), - }, - )); - + if (result.action === "reactivated") { publishAllocationUpdated(ctx.db, { - id: updated.id, - projectId: updated.projectId, - resourceId: updated.resourceId, + id: result.assignment.id, + projectId: result.assignment.projectId, + resourceId: result.assignment.resourceId, }, { dispatchWebhook: true }); - return { assignment: updated, action: "reactivated" as const }; + return result; } - 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 } : {}), - }, - )); - publishAllocationCreated(ctx.db, { - id: assignment.id, - projectId: assignment.projectId, - resourceId: assignment.resourceId, + id: result.assignment.id, + projectId: result.assignment.projectId, + resourceId: result.assignment.resourceId, }); - return { assignment, action: "created" as const }; + return result; }), updateAssignment: managerProcedure @@ -239,34 +139,7 @@ export const allocationAssignmentProcedures = { .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; - }); + const { existing, updated } = await updateAllocationWithAudit(ctx.db, input.id, input.data); const affectedResourceIds = [existing.entry.resourceId, updated.resourceId].filter( (resourceId): resourceId is string => Boolean(resourceId), @@ -286,30 +159,7 @@ export const allocationAssignmentProcedures = { .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, - }, - }); - }); + const existing = await deleteAssignmentWithAudit(ctx.db, input.id); publishAllocationDeleted(ctx.db, { id: existing.id, @@ -324,23 +174,7 @@ export const allocationAssignmentProcedures = { .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, - }, - }); - }); + const existing = await deleteAllocationWithAudit(ctx.db, input.id); publishAllocationDeleted(ctx.db, { id: existing.entry.id, @@ -355,31 +189,7 @@ export const allocationAssignmentProcedures = { .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, - }, - }); - }); + const existing = await batchDeleteAllocationsWithAudit(ctx.db, input.ids); publishBatchAllocationDeletes(ctx.db, existing.map((allocation) => ({ id: allocation.entry.id, @@ -399,33 +209,7 @@ export const allocationAssignmentProcedures = { ) .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 } }, - }, - }); + const updated = await batchUpdateAllocationStatusWithAudit(ctx.db, input); publishBatchAllocationStatusUpdates(ctx.db, updated.map((allocation) => ({ id: allocation.id,