import { countPlanningEntries, listAssignmentBookings, } 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 { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js"; import { invalidateDashboardCache } from "../lib/cache.js"; import { logger } from "../lib/logger.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import { validateImageDataUrl } from "../lib/image-validation.js"; import type { TRPCContext } from "../trpc.js"; import { calculateEffectiveBookedHours, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; import { fmtEur } from "../lib/format-utils.js"; const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload) const PROJECT_SUMMARY_SELECT = { id: true, shortCode: true, name: true, status: true, startDate: true, endDate: true, client: { select: { name: true } }, } as const; const PROJECT_SUMMARY_DETAIL_SELECT = { ...PROJECT_SUMMARY_SELECT, budgetCents: true, winProbability: true, _count: { select: { assignments: true, estimates: true } }, } as const; const PROJECT_IDENTIFIER_SELECT = { id: true, shortCode: true, name: true, status: true, startDate: true, endDate: true, } as const; const PROJECT_DETAIL_SELECT = { ...PROJECT_IDENTIFIER_SELECT, id: true, shortCode: true, name: true, status: true, orderType: true, allocationType: true, budgetCents: true, winProbability: true, startDate: true, endDate: true, responsiblePerson: true, client: { select: { name: true } }, utilizationCategory: { select: { code: true, name: true } }, _count: { select: { assignments: true, estimates: true } }, } as const; 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 }, ); } function formatDate(value: Date | null): string | null { return value ? value.toISOString().slice(0, 10) : null; } function mapProjectSummary(project: { id: string; shortCode: string; name: string; status: string; startDate: Date | null; endDate: Date | null; client: { name: string } | null; }) { return { id: project.id, code: project.shortCode, name: project.name, status: project.status, start: formatDate(project.startDate), end: formatDate(project.endDate), client: project.client?.name ?? null, }; } function mapProjectSummaryDetail(project: { id: string; shortCode: string; name: string; status: string; budgetCents: number | null; winProbability: number; startDate: Date | null; endDate: Date | null; client: { name: string } | null; _count: { assignments: number; estimates: number }; }) { return { id: project.id, code: project.shortCode, name: project.name, status: project.status, budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set", winProbability: `${project.winProbability}%`, start: formatDate(project.startDate), end: formatDate(project.endDate), client: project.client?.name ?? null, assignmentCount: project._count.assignments, estimateCount: project._count.estimates, }; } function mapProjectDetail( project: { id: string; shortCode: string; name: string; status: string; orderType: string; allocationType: string; budgetCents: number | null; winProbability: number; startDate: Date | null; endDate: Date | null; responsiblePerson: string | null; client: { name: string } | null; utilizationCategory: { code: string; name: string } | null; _count: { assignments: number; estimates: number }; }, topAssignments: Array<{ resource: { displayName: string; eid: string }; role: string | null; status: string; hoursPerDay: number; startDate: Date; endDate: Date; }>, ) { return { id: project.id, code: project.shortCode, name: project.name, status: project.status, orderType: project.orderType, allocationType: project.allocationType, budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set", budgetCents: project.budgetCents, winProbability: `${project.winProbability}%`, start: formatDate(project.startDate), end: formatDate(project.endDate), responsible: project.responsiblePerson, client: project.client?.name ?? null, category: project.utilizationCategory?.name ?? null, assignmentCount: project._count.assignments, estimateCount: project._count.estimates, topAllocations: topAssignments.map((assignment) => ({ resource: assignment.resource.displayName, eid: assignment.resource.eid, role: assignment.role ?? null, status: assignment.status, hoursPerDay: assignment.hoursPerDay, start: formatDate(assignment.startDate), end: formatDate(assignment.endDate), })), }; } async function readProjectSummariesSnapshot( ctx: Pick, input: { search?: string | undefined; status?: ProjectStatus | undefined; limit: number; }, ) { const buildWhere = (search: string | undefined) => ({ ...(input.status ? { status: input.status } : {}), ...(search ? { OR: [ { name: { contains: search, mode: "insensitive" as const } }, { shortCode: { contains: search, mode: "insensitive" as const } }, ], } : {}), }); let projects = await ctx.db.project.findMany({ where: buildWhere(input.search), select: PROJECT_SUMMARY_SELECT, take: input.limit, orderBy: { name: "asc" }, }); if (projects.length === 0 && input.search) { const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2); if (words.length > 1) { const candidates = await ctx.db.project.findMany({ where: { ...(input.status ? { status: input.status } : {}), OR: words.flatMap((word) => ([ { name: { contains: word, mode: "insensitive" as const } }, { shortCode: { contains: word, mode: "insensitive" as const } }, ])), }, select: PROJECT_SUMMARY_SELECT, take: input.limit * 2, orderBy: { name: "asc" }, }); projects = candidates .map((project) => { const haystack = `${project.name} ${project.shortCode}`.toLowerCase(); const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length; return { project, matchCount }; }) .sort((left, right) => right.matchCount - left.matchCount) .slice(0, input.limit) .map((entry) => entry.project); } } return { items: projects, exactMatch: input.search ? projects.some((project) => project.name.toLowerCase().includes(input.search!.toLowerCase()) || project.shortCode.toLowerCase().includes(input.search!.toLowerCase())) : true, }; } async function readProjectSummaryDetailsSnapshot( ctx: Pick, input: { search?: string | undefined; status?: ProjectStatus | undefined; limit: number; }, ) { const buildWhere = (search: string | undefined) => ({ ...(input.status ? { status: input.status } : {}), ...(search ? { OR: [ { name: { contains: search, mode: "insensitive" as const } }, { shortCode: { contains: search, mode: "insensitive" as const } }, ], } : {}), }); let projects = await ctx.db.project.findMany({ where: buildWhere(input.search), select: PROJECT_SUMMARY_DETAIL_SELECT, take: input.limit, orderBy: { name: "asc" }, }); if (projects.length === 0 && input.search) { const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2); if (words.length > 1) { const candidates = await ctx.db.project.findMany({ where: { ...(input.status ? { status: input.status } : {}), OR: words.flatMap((word) => ([ { name: { contains: word, mode: "insensitive" as const } }, { shortCode: { contains: word, mode: "insensitive" as const } }, ])), }, select: PROJECT_SUMMARY_DETAIL_SELECT, take: input.limit * 2, orderBy: { name: "asc" }, }); projects = candidates .map((project) => { const haystack = `${project.name} ${project.shortCode}`.toLowerCase(); const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length; return { project, matchCount }; }) .sort((left, right) => right.matchCount - left.matchCount) .slice(0, input.limit) .map((entry) => entry.project); } } return { items: projects, exactMatch: input.search ? projects.some((project) => project.name.toLowerCase().includes(input.search!.toLowerCase()) || project.shortCode.toLowerCase().includes(input.search!.toLowerCase())) : true, }; } async function resolveProjectIdentifierSnapshot( ctx: Pick, identifier: string, ) { let project = await ctx.db.project.findUnique({ where: { id: identifier }, select: PROJECT_IDENTIFIER_SELECT, }); if (!project) { project = await ctx.db.project.findUnique({ where: { shortCode: identifier }, select: PROJECT_IDENTIFIER_SELECT, }); } if (!project) { project = await ctx.db.project.findFirst({ where: { name: { equals: identifier, mode: "insensitive" } }, select: PROJECT_IDENTIFIER_SELECT, }); } if (!project) { project = await ctx.db.project.findFirst({ where: { name: { contains: identifier, mode: "insensitive" } }, select: PROJECT_IDENTIFIER_SELECT, }); } if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } return project; } async function readProjectByIdentifierDetailSnapshot( ctx: Pick, identifier: string, ) { const projectIdentity = await resolveProjectIdentifierSnapshot(ctx, identifier); const project = await ctx.db.project.findUnique({ where: { id: projectIdentity.id }, select: PROJECT_DETAIL_SELECT, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } const topAssignments = await ctx.db.assignment.findMany({ where: { projectId: project.id, status: { not: "CANCELLED" }, }, select: { resource: { select: { displayName: true, eid: true } }, role: true, status: true, hoursPerDay: true, startDate: true, endDate: true, }, take: 10, orderBy: { startDate: "desc" }, }); return { ...project, topAssignments, }; } export const projectRouter = createTRPCRouter({ resolveByIdentifier: protectedProcedure .input(z.object({ identifier: z.string() })) .query(async ({ ctx, input }) => { const select = { id: true, shortCode: true, name: true, status: true, responsiblePerson: true, startDate: true, endDate: true, } as const; let project = await ctx.db.project.findUnique({ where: { id: input.identifier }, select, }); if (!project) { project = await ctx.db.project.findUnique({ where: { shortCode: input.identifier }, select, }); } if (!project) { project = await ctx.db.project.findFirst({ where: { name: { equals: input.identifier, mode: "insensitive" } }, select, }); } if (!project) { project = await ctx.db.project.findFirst({ where: { name: { contains: input.identifier, mode: "insensitive" } }, select, }); } if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } return project; }), searchSummaries: protectedProcedure .input(z.object({ search: z.string().optional(), status: z.nativeEnum(ProjectStatus).optional(), limit: z.number().int().min(1).max(50).default(20), })) .query(async ({ ctx, input }) => { const { items, exactMatch } = await readProjectSummariesSnapshot(ctx, input); const formatted = items.map(mapProjectSummary); if (items.length > 0 && input.search && !exactMatch) { return { suggestions: formatted, note: `No exact match for "${input.search}". These projects match some of the search terms:`, }; } return formatted; }), searchSummariesDetail: controllerProcedure .input(z.object({ search: z.string().optional(), status: z.nativeEnum(ProjectStatus).optional(), limit: z.number().int().min(1).max(50).default(20), })) .query(async ({ ctx, input }) => { const { items, exactMatch } = await readProjectSummaryDetailsSnapshot(ctx, input); const formatted = items.map(mapProjectSummaryDetail); if (items.length > 0 && input.search && !exactMatch) { return { suggestions: formatted, note: `No exact match for "${input.search}". These projects match some of the search terms:`, }; } return formatted; }), 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, }; }), getByIdentifier: protectedProcedure .input(z.object({ identifier: z.string() })) .query(async ({ ctx, input }) => resolveProjectIdentifierSnapshot(ctx, input.identifier)), getByIdentifierDetail: controllerProcedure .input(z.object({ identifier: z.string() })) .query(async ({ ctx, input }) => { const project = await readProjectByIdentifierDetailSnapshot(ctx, input.identifier); return mapProjectDetail(project, project.topAssignments); }), 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; }), 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 }, }); invalidateDashboardCacheInBackground(); dispatchProjectWebhookInBackground(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 } }, }, }); invalidateDashboardCacheInBackground(); 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, }, }); }); invalidateDashboardCacheInBackground(); 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, }, }); }); invalidateDashboardCacheInBackground(); 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" }, }); const imageProvider = settings?.imageProvider ?? "dalle"; const useGemini = imageProvider === "gemini" && isGeminiConfigured(settings); const useDalle = imageProvider === "dalle" && isDalleConfigured(settings); if (!useGemini && !useDalle) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "No image provider configured. Set up DALL-E or Gemini 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; let coverImageUrl: string; if (useGemini) { try { coverImageUrl = await generateGeminiImage( settings!.geminiApiKey!, finalPrompt, settings!.geminiModel ?? undefined, ); } catch (err) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Gemini error: ${parseGeminiError(err)}`, }); } } else { 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 loggedAiCall("dalle", model, finalPrompt.length, () => 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", }); } 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/'.", }); } // Validate magic bytes match declared MIME type const magicCheck = validateImageDataUrl(input.imageDataUrl); if (!magicCheck.valid) { throw new TRPCError({ code: "BAD_REQUEST", message: `File validation failed: ${magicCheck.reason}`, }); } 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 }; }), isImageGenConfigured: protectedProcedure .query(async ({ ctx }) => { const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, }); const imageProvider = settings?.imageProvider ?? "dalle"; const configured = imageProvider === "gemini" ? isGeminiConfigured(settings) : isDalleConfigured(settings); return { configured, provider: imageProvider }; }), /** @deprecated Use isImageGenConfigured instead */ isDalleConfigured: protectedProcedure .query(async ({ ctx }) => { const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" }, }); const imageProvider = settings?.imageProvider ?? "dalle"; const configured = imageProvider === "gemini" ? isGeminiConfigured(settings) : isDalleConfigured(settings); return { configured }; }), });