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 { 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 { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; export const projectRouter = createTRPCRouter({ list: protectedProcedure .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: 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 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.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( CursorInputSchema.extend({ status: z.nativeEnum(ProjectStatus).optional(), search: z.string().optional(), }), ) .query(async ({ ctx, input }) => { const { status, search, 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 result = await paginateCursor( ({ take }) => ctx.db.project.findMany({ where: whereWithCursor, take, orderBy: [{ startDate: "asc" }, { id: "asc" }], }), input, ); const projectIds = result.items.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 = result.items.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: result.nextCursor }; }), delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.id }, select: { id: true, name: true, shortCode: true }, }); if (!project) throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); // Delete all related records in a transaction await ctx.db.$transaction(async (tx) => { // Delete assignments (which reference demandRequirements) await tx.assignment.deleteMany({ where: { projectId: input.id } }); // Delete demand requirements await tx.demandRequirement.deleteMany({ where: { projectId: input.id } }); // Unlink calculation rules await tx.calculationRule.updateMany({ where: { projectId: input.id }, data: { projectId: null }, }); // Delete the project await tx.project.delete({ where: { id: input.id } }); // Audit log await tx.auditLog.create({ data: { entityType: "Project", entityId: input.id, action: "DELETE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { before: { id: project.id, name: project.name, shortCode: project.shortCode } } as never, }, }); }); return { id: input.id, name: project.name }; }), });