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 "./shared.js"; import { findAllocationEntryOrNull, toAssignmentUpdateInput, toDemandRequirementUpdateInput, } from "./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; }