import { buildSplitAllocationReadModel, createAssignment, findAllocationEntry, loadAllocationEntry, updateAllocationEntry, } from "@capakraken/application"; import { Prisma, VacationType } from "@capakraken/db"; import type { PrismaClient } from "@capakraken/db"; import { calculateAllocation, DEFAULT_CALCULATION_RULES } from "@capakraken/engine"; import type { AbsenceDay, CalculationRule } from "@capakraken/shared"; 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 { logger } from "../lib/logger.js"; import { managerProcedure, requirePermission } from "../trpc.js"; function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean { if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error.code !== "P2021") { return false; } const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : ""; const message = error.message.toLowerCase(); return tableHints.some((hint) => table.includes(hint) || message.includes(hint)); } if (typeof error !== "object" || error === null || !("code" in error)) { return false; } const candidate = error as { code?: unknown; message?: unknown; meta?: { table?: unknown }; }; const code = typeof candidate.code === "string" ? candidate.code : ""; if (code !== "P2021") { return false; } const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : ""; const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : ""; return tableHints.some((hint) => table.includes(hint) || message.includes(hint)); } export async function loadCalculationRules(db: PrismaClient): Promise { const calculationRuleModel = (db as PrismaClient & { calculationRule?: { findMany?: (args: unknown) => Promise }; }).calculationRule; if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") { return DEFAULT_CALCULATION_RULES; } try { const rules = await calculationRuleModel.findMany({ where: { isActive: true }, orderBy: [{ priority: "desc" }], }); if (rules.length > 0) { return rules as unknown as CalculationRule[]; } } catch (error) { if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) { logger.error({ err: error }, "Failed to load active calculation rules for timeline"); throw error; } } return DEFAULT_CALCULATION_RULES; } export async function buildAbsenceDays( db: PrismaClient, resourceId: string, startDate: Date, endDate: Date, ): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> { const absenceDays: AbsenceDay[] = []; const legacyVacationDates: Date[] = []; try { const vacations = await db.vacation.findMany({ where: { resourceId, status: "APPROVED", startDate: { lte: endDate }, endDate: { gte: startDate }, }, select: { startDate: true, endDate: true, type: true, isHalfDay: true }, }); for (const vacation of vacations) { const cur = new Date(vacation.startDate); cur.setHours(0, 0, 0, 0); const vacationEnd = new Date(vacation.endDate); vacationEnd.setHours(0, 0, 0, 0); const triggerType = vacation.type === VacationType.SICK ? "SICK" as const : vacation.type === VacationType.PUBLIC_HOLIDAY ? "PUBLIC_HOLIDAY" as const : "VACATION" as const; while (cur <= vacationEnd) { absenceDays.push({ date: new Date(cur), type: triggerType, ...(vacation.isHalfDay ? { isHalfDay: true } : {}), }); if (triggerType === "VACATION") { legacyVacationDates.push(new Date(cur)); } cur.setDate(cur.getDate() + 1); } } } catch (error) { if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) { logger.error( { err: error, resourceId, startDate, endDate }, "Failed to load timeline absence days", ); throw error; } } return { absenceDays, legacyVacationDates }; } 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; const [absenceData, calculationRules] = await Promise.all([ buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate), loadCalculationRules(ctx.db as PrismaClient), ]); newDailyCostCents = calculateAllocation({ lcrCents: existingResource.lcrCents, hoursPerDay: newHoursPerDay, startDate: newStartDate, endDate: newEndDate, availability, includeSaturday, ...(recurrence ? { recurrence } : {}), vacationDates: absenceData.legacyVacationDates, absenceDays: absenceData.absenceDays, calculationRules, }).dailyCostCents; } 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 }; }), };