From 7c4dffaf8424e56dc14fec2ba00a596c8c9adbde Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 27 Feb 2026 00:57:57 +0100 Subject: [PATCH] feat(01-03): create tRPC rankingRouter with 5 admin-gated procedures - parseRankingCriteria: parse natural-language criteria, returns ParsedRankingRule[] - executeRanking: fetch+rank both categories, persist CONFIRMED RankingSnapshot - quickRank: parse+execute in one step, persist QUICK RankingSnapshot - listSnapshots: list snapshots for a round ordered by createdAt desc - getSnapshot: fetch full snapshot by ID with NOT_FOUND guard - All procedures use adminProcedure (SUPER_ADMIN, PROGRAM_ADMIN only) - Casts to Prisma.InputJsonValue via unknown to satisfy strict TS --- src/server/routers/ranking.ts | 205 ++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/server/routers/ranking.ts diff --git a/src/server/routers/ranking.ts b/src/server/routers/ranking.ts new file mode 100644 index 0000000..7253e2f --- /dev/null +++ b/src/server/routers/ranking.ts @@ -0,0 +1,205 @@ +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 + }), +})