import { countPlanningEntries } from "@capakraken/application"; import type { WeekdayAvailability } from "@capakraken/shared"; import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { calculateShoringRatio, type ShoringAssignment } from "@capakraken/engine/allocation"; import { findUniqueOrThrow } from "../db/helpers.js"; import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js"; import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; import { projectCostReadProcedures } from "./project-cost-read.js"; import { projectCoverProcedures } from "./project-cover.js"; import { projectIdentifierReadProcedures } from "./project-identifier-read.js"; import { createProjectLifecycleProcedures } from "./project-lifecycle.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; import { invalidateDashboardCache } from "../lib/cache.js"; import { logger } from "../lib/logger.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import type { TRPCContext } from "../trpc.js"; import { calculateEffectiveBookedHours, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; function runProjectBackgroundEffect( effectName: string, execute: () => unknown, metadata: Record = {}, ): void { void Promise.resolve() .then(execute) .catch((error) => { logger.error( { err: error, effectName, ...metadata }, "Project background side effect failed", ); }); } function invalidateDashboardCacheInBackground(): void { runProjectBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache()); } function dispatchProjectWebhookInBackground( db: TRPCContext["db"], event: string, payload: Record, ): void { runProjectBackgroundEffect( "dispatchWebhooks", () => dispatchWebhooks(db, event, payload), { event }, ); } export const projectRouter = createTRPCRouter({ ...projectCostReadProcedures, ...projectCoverProcedures, ...projectIdentifierReadProcedures, ...createProjectLifecycleProcedures({ invalidateDashboardCacheInBackground, dispatchProjectWebhookInBackground, }), list: controllerProcedure .input( PaginationInputSchema.extend({ status: z.nativeEnum(ProjectStatus).optional(), search: z.string().optional(), // Custom field JSONB filters customFieldFilters: z.array(z.object({ key: z.string(), value: z.string(), type: z.nativeEnum(FieldType), })).optional(), }), ) .query(async ({ ctx, input }) => { const { status, search, cursor, customFieldFilters } = input; const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const where: any = { ...(status ? { status } : {}), ...(search ? { OR: [ { name: { contains: search, mode: "insensitive" as const } }, { shortCode: { contains: search, mode: "insensitive" as const } }, ], } : {}), ...(cfConditions.length > 0 ? { AND: cfConditions } : {}), }; const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where; const result = await paginate( ({ skip, take }) => ctx.db.project.findMany({ where: whereWithCursor, skip, take, orderBy: [{ startDate: "asc" }, { id: "asc" }], }), () => ctx.db.project.count({ where }), input, ); const { countsByProjectId } = await countPlanningEntries(ctx.db, { projectIds: result.items.map((project) => project.id), }); return { projects: result.items.map((project) => ({ ...project, _count: { allocations: countsByProjectId.get(project.id) ?? 0, }, })), total: result.total, page: result.page, limit: result.limit, nextCursor: result.nextCursor, }; }), getById: controllerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const [project, planningRead] = await Promise.all([ ctx.db.project.findUnique({ where: { id: input.id }, include: { blueprint: true }, }), loadProjectPlanningReadModel(ctx.db, { projectId: input.id }), ]); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } return { ...project, allocations: planningRead.readModel.assignments, demands: planningRead.readModel.demands, assignments: planningRead.readModel.assignments, }; }), getShoringRatio: controllerProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true, name: true, shoringThreshold: true, onshoreCountryCode: true, }, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } const assignments = await ctx.db.assignment.findMany({ where: { projectId: input.projectId, status: { not: "CANCELLED" } }, include: { resource: { include: { country: { select: { id: true, code: true } }, metroCity: { select: { id: true, name: true } }, }, }, }, }); const periodStart = assignments.length > 0 ? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime()))) : new Date(); const periodEnd = assignments.length > 0 ? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime()))) : new Date(); const contexts = await loadResourceDailyAvailabilityContexts( ctx.db, assignments.map((assignment) => ({ id: assignment.resource.id, availability: assignment.resource.availability as unknown as WeekdayAvailability, countryId: assignment.resource.country?.id ?? assignment.resource.countryId, countryCode: assignment.resource.country?.code, federalState: assignment.resource.federalState, metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId, metroCityName: assignment.resource.metroCity?.name, })), periodStart, periodEnd, ); const mapped: ShoringAssignment[] = assignments.map((a) => { const workingDays = a.hoursPerDay > 0 ? calculateEffectiveBookedHours({ availability: a.resource.availability as unknown as WeekdayAvailability, startDate: a.startDate, endDate: a.endDate, hoursPerDay: a.hoursPerDay, periodStart, periodEnd, context: contexts.get(a.resourceId ?? a.resource.id), }) / a.hoursPerDay : 0; return { resourceId: a.resourceId, countryCode: a.resource.country?.code ?? null, hoursPerDay: a.hoursPerDay, workingDays: Math.max(0, workingDays), }; }); return calculateShoringRatio( mapped, project.shoringThreshold ?? 55, project.onshoreCountryCode ?? "DE", ); }), create: managerProcedure .input(CreateProjectSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); const existing = await ctx.db.project.findUnique({ where: { shortCode: input.shortCode }, }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: `Project with short code "${input.shortCode}" already exists`, }); } await assertBlueprintDynamicFields({ db: ctx.db, blueprintId: input.blueprintId, dynamicFields: input.dynamicFields, target: BlueprintTarget.PROJECT, }); const project = await ctx.db.project.create({ data: { shortCode: input.shortCode, name: input.name, orderType: input.orderType, allocationType: input.allocationType, winProbability: input.winProbability, budgetCents: input.budgetCents, startDate: input.startDate, endDate: input.endDate, status: input.status, responsiblePerson: input.responsiblePerson, ...(input.color !== undefined ? { color: input.color } : {}), staffingReqs: input.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue, dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue, blueprintId: input.blueprintId, ...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}), ...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}), } as unknown as Parameters[0]["data"], }); await ctx.db.auditLog.create({ data: { entityType: "Project", entityId: project.id, action: "CREATE", changes: { after: project }, }, }); invalidateDashboardCacheInBackground(); dispatchProjectWebhookInBackground(ctx.db, "project.created", { id: project.id, shortCode: project.shortCode, name: project.name, status: project.status, }); return project; }), update: managerProcedure .input(z.object({ id: z.string(), data: UpdateProjectSchema })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); const existing = await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.id } }), "Project", ); const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined; const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record; await assertBlueprintDynamicFields({ db: ctx.db, blueprintId: nextBlueprintId, dynamicFields: nextDynamicFields, target: BlueprintTarget.PROJECT, }); const updated = await ctx.db.project.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), ...(input.data.orderType !== undefined ? { orderType: input.data.orderType } : {}), ...(input.data.allocationType !== undefined ? { allocationType: input.data.allocationType } : {}), ...(input.data.winProbability !== undefined ? { winProbability: input.data.winProbability } : {}), ...(input.data.budgetCents !== undefined ? { budgetCents: input.data.budgetCents } : {}), ...(input.data.startDate !== undefined ? { startDate: input.data.startDate } : {}), ...(input.data.endDate !== undefined ? { endDate: input.data.endDate } : {}), ...(input.data.status !== undefined ? { status: input.data.status } : {}), ...(input.data.responsiblePerson !== undefined ? { responsiblePerson: input.data.responsiblePerson } : {}), ...(input.data.color !== undefined ? { color: input.data.color } : {}), ...(input.data.staffingReqs !== undefined ? { staffingReqs: input.data.staffingReqs as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), ...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), ...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}), ...(input.data.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.data.utilizationCategoryId || null } : {}), ...(input.data.clientId !== undefined ? { clientId: input.data.clientId || null } : {}), ...(input.data.shoringThreshold !== undefined ? { shoringThreshold: input.data.shoringThreshold } : {}), ...(input.data.onshoreCountryCode !== undefined ? { onshoreCountryCode: input.data.onshoreCountryCode } : {}), } as unknown as Parameters[0]["data"], }); await ctx.db.auditLog.create({ data: { entityType: "Project", entityId: input.id, action: "UPDATE", changes: { before: existing, after: updated }, }, }); invalidateDashboardCacheInBackground(); return updated; }), });