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
This commit is contained in:
205
src/server/routers/ranking.ts
Normal file
205
src/server/routers/ranking.ts
Normal file
@@ -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<ParsedRankingRule[]> => {
|
||||
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
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user