import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { applyAutoTagRules, aiInterpretCriteria, type AutoTagRule, } from '../services/ai-award-eligibility' export const specialAwardRouter = router({ // ─── Admin Queries ────────────────────────────────────────────────────── /** * List awards for a program */ list: protectedProcedure .input( z.object({ programId: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.specialAward.findMany({ where: input.programId ? { programId: input.programId } : {}, orderBy: { sortOrder: 'asc' }, include: { _count: { select: { eligibilities: true, jurors: true, votes: true, }, }, winnerProject: { select: { id: true, title: true, teamName: true }, }, }, }) }), /** * Get award detail with stats */ get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.id }, include: { _count: { select: { eligibilities: true, jurors: true, votes: true, }, }, winnerProject: { select: { id: true, title: true, teamName: true }, }, program: { select: { id: true, name: true, year: true }, }, }, }) // Count eligible projects const eligibleCount = await ctx.prisma.awardEligibility.count({ where: { awardId: input.id, eligible: true }, }) return { ...award, eligibleCount } }), // ─── Admin Mutations ──────────────────────────────────────────────────── /** * Create award */ create: adminProcedure .input( z.object({ programId: z.string(), name: z.string().min(1), description: z.string().optional(), criteriaText: z.string().optional(), useAiEligibility: z.boolean().optional(), scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']), maxRankedPicks: z.number().int().min(1).max(20).optional(), autoTagRulesJson: z.record(z.unknown()).optional(), }) ) .mutation(async ({ ctx, input }) => { const maxOrder = await ctx.prisma.specialAward.aggregate({ where: { programId: input.programId }, _max: { sortOrder: true }, }) const award = await ctx.prisma.specialAward.create({ data: { programId: input.programId, name: input.name, description: input.description, criteriaText: input.criteriaText, useAiEligibility: input.useAiEligibility ?? true, scoringMode: input.scoringMode, maxRankedPicks: input.maxRankedPicks, autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined, sortOrder: (maxOrder._max.sortOrder || 0) + 1, }, }) await logAudit({ userId: ctx.user.id, action: 'CREATE', entityType: 'SpecialAward', entityId: award.id, detailsJson: { name: input.name, scoringMode: input.scoringMode }, }) return award }), /** * Update award config */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().min(1).optional(), description: z.string().optional(), criteriaText: z.string().optional(), useAiEligibility: z.boolean().optional(), scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(), maxRankedPicks: z.number().int().min(1).max(20).optional(), autoTagRulesJson: z.record(z.unknown()).optional(), votingStartAt: z.date().optional(), votingEndAt: z.date().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, autoTagRulesJson, ...rest } = input const award = await ctx.prisma.specialAward.update({ where: { id }, data: { ...rest, ...(autoTagRulesJson !== undefined && { autoTagRulesJson: autoTagRulesJson as Prisma.InputJsonValue }), }, }) await logAudit({ userId: ctx.user.id, action: 'UPDATE', entityType: 'SpecialAward', entityId: id, }) return award }), /** * Delete award */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.prisma.specialAward.delete({ where: { id: input.id } }) await logAudit({ userId: ctx.user.id, action: 'DELETE', entityType: 'SpecialAward', entityId: input.id, }) }), /** * Update award status */ updateStatus: adminProcedure .input( z.object({ id: z.string(), status: z.enum([ 'DRAFT', 'NOMINATIONS_OPEN', 'VOTING_OPEN', 'CLOSED', 'ARCHIVED', ]), }) ) .mutation(async ({ ctx, input }) => { const current = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.id }, select: { status: true, votingStartAt: true, votingEndAt: true }, }) const now = new Date() // When opening voting, auto-set votingStartAt to now if it's in the future or not set let votingStartAtUpdated = false const updateData: Parameters[0]['data'] = { status: input.status, } if (input.status === 'VOTING_OPEN' && current.status !== 'VOTING_OPEN') { // If no voting start date, or if it's in the future, set it to 1 minute ago // to ensure voting is immediately open (avoids race condition with page render) if (!current.votingStartAt || current.votingStartAt > now) { const oneMinuteAgo = new Date(now.getTime() - 60 * 1000) updateData.votingStartAt = oneMinuteAgo votingStartAtUpdated = true } } const award = await ctx.prisma.specialAward.update({ where: { id: input.id }, data: updateData, }) await logAudit({ userId: ctx.user.id, action: 'UPDATE_STATUS', entityType: 'SpecialAward', entityId: input.id, detailsJson: { previousStatus: current.status, newStatus: input.status, ...(votingStartAtUpdated && { votingStartAtUpdated: true, previousVotingStartAt: current.votingStartAt, newVotingStartAt: now, }), }, }) return award }), // ─── Eligibility ──────────────────────────────────────────────────────── /** * Run auto-tag + AI eligibility */ runEligibility: adminProcedure .input(z.object({ awardId: z.string(), includeSubmitted: z.boolean().optional(), })) .mutation(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, include: { program: true }, }) // Get projects in the program's rounds const statusFilter = input.includeSubmitted ? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) : (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) const projects = await ctx.prisma.project.findMany({ where: { round: { programId: award.programId }, status: { in: [...statusFilter] }, }, select: { id: true, title: true, description: true, competitionCategory: true, country: true, geographicZone: true, tags: true, oceanIssue: true, }, }) if (projects.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No eligible projects found', }) } // Phase 1: Auto-tag rules (deterministic) const autoTagRules = award.autoTagRulesJson as unknown as AutoTagRule[] | null let autoResults: Map | undefined if (autoTagRules && Array.isArray(autoTagRules) && autoTagRules.length > 0) { autoResults = applyAutoTagRules(autoTagRules, projects) } // Phase 2: AI interpretation (if criteria text exists AND AI eligibility is enabled) let aiResults: Map | undefined if (award.criteriaText && award.useAiEligibility) { const aiEvals = await aiInterpretCriteria(award.criteriaText, projects) aiResults = new Map( aiEvals.map((e) => [ e.projectId, { eligible: e.eligible, confidence: e.confidence, reasoning: e.reasoning }, ]) ) } // Combine results: auto-tag AND AI must agree (or just one if only one configured) const eligibilities = projects.map((project) => { const autoEligible = autoResults?.get(project.id) ?? true const aiEval = aiResults?.get(project.id) const aiEligible = aiEval?.eligible ?? true const eligible = autoEligible && aiEligible const method = autoResults && aiResults ? 'AUTO' : autoResults ? 'AUTO' : 'MANUAL' return { projectId: project.id, eligible, method, aiReasoningJson: aiEval ? { confidence: aiEval.confidence, reasoning: aiEval.reasoning } : null, } }) // Upsert eligibilities await ctx.prisma.$transaction( eligibilities.map((e) => ctx.prisma.awardEligibility.upsert({ where: { awardId_projectId: { awardId: input.awardId, projectId: e.projectId, }, }, create: { awardId: input.awardId, projectId: e.projectId, eligible: e.eligible, method: e.method as 'AUTO' | 'MANUAL', aiReasoningJson: e.aiReasoningJson ?? undefined, }, update: { eligible: e.eligible, method: e.method as 'AUTO' | 'MANUAL', aiReasoningJson: e.aiReasoningJson ?? undefined, // Clear overrides overriddenBy: null, overriddenAt: null, }, }) ) ) const eligibleCount = eligibilities.filter((e) => e.eligible).length await logAudit({ userId: ctx.user.id, action: 'UPDATE', entityType: 'SpecialAward', entityId: input.awardId, detailsJson: { action: 'RUN_ELIGIBILITY', totalProjects: projects.length, eligible: eligibleCount, }, }) return { total: projects.length, eligible: eligibleCount, ineligible: projects.length - eligibleCount, } }), /** * List eligible projects */ listEligible: protectedProcedure .input( z.object({ awardId: z.string(), eligibleOnly: z.boolean().default(false), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(50), }) ) .query(async ({ ctx, input }) => { const { awardId, eligibleOnly, page, perPage } = input const skip = (page - 1) * perPage const where: Record = { awardId } if (eligibleOnly) where.eligible = true const [eligibilities, total] = await Promise.all([ ctx.prisma.awardEligibility.findMany({ where, skip, take: perPage, include: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true, country: true, tags: true, }, }, }, orderBy: { project: { title: 'asc' } }, }), ctx.prisma.awardEligibility.count({ where }), ]) return { eligibilities, total, page, perPage, totalPages: Math.ceil(total / perPage) } }), /** * Manual eligibility override */ setEligibility: adminProcedure .input( z.object({ awardId: z.string(), projectId: z.string(), eligible: z.boolean(), }) ) .mutation(async ({ ctx, input }) => { await ctx.prisma.awardEligibility.upsert({ where: { awardId_projectId: { awardId: input.awardId, projectId: input.projectId, }, }, create: { awardId: input.awardId, projectId: input.projectId, eligible: input.eligible, method: 'MANUAL', overriddenBy: ctx.user.id, overriddenAt: new Date(), }, update: { eligible: input.eligible, overriddenBy: ctx.user.id, overriddenAt: new Date(), }, }) }), // ─── Jurors ───────────────────────────────────────────────────────────── /** * List jurors for an award */ listJurors: protectedProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.awardJuror.findMany({ where: { awardId: input.awardId }, include: { user: { select: { id: true, name: true, email: true, role: true, profileImageKey: true, profileImageProvider: true, }, }, }, }) }), /** * Add juror */ addJuror: adminProcedure .input( z.object({ awardId: z.string(), userId: z.string(), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.awardJuror.create({ data: { awardId: input.awardId, userId: input.userId, }, }) }), /** * Remove juror */ removeJuror: adminProcedure .input( z.object({ awardId: z.string(), userId: z.string(), }) ) .mutation(async ({ ctx, input }) => { await ctx.prisma.awardJuror.delete({ where: { awardId_userId: { awardId: input.awardId, userId: input.userId, }, }, }) }), /** * Bulk add jurors */ bulkAddJurors: adminProcedure .input( z.object({ awardId: z.string(), userIds: z.array(z.string()), }) ) .mutation(async ({ ctx, input }) => { const data = input.userIds.map((userId) => ({ awardId: input.awardId, userId, })) await ctx.prisma.awardJuror.createMany({ data, skipDuplicates: true, }) return { added: input.userIds.length } }), // ─── Jury Queries ─────────────────────────────────────────────────────── /** * Get awards where current user is a juror */ getMyAwards: protectedProcedure.query(async ({ ctx }) => { const jurorships = await ctx.prisma.awardJuror.findMany({ where: { userId: ctx.user.id }, include: { award: { include: { _count: { select: { eligibilities: { where: { eligible: true } } }, }, }, }, }, }) return jurorships.map((j) => j.award) }), /** * Get award detail for voting (jury view) */ getMyAwardDetail: protectedProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { // Verify user is a juror const juror = await ctx.prisma.awardJuror.findUnique({ where: { awardId_userId: { awardId: input.awardId, userId: ctx.user.id, }, }, }) if (!juror) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a juror for this award', }) } const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, }) // Get eligible projects const eligibleProjects = await ctx.prisma.awardEligibility.findMany({ where: { awardId: input.awardId, eligible: true }, include: { project: { select: { id: true, title: true, teamName: true, description: true, competitionCategory: true, country: true, tags: true, }, }, }, }) // Get user's existing votes const myVotes = await ctx.prisma.awardVote.findMany({ where: { awardId: input.awardId, userId: ctx.user.id }, }) return { award, projects: eligibleProjects.map((e) => e.project), myVotes, } }), // ─── Voting ───────────────────────────────────────────────────────────── /** * Submit vote (PICK_WINNER or RANKED) */ submitVote: protectedProcedure .input( z.object({ awardId: z.string(), votes: z.array( z.object({ projectId: z.string(), rank: z.number().int().min(1).optional(), }) ), }) ) .mutation(async ({ ctx, input }) => { // Verify juror const juror = await ctx.prisma.awardJuror.findUnique({ where: { awardId_userId: { awardId: input.awardId, userId: ctx.user.id, }, }, }) if (!juror) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a juror for this award', }) } // Verify award is open for voting const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, }) if (award.status !== 'VOTING_OPEN') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting is not currently open for this award', }) } // Delete existing votes and create new ones await ctx.prisma.$transaction([ ctx.prisma.awardVote.deleteMany({ where: { awardId: input.awardId, userId: ctx.user.id }, }), ...input.votes.map((vote) => ctx.prisma.awardVote.create({ data: { awardId: input.awardId, userId: ctx.user.id, projectId: vote.projectId, rank: vote.rank, }, }) ), ]) await logAudit({ userId: ctx.user.id, action: 'CREATE', entityType: 'AwardVote', entityId: input.awardId, detailsJson: { awardId: input.awardId, voteCount: input.votes.length, scoringMode: award.scoringMode, }, }) return { submitted: input.votes.length } }), // ─── Results ──────────────────────────────────────────────────────────── /** * Get aggregated vote results */ getVoteResults: adminProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, }) const votes = await ctx.prisma.awardVote.findMany({ where: { awardId: input.awardId }, include: { project: { select: { id: true, title: true, teamName: true }, }, user: { select: { id: true, name: true, email: true }, }, }, }) const jurorCount = await ctx.prisma.awardJuror.count({ where: { awardId: input.awardId }, }) const votedJurorCount = new Set(votes.map((v) => v.userId)).size // Tally by scoring mode const projectTallies = new Map< string, { project: { id: string; title: string; teamName: string | null }; votes: number; points: number } >() for (const vote of votes) { const existing = projectTallies.get(vote.projectId) || { project: vote.project, votes: 0, points: 0, } existing.votes += 1 if (award.scoringMode === 'RANKED' && vote.rank) { existing.points += (award.maxRankedPicks || 5) - vote.rank + 1 } else { existing.points += 1 } projectTallies.set(vote.projectId, existing) } const ranked = Array.from(projectTallies.values()).sort( (a, b) => b.points - a.points ) return { scoringMode: award.scoringMode, jurorCount, votedJurorCount, results: ranked, winnerId: award.winnerProjectId, winnerOverridden: award.winnerOverridden, } }), /** * Set/override winner */ setWinner: adminProcedure .input( z.object({ awardId: z.string(), projectId: z.string(), overridden: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) => { const previous = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, select: { winnerProjectId: true }, }) const award = await ctx.prisma.specialAward.update({ where: { id: input.awardId }, data: { winnerProjectId: input.projectId, winnerOverridden: input.overridden, winnerOverriddenBy: input.overridden ? ctx.user.id : null, }, }) await logAudit({ userId: ctx.user.id, action: 'UPDATE', entityType: 'SpecialAward', entityId: input.awardId, detailsJson: { action: 'SET_AWARD_WINNER', previousWinner: previous.winnerProjectId, newWinner: input.projectId, overridden: input.overridden, }, }) return award }), })