import { buildSplitAllocationReadModel, createAssignment, findAllocationEntry, loadAllocationEntry, updateAllocationEntry, } from "@capakraken/application"; import type { PrismaClient } from "@capakraken/db"; import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { emitAllocationCreated, emitAllocationUpdated, } from "../sse/event-bus.js"; import { managerProcedure, requirePermission } from "../trpc.js"; import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; export const timelineAllocationMutationProcedures = { updateAllocationInline: managerProcedure .input(UpdateAllocationHoursSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); const resolved = await loadAllocationEntry(ctx.db, input.allocationId); const existing = resolved.entry; const existingResource = resolved.resourceId ? await ctx.db.resource.findUnique({ where: { id: resolved.resourceId }, select: { id: true, lcrCents: true, availability: true }, }) : null; const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay; const newStartDate = input.startDate ?? existing.startDate; const newEndDate = input.endDate ?? existing.endDate; if (newEndDate < newStartDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date", }); } const existingMeta = (existing.metadata as Record) ?? {}; const newMeta: Record = { ...existingMeta, ...(input.includeSaturday !== undefined ? { includeSaturday: input.includeSaturday } : {}), }; const includeSaturday = input.includeSaturday ?? (existingMeta.includeSaturday as boolean | undefined) ?? false; let newDailyCostCents = 0; if (resolved.resourceId) { if (!existingResource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } const availability = existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability; const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined; newDailyCostCents = await calculateTimelineAllocationDailyCost({ db: ctx.db as PrismaClient, resourceId: resolved.resourceId, lcrCents: existingResource.lcrCents, hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, availability, includeSaturday, ...(recurrence ? { recurrence } : {}), }); } const updated = await ctx.db.$transaction(async (tx) => { const { allocation: updatedAllocation } = await updateAllocationEntry( tx as unknown as Parameters[0], { id: input.allocationId, demandRequirementUpdate: { hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, metadata: newMeta, ...(input.role !== undefined ? { role: input.role } : {}), }, assignmentUpdate: { hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, dailyCostCents: newDailyCostCents, metadata: newMeta, ...(input.role !== undefined ? { role: input.role } : {}), }, }, ); await tx.auditLog.create({ data: { entityType: "Allocation", entityId: input.allocationId, action: "UPDATE", changes: { before: { id: resolved.entry.id, hoursPerDay: existing.hoursPerDay, startDate: existing.startDate, endDate: existing.endDate, }, after: { id: updatedAllocation.id, hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, includeSaturday, }, }, }, }); return updatedAllocation; }); emitAllocationUpdated({ id: updated.id, projectId: updated.projectId, resourceId: updated.resourceId, }); return updated; }), quickAssign: 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).default(8), role: z.string().min(1).max(200).default("Team Member"), roleId: z.string().optional(), status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); if (input.endDate < input.startDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date" }); } const percentage = Math.min(100, Math.round((input.hoursPerDay / 8) * 100)); const metadata = { source: "quickAssign" } satisfies Record; const allocation = await ctx.db.$transaction(async (tx) => { 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, role: input.role, roleId: input.roleId ?? undefined, status: input.status, metadata, }, ); return buildSplitAllocationReadModel({ demandRequirements: [], assignments: [assignment], }).allocations[0]!; }); emitAllocationCreated({ id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId, }); return allocation; }), batchQuickAssign: managerProcedure .input( z.object({ assignments: z .array( z.object({ resourceId: z.string(), projectId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0.5).max(24).default(8), role: z.string().min(1).max(200).default("Team Member"), status: z .nativeEnum(AllocationStatus) .default(AllocationStatus.PROPOSED), }), ) .min(1) .max(50), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); for (const assignment of input.assignments) { if (assignment.endDate < assignment.startDate) { throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date", }); } } const results = await ctx.db.$transaction(async (tx) => { const created = []; for (const assignment of input.assignments) { const percentage = Math.min( 100, Math.round((assignment.hoursPerDay / 8) * 100), ); const metadata = { source: "batchQuickAssign", } satisfies Record; const createdAssignment = await createAssignment( tx as unknown as Parameters[0], { resourceId: assignment.resourceId, projectId: assignment.projectId, startDate: assignment.startDate, endDate: assignment.endDate, hoursPerDay: assignment.hoursPerDay, percentage, role: assignment.role, status: assignment.status, metadata, }, ); created.push(createdAssignment); } return created; }); for (const assignment of results) { emitAllocationCreated({ id: assignment.id, projectId: assignment.projectId, resourceId: assignment.resourceId, }); } return { count: results.length }; }), batchShiftAllocations: managerProcedure .input( z.object({ allocationIds: z.array(z.string()).min(1).max(100), daysDelta: z.number().int().min(-3650).max(3650), mode: z.enum(["move", "resize-start", "resize-end"]).default("move"), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); if (input.daysDelta === 0) { return { count: 0 }; } const entries = await Promise.all( input.allocationIds.map((allocationId) => findAllocationEntry(ctx.db, allocationId)), ); const resolved = entries.filter( (entry): entry is NonNullable => entry !== null, ); if (resolved.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" }); } const results = await ctx.db.$transaction(async (tx) => { const updated = []; for (const entry of resolved) { const existing = entry.entry; const newStart = new Date(existing.startDate); const newEnd = new Date(existing.endDate); if (input.mode === "move") { newStart.setDate(newStart.getDate() + input.daysDelta); newEnd.setDate(newEnd.getDate() + input.daysDelta); } else if (input.mode === "resize-start") { newStart.setDate(newStart.getDate() + input.daysDelta); if (newStart > newEnd) { newStart.setTime(newEnd.getTime()); } } else { newEnd.setDate(newEnd.getDate() + input.daysDelta); if (newEnd < newStart) { newEnd.setTime(newStart.getTime()); } } const result = await updateAllocationEntry( tx as unknown as Parameters[0], { id: existing.id, demandRequirementUpdate: { startDate: newStart, endDate: newEnd, }, assignmentUpdate: { startDate: newStart, endDate: newEnd, }, }, ); updated.push(result.allocation); } await tx.auditLog.create({ data: { entityType: "Allocation", entityId: input.allocationIds.join(","), action: "UPDATE", changes: { operation: "batchShift", mode: input.mode, daysDelta: input.daysDelta, count: resolved.length, } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); return updated; }); for (const allocation of results) { emitAllocationUpdated({ id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId, }); } return { count: results.length }; }), };