import { calculateAllocation, validateAvailability, checkDuplicateAssignment } from "@nexus/engine"; import type { PrismaClient, Prisma } from "@nexus/db"; import { type Allocation, type CreateAssignmentInput, type WeekdayAvailability, } from "@nexus/shared"; import { TRPCError } from "@trpc/server"; import { listAssignmentBookings } from "./list-assignment-bookings.js"; type DbClient = PrismaClient | Prisma.TransactionClient; export const ASSIGNMENT_RELATIONS_INCLUDE = { resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } }, project: { select: { id: true, name: true, shortCode: true } }, roleEntity: { select: { id: true, name: true, color: true } }, demandRequirement: { select: { id: true, projectId: true, startDate: true, endDate: true, hoursPerDay: true, percentage: true, role: true, roleId: true, headcount: true, status: true, }, }, } as const; export type AssignmentWithRelations = Prisma.AssignmentGetPayload<{ include: typeof ASSIGNMENT_RELATIONS_INCLUDE; }>; async function getVacationDates(db: DbClient, resourceId: string, startDate: Date, endDate: Date) { const vacationDates: Date[] = []; try { const vacations = await db.vacation.findMany({ where: { resourceId, status: "APPROVED", startDate: { lte: endDate }, endDate: { gte: startDate }, }, select: { startDate: true, endDate: true }, }); for (const vacation of vacations) { const current = new Date(vacation.startDate); current.setHours(0, 0, 0, 0); const vacationEnd = new Date(vacation.endDate); vacationEnd.setHours(0, 0, 0, 0); while (current <= vacationEnd) { vacationDates.push(new Date(current)); current.setDate(current.getDate() + 1); } } } catch { // Vacation persistence may not be available in all environments yet. } return vacationDates; } export async function createAssignment( db: DbClient, input: CreateAssignmentInput, ): Promise { const project = await db.project.findUnique({ where: { id: input.projectId } }); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } const resource = await db.resource.findUnique({ where: { id: input.resourceId } }); if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } if (input.demandRequirementId) { const demandRequirement = await db.demandRequirement.findUnique({ where: { id: input.demandRequirementId }, select: { id: true, projectId: true }, }); if (!demandRequirement) { throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" }); } if (demandRequirement.projectId !== input.projectId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Demand requirement belongs to a different project", }); } } const existingBookings = await listAssignmentBookings( db as unknown as Parameters[0], { resourceIds: [input.resourceId], }, ); const duplicateResult = checkDuplicateAssignment( input.resourceId, input.projectId, input.startDate, input.endDate, existingBookings.map((b) => ({ id: b.id, resourceId: b.resourceId ?? "", projectId: b.projectId, startDate: b.startDate, endDate: b.endDate, status: b.status, })), ); if (duplicateResult.isDuplicate) { throw new TRPCError({ code: "CONFLICT", message: duplicateResult.message ?? "Resource is already assigned to this project with overlapping dates", }); } const availability = resource.availability as unknown as WeekdayAvailability; const availabilityWindows = existingBookings.map((booking) => ({ startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, status: booking.status, })) as Pick[]; const availabilityResult = validateAvailability( input.startDate, input.endDate, input.hoursPerDay, availability, availabilityWindows, ); if (!availabilityResult.valid && !input.allowOverbooking) { throw new TRPCError({ code: "CONFLICT", message: `Resource is overbooked on ${availabilityResult.totalConflictDays} day(s). Pass allowOverbooking: true to proceed.`, }); } const vacationDates = await getVacationDates( db, input.resourceId, input.startDate, input.endDate, ); const calculation = calculateAllocation({ lcrCents: resource.lcrCents, hoursPerDay: input.hoursPerDay, startDate: input.startDate, endDate: input.endDate, availability, vacationDates, }); const assignment = await db.assignment.create({ data: { demandRequirementId: input.demandRequirementId ?? null, resourceId: input.resourceId, projectId: input.projectId, startDate: input.startDate, endDate: input.endDate, hoursPerDay: input.hoursPerDay, percentage: input.percentage, role: input.role ?? null, roleId: input.roleId ?? null, dailyCostCents: input.dailyCostCents ?? calculation.dailyCostCents, status: input.status, metadata: input.metadata as unknown as Prisma.InputJsonValue, overbookingAcknowledged: input.allowOverbooking === true && !availabilityResult.valid, }, include: ASSIGNMENT_RELATIONS_INCLUDE, }); await db.auditLog.create({ data: { entityType: "Assignment", entityId: assignment.id, action: "CREATE", changes: { after: assignment } as unknown as Prisma.InputJsonValue, }, }); return assignment; } /** * Creates an assignment record without running availability or duplicate checks. * Use ONLY for fragment operations (splitting/carving an existing allocation) * where the resource's total load does not change. */ export async function createAssignmentFragment( db: DbClient, input: { demandRequirementId?: string | null | undefined; resourceId: string; projectId: string; startDate: Date; endDate: Date; hoursPerDay: number; percentage: number; role?: string | null | undefined; roleId?: string | null | undefined; dailyCostCents: number; status: string; metadata: unknown; }, ): Promise { const assignment = await db.assignment.create({ data: { demandRequirementId: input.demandRequirementId ?? null, resourceId: input.resourceId, projectId: input.projectId, startDate: input.startDate, endDate: input.endDate, hoursPerDay: input.hoursPerDay, percentage: input.percentage, role: input.role ?? null, roleId: input.roleId ?? null, dailyCostCents: input.dailyCostCents, status: input.status as import("@nexus/db").AllocationStatus, metadata: input.metadata as import("@nexus/db").Prisma.InputJsonValue, }, include: ASSIGNMENT_RELATIONS_INCLUDE, }); await db.auditLog.create({ data: { entityType: "Assignment", entityId: assignment.id, action: "CREATE", changes: { after: assignment } as unknown as import("@nexus/db").Prisma.InputJsonValue, }, }); return assignment; }