From daed9c2d16b6b2ddfe77eb7b5d75eea3817ace21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 10:46:25 +0200 Subject: [PATCH] refactor(api): extract allocation router support modules --- .../api/src/router/allocation-availability.ts | 231 +++++++ packages/api/src/router/allocation-effects.ts | 156 +++++ packages/api/src/router/allocation-shared.ts | 80 +++ packages/api/src/router/allocation-support.ts | 178 +++++ packages/api/src/router/allocation.ts | 649 +----------------- 5 files changed, 667 insertions(+), 627 deletions(-) create mode 100644 packages/api/src/router/allocation-availability.ts create mode 100644 packages/api/src/router/allocation-effects.ts create mode 100644 packages/api/src/router/allocation-shared.ts create mode 100644 packages/api/src/router/allocation-support.ts diff --git a/packages/api/src/router/allocation-availability.ts b/packages/api/src/router/allocation-availability.ts new file mode 100644 index 0000000..42172d8 --- /dev/null +++ b/packages/api/src/router/allocation-availability.ts @@ -0,0 +1,231 @@ +import type { WeekdayAvailability } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { + calculateEffectiveAvailableHours, + calculateEffectiveBookedHours, + calculateEffectiveDayAvailability, + countEffectiveWorkingDays, + loadResourceDailyAvailabilityContexts, +} from "../lib/resource-capacity.js"; +import { averagePerWorkingDay, round1, toIsoDate } from "./allocation-shared.js"; + +export async function buildResourceAvailabilityView( + db: Pick, + input: { + resourceId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + }, +) { + const resource = await db.resource.findUnique({ + where: { id: input.resourceId }, + select: { + id: true, displayName: true, eid: true, fte: true, + availability: true, + countryId: true, + federalState: true, + metroCityId: true, + country: { select: { dailyWorkingHours: true, code: true } }, + metroCity: { select: { name: true } }, + }, + }); + if (!resource) { + throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + } + + const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1); + const availability = (resource.availability as WeekdayAvailability | null) ?? { + monday: fallbackDailyHours, + tuesday: fallbackDailyHours, + wednesday: fallbackDailyHours, + thursday: fallbackDailyHours, + friday: fallbackDailyHours, + saturday: 0, + sunday: 0, + }; + + const [existingAssignments, vacations] = await Promise.all([ + db.assignment.findMany({ + where: { + resourceId: input.resourceId, + status: { not: "CANCELLED" }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + select: { + id: true, startDate: true, endDate: true, hoursPerDay: true, status: true, + project: { select: { name: true, shortCode: true } }, + }, + orderBy: { startDate: "asc" }, + }), + db.vacation.findMany({ + where: { + resourceId: input.resourceId, + status: { in: ["APPROVED", "PENDING"] }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + select: { + id: true, + type: true, + startDate: true, + endDate: true, + isHalfDay: true, + halfDayPart: true, + status: true, + }, + orderBy: { startDate: "asc" }, + }), + ]); + + const contexts = await loadResourceDailyAvailabilityContexts( + db, + [{ + id: resource.id, + availability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }], + input.startDate, + input.endDate, + ); + const context = contexts.get(resource.id); + + const totalWorkingDays = countEffectiveWorkingDays({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + let availableDays = 0; + let conflictDays = 0; + let partialDays = 0; + let totalAvailableHours = 0; + const requestedHpd = input.hoursPerDay; + + const currentDate = new Date(input.startDate); + const endDate = new Date(input.endDate); + while (currentDate <= endDate) { + const effectiveDayCapacity = calculateEffectiveDayAvailability({ + availability, + date: currentDate, + context, + }); + + if (effectiveDayCapacity > 0) { + let bookedHours = 0; + for (const assignment of existingAssignments) { + bookedHours += calculateEffectiveBookedHours({ + availability, + startDate: assignment.startDate, + endDate: assignment.endDate, + hoursPerDay: assignment.hoursPerDay, + periodStart: currentDate, + periodEnd: currentDate, + context, + }); + } + + const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours); + if (remainingCapacity >= requestedHpd) { + availableDays++; + totalAvailableHours += requestedHpd; + } else if (remainingCapacity > 0) { + partialDays++; + totalAvailableHours += remainingCapacity; + } else { + conflictDays++; + } + } + currentDate.setDate(currentDate.getDate() + 1); + } + + const totalRequestedHours = totalWorkingDays * requestedHpd; + const totalPeriodCapacity = calculateEffectiveAvailableHours({ + availability, + periodStart: input.startDate, + periodEnd: input.endDate, + context, + }); + const dailyCapacity = totalWorkingDays > 0 + ? round1(totalPeriodCapacity / totalWorkingDays) + : 0; + + return { + resource: { id: resource.id, name: resource.displayName, eid: resource.eid }, + dailyCapacity, + totalWorkingDays, + availableDays, + partialDays, + conflictDays, + totalAvailableHours: round1(totalAvailableHours), + totalRequestedHours, + coveragePercent: totalRequestedHours > 0 + ? Math.round((totalAvailableHours / totalRequestedHours) * 100) + : 0, + existingAssignments: existingAssignments.map((assignment) => ({ + project: assignment.project.name, + code: assignment.project.shortCode, + hoursPerDay: assignment.hoursPerDay, + start: toIsoDate(assignment.startDate), + end: toIsoDate(assignment.endDate), + status: assignment.status, + })), + vacations: vacations.map((vacation) => ({ + id: vacation.id, + type: vacation.type, + status: vacation.status, + start: toIsoDate(vacation.startDate), + end: toIsoDate(vacation.endDate), + isHalfDay: vacation.isHalfDay, + halfDayPart: vacation.halfDayPart, + })), + }; +} + +export function buildResourceAvailabilitySummary( + availability: Awaited>, + period: { startDate: Date; endDate: Date }, +) { + const periodAvailableHours = availability.totalRequestedHours > 0 + ? round1(availability.dailyCapacity * availability.totalWorkingDays) + : 0; + const periodRemainingHours = round1(availability.totalAvailableHours); + const periodBookedHours = round1(Math.max(0, periodAvailableHours - periodRemainingHours)); + + return { + resource: availability.resource.name, + period: `${toIsoDate(period.startDate)} to ${toIsoDate(period.endDate)}`, + fte: null, + workingDays: availability.totalWorkingDays, + periodAvailableHours, + periodBookedHours, + periodRemainingHours, + maxHoursPerDay: availability.dailyCapacity, + currentBookedHoursPerDay: round1( + Math.max( + 0, + availability.dailyCapacity - availability.totalAvailableHours / Math.max(availability.totalWorkingDays, 1), + ), + ), + availableHoursPerDay: averagePerWorkingDay(availability.totalAvailableHours, availability.totalWorkingDays), + isFullyAvailable: availability.existingAssignments.length === 0 && availability.vacations.length === 0, + existingAllocations: availability.existingAssignments.map((assignment) => ({ + project: `${assignment.project} (${assignment.code})`, + hoursPerDay: assignment.hoursPerDay, + status: assignment.status, + start: assignment.start, + end: assignment.end, + })), + vacations: availability.vacations.map((vacation) => ({ + type: vacation.type, + start: vacation.start, + end: vacation.end, + isHalfDay: vacation.isHalfDay, + })), + }; +} diff --git a/packages/api/src/router/allocation-effects.ts b/packages/api/src/router/allocation-effects.ts new file mode 100644 index 0000000..80803bd --- /dev/null +++ b/packages/api/src/router/allocation-effects.ts @@ -0,0 +1,156 @@ +import { createDemandRequirement, fillDemandRequirement } from "@capakraken/application"; +import { buildTaskAction, CreateDemandRequirementSchema, FillDemandRequirementSchema } from "@capakraken/shared"; +import { z } from "zod"; +import { checkBudgetThresholds } from "../lib/budget-alerts.js"; +import { generateAutoSuggestions } from "../lib/auto-staffing.js"; +import { invalidateDashboardCache } from "../lib/cache.js"; +import { logger } from "../lib/logger.js"; +import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; +import { emitAllocationCreated, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js"; + +export function runAllocationBackgroundEffect( + effectName: string, + execute: () => unknown, + metadata: Record = {}, +): void { + void Promise.resolve() + .then(execute) + .catch((error) => { + logger.error( + { err: error, effectName, ...metadata }, + "Allocation background side effect failed", + ); + }); +} + +export function invalidateDashboardCacheInBackground(): void { + runAllocationBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache()); +} + +export function checkBudgetThresholdsInBackground( + db: import("@capakraken/db").PrismaClient, + projectId: string, +): void { + runAllocationBackgroundEffect( + "checkBudgetThresholds", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => checkBudgetThresholds(db as any, projectId), + { projectId }, + ); +} + +export function dispatchAllocationWebhookInBackground( + db: import("@capakraken/db").PrismaClient, + event: string, + payload: Record, +): void { + runAllocationBackgroundEffect( + "dispatchWebhooks", + () => dispatchWebhooks(db, event, payload), + { event }, + ); +} + +export function generateAutoSuggestionsInBackground( + db: import("@capakraken/db").PrismaClient, + demandRequirementId: string, +): void { + runAllocationBackgroundEffect( + "generateAutoSuggestions", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => generateAutoSuggestions(db as any, demandRequirementId), + { demandRequirementId }, + ); +} + +export async function createDemandRequirementWithEffects( + db: import("@capakraken/db").PrismaClient, + input: z.infer, +) { + const demandRequirement = await db.$transaction(async (tx) => { + return createDemandRequirement( + tx as unknown as Parameters[0], + input, + ); + }); + + emitAllocationCreated({ + id: demandRequirement.id, + projectId: demandRequirement.projectId, + resourceId: null, + }); + invalidateDashboardCacheInBackground(); + + const [project, roleEntity, managers] = await Promise.all([ + db.project.findUnique({ + where: { id: demandRequirement.projectId }, + select: { name: true }, + }), + demandRequirement.roleId + ? db.role.findUnique({ + where: { id: demandRequirement.roleId }, + select: { name: true }, + }) + : Promise.resolve(null), + db.user.findMany({ + where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, + select: { id: true }, + }), + ]); + const roleName = roleEntity?.name ?? demandRequirement.role ?? "Unspecified role"; + const projectName = project?.name ?? "Unknown project"; + const headcount = demandRequirement.headcount ?? 1; + + for (const manager of managers) { + const task = await db.notification.create({ + data: { + userId: manager.id, + category: "TASK", + type: "DEMAND_FILL", + priority: "NORMAL", + title: `Staff demand: ${roleName} for ${projectName}`, + body: `${headcount} ${roleName} needed for project ${projectName}`, + taskStatus: "OPEN", + taskAction: buildTaskAction("fill_demand", demandRequirement.id), + entityId: demandRequirement.id, + entityType: "demand", + link: `/projects/${demandRequirement.projectId}`, + channel: "in_app", + }, + }); + emitNotificationCreated(manager.id, task.id); + } + + checkBudgetThresholdsInBackground(db, demandRequirement.projectId); + generateAutoSuggestionsInBackground(db, demandRequirement.id); + + return demandRequirement; +} + +export async function fillDemandRequirementWithEffects( + db: import("@capakraken/db").PrismaClient, + input: z.infer, +) { + const result = await fillDemandRequirement(db, input); + + emitAllocationCreated({ + id: result.assignment.id, + projectId: result.assignment.projectId, + resourceId: result.assignment.resourceId, + }); + + emitAllocationUpdated({ + id: result.updatedDemandRequirement.id, + projectId: result.updatedDemandRequirement.projectId, + resourceId: null, + }); + invalidateDashboardCacheInBackground(); + checkBudgetThresholdsInBackground(db, result.assignment.projectId); + + if (result.updatedDemandRequirement.headcount > 0 + && result.updatedDemandRequirement.status !== "COMPLETED") { + generateAutoSuggestionsInBackground(db, result.updatedDemandRequirement.id); + } + + return result; +} diff --git a/packages/api/src/router/allocation-shared.ts b/packages/api/src/router/allocation-shared.ts new file mode 100644 index 0000000..89ff842 --- /dev/null +++ b/packages/api/src/router/allocation-shared.ts @@ -0,0 +1,80 @@ +import { AllocationStatus, UpdateAllocationSchema } from "@capakraken/shared"; +import { z } from "zod"; +import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js"; + +export const DEMAND_INCLUDE = { + project: { select: PROJECT_BRIEF_SELECT }, + roleEntity: { select: ROLE_BRIEF_SELECT }, + assignments: { + include: { + resource: { select: RESOURCE_BRIEF_SELECT }, + project: { select: PROJECT_BRIEF_SELECT }, + roleEntity: { select: ROLE_BRIEF_SELECT }, + }, + }, +} as const; + +export const ASSIGNMENT_INCLUDE = { + resource: { select: RESOURCE_BRIEF_SELECT }, + project: { select: PROJECT_BRIEF_SELECT }, + roleEntity: { select: ROLE_BRIEF_SELECT }, + 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 AllocationListFilters = { + projectId?: string | undefined; + resourceId?: string | undefined; + status?: AllocationStatus | undefined; +}; + +export type AllocationEntryUpdateInput = z.infer; + +export type AssignmentResolutionInput = { + assignmentId?: string | undefined; + resourceId?: string | undefined; + projectId?: string | undefined; + startDate?: Date | undefined; + endDate?: Date | undefined; + selectionMode?: "WINDOW" | "EXACT_START" | undefined; + excludeCancelled?: boolean | undefined; +}; + +export type CreateDemandDraftInput = { + projectId: string; + role?: string | undefined; + roleId?: string | undefined; + headcount?: number | undefined; + hoursPerDay: number; + startDate: Date; + endDate: Date; + budgetCents?: number | undefined; + metadata?: Record | undefined; +}; + +export function toIsoDate(value: Date) { + return value.toISOString().slice(0, 10); +} + +export function round1(value: number) { + return Math.round(value * 10) / 10; +} + +export function averagePerWorkingDay(totalHours: number, workingDays: number) { + if (workingDays <= 0) { + return 0; + } + return round1(totalHours / workingDays); +} diff --git a/packages/api/src/router/allocation-support.ts b/packages/api/src/router/allocation-support.ts new file mode 100644 index 0000000..5c24a62 --- /dev/null +++ b/packages/api/src/router/allocation-support.ts @@ -0,0 +1,178 @@ +import { buildSplitAllocationReadModel, loadAllocationEntry } from "@capakraken/application"; +import { AllocationStatus, CreateDemandRequirementSchema } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; +import { ASSIGNMENT_INCLUDE, type AllocationEntryUpdateInput, type AllocationListFilters, type AssignmentResolutionInput, type CreateDemandDraftInput, DEMAND_INCLUDE, toIsoDate } from "./allocation-shared.js"; + +export function toDemandRequirementUpdateInput(input: AllocationEntryUpdateInput) { + return { + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + ...(input.startDate !== undefined ? { startDate: input.startDate } : {}), + ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), + ...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}), + ...(input.percentage !== undefined ? { percentage: input.percentage } : {}), + ...(input.role !== undefined ? { role: input.role } : {}), + ...(input.roleId !== undefined ? { roleId: input.roleId } : {}), + ...(input.headcount !== undefined ? { headcount: input.headcount } : {}), + ...(input.budgetCents !== undefined ? { budgetCents: input.budgetCents } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.metadata !== undefined ? { metadata: input.metadata } : {}), + }; +} + +export function toAssignmentUpdateInput(input: AllocationEntryUpdateInput) { + return { + ...(input.resourceId !== undefined ? { resourceId: input.resourceId } : {}), + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + ...(input.startDate !== undefined ? { startDate: input.startDate } : {}), + ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), + ...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}), + ...(input.percentage !== undefined ? { percentage: input.percentage } : {}), + ...(input.role !== undefined ? { role: input.role } : {}), + ...(input.roleId !== undefined ? { roleId: input.roleId } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.metadata !== undefined ? { metadata: input.metadata } : {}), + }; +} + +export async function loadAllocationReadModel( + db: Pick, + input: AllocationListFilters, +) { + const [demandRequirements, assignments] = await Promise.all([ + input.resourceId + ? Promise.resolve([]) + : db.demandRequirement.findMany({ + where: { + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.status ? { status: input.status } : {}), + }, + include: DEMAND_INCLUDE, + orderBy: { startDate: "asc" }, + }), + db.assignment.findMany({ + where: { + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.resourceId ? { resourceId: input.resourceId } : {}), + ...(input.status ? { status: input.status } : {}), + }, + include: ASSIGNMENT_INCLUDE, + orderBy: { startDate: "asc" }, + }), + ]); + + const readModel = buildSplitAllocationReadModel({ demandRequirements, assignments }); + const directory = await getAnonymizationDirectory(db as import("@capakraken/db").PrismaClient); + if (!directory) { + return readModel; + } + + function anonymizeAllocation(allocation: T): T { + if (!allocation.resource) { + return allocation; + } + return { ...allocation, resource: anonymizeResource(allocation.resource, directory) }; + } + + return { + ...readModel, + allocations: readModel.allocations.map(anonymizeAllocation), + demands: readModel.demands.map(anonymizeAllocation), + assignments: readModel.assignments.map(anonymizeAllocation), + }; +} + +export async function findAllocationEntryOrNull( + db: Pick, + id: string, +) { + try { + return await loadAllocationEntry(db, id); + } catch (error) { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return null; + } + + throw error; + } +} + +export function buildCreateDemandRequirementInput(input: CreateDemandDraftInput): z.infer { + return { + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + hoursPerDay: input.hoursPerDay, + percentage: (input.hoursPerDay / 8) * 100, + status: AllocationStatus.PROPOSED, + headcount: input.headcount ?? 1, + budgetCents: input.budgetCents ?? 0, + metadata: input.metadata ?? {}, + ...(input.role ? { role: input.role } : {}), + ...(input.roleId ? { roleId: input.roleId } : {}), + }; +} + +export async function getDemandRequirementByIdOrThrow( + db: Pick, + id: string, +) { + return findUniqueOrThrow( + db.demandRequirement.findUnique({ + where: { id }, + include: DEMAND_INCLUDE, + }), + "Demand requirement", + ); +} + +export async function resolveAssignmentBySelection( + db: Pick, + input: AssignmentResolutionInput, +) { + if (input.assignmentId) { + return findUniqueOrThrow( + db.assignment.findUnique({ + where: { id: input.assignmentId }, + include: ASSIGNMENT_INCLUDE, + }), + "Assignment", + ); + } + + if (!input.resourceId || !input.projectId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "resourceId and projectId are required when assignmentId is not provided", + }); + } + + const assignments = await db.assignment.findMany({ + where: { + resourceId: input.resourceId, + projectId: input.projectId, + ...(input.excludeCancelled ? { status: { not: AllocationStatus.CANCELLED } } : {}), + }, + include: ASSIGNMENT_INCLUDE, + orderBy: { startDate: "asc" }, + }); + + const matchingAssignment = assignments + .filter((assignment) => { + if (input.selectionMode === "WINDOW") { + return (!input.startDate || assignment.startDate >= input.startDate) + && (!input.endDate || assignment.endDate <= input.endDate); + } + + return !input.startDate || toIsoDate(assignment.startDate) === toIsoDate(input.startDate); + }) + .sort((left, right) => right.startDate.getTime() - left.startDate.getTime())[0] ?? null; + + if (!matchingAssignment) { + throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }); + } + + return matchingAssignment; +} diff --git a/packages/api/src/router/allocation.ts b/packages/api/src/router/allocation.ts index 673b775..318d6b9 100644 --- a/packages/api/src/router/allocation.ts +++ b/packages/api/src/router/allocation.ts @@ -5,7 +5,6 @@ import { deleteAssignment, deleteAllocationEntry, deleteDemandRequirement, - fillDemandRequirement, fillOpenDemand, loadAllocationEntry, updateAllocationEntry, @@ -14,14 +13,12 @@ import { } from "@capakraken/application"; import { AllocationStatus, - buildTaskAction, CreateAllocationSchema, CreateAssignmentSchema, CreateDemandRequirementSchema, FillDemandRequirementSchema, FillOpenDemandByAllocationSchema, PermissionKey, - type WeekdayAvailability, UpdateAssignmentSchema, UpdateAllocationSchema, UpdateDemandRequirementSchema, @@ -30,632 +27,30 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; -import { checkBudgetThresholds } from "../lib/budget-alerts.js"; -import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; -import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js"; -import { generateAutoSuggestions } from "../lib/auto-staffing.js"; -import { invalidateDashboardCache } from "../lib/cache.js"; -import { logger } from "../lib/logger.js"; +import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated } from "../sse/event-bus.js"; +import { buildResourceAvailabilitySummary, buildResourceAvailabilityView } from "./allocation-availability.js"; import { - calculateEffectiveAvailableHours, - calculateEffectiveBookedHours, - calculateEffectiveDayAvailability, - countEffectiveWorkingDays, - loadResourceDailyAvailabilityContexts, -} from "../lib/resource-capacity.js"; + checkBudgetThresholdsInBackground, + createDemandRequirementWithEffects, + dispatchAllocationWebhookInBackground, + fillDemandRequirementWithEffects, + invalidateDashboardCacheInBackground, +} from "./allocation-effects.js"; +import { + ASSIGNMENT_INCLUDE, + DEMAND_INCLUDE, + toIsoDate, +} from "./allocation-shared.js"; +import { + buildCreateDemandRequirementInput, + findAllocationEntryOrNull, + getDemandRequirementByIdOrThrow, + loadAllocationReadModel, + resolveAssignmentBySelection, + toAssignmentUpdateInput, + toDemandRequirementUpdateInput, +} from "./allocation-support.js"; import { createTRPCRouter, managerProcedure, planningReadProcedure, requirePermission } from "../trpc.js"; -import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js"; - -const DEMAND_INCLUDE = { - project: { select: PROJECT_BRIEF_SELECT }, - roleEntity: { select: ROLE_BRIEF_SELECT }, - assignments: { - include: { - resource: { select: RESOURCE_BRIEF_SELECT }, - project: { select: PROJECT_BRIEF_SELECT }, - roleEntity: { select: ROLE_BRIEF_SELECT }, - }, - }, -} as const; - -const ASSIGNMENT_INCLUDE = { - resource: { select: RESOURCE_BRIEF_SELECT }, - project: { select: PROJECT_BRIEF_SELECT }, - roleEntity: { select: ROLE_BRIEF_SELECT }, - demandRequirement: { - select: { - id: true, - projectId: true, - startDate: true, - endDate: true, - hoursPerDay: true, - percentage: true, - role: true, - roleId: true, - headcount: true, - status: true, - }, - }, -} as const; - -type AllocationListFilters = { - projectId?: string | undefined; - resourceId?: string | undefined; - status?: AllocationStatus | undefined; -}; - -type AllocationEntryUpdateInput = z.infer; - -type AssignmentResolutionInput = { - assignmentId?: string | undefined; - resourceId?: string | undefined; - projectId?: string | undefined; - startDate?: Date | undefined; - endDate?: Date | undefined; - selectionMode?: "WINDOW" | "EXACT_START" | undefined; - excludeCancelled?: boolean | undefined; -}; - -type CreateDemandDraftInput = { - projectId: string; - role?: string | undefined; - roleId?: string | undefined; - headcount?: number | undefined; - hoursPerDay: number; - startDate: Date; - endDate: Date; - budgetCents?: number | undefined; - metadata?: Record | undefined; -}; - -function runAllocationBackgroundEffect( - effectName: string, - execute: () => unknown, - metadata: Record = {}, -): void { - void Promise.resolve() - .then(execute) - .catch((error) => { - logger.error( - { err: error, effectName, ...metadata }, - "Allocation background side effect failed", - ); - }); -} - -function invalidateDashboardCacheInBackground(): void { - runAllocationBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache()); -} - -function checkBudgetThresholdsInBackground( - db: import("@capakraken/db").PrismaClient, - projectId: string, -): void { - runAllocationBackgroundEffect( - "checkBudgetThresholds", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - () => checkBudgetThresholds(db as any, projectId), - { projectId }, - ); -} - -function dispatchAllocationWebhookInBackground( - db: import("@capakraken/db").PrismaClient, - event: string, - payload: Record, -): void { - runAllocationBackgroundEffect( - "dispatchWebhooks", - () => dispatchWebhooks(db, event, payload), - { event }, - ); -} - -function generateAutoSuggestionsInBackground( - db: import("@capakraken/db").PrismaClient, - demandRequirementId: string, -): void { - runAllocationBackgroundEffect( - "generateAutoSuggestions", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - () => generateAutoSuggestions(db as any, demandRequirementId), - { demandRequirementId }, - ); -} - -function toDemandRequirementUpdateInput(input: AllocationEntryUpdateInput) { - return { - ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), - ...(input.startDate !== undefined ? { startDate: input.startDate } : {}), - ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), - ...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}), - ...(input.percentage !== undefined ? { percentage: input.percentage } : {}), - ...(input.role !== undefined ? { role: input.role } : {}), - ...(input.roleId !== undefined ? { roleId: input.roleId } : {}), - ...(input.headcount !== undefined ? { headcount: input.headcount } : {}), - ...(input.budgetCents !== undefined ? { budgetCents: input.budgetCents } : {}), - ...(input.status !== undefined ? { status: input.status } : {}), - ...(input.metadata !== undefined ? { metadata: input.metadata } : {}), - }; -} - -function toAssignmentUpdateInput(input: AllocationEntryUpdateInput) { - return { - ...(input.resourceId !== undefined ? { resourceId: input.resourceId } : {}), - ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), - ...(input.startDate !== undefined ? { startDate: input.startDate } : {}), - ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), - ...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}), - ...(input.percentage !== undefined ? { percentage: input.percentage } : {}), - ...(input.role !== undefined ? { role: input.role } : {}), - ...(input.roleId !== undefined ? { roleId: input.roleId } : {}), - ...(input.status !== undefined ? { status: input.status } : {}), - ...(input.metadata !== undefined ? { metadata: input.metadata } : {}), - }; -} - -async function loadAllocationReadModel( - db: Pick, - input: AllocationListFilters, -) { - const [demandRequirements, assignments] = await Promise.all([ - input.resourceId - ? Promise.resolve([]) - : db.demandRequirement.findMany({ - where: { - ...(input.projectId ? { projectId: input.projectId } : {}), - ...(input.status ? { status: input.status } : {}), - }, - include: DEMAND_INCLUDE, - orderBy: { startDate: "asc" }, - }), - db.assignment.findMany({ - where: { - ...(input.projectId ? { projectId: input.projectId } : {}), - ...(input.resourceId ? { resourceId: input.resourceId } : {}), - ...(input.status ? { status: input.status } : {}), - }, - include: ASSIGNMENT_INCLUDE, - orderBy: { startDate: "asc" }, - }), - ]); - - const readModel = buildSplitAllocationReadModel({ demandRequirements, assignments }); - - const directory = await getAnonymizationDirectory(db as import("@capakraken/db").PrismaClient); - if (!directory) return readModel; - - function anonymizeAllocation(alloc: T): T { - if (!alloc.resource) return alloc; - return { ...alloc, resource: anonymizeResource(alloc.resource, directory) }; - } - - return { - ...readModel, - allocations: readModel.allocations.map(anonymizeAllocation), - demands: readModel.demands.map(anonymizeAllocation), - assignments: readModel.assignments.map(anonymizeAllocation), - }; -} - -async function findAllocationEntryOrNull( - db: Pick, - id: string, -) { - try { - return await loadAllocationEntry(db, id); - } catch (error) { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return null; - } - - throw error; - } -} - -function toIsoDate(value: Date) { - return value.toISOString().slice(0, 10); -} - -function round1(value: number) { - return Math.round(value * 10) / 10; -} - -function averagePerWorkingDay(totalHours: number, workingDays: number) { - if (workingDays <= 0) { - return 0; - } - return round1(totalHours / workingDays); -} - -function buildCreateDemandRequirementInput(input: CreateDemandDraftInput): z.infer { - return { - projectId: input.projectId, - startDate: input.startDate, - endDate: input.endDate, - hoursPerDay: input.hoursPerDay, - percentage: (input.hoursPerDay / 8) * 100, - status: AllocationStatus.PROPOSED, - headcount: input.headcount ?? 1, - budgetCents: input.budgetCents ?? 0, - metadata: input.metadata ?? {}, - ...(input.role ? { role: input.role } : {}), - ...(input.roleId ? { roleId: input.roleId } : {}), - }; -} - -async function getDemandRequirementByIdOrThrow( - db: Pick, - id: string, -) { - return findUniqueOrThrow( - db.demandRequirement.findUnique({ - where: { id }, - include: DEMAND_INCLUDE, - }), - "Demand requirement", - ); -} - -async function createDemandRequirementWithEffects( - db: import("@capakraken/db").PrismaClient, - input: z.infer, -) { - const demandRequirement = await db.$transaction(async (tx) => { - return createDemandRequirement( - tx as unknown as Parameters[0], - input, - ); - }); - - emitAllocationCreated({ - id: demandRequirement.id, - projectId: demandRequirement.projectId, - resourceId: null, - }); - invalidateDashboardCacheInBackground(); - - const [project, roleEntity, managers] = await Promise.all([ - db.project.findUnique({ - where: { id: demandRequirement.projectId }, - select: { name: true }, - }), - demandRequirement.roleId - ? db.role.findUnique({ - where: { id: demandRequirement.roleId }, - select: { name: true }, - }) - : Promise.resolve(null), - db.user.findMany({ - where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, - select: { id: true }, - }), - ]); - const roleName = roleEntity?.name ?? demandRequirement.role ?? "Unspecified role"; - const projectName = project?.name ?? "Unknown project"; - const headcount = demandRequirement.headcount ?? 1; - - for (const manager of managers) { - const task = await db.notification.create({ - data: { - userId: manager.id, - category: "TASK", - type: "DEMAND_FILL", - priority: "NORMAL", - title: `Staff demand: ${roleName} for ${projectName}`, - body: `${headcount} ${roleName} needed for project ${projectName}`, - taskStatus: "OPEN", - taskAction: buildTaskAction("fill_demand", demandRequirement.id), - entityId: demandRequirement.id, - entityType: "demand", - link: `/projects/${demandRequirement.projectId}`, - channel: "in_app", - }, - }); - emitNotificationCreated(manager.id, task.id); - } - - checkBudgetThresholdsInBackground(db, demandRequirement.projectId); - generateAutoSuggestionsInBackground(db, demandRequirement.id); - - return demandRequirement; -} - -async function fillDemandRequirementWithEffects( - db: import("@capakraken/db").PrismaClient, - input: z.infer, -) { - const result = await fillDemandRequirement(db, input); - - emitAllocationCreated({ - id: result.assignment.id, - projectId: result.assignment.projectId, - resourceId: result.assignment.resourceId, - }); - - emitAllocationUpdated({ - id: result.updatedDemandRequirement.id, - projectId: result.updatedDemandRequirement.projectId, - resourceId: null, - }); - invalidateDashboardCacheInBackground(); - checkBudgetThresholdsInBackground(db, result.assignment.projectId); - - if (result.updatedDemandRequirement.headcount > 0 - && result.updatedDemandRequirement.status !== "COMPLETED") { - generateAutoSuggestionsInBackground(db, result.updatedDemandRequirement.id); - } - - return result; -} - -async function resolveAssignmentBySelection( - db: Pick, - input: AssignmentResolutionInput, -) { - if (input.assignmentId) { - return findUniqueOrThrow( - db.assignment.findUnique({ - where: { id: input.assignmentId }, - include: ASSIGNMENT_INCLUDE, - }), - "Assignment", - ); - } - - if (!input.resourceId || !input.projectId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "resourceId and projectId are required when assignmentId is not provided", - }); - } - - const assignments = await db.assignment.findMany({ - where: { - resourceId: input.resourceId, - projectId: input.projectId, - ...(input.excludeCancelled ? { status: { not: AllocationStatus.CANCELLED } } : {}), - }, - include: ASSIGNMENT_INCLUDE, - orderBy: { startDate: "asc" }, - }); - - const matchingAssignment = assignments - .filter((assignment) => { - if (input.selectionMode === "WINDOW") { - return (!input.startDate || assignment.startDate >= input.startDate) - && (!input.endDate || assignment.endDate <= input.endDate); - } - - return !input.startDate || toIsoDate(assignment.startDate) === toIsoDate(input.startDate); - }) - .sort((left, right) => right.startDate.getTime() - left.startDate.getTime())[0] ?? null; - - if (!matchingAssignment) { - throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" }); - } - - return matchingAssignment; -} - -async function buildResourceAvailabilityView( - db: Pick, - input: { - resourceId: string; - startDate: Date; - endDate: Date; - hoursPerDay: number; - }, -) { - const resource = await db.resource.findUnique({ - where: { id: input.resourceId }, - select: { - id: true, displayName: true, eid: true, fte: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - country: { select: { dailyWorkingHours: true, code: true } }, - metroCity: { select: { name: true } }, - }, - }); - if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); - - const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1); - const availability = (resource.availability as WeekdayAvailability | null) ?? { - monday: fallbackDailyHours, - tuesday: fallbackDailyHours, - wednesday: fallbackDailyHours, - thursday: fallbackDailyHours, - friday: fallbackDailyHours, - saturday: 0, - sunday: 0, - }; - - const [existingAssignments, vacations] = await Promise.all([ - db.assignment.findMany({ - where: { - resourceId: input.resourceId, - status: { not: "CANCELLED" }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - }, - select: { - id: true, startDate: true, endDate: true, hoursPerDay: true, status: true, - project: { select: { name: true, shortCode: true } }, - }, - orderBy: { startDate: "asc" }, - }), - db.vacation.findMany({ - where: { - resourceId: input.resourceId, - status: { in: ["APPROVED", "PENDING"] }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - }, - select: { - id: true, - type: true, - startDate: true, - endDate: true, - isHalfDay: true, - halfDayPart: true, - status: true, - }, - orderBy: { startDate: "asc" }, - }), - ]); - - const contexts = await loadResourceDailyAvailabilityContexts( - db, - [{ - id: resource.id, - availability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - input.startDate, - input.endDate, - ); - const context = contexts.get(resource.id); - - const totalWorkingDays = countEffectiveWorkingDays({ - availability, - periodStart: input.startDate, - periodEnd: input.endDate, - context, - }); - let availableDays = 0; - let conflictDays = 0; - let partialDays = 0; - let totalAvailableHours = 0; - const requestedHpd = input.hoursPerDay; - - const d = new Date(input.startDate); - const end = new Date(input.endDate); - while (d <= end) { - const effectiveDayCapacity = calculateEffectiveDayAvailability({ - availability, - date: d, - context, - }); - - if (effectiveDayCapacity > 0) { - let bookedHours = 0; - for (const assignment of existingAssignments) { - bookedHours += calculateEffectiveBookedHours({ - availability, - startDate: assignment.startDate, - endDate: assignment.endDate, - hoursPerDay: assignment.hoursPerDay, - periodStart: d, - periodEnd: d, - context, - }); - } - - const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours); - if (remainingCapacity >= requestedHpd) { - availableDays++; - totalAvailableHours += requestedHpd; - } else if (remainingCapacity > 0) { - partialDays++; - totalAvailableHours += remainingCapacity; - } else { - conflictDays++; - } - } - d.setDate(d.getDate() + 1); - } - - const totalRequestedHours = totalWorkingDays * requestedHpd; - const totalPeriodCapacity = calculateEffectiveAvailableHours({ - availability, - periodStart: input.startDate, - periodEnd: input.endDate, - context, - }); - const dailyCapacity = totalWorkingDays > 0 - ? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10 - : 0; - - return { - resource: { id: resource.id, name: resource.displayName, eid: resource.eid }, - dailyCapacity, - totalWorkingDays, - availableDays, - partialDays, - conflictDays, - totalAvailableHours: Math.round(totalAvailableHours * 10) / 10, - totalRequestedHours, - coveragePercent: totalRequestedHours > 0 - ? Math.round((totalAvailableHours / totalRequestedHours) * 100) - : 0, - existingAssignments: existingAssignments.map((assignment) => ({ - project: assignment.project.name, - code: assignment.project.shortCode, - hoursPerDay: assignment.hoursPerDay, - start: assignment.startDate.toISOString().slice(0, 10), - end: assignment.endDate.toISOString().slice(0, 10), - status: assignment.status, - })), - vacations: vacations.map((vacation) => ({ - id: vacation.id, - type: vacation.type, - status: vacation.status, - start: vacation.startDate.toISOString().slice(0, 10), - end: vacation.endDate.toISOString().slice(0, 10), - isHalfDay: vacation.isHalfDay, - halfDayPart: vacation.halfDayPart, - })), - }; -} - -function buildResourceAvailabilitySummary( - availability: Awaited>, - period: { startDate: Date; endDate: Date }, -) { - const periodAvailableHours = availability.totalRequestedHours > 0 - ? round1(availability.dailyCapacity * availability.totalWorkingDays) - : 0; - const periodRemainingHours = round1(availability.totalAvailableHours); - const periodBookedHours = round1(Math.max(0, periodAvailableHours - periodRemainingHours)); - - return { - resource: availability.resource.name, - period: `${toIsoDate(period.startDate)} to ${toIsoDate(period.endDate)}`, - fte: null, - workingDays: availability.totalWorkingDays, - periodAvailableHours, - periodBookedHours, - periodRemainingHours, - maxHoursPerDay: availability.dailyCapacity, - currentBookedHoursPerDay: round1( - Math.max( - 0, - availability.dailyCapacity - availability.totalAvailableHours / Math.max(availability.totalWorkingDays, 1), - ), - ), - availableHoursPerDay: averagePerWorkingDay(availability.totalAvailableHours, availability.totalWorkingDays), - isFullyAvailable: availability.existingAssignments.length === 0 && availability.vacations.length === 0, - existingAllocations: availability.existingAssignments.map((assignment) => ({ - project: `${assignment.project} (${assignment.code})`, - hoursPerDay: assignment.hoursPerDay, - status: assignment.status, - start: assignment.start, - end: assignment.end, - })), - vacations: availability.vacations.map((vacation) => ({ - type: vacation.type, - start: vacation.start, - end: vacation.end, - isHalfDay: vacation.isHalfDay, - })), - }; -} export const allocationRouter = createTRPCRouter({ list: planningReadProcedure