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 any stage). * Provides procedures for listing unassigned projects and bulk assigning them to stages. */ export const projectPoolRouter = router({ /** * List unassigned projects with filtering and pagination * Projects not assigned to any round */ 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, projectRoundStates: { none: {} }, // Only unassigned projects (not in any round) } // 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 * - Round exists * * Creates: * - RoundAssignment entries for each project * - Project.status updated to 'ASSIGNED' * - ProjectStatusHistory records for each project * - 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 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(', ')}`, }) } // Verify round exists const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { id: true }, }) // Step 2: Perform bulk assignment in a transaction const result = await ctx.prisma.$transaction(async (tx) => { // Create ProjectRoundState entries for each project (skip existing) const assignmentData = projectIds.map((projectId) => ({ projectId, roundId, })) await tx.projectRoundState.createMany({ data: assignmentData, skipDuplicates: true, }) // Update project statuses const updatedProjects = await tx.project.updateMany({ where: { id: { in: projectIds }, }, data: { 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, projectCount: projectIds.length, projectIds, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updatedProjects }) return { success: true, assignedCount: result.count, roundId, } }), })