import { countPlanningEntries, listAssignmentBookings, } from "@planarchy/application"; import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; export const projectRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ status: z.nativeEnum(ProjectStatus).optional(), search: z.string().optional(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(500).default(50), // Cursor-based pagination (additive — page/limit still supported) cursor: 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, page, limit, 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 skip = cursor ? 0 : (page - 1) * limit; const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where; const [rawProjects, total] = await Promise.all([ ctx.db.project.findMany({ where: whereWithCursor, skip, take: limit + 1, orderBy: [{ startDate: "asc" }, { id: "asc" }], }), ctx.db.project.count({ where }), ]); const hasMore = rawProjects.length > limit; const projects = hasMore ? rawProjects.slice(0, limit) : rawProjects; const nextCursor = hasMore ? projects[projects.length - 1]!.id : null; const { countsByProjectId } = await countPlanningEntries(ctx.db, { projectIds: projects.map((project) => project.id), }); return { projects: projects.map((project) => ({ ...project, _count: { allocations: countsByProjectId.get(project.id) ?? 0, }, })), total, page, limit, nextCursor, }; }), getById: protectedProcedure .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, }; }), 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, staffingReqs: input.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue, dynamicFields: input.dynamicFields as unknown as import("@planarchy/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 }, }, }); return project; }), update: managerProcedure .input(z.object({ id: z.string(), data: UpdateProjectSchema })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); const existing = await ctx.db.project.findUnique({ where: { id: input.id } }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } 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.staffingReqs !== undefined ? { staffingReqs: input.data.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}), ...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/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 } : {}), } as unknown as Parameters[0]["data"], }); await ctx.db.auditLog.create({ data: { entityType: "Project", entityId: input.id, action: "UPDATE", changes: { before: existing, after: updated }, }, }); return updated; }), updateStatus: managerProcedure .input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); return ctx.db.project.update({ where: { id: input.id }, data: { status: input.status }, }); }), batchUpdateStatus: managerProcedure .input( z.object({ ids: z.array(z.string()).min(1).max(100), status: z.nativeEnum(ProjectStatus), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); const updated = await ctx.db.$transaction( input.ids.map((id) => ctx.db.project.update({ where: { id }, data: { status: input.status } }), ), ); await ctx.db.auditLog.create({ data: { entityType: "Project", entityId: input.ids.join(","), action: "UPDATE", changes: { after: { status: input.status, ids: input.ids } }, }, }); return { count: updated.length }; }), listWithCosts: controllerProcedure .input( z.object({ status: z.nativeEnum(ProjectStatus).optional(), search: z.string().optional(), limit: z.number().int().min(1).max(500).default(50), cursor: z.string().optional(), }), ) .query(async ({ ctx, input }) => { const { status, search, limit, cursor } = input; const where = { ...(status ? { status } : {}), ...(search ? { OR: [ { name: { contains: search, mode: "insensitive" as const } }, { shortCode: { contains: search, mode: "insensitive" as const } }, ], } : {}), }; const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where; const rawProjects = await ctx.db.project.findMany({ where: whereWithCursor, take: limit + 1, orderBy: [{ startDate: "asc" }, { id: "asc" }], }); const hasMore = rawProjects.length > limit; const projectsRaw = hasMore ? rawProjects.slice(0, limit) : rawProjects; const nextCursor = hasMore ? projectsRaw[projectsRaw.length - 1]!.id : null; const projectIds = projectsRaw.map((project) => project.id); const bookings = projectIds.length ? await listAssignmentBookings(ctx.db, { startDate: new Date("1900-01-01T00:00:00.000Z"), endDate: new Date("2100-12-31T23:59:59.999Z"), projectIds, }) : []; // Compute cost + person days per project const projects = projectsRaw.map((p) => { const projectBookings = bookings.filter((booking) => booking.projectId === p.id); let totalCostCents = 0; let totalPersonDays = 0; for (const a of projectBookings) { const days = (new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) / (1000 * 60 * 60 * 24) + 1; totalCostCents += a.dailyCostCents * days; totalPersonDays += (a.hoursPerDay * days) / 8; } const utilizationPercent = p.budgetCents > 0 ? Math.round((totalCostCents / p.budgetCents) * 100) : 0; return { ...p, totalCostCents: Math.round(totalCostCents), totalPersonDays: Math.round(totalPersonDays * 10) / 10, utilizationPercent, }; }); return { projects, nextCursor }; }), });