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"; import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js"; import { invalidateDashboardCache } from "../lib/cache.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload) 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, ...(input.color !== undefined ? { color: input.color } : {}), 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 }, }, }); void invalidateDashboardCache(); void dispatchWebhooks(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("@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 }, }, }); void invalidateDashboardCache(); return updated; }), updateStatus: managerProcedure .input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); const result = await ctx.db.project.update({ where: { id: input.id }, data: { status: input.status }, }); void invalidateDashboardCache(); void dispatchWebhooks(ctx.db, "project.status_changed", { id: result.id, shortCode: result.shortCode, name: result.name, status: result.status, }); return result; }), 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 } }, }, }); void invalidateDashboardCache(); 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, }, }); }); void invalidateDashboardCache(); return { id: input.id, name: project.name }; }), batchDelete: adminProcedure .input( z.object({ ids: z.array(z.string()).min(1).max(50), }), ) .mutation(async ({ ctx, input }) => { const projects = await ctx.db.project.findMany({ where: { id: { in: input.ids } }, select: { id: true, name: true, shortCode: true }, }); if (projects.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "No projects found" }); } await ctx.db.$transaction(async (tx) => { const ids = projects.map((p) => p.id); await tx.assignment.deleteMany({ where: { projectId: { in: ids } } }); await tx.demandRequirement.deleteMany({ where: { projectId: { in: ids } } }); await tx.calculationRule.updateMany({ where: { projectId: { in: ids } }, data: { projectId: null }, }); await tx.project.deleteMany({ where: { id: { in: ids } } }); await tx.auditLog.create({ data: { entityType: "Project", entityId: ids.join(","), action: "DELETE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { before: projects } as never, }, }); }); void invalidateDashboardCache(); return { count: projects.length }; }), // ─── Cover Art ────────────────────────────────────────────────────────────── generateCover: managerProcedure .input(z.object({ projectId: z.string(), prompt: z.string().max(500).optional(), })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); const project = await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, include: { client: { select: { name: true } } }, }), "Project", ); const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, }); if (!isDalleConfigured(settings)) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DALL-E is not configured. Set up the DALL-E deployment in Admin → Settings.", }); } const clientName = project.client?.name ? ` for ${project.client.name}` : ""; const basePrompt = `Professional cover art for a 3D automotive visualization project: "${project.name}"${clientName}. Style: cinematic, modern, photorealistic CGI rendering, dramatic lighting, studio environment. No text or typography in the image.`; const finalPrompt = input.prompt ? `${basePrompt} Additional direction: ${input.prompt}` : basePrompt; const dalleClient = createDalleClient(settings!); const model = settings!.aiProvider === "azure" ? settings!.azureDalleDeployment! : "dall-e-3"; // eslint-disable-next-line @typescript-eslint/no-explicit-any let response: any; try { response = await dalleClient.images.generate({ model, prompt: finalPrompt, size: "1024x1024", n: 1, response_format: "b64_json", }); } catch (err) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `DALL-E error: ${parseAiError(err)}`, }); } const b64 = response.data?.[0]?.b64_json; if (!b64) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "No image data returned from DALL-E", }); } const coverImageUrl = `data:image/png;base64,${b64}`; await ctx.db.project.update({ where: { id: input.projectId }, data: { coverImageUrl }, }); return { coverImageUrl }; }), uploadCover: managerProcedure .input(z.object({ projectId: z.string(), imageDataUrl: z.string(), })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); if (!input.imageDataUrl.startsWith("data:image/")) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid image format. Must be a data URL starting with 'data:image/'.", }); } if (input.imageDataUrl.length > MAX_COVER_SIZE) { throw new TRPCError({ code: "BAD_REQUEST", message: "Image too large. Maximum compressed size is 4 MB.", }); } await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId } }), "Project", ); await ctx.db.project.update({ where: { id: input.projectId }, data: { coverImageUrl: input.imageDataUrl }, }); return { coverImageUrl: input.imageDataUrl }; }), removeCover: managerProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId } }), "Project", ); await ctx.db.project.update({ where: { id: input.projectId }, data: { coverImageUrl: null }, }); return { ok: true }; }), updateCoverFocus: managerProcedure .input(z.object({ projectId: z.string(), coverFocusY: z.number().int().min(0).max(100), })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); await ctx.db.project.update({ where: { id: input.projectId }, data: { coverFocusY: input.coverFocusY }, }); return { ok: true }; }), isDalleConfigured: protectedProcedure .query(async ({ ctx }) => { const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, }); return { configured: isDalleConfigured(settings) }; }), });