import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, adminProcedure, protectedProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs' import { generateShortlist } from '../services/ai-shortlist' import { openWindow, closeWindow, lockWindow, checkDeadlinePolicy, validateSubmission, getVisibleWindows, } from '../services/submission-manager' const roundTypeEnum = z.enum([ 'INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION', ]) export const roundRouter = router({ /** * Create a new round within a competition */ create: adminProcedure .input( z.object({ competitionId: z.string(), name: z.string().min(1).max(255), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), roundType: roundTypeEnum, sortOrder: z.number().int().nonnegative(), configJson: z.record(z.unknown()).optional(), windowOpenAt: z.date().nullable().optional(), windowCloseAt: z.date().nullable().optional(), juryGroupId: z.string().nullable().optional(), submissionWindowId: z.string().nullable().optional(), purposeKey: z.string().nullable().optional(), }) ) .mutation(async ({ ctx, input }) => { // Verify competition exists await ctx.prisma.competition.findUniqueOrThrow({ where: { id: input.competitionId }, }) // Validate configJson against the Zod schema for this roundType const config = input.configJson ? validateRoundConfig(input.roundType, input.configJson) : defaultRoundConfig(input.roundType) const round = await ctx.prisma.round.create({ data: { competitionId: input.competitionId, name: input.name, slug: input.slug, roundType: input.roundType, sortOrder: input.sortOrder, configJson: config as unknown as Prisma.InputJsonValue, windowOpenAt: input.windowOpenAt ?? undefined, windowCloseAt: input.windowCloseAt ?? undefined, juryGroupId: input.juryGroupId ?? undefined, submissionWindowId: input.submissionWindowId ?? undefined, purposeKey: input.purposeKey ?? undefined, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'Round', entityId: round.id, detailsJson: { name: input.name, roundType: input.roundType, competitionId: input.competitionId, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return round }), /** * Get round by ID with all relations */ getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUnique({ where: { id: input.id }, include: { juryGroup: { include: { members: true }, }, submissionWindow: { include: { fileRequirements: true }, }, advancementRules: { orderBy: { sortOrder: 'asc' } }, visibleSubmissionWindows: { include: { submissionWindow: true }, }, _count: { select: { projectRoundStates: true }, }, }, }) if (!round) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Round not found' }) } return round }), /** * Update round settings/config */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(), status: z.enum(['ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED']).optional(), configJson: z.record(z.unknown()).optional(), windowOpenAt: z.date().nullable().optional(), windowCloseAt: z.date().nullable().optional(), juryGroupId: z.string().nullable().optional(), submissionWindowId: z.string().nullable().optional(), purposeKey: z.string().nullable().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, configJson, ...data } = input const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } }) // If configJson provided, validate it against the round type let validatedConfig: Prisma.InputJsonValue | undefined if (configJson) { const parsed = validateRoundConfig(existing.roundType, configJson) validatedConfig = parsed as unknown as Prisma.InputJsonValue } const round = await ctx.prisma.round.update({ where: { id }, data: { ...data, ...(validatedConfig !== undefined ? { configJson: validatedConfig } : {}), }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'Round', entityId: id, detailsJson: { changes: input, previous: { name: existing.name, status: existing.status, }, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return round }), /** * Reorder rounds within a competition */ updateOrder: adminProcedure .input( z.object({ competitionId: z.string(), roundIds: z.array(z.string()), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.$transaction( input.roundIds.map((roundId, index) => ctx.prisma.round.update({ where: { id: roundId }, data: { sortOrder: index }, }) ) ) }), /** * Delete a round */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.id } }) await ctx.prisma.round.delete({ where: { id: input.id } }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'Round', entityId: input.id, detailsJson: { name: existing.name, roundType: existing.roundType, competitionId: existing.competitionId, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return existing }), // ========================================================================= // Project Advancement (Manual Only) // ========================================================================= /** * Advance PASSED projects from one round to the next. * This is ALWAYS manual — no auto-advancement after AI filtering. * Admin must explicitly trigger this after reviewing results. */ advanceProjects: adminProcedure .input( z.object({ roundId: z.string(), targetRoundId: z.string().optional(), projectIds: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { const { roundId, targetRoundId, projectIds } = input // Get current round with competition context const currentRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { id: true, name: true, competitionId: true, sortOrder: true }, }) // Determine target round let targetRound: { id: string; name: string } if (targetRoundId) { targetRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: targetRoundId }, select: { id: true, name: true }, }) } else { // Find next round in same competition by sortOrder const nextRound = await ctx.prisma.round.findFirst({ where: { competitionId: currentRound.competitionId, sortOrder: { gt: currentRound.sortOrder }, }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true }, }) if (!nextRound) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No subsequent round exists in this competition. Create the next round first.', }) } targetRound = nextRound } // Determine which projects to advance let idsToAdvance: string[] if (projectIds && projectIds.length > 0) { idsToAdvance = projectIds } else { // Default: all PASSED projects in current round const passedStates = await ctx.prisma.projectRoundState.findMany({ where: { roundId, state: 'PASSED' }, select: { projectId: true }, }) idsToAdvance = passedStates.map((s) => s.projectId) } if (idsToAdvance.length === 0) { return { advancedCount: 0, targetRoundId: targetRound.id, targetRoundName: targetRound.name } } // Transaction: create entries in target round + mark current as COMPLETED await ctx.prisma.$transaction(async (tx) => { // Create ProjectRoundState in target round await tx.projectRoundState.createMany({ data: idsToAdvance.map((projectId) => ({ projectId, roundId: targetRound.id, })), skipDuplicates: true, }) // Mark current round states as COMPLETED await tx.projectRoundState.updateMany({ where: { roundId, projectId: { in: idsToAdvance }, state: 'PASSED', }, data: { state: 'COMPLETED' }, }) // Update project status to ASSIGNED await tx.project.updateMany({ where: { id: { in: idsToAdvance } }, data: { status: 'ASSIGNED' }, }) // Status history await tx.projectStatusHistory.createMany({ data: idsToAdvance.map((projectId) => ({ projectId, status: 'ASSIGNED', changedBy: ctx.user?.id, })), }) }) // Audit await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'ADVANCE_PROJECTS', entityType: 'Round', entityId: roundId, detailsJson: { fromRound: currentRound.name, toRound: targetRound.name, targetRoundId: targetRound.id, projectCount: idsToAdvance.length, projectIds: idsToAdvance, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { advancedCount: idsToAdvance.length, targetRoundId: targetRound.id, targetRoundName: targetRound.name, } }), // ========================================================================= // AI Shortlist Recommendations // ========================================================================= /** * Generate AI-powered shortlist recommendations for a round. * Runs independently for STARTUP and BUSINESS_CONCEPT categories. * Uses per-round config for advancement targets and file parsing. */ generateAIRecommendations: adminProcedure .input( z.object({ roundId: z.string(), rubric: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { id: true, name: true, competitionId: true, configJson: true, }, }) const config = (round.configJson as Record) ?? {} const startupTopN = (config.startupAdvanceCount as number) || 10 const conceptTopN = (config.conceptAdvanceCount as number) || 10 const aiParseFiles = !!config.aiParseFiles const result = await generateShortlist( { roundId: input.roundId, competitionId: round.competitionId, startupTopN, conceptTopN, rubric: input.rubric, aiParseFiles, }, ctx.prisma, ) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'AI_SHORTLIST', entityType: 'Round', entityId: input.roundId, detailsJson: { roundName: round.name, startupTopN, conceptTopN, aiParseFiles, success: result.success, startupCount: result.recommendations.STARTUP.length, conceptCount: result.recommendations.BUSINESS_CONCEPT.length, tokensUsed: result.tokensUsed, errors: result.errors, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return result }), // ========================================================================= // Submission Window Management // ========================================================================= /** * Create a submission window for a round */ createSubmissionWindow: adminProcedure .input( z.object({ competitionId: z.string(), name: z.string().min(1).max(255), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), roundNumber: z.number().int().min(1), windowOpenAt: z.date().optional(), windowCloseAt: z.date().optional(), deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).default('HARD_DEADLINE'), graceHours: z.number().int().min(0).optional(), lockOnClose: z.boolean().default(true), }) ) .mutation(async ({ ctx, input }) => { const window = await ctx.prisma.submissionWindow.create({ data: { competitionId: input.competitionId, name: input.name, slug: input.slug, roundNumber: input.roundNumber, windowOpenAt: input.windowOpenAt, windowCloseAt: input.windowCloseAt, deadlinePolicy: input.deadlinePolicy, graceHours: input.graceHours, lockOnClose: input.lockOnClose, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'SubmissionWindow', entityId: window.id, detailsJson: { name: input.name, competitionId: input.competitionId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return window }), /** * Update an existing submission window */ updateSubmissionWindow: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).max(255).optional(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(), roundNumber: z.number().int().min(1).optional(), windowOpenAt: z.date().nullable().optional(), windowCloseAt: z.date().nullable().optional(), deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).optional(), graceHours: z.number().int().min(0).nullable().optional(), lockOnClose: z.boolean().optional(), sortOrder: z.number().int().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input const window = await ctx.prisma.submissionWindow.update({ where: { id }, data, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'SubmissionWindow', entityId: id, detailsJson: data, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return window }), /** * Delete a submission window (only if no files uploaded) */ deleteSubmissionWindow: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Check if window has uploaded files const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({ where: { id: input.id }, select: { id: true, name: true, _count: { select: { projectFiles: true } } }, }) if (window._count.projectFiles > 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Cannot delete window "${window.name}" — it has ${window._count.projectFiles} uploaded files. Remove files first.`, }) } await ctx.prisma.submissionWindow.delete({ where: { id: input.id } }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'SubmissionWindow', entityId: input.id, detailsJson: { name: window.name }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { success: true } }), /** * Open a submission window */ openSubmissionWindow: adminProcedure .input(z.object({ windowId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await openWindow(input.windowId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to open window', }) } return result }), /** * Close a submission window */ closeSubmissionWindow: adminProcedure .input(z.object({ windowId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await closeWindow(input.windowId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to close window', }) } return result }), /** * Lock a submission window */ lockSubmissionWindow: adminProcedure .input(z.object({ windowId: z.string() })) .mutation(async ({ ctx, input }) => { const result = await lockWindow(input.windowId, ctx.user.id, ctx.prisma) if (!result.success) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.errors?.join('; ') ?? 'Failed to lock window', }) } return result }), /** * Check deadline status of a window */ checkDeadline: protectedProcedure .input(z.object({ windowId: z.string() })) .query(async ({ ctx, input }) => { return checkDeadlinePolicy(input.windowId, ctx.prisma) }), /** * Validate files against window requirements */ validateSubmission: protectedProcedure .input( z.object({ projectId: z.string(), windowId: z.string(), files: z.array( z.object({ mimeType: z.string(), size: z.number(), requirementId: z.string().optional(), }) ), }) ) .mutation(async ({ ctx, input }) => { return validateSubmission(input.projectId, input.windowId, input.files, ctx.prisma) }), /** * Get visible submission windows for a round */ getVisibleWindows: protectedProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { return getVisibleWindows(input.roundId, ctx.prisma) }), // ========================================================================= // File Requirements Management // ========================================================================= /** * Create a file requirement for a submission window */ createFileRequirement: adminProcedure .input( z.object({ submissionWindowId: z.string(), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), label: z.string().min(1).max(255), description: z.string().max(2000).optional(), mimeTypes: z.array(z.string()).default([]), maxSizeMb: z.number().int().min(0).optional(), required: z.boolean().default(false), sortOrder: z.number().int().default(0), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.submissionFileRequirement.create({ data: input, }) }), /** * Update a file requirement */ updateFileRequirement: adminProcedure .input( z.object({ id: z.string(), label: z.string().min(1).max(255).optional(), description: z.string().max(2000).optional().nullable(), mimeTypes: z.array(z.string()).optional(), maxSizeMb: z.number().min(0).optional().nullable(), required: z.boolean().optional(), sortOrder: z.number().int().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input return ctx.prisma.submissionFileRequirement.update({ where: { id }, data, }) }), /** * Delete a file requirement */ deleteFileRequirement: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.prisma.submissionFileRequirement.delete({ where: { id: input.id }, }) }), /** * Get submission windows for applicants in a competition */ getApplicantWindows: protectedProcedure .input(z.object({ competitionId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.submissionWindow.findMany({ where: { competitionId: input.competitionId }, include: { fileRequirements: { orderBy: { sortOrder: 'asc' } }, }, orderBy: { sortOrder: 'asc' }, }) }), })