import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, adminProcedure, protectedProcedure } from '../trpc' import { activateRound, closeRound, archiveRound, reopenRound, transitionProject, batchTransitionProjects, getProjectRoundStates, getProjectRoundState, } from '../services/round-engine' const projectRoundStateEnum = z.enum([ 'PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN', ]) export const roundEngineRouter = router({ /** * Activate a round: ROUND_DRAFT → ROUND_ACTIVE */ activate: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await activateRound(input.roundId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to activate round', }) } return result }), /** * Close a round: ROUND_ACTIVE → ROUND_CLOSED */ close: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await closeRound(input.roundId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to close round', }) } return result }), /** * Reopen a round: ROUND_CLOSED → ROUND_ACTIVE * Pauses any subsequent active rounds in the same competition. */ reopen: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await reopenRound(input.roundId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to reopen round', }) } return result }), /** * Archive a round: ROUND_CLOSED → ROUND_ARCHIVED */ archive: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await archiveRound(input.roundId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to archive round', }) } return result }), /** * Transition a single project within a round */ transitionProject: adminProcedure .input( z.object({ projectId: z.string(), roundId: z.string(), newState: projectRoundStateEnum, }) ) .mutation(async ({ ctx, input }) => { const result = await transitionProject( input.projectId, input.roundId, input.newState, ctx.user.id, ctx.prisma, ) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to transition project', }) } return result }), /** * Batch transition multiple projects within a round */ batchTransition: adminProcedure .input( z.object({ projectIds: z.array(z.string()).min(1), roundId: z.string(), newState: projectRoundStateEnum, }) ) .mutation(async ({ ctx, input }) => { return batchTransitionProjects( input.projectIds, input.roundId, input.newState, ctx.user.id, ctx.prisma, ) }), /** * Get all project round states for a round */ getProjectStates: protectedProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { return getProjectRoundStates(input.roundId, ctx.prisma) }), /** * Get a single project's state within a round */ getProjectState: protectedProcedure .input( z.object({ projectId: z.string(), roundId: z.string(), }) ) .query(async ({ ctx, input }) => { return getProjectRoundState(input.projectId, input.roundId, ctx.prisma) }), /** * Remove a project from a round (and all subsequent rounds in that competition). * The project remains in all prior rounds. */ removeFromRound: adminProcedure .input( z.object({ projectId: z.string(), roundId: z.string(), }) ) .mutation(async ({ ctx, input }) => { // Get the round to know its competition and sort order const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { id: true, competitionId: true, sortOrder: true }, }) // Find all rounds at this sort order or later in the same competition const roundsToRemoveFrom = await ctx.prisma.round.findMany({ where: { competitionId: round.competitionId, sortOrder: { gte: round.sortOrder }, }, select: { id: true }, }) const roundIds = roundsToRemoveFrom.map((r) => r.id) // Delete ProjectRoundState entries for this project in all affected rounds const deleted = await ctx.prisma.projectRoundState.deleteMany({ where: { projectId: input.projectId, roundId: { in: roundIds }, }, }) // Check if the project is still in any round at all const remainingStates = await ctx.prisma.projectRoundState.count({ where: { projectId: input.projectId }, }) // If no longer in any round, reset project status back to SUBMITTED if (remainingStates === 0) { await ctx.prisma.project.update({ where: { id: input.projectId }, data: { status: 'SUBMITTED' }, }) } return { success: true, removedFromRounds: deleted.count } }), /** * Batch remove projects from a round (and all subsequent rounds). */ batchRemoveFromRound: adminProcedure .input( z.object({ projectIds: z.array(z.string()).min(1), roundId: z.string(), }) ) .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { id: true, competitionId: true, sortOrder: true }, }) const roundsToRemoveFrom = await ctx.prisma.round.findMany({ where: { competitionId: round.competitionId, sortOrder: { gte: round.sortOrder }, }, select: { id: true }, }) const roundIds = roundsToRemoveFrom.map((r) => r.id) const deleted = await ctx.prisma.projectRoundState.deleteMany({ where: { projectId: { in: input.projectIds }, roundId: { in: roundIds }, }, }) // For projects with no remaining round states, reset to SUBMITTED const projectsStillInRounds = await ctx.prisma.projectRoundState.findMany({ where: { projectId: { in: input.projectIds } }, select: { projectId: true }, distinct: ['projectId'], }) const stillInRoundIds = new Set(projectsStillInRounds.map((p) => p.projectId)) const orphanedIds = input.projectIds.filter((id) => !stillInRoundIds.has(id)) if (orphanedIds.length > 0) { await ctx.prisma.project.updateMany({ where: { id: { in: orphanedIds } }, data: { status: 'SUBMITTED' }, }) } return { success: true, removedCount: deleted.count } }), /** * Retroactive document check: auto-PASS any PENDING/IN_PROGRESS projects * that already have all required documents uploaded for this round. * Useful for rounds activated before the auto-transition feature was deployed. */ checkDocumentCompletion: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const { batchCheckRequirementsAndTransition } = await import('../services/round-engine') const projectStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId: input.roundId, state: { in: ['PENDING', 'IN_PROGRESS'] }, }, select: { projectId: true }, }) if (projectStates.length === 0) { return { transitionedCount: 0, checkedCount: 0, projectIds: [] } } const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId) const result = await batchCheckRequirementsAndTransition( input.roundId, projectIds, ctx.user.id, ctx.prisma, ) return { transitionedCount: result.transitionedCount, checkedCount: projectIds.length, projectIds: result.projectIds, } }), })