import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' /** * Project Pool Router * * Manages the project pool for assigning projects to competition rounds. * Shows all projects by default, with optional filtering for unassigned-only * or projects not yet in a specific round. */ export const projectPoolRouter = router({ /** * List projects in the pool with filtering and pagination. * By default shows ALL projects. Use filters to narrow: * - unassignedOnly: true → only projects not in any round * - excludeRoundId: "..." → only projects not already in that round */ listUnassigned: adminProcedure .input( z.object({ programId: z.string(), competitionCategory: z .enum(['STARTUP', 'BUSINESS_CONCEPT']) .optional(), search: z.string().optional(), unassignedOnly: z.boolean().optional().default(false), excludeRoundId: z.string().optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(200).default(50), }) ) .query(async ({ ctx, input }) => { const { programId, competitionCategory, search, unassignedOnly, excludeRoundId, page, perPage } = input const skip = (page - 1) * perPage // Build where clause const where: Record = { programId, } // Optional: only show projects not in any round if (unassignedOnly) { where.projectRoundStates = { none: {} } } // Optional: exclude projects already in a specific round if (excludeRoundId && !unassignedOnly) { where.projectRoundStates = { none: { roundId: excludeRoundId }, } } // Filter by competition category if (competitionCategory) { where.competitionCategory = competitionCategory } // Search in title, teamName, description, institution, country, geographicZone, team member names/emails if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, { institution: { contains: search, mode: 'insensitive' } }, { country: { contains: search, mode: 'insensitive' } }, { geographicZone: { contains: search, mode: 'insensitive' } }, { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } }, { teamMembers: { some: { user: { email: { 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, }, }, projectRoundStates: { select: { roundId: true, state: true, round: { select: { name: true, roundType: true, sortOrder: true, }, }, }, orderBy: { round: { sortOrder: 'asc' }, }, }, }, }), ctx.prisma.project.count({ where }), ]) return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage), } }), /** * Bulk assign projects to a round */ assignToRound: adminProcedure .input( z.object({ projectIds: z.array(z.string()).min(1).max(1000), 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, name: 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, })), }) return updatedProjects }) // Audit outside transaction so failures don't roll back the assignment await logAudit({ prisma: ctx.prisma, userId: ctx.user?.id, action: 'BULK_ASSIGN_TO_ROUND', entityType: 'Project', detailsJson: { roundId, projectCount: projectIds.length, projectIds, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { success: true, assignedCount: result.count, roundId, } }), /** * Assign ALL matching projects in a program to a round (server-side, no ID limit). * Skips projects already in the target round. */ assignAllToRound: adminProcedure .input( z.object({ programId: z.string(), roundId: z.string(), competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(), unassignedOnly: z.boolean().optional().default(false), }) ) .mutation(async ({ ctx, input }) => { const { programId, roundId, competitionCategory, unassignedOnly } = input // Verify round exists await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { id: true }, }) // Find projects to assign const where: Record = { programId, } if (unassignedOnly) { // Only projects not in any round where.projectRoundStates = { none: {} } } else { // All projects not already in the target round where.projectRoundStates = { none: { roundId }, } } if (competitionCategory) { where.competitionCategory = competitionCategory } const projects = await ctx.prisma.project.findMany({ where, select: { id: true }, }) if (projects.length === 0) { return { success: true, assignedCount: 0, roundId } } const projectIds = projects.map((p) => p.id) const result = await ctx.prisma.$transaction(async (tx) => { await tx.projectRoundState.createMany({ data: projectIds.map((projectId) => ({ projectId, roundId })), skipDuplicates: true, }) const updated = await tx.project.updateMany({ where: { id: { in: projectIds } }, data: { status: 'ASSIGNED' }, }) await tx.projectStatusHistory.createMany({ data: projectIds.map((projectId) => ({ projectId, status: 'ASSIGNED', changedBy: ctx.user?.id, })), }) return updated }) // Audit outside transaction so failures don't roll back the assignment await logAudit({ prisma: ctx.prisma, userId: ctx.user?.id, action: 'BULK_ASSIGN_ALL_TO_ROUND', entityType: 'Project', detailsJson: { roundId, programId, competitionCategory: competitionCategory || 'ALL', unassignedOnly, projectCount: projectIds.length, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { success: true, assignedCount: result.count, roundId } }), /** * List projects in a specific round (for import-from-round picker). */ getProjectsInRound: adminProcedure .input( z.object({ roundId: z.string(), states: z.array(z.string()).optional(), search: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const { roundId, states, search } = input const where: Record = { roundId } if (states && states.length > 0) { where.state = { in: states } } if (search?.trim()) { where.project = { OR: [ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } }, { teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } }, ], } } const projectStates = await ctx.prisma.projectRoundState.findMany({ where, select: { projectId: true, state: true, project: { select: { id: true, title: true, teamName: true, competitionCategory: true, country: true, }, }, }, orderBy: { project: { title: 'asc' } }, }) return projectStates.map((ps) => ({ id: ps.project.id, title: ps.project.title, teamName: ps.project.teamName, competitionCategory: ps.project.competitionCategory, country: ps.project.country, state: ps.state, })) }), /** * Import projects from an earlier round into a later round. * Fills intermediate rounds with COMPLETED states to keep history clean. */ importFromRound: adminProcedure .input( z.object({ sourceRoundId: z.string(), targetRoundId: z.string(), projectIds: z.array(z.string()).min(1).max(1000), }) ) .mutation(async ({ ctx, input }) => { const { sourceRoundId, targetRoundId, projectIds } = input // Validate both rounds exist and belong to the same competition const [sourceRound, targetRound] = await Promise.all([ ctx.prisma.round.findUniqueOrThrow({ where: { id: sourceRoundId }, select: { id: true, name: true, competitionId: true, sortOrder: true }, }), ctx.prisma.round.findUniqueOrThrow({ where: { id: targetRoundId }, select: { id: true, name: true, competitionId: true, sortOrder: true }, }), ]) if (sourceRound.competitionId !== targetRound.competitionId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Source and target rounds must belong to the same competition', }) } if (sourceRound.sortOrder >= targetRound.sortOrder) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Source round must come before target round', }) } // Validate all projectIds exist in the source round const sourceStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId: sourceRoundId, projectId: { in: projectIds } }, select: { projectId: true, state: true }, }) if (sourceStates.length !== projectIds.length) { const foundIds = new Set(sourceStates.map((s) => s.projectId)) const missing = projectIds.filter((id) => !foundIds.has(id)) throw new TRPCError({ code: 'BAD_REQUEST', message: `${missing.length} project(s) not found in source round`, }) } // Find intermediate rounds const intermediateRounds = await ctx.prisma.round.findMany({ where: { competitionId: sourceRound.competitionId, sortOrder: { gt: sourceRound.sortOrder, lt: targetRound.sortOrder }, }, select: { id: true }, orderBy: { sortOrder: 'asc' }, }) // Check which projects are already in the target round const existingInTarget = await ctx.prisma.projectRoundState.findMany({ where: { roundId: targetRoundId, projectId: { in: projectIds } }, select: { projectId: true }, }) const alreadyInTarget = new Set(existingInTarget.map((e) => e.projectId)) const toImport = projectIds.filter((id) => !alreadyInTarget.has(id)) if (toImport.length === 0) { return { imported: 0, skipped: projectIds.length } } await ctx.prisma.$transaction(async (tx) => { // Update source round states to COMPLETED (if PASSED or PENDING) await tx.projectRoundState.updateMany({ where: { roundId: sourceRoundId, projectId: { in: toImport }, state: { in: ['PASSED', 'PENDING', 'IN_PROGRESS'] }, }, data: { state: 'COMPLETED' }, }) // Create COMPLETED states for intermediate rounds if (intermediateRounds.length > 0) { const intermediateData = intermediateRounds.flatMap((round) => toImport.map((projectId) => ({ projectId, roundId: round.id, state: 'COMPLETED' as const, })) ) await tx.projectRoundState.createMany({ data: intermediateData, skipDuplicates: true, }) } // Create PENDING states in the target round await tx.projectRoundState.createMany({ data: toImport.map((projectId) => ({ projectId, roundId: targetRoundId, })), skipDuplicates: true, }) // Update project status to ASSIGNED await tx.project.updateMany({ where: { id: { in: toImport } }, data: { status: 'ASSIGNED' }, }) // Create status history records await tx.projectStatusHistory.createMany({ data: toImport.map((projectId) => ({ projectId, status: 'ASSIGNED', changedBy: ctx.user?.id, })), }) }) await logAudit({ prisma: ctx.prisma, userId: ctx.user?.id, action: 'IMPORT_FROM_ROUND', entityType: 'Project', detailsJson: { sourceRoundId, sourceRoundName: sourceRound.name, targetRoundId, targetRoundName: targetRound.name, importedCount: toImport.length, skippedCount: alreadyInTarget.size, intermediateRounds: intermediateRounds.length, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { imported: toImport.length, skipped: alreadyInTarget.size } }), })