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' // ─── 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 }), })