import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' /** * Project Pool Router * * Manages the pool of unassigned projects (projects not yet assigned to a round). * Provides procedures for listing unassigned projects and bulk assigning them to rounds. */ export const projectPoolRouter = router({ /** * List unassigned projects with filtering and pagination * Projects where roundId IS NULL */ listUnassigned: adminProcedure .input( z.object({ programId: z.string(), // Required - must specify which program competitionCategory: z .enum(['STARTUP', 'BUSINESS_CONCEPT']) .optional(), search: z.string().optional(), // Search in title, teamName, description page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(200).default(20), }) ) .query(async ({ ctx, input }) => { const { programId, competitionCategory, search, page, perPage } = input const skip = (page - 1) * perPage // Build where clause const where: Record = { programId, roundId: null, // Only unassigned projects } // Filter by competition category if (competitionCategory) { where.competitionCategory = competitionCategory } // Search in title, teamName, description if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, ] } // Execute queries in parallel const [projects, total] = await Promise.all([ ctx.prisma.project.findMany({ where, skip, take: perPage, orderBy: { createdAt: 'desc' }, select: { id: true, title: true, teamName: true, description: true, competitionCategory: true, oceanIssue: true, country: true, status: true, submittedAt: true, createdAt: true, tags: true, wantsMentorship: true, programId: true, _count: { select: { files: true, teamMembers: true, }, }, }, }), ctx.prisma.project.count({ where }), ]) return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage), } }), /** * Bulk assign projects to a round * * Validates that: * - All projects exist * - All projects belong to the same program as the target round * - Round exists and belongs to a program * * Updates: * - Project.roundId * - Project.status to 'ASSIGNED' * - Creates ProjectStatusHistory records for each project * - Creates audit log */ assignToRound: adminProcedure .input( z.object({ projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once roundId: z.string(), }) ) .mutation(async ({ ctx, input }) => { const { projectIds, roundId } = input // Step 1: Fetch round to get programId const round = await ctx.prisma.round.findUnique({ where: { id: roundId }, select: { id: true, programId: true, name: true, }, }) if (!round) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found', }) } // Step 2: Fetch all projects to validate const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectIds }, }, select: { id: true, title: true, programId: true, }, }) // Validate all projects were found if (projects.length !== projectIds.length) { const foundIds = new Set(projects.map((p) => p.id)) const missingIds = projectIds.filter((id) => !foundIds.has(id)) throw new TRPCError({ code: 'BAD_REQUEST', message: `Some projects were not found: ${missingIds.join(', ')}`, }) } // Validate all projects belong to the same program as the round const invalidProjects = projects.filter( (p) => p.programId !== round.programId ) if (invalidProjects.length > 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Cannot assign projects from different programs. The following projects do not belong to the target program: ${invalidProjects .map((p) => p.title) .join(', ')}`, }) } // Step 3: Perform bulk assignment in a transaction const result = await ctx.prisma.$transaction(async (tx) => { // Update all projects const updatedProjects = await tx.project.updateMany({ where: { id: { in: projectIds }, }, data: { roundId: roundId, status: 'ASSIGNED', }, }) // Create status history records for each project await tx.projectStatusHistory.createMany({ data: projectIds.map((projectId) => ({ projectId, status: 'ASSIGNED', changedBy: ctx.user?.id, })), }) // Create audit log await logAudit({ prisma: tx, userId: ctx.user?.id, action: 'BULK_ASSIGN_TO_ROUND', entityType: 'Project', detailsJson: { roundId, roundName: round.name, projectCount: projectIds.length, projectIds, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updatedProjects }) return { success: true, assignedCount: result.count, roundId, roundName: round.name, } }), })