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 { processEligibilityJob } from '../services/award-eligibility-job' 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 }, }, competition: { select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } }, }, evaluationRound: { select: { id: true, name: true, roundType: true }, }, awardJuryGroup: { select: { id: true, name: 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(), competitionId: z.string().optional(), evaluationRoundId: z.string().optional(), juryGroupId: z.string().optional(), eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).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, competitionId: input.competitionId, evaluationRoundId: input.evaluationRoundId, juryGroupId: input.juryGroupId, eligibilityMode: input.eligibilityMode, sortOrder: (maxOrder._max.sortOrder || 0) + 1, }, }) // Audit outside transaction so failures don't roll back the create await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', entityType: 'SpecialAward', entityId: award.id, detailsJson: { name: input.name, scoringMode: input.scoringMode }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) 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(), competitionId: z.string().nullable().optional(), evaluationRoundId: z.string().nullable().optional(), juryGroupId: z.string().nullable().optional(), eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).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 } }) // Audit outside transaction so failures don't break the delete await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'SpecialAward', entityId: input.id, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) }), /** * 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, }) // Audit outside transaction so failures don't break the status update await logAudit({ prisma: ctx.prisma, 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, }), }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return award }), // ─── Eligibility ──────────────────────────────────────────────────────── /** * Run auto-tag + AI eligibility */ runEligibility: adminProcedure .input(z.object({ awardId: z.string(), includeSubmitted: z.boolean().optional(), })) .mutation(async ({ ctx, input }) => { // Set job status to PENDING immediately await ctx.prisma.specialAward.update({ where: { id: input.awardId }, data: { eligibilityJobStatus: 'PENDING', eligibilityJobTotal: null, eligibilityJobDone: null, eligibilityJobError: null, eligibilityJobStarted: null, }, }) await logAudit({ userId: ctx.user.id, action: 'UPDATE', entityType: 'SpecialAward', entityId: input.awardId, detailsJson: { action: 'RUN_ELIGIBILITY_STARTED' }, }) // Fire and forget - process in background void processEligibilityJob( input.awardId, input.includeSubmitted ?? false, ctx.user.id ) return { started: true } }), /** * Get eligibility job status for polling */ getEligibilityJobStatus: protectedProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, select: { eligibilityJobStatus: true, eligibilityJobTotal: true, eligibilityJobDone: true, eligibilityJobError: true, eligibilityJobStarted: true, }, }) return award }), /** * 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', }) } // Fetch award, eligible projects, and votes in parallel const [award, eligibleProjects, myVotes] = await Promise.all([ ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, }), 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, }, }, }, }), 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, votes, jurorCount] = await Promise.all([ ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, }), 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 }, }, }, }), 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, }, }) // Audit outside transaction so failures don't break the winner update await logAudit({ prisma: ctx.prisma, 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, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return award }), })