import { router, adminProcedure } from '../trpc' import { z } from 'zod' import { TRPCError } from '@trpc/server' import type { Prisma } from '@prisma/client' import { parseRankingCriteria, executeAIRanking, quickRank as aiQuickRank, fetchAndRankCategory, type ParsedRankingRule, } from '../services/ai-ranking' import { logAudit } from '../utils/audit' import type { EvaluationConfig } from '@/types/competition-configs' // ─── Zod Schemas ────────────────────────────────────────────────────────────── const ParsedRuleSchema = z.object({ step: z.number().int(), type: z.enum(['filter', 'sort', 'limit']), description: z.string(), field: z.enum(['pass_rate', 'avg_score', 'evaluator_count']).nullable(), operator: z.enum(['gte', 'lte', 'eq', 'top_n']).nullable(), value: z.number().nullable(), dataAvailable: z.boolean(), }) // ─── Router ─────────────────────────────────────────────────────────────────── export const rankingRouter = router({ /** * Parse natural-language criteria into structured rules (preview mode). * RANK-01, RANK-02, RANK-03 — admin reviews parsed rules before executing. */ parseRankingCriteria: adminProcedure .input( z.object({ roundId: z.string(), criteriaText: z.string().min(1).max(5000), }), ) .mutation(async ({ ctx, input }): Promise => { const rules = await parseRankingCriteria(input.criteriaText, ctx.user.id, input.roundId) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'RANKING_CRITERIA_PARSED', entityType: 'Round', entityId: input.roundId, detailsJson: { ruleCount: rules.length }, }) return rules }), /** * Execute ranking using pre-parsed rules (confirmed mode). * Fetches both categories in parallel, persists a RankingSnapshot. * RANK-05, RANK-06, RANK-08. */ executeRanking: adminProcedure .input( z.object({ roundId: z.string(), criteriaText: z.string(), parsedRules: z.array(ParsedRuleSchema), }), ) .mutation(async ({ ctx, input }) => { // Cast to service type — validated by Zod above const rules = input.parsedRules as ParsedRankingRule[] // Fetch and rank both categories in parallel const [startup, concept] = await Promise.all([ fetchAndRankCategory('STARTUP', rules, input.roundId, ctx.prisma, ctx.user.id), fetchAndRankCategory('BUSINESS_CONCEPT', rules, input.roundId, ctx.prisma, ctx.user.id), ]) // Persist snapshot const snapshot = await ctx.prisma.rankingSnapshot.create({ data: { roundId: input.roundId, triggeredById: ctx.user.id, triggerType: 'MANUAL', mode: 'CONFIRMED', status: 'COMPLETED', criteriaText: input.criteriaText, parsedRulesJson: rules as unknown as Prisma.InputJsonValue, startupRankingJson: startup.rankedProjects as unknown as Prisma.InputJsonValue, conceptRankingJson: concept.rankedProjects as unknown as Prisma.InputJsonValue, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'RANKING_EXECUTED', entityType: 'RankingSnapshot', entityId: snapshot.id, detailsJson: { roundId: input.roundId, startupCount: startup.rankedProjects.length, conceptCount: concept.rankedProjects.length, }, }) return { snapshotId: snapshot.id, startup, concept } }), /** * Quick rank: parse criteria and execute in one step (RANK-04). * Persists a RankingSnapshot with mode=QUICK. */ quickRank: adminProcedure .input( z.object({ roundId: z.string(), criteriaText: z.string().min(1).max(5000), }), ) .mutation(async ({ ctx, input }) => { const { startup, concept, parsedRules } = await aiQuickRank( input.criteriaText, input.roundId, ctx.prisma, ctx.user.id, ) // Persist snapshot const snapshot = await ctx.prisma.rankingSnapshot.create({ data: { roundId: input.roundId, triggeredById: ctx.user.id, triggerType: 'QUICK', mode: 'QUICK', status: 'COMPLETED', criteriaText: input.criteriaText, parsedRulesJson: parsedRules as unknown as Prisma.InputJsonValue, startupRankingJson: startup.rankedProjects as unknown as Prisma.InputJsonValue, conceptRankingJson: concept.rankedProjects as unknown as Prisma.InputJsonValue, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'RANKING_QUICK_EXECUTED', entityType: 'RankingSnapshot', entityId: snapshot.id, detailsJson: { roundId: input.roundId, startupCount: startup.rankedProjects.length, conceptCount: concept.rankedProjects.length, }, }) return { snapshotId: snapshot.id, startup, concept, parsedRules } }), /** * List all ranking snapshots for a round (Phase 2 dashboard prep). * Ordered by createdAt desc — newest first. */ listSnapshots: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.rankingSnapshot.findMany({ where: { roundId: input.roundId }, orderBy: { createdAt: 'desc' }, select: { id: true, triggeredById: true, triggerType: true, mode: true, status: true, criteriaText: true, tokensUsed: true, createdAt: true, triggeredBy: { select: { name: true }, }, }, }) }), /** * Get a single ranking snapshot by ID (Phase 2 dashboard prep). * Returns full snapshot including ranking results. */ getSnapshot: adminProcedure .input(z.object({ snapshotId: z.string() })) .query(async ({ ctx, input }) => { const snapshot = await ctx.prisma.rankingSnapshot.findUnique({ where: { id: input.snapshotId }, }) if (!snapshot) { throw new TRPCError({ code: 'NOT_FOUND', message: `Ranking snapshot ${input.snapshotId} not found`, }) } return snapshot }), /** * RANK-09 — Manual trigger for auto-rank (admin button on round detail page). * Reads ranking criteria from round configJson and executes quickRank. */ triggerAutoRank: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const { roundId } = input const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { id: true, name: true, configJson: true, competition: { select: { id: true } } }, }) const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig) const criteriaText = config?.rankingCriteria ?? null if (!criteriaText) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No ranking criteria configured for this round. Add criteria in round settings first.', }) } const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id) const snapshot = await ctx.prisma.rankingSnapshot.create({ data: { roundId, triggeredById: ctx.user.id, triggerType: 'MANUAL', criteriaText, parsedRulesJson: result.parsedRules as unknown as Prisma.InputJsonValue, startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue, conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue, mode: 'QUICK', status: 'COMPLETED', }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'RANKING_MANUAL_TRIGGERED', entityType: 'RankingSnapshot', entityId: snapshot.id, detailsJson: { roundId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { snapshotId: snapshot.id, startup: result.startup, concept: result.concept } }), /** * RANK-10 — Retroactive scan: finds all active/closed rounds with autoRankOnComplete * configured but no RETROACTIVE snapshot yet, then executes ranking for each. * Runs sequentially to avoid hammering OpenAI. */ retroactiveScan: adminProcedure .input(z.object({})) .mutation(async ({ ctx }) => { const rounds = await ctx.prisma.round.findMany({ where: { status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } }, select: { id: true, name: true, configJson: true }, }) const results: Array<{ roundId: string; triggered: boolean; reason?: string }> = [] for (const round of rounds) { const config = (round.configJson as EvaluationConfig | null) ?? ({} as EvaluationConfig) const autoRankEnabled = config?.autoRankOnComplete ?? false const criteriaText = config?.rankingCriteria ?? null if (!autoRankEnabled || !criteriaText) { results.push({ roundId: round.id, triggered: false, reason: 'auto-rank not configured' }) continue } // Check if fully evaluated const [total, completed] = await Promise.all([ ctx.prisma.assignment.count({ where: { roundId: round.id, isRequired: true } }), ctx.prisma.assignment.count({ where: { roundId: round.id, isRequired: true, isCompleted: true } }), ]) if (total === 0 || total !== completed) { results.push({ roundId: round.id, triggered: false, reason: `${completed}/${total} assignments complete`, }) continue } // Check if a RETROACTIVE snapshot already exists const existing = await ctx.prisma.rankingSnapshot.findFirst({ where: { roundId: round.id, triggerType: 'RETROACTIVE' }, select: { id: true }, }) if (existing) { results.push({ roundId: round.id, triggered: false, reason: 'retroactive snapshot already exists' }) continue } // Execute ranking sequentially to avoid rate limits try { const result = await aiQuickRank(criteriaText, round.id, ctx.prisma, ctx.user.id) await ctx.prisma.rankingSnapshot.create({ data: { roundId: round.id, triggeredById: ctx.user.id, triggerType: 'RETROACTIVE', criteriaText, parsedRulesJson: result.parsedRules as unknown as Prisma.InputJsonValue, startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue, conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue, mode: 'QUICK', status: 'COMPLETED', }, }) results.push({ roundId: round.id, triggered: true }) } catch (err) { results.push({ roundId: round.id, triggered: false, reason: String(err) }) } } await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'RANKING_RETROACTIVE_SCAN', entityType: 'Round', detailsJson: { results }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { results, total: results.length, triggered: results.filter((r) => r.triggered).length, } }), })