import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc' import { getUserAvatarUrl } from '../utils/avatar-url' import { logAudit } from '../utils/audit' import { processEligibilityJob } from '../services/award-eligibility-job' import { resolveAwardWinner } from '../services/award-winner-resolver' import { getAwardSelectionNotificationTemplate, sendJuryInvitationEmail } from '@/lib/email' import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' import { attachProjectLogoUrls } from '../utils/project-logo-url' import { sendBatchNotifications } from '../services/notification-sender' import type { NotificationItem } from '../services/notification-sender' import type { PrismaClient } from '@prisma/client' /** * Verify the current session user exists in the database. * Guards against stale JWT sessions (e.g., after database reseed). */ async function ensureUserExists(db: PrismaClient, userId: string): Promise { const user = await db.user.findUnique({ where: { id: userId }, select: { id: true }, }) if (!user) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Your session refers to a user that no longer exists. Please log out and log back in.', }) } return user.id } 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: { where: { eligible: 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: { where: { eligible: 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 }, }, }, }) // Auto-resolve competition if missing (legacy awards created without competitionId) let { competition } = award if (!competition && award.programId) { const comp = await ctx.prisma.competition.findFirst({ where: { programId: award.programId }, orderBy: { createdAt: 'desc' }, select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } }, }) if (comp) { competition = comp } } // Count eligible projects and total assessed const [eligibleCount, totalAssessed] = await Promise.all([ ctx.prisma.awardEligibility.count({ where: { awardId: input.id, eligible: true }, }), ctx.prisma.awardEligibility.count({ where: { awardId: input.id }, }), ]) return { ...award, competition, eligibleCount, totalAssessed } }), // ─── 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(), 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 }) => { // Auto-resolve competitionId from program if not provided let competitionId = input.competitionId if (!competitionId) { const comp = await ctx.prisma.competition.findFirst({ where: { programId: input.programId }, orderBy: { createdAt: 'desc' }, select: { id: true }, }) competitionId = comp?.id ?? undefined } 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, 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(), 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(), decisionMode: z.enum(['JURY_VOTE', 'AWARD_MASTER_DECISION', 'ADMIN_DECISION']).nullable().optional(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...rest } = input // Auto-resolve competitionId if missing on existing award if (rest.competitionId === undefined) { const existing = await ctx.prisma.specialAward.findUnique({ where: { id }, select: { competitionId: true, programId: true }, }) if (existing && !existing.competitionId) { const comp = await ctx.prisma.competition.findFirst({ where: { programId: existing.programId }, orderBy: { createdAt: 'desc' }, select: { id: true }, }) if (comp) rest.competitionId = comp.id } } const award = await ctx.prisma.specialAward.update({ where: { id }, data: rest, }) 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 processEligibilityJob( input.awardId, input.includeSubmitted ?? false, ctx.user.id ).catch((err) => { console.error('[SpecialAward] processEligibilityJob failed:', err) }) 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 }) => { const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id) await ctx.prisma.awardEligibility.upsert({ where: { awardId_projectId: { awardId: input.awardId, projectId: input.projectId, }, }, create: { award: { connect: { id: input.awardId } }, project: { connect: { id: input.projectId } }, eligible: input.eligible, method: 'MANUAL', overriddenByUser: { connect: { id: verifiedUserId } }, overriddenAt: new Date(), }, update: { eligible: input.eligible, overriddenByUser: { connect: { id: verifiedUserId } }, overriddenAt: new Date(), }, }) }), // ─── Jurors ───────────────────────────────────────────────────────────── /** * List jurors for an award */ listJurors: protectedProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { const jurors = await ctx.prisma.awardJuror.findMany({ where: { awardId: input.awardId }, include: { user: { select: { id: true, name: true, email: true, role: true, profileImageKey: true, profileImageProvider: true, }, }, }, }) return Promise.all( jurors.map(async (j) => ({ ...j, user: { ...j.user, avatarUrl: await getUserAvatarUrl(j.user.profileImageKey, j.user.profileImageProvider), }, })) ) }), /** * 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 } }), /** * Bulk invite new users as award jurors — creates accounts, assigns role, sends invite emails */ bulkInviteJurors: adminProcedure .input( z.object({ awardId: z.string(), role: z.enum(['JURY_MEMBER', 'AWARD_MASTER']).default('AWARD_MASTER'), invitees: z.array( z.object({ name: z.string().optional(), email: z.string().email(), }) ).min(1).max(50), }) ) .mutation(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, select: { id: true, name: true }, }) const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = [] for (const invitee of input.invitees) { try { let user = await ctx.prisma.user.findUnique({ where: { email: invitee.email }, select: { id: true, status: true, role: true }, }) if (!user) { const inviteToken = generateInviteToken() const expiryMs = await getInviteExpiryMs(ctx.prisma) user = await ctx.prisma.user.create({ data: { email: invitee.email, name: invitee.name || null, role: input.role, status: 'INVITED', inviteToken, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), }, select: { id: true, status: true, role: true }, }) const inviteUrl = `${process.env.NEXTAUTH_URL}/accept-invite?token=${inviteToken}` try { await sendJuryInvitationEmail( invitee.email, invitee.name || null, inviteUrl, award.name ) } catch { // Email failure shouldn't block the invite } results.push({ email: invitee.email, status: 'created' }) } else { results.push({ email: invitee.email, status: 'existing' }) } await ctx.prisma.awardJuror.upsert({ where: { awardId_userId: { awardId: input.awardId, userId: user.id }, }, update: {}, create: { awardId: input.awardId, userId: user.id }, }) } catch (err) { results.push({ email: invitee.email, status: 'error', error: err instanceof Error ? err.message : 'Unknown error', }) } } await logAudit({ userId: ctx.user.id, action: 'CREATE', entityType: 'AwardJuror', entityId: input.awardId, detailsJson: { action: 'BULK_INVITE', awardName: award.name, role: input.role, count: input.invitees.length, results, }, }) return { created: results.filter((r) => r.status === 'created').length, existing: results.filter((r) => r.status === 'existing').length, errors: results.filter((r) => r.status === 'error').length, results, } }), // ─── 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, logoKey: true, logoProvider: true, files: { where: { replacedById: null }, select: { id: true, fileName: true, fileType: true, mimeType: true, size: true, bucket: true, objectKey: true, version: true, isLate: true, pageCount: true, detectedLang: true, langConfidence: true, analyzedAt: true, createdAt: true, }, orderBy: { createdAt: 'desc' }, }, teamMembers: { select: { id: true, role: true, user: { select: { name: true, email: true } }, }, }, }, }, }, }), ctx.prisma.awardVote.findMany({ where: { awardId: input.awardId, userId: ctx.user.id }, }), ]) const projectsRaw = eligibleProjects.map((e) => e.project) const projectsWithLogos = await attachProjectLogoUrls(projectsRaw) return { award, projects: projectsWithLogos, myVotes, } }), /** * Enhanced award detail for Award Master — includes project scores and chair vote visibility */ getMyAwardDetailEnhanced: awardMasterProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { 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 assigned to this award' }) } const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([ ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, include: { competition: { select: { id: true, name: true } }, }, }), 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 }, }), ctx.prisma.awardJuror.findMany({ where: { awardId: input.awardId }, select: { userId: true, isChair: true, user: { select: { name: true } } }, }), ]) // Fetch evaluation scores for eligible projects const projectIds = eligibleProjects.map((e) => e.project.id) const projectScores: Record = {} if (award.evaluationRoundId) { const evaluations = await ctx.prisma.evaluation.findMany({ where: { status: 'SUBMITTED', assignment: { roundId: award.evaluationRoundId, projectId: { in: projectIds }, }, }, select: { globalScore: true, assignment: { select: { projectId: true } }, }, }) const scoreMap = new Map() for (const ev of evaluations) { if (ev.globalScore !== null) { const pid = ev.assignment.projectId if (!scoreMap.has(pid)) scoreMap.set(pid, []) scoreMap.get(pid)!.push(ev.globalScore) } } for (const [pid, scores] of scoreMap) { projectScores[pid] = { avg: scores.reduce((a, b) => a + b, 0) / scores.length, count: scores.length, } } } // Chair sees other votes const isSolo = allJurors.length === 1 const isChair = juror.isChair || isSolo let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = [] if (isChair && !isSolo) { const votes = await ctx.prisma.awardVote.findMany({ where: { awardId: input.awardId, userId: { not: ctx.user.id } }, select: { userId: true, projectId: true, justification: true, user: { select: { name: true } }, }, }) otherVotes = votes.map((v) => ({ userId: v.userId, userName: v.user.name, projectId: v.projectId, justification: v.justification, })) } return { award, projects: eligibleProjects.map((e) => ({ ...e.project, evaluationScore: projectScores[e.project.id] ?? null, })), myVotes, isChair, otherVotes, totalJurors: allJurors.length, jurors: allJurors.map((j) => ({ userId: j.userId, name: j.user.name, isChair: j.isChair })), } }), // ─── 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 } }), /** * Submit award master vote with optional justification (PICK_WINNER only) */ submitAwardMasterVote: awardMasterProcedure .input(z.object({ awardId: z.string(), projectId: z.string(), justification: z.string().max(2000).optional(), })) .mutation(async ({ ctx, input }) => { 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: 'Not assigned to this award' }) } 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 open' }) } await ctx.prisma.$transaction([ ctx.prisma.awardVote.deleteMany({ where: { awardId: input.awardId, userId: ctx.user.id }, }), ctx.prisma.awardVote.create({ data: { awardId: input.awardId, userId: ctx.user.id, projectId: input.projectId, justification: input.justification || null, }, }), ]) await logAudit({ userId: ctx.user.id, action: 'CREATE', entityType: 'AwardVote', entityId: input.awardId, detailsJson: { awardId: input.awardId, projectId: input.projectId, mode: 'AWARD_MASTER_PICK' }, }) return { submitted: true } }), // ─── 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 }), /** * Chair confirms the winner — resolves tiebreaks, sets winner, closes the award */ confirmWinner: awardMasterProcedure .input(z.object({ awardId: z.string() })) .mutation(async ({ ctx, input }) => { const allJurors = await ctx.prisma.awardJuror.findMany({ where: { awardId: input.awardId }, select: { userId: true, isChair: true }, }) const myJuror = allJurors.find((j) => j.userId === ctx.user.id) if (!myJuror) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' }) } const isSolo = allJurors.length === 1 if (!myJuror.isChair && !isSolo) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the chair can confirm the winner' }) } const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, }) if (award.status !== 'VOTING_OPEN') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Award must be in VOTING_OPEN status' }) } const chairVote = await ctx.prisma.awardVote.findFirst({ where: { awardId: input.awardId, userId: ctx.user.id }, }) if (!chairVote) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'You must vote before confirming' }) } const allVotes = await ctx.prisma.awardVote.findMany({ where: { awardId: input.awardId }, select: { projectId: true, userId: true }, }) const winnerId = resolveAwardWinner(allVotes, ctx.user.id) await ctx.prisma.specialAward.update({ where: { id: input.awardId }, data: { winnerProjectId: winnerId, status: 'CLOSED', winnerOverridden: false, winnerOverriddenBy: null, }, }) await logAudit({ userId: ctx.user.id, action: 'UPDATE', entityType: 'SpecialAward', entityId: input.awardId, detailsJson: { action: 'CONFIRM_WINNER', winnerId, totalVotes: allVotes.length, confirmedBy: ctx.user.id, }, }) return { winnerId, closed: true } }), /** * Admin: set/unset chair status for an award juror (only one chair per award) */ setChair: adminProcedure .input(z.object({ awardId: z.string(), userId: z.string(), isChair: z.boolean(), })) .mutation(async ({ ctx, input }) => { if (input.isChair) { await ctx.prisma.awardJuror.updateMany({ where: { awardId: input.awardId, isChair: true }, data: { isChair: false }, }) } await ctx.prisma.awardJuror.update({ where: { awardId_userId: { awardId: input.awardId, userId: input.userId } }, data: { isChair: input.isChair }, }) await logAudit({ userId: ctx.user.id, action: 'UPDATE', entityType: 'AwardJuror', entityId: `${input.awardId}:${input.userId}`, detailsJson: { action: 'SET_CHAIR', isChair: input.isChair }, }) return { success: true } }), // ─── Round-Scoped Eligibility & Shortlists ────────────────────────────── /** * List awards for a competition (from a filtering round context) */ listForRound: protectedProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { // Get competition from round const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { competitionId: true }, }) const awards = await ctx.prisma.specialAward.findMany({ where: { competitionId: round.competitionId }, orderBy: { sortOrder: 'asc' }, include: { _count: { select: { eligibilities: { where: { eligible: true } }, }, }, }, }) // Get shortlisted counts const shortlistedCounts = await ctx.prisma.awardEligibility.groupBy({ by: ['awardId'], where: { awardId: { in: awards.map((a) => a.id) }, shortlisted: true, }, _count: true, }) const shortlistMap = new Map(shortlistedCounts.map((s) => [s.awardId, s._count])) return awards.map((a) => ({ ...a, shortlistedCount: shortlistMap.get(a.id) ?? 0, })) }), /** * Run eligibility scoped to a filtering round's PASSED projects */ runEligibilityForRound: adminProcedure .input(z.object({ awardId: z.string(), roundId: z.string(), })) .mutation(async ({ ctx, input }) => { // Set job status to PENDING 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_FOR_ROUND', roundId: input.roundId }, }) // Fire and forget - process in background with round scoping processEligibilityJob( input.awardId, true, // include submitted ctx.user.id, input.roundId ).catch((err) => { console.error('[SpecialAward] processEligibilityJob (round) failed:', err) }) return { started: true } }), /** * Get ranked shortlist for an award */ listShortlist: protectedProcedure .input(z.object({ awardId: z.string(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(50), })) .query(async ({ ctx, input }) => { const { awardId, page, perPage } = input const skip = (page - 1) * perPage const [eligibilities, total, award] = await Promise.all([ ctx.prisma.awardEligibility.findMany({ where: { awardId, eligible: true }, skip, take: perPage, include: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true, country: true, tags: true, description: true, }, }, }, orderBy: { qualityScore: 'desc' }, }), ctx.prisma.awardEligibility.count({ where: { awardId, eligible: true } }), ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: awardId }, select: { shortlistSize: true, eligibilityMode: true, name: true }, }), ]) return { eligibilities, total, page, perPage, totalPages: Math.ceil(total / perPage), shortlistSize: award.shortlistSize, eligibilityMode: award.eligibilityMode, awardName: award.name, } }), /** * Toggle shortlisted status for a project-award pair */ toggleShortlisted: adminProcedure .input(z.object({ awardId: z.string(), projectId: z.string(), })) .mutation(async ({ ctx, input }) => { const current = await ctx.prisma.awardEligibility.findUniqueOrThrow({ where: { awardId_projectId: { awardId: input.awardId, projectId: input.projectId, }, }, select: { shortlisted: true }, }) const updated = await ctx.prisma.awardEligibility.update({ where: { awardId_projectId: { awardId: input.awardId, projectId: input.projectId, }, }, data: { shortlisted: !current.shortlisted }, }) return { shortlisted: updated.shortlisted } }), /** * Bulk toggle shortlisted status for multiple projects */ bulkToggleShortlisted: adminProcedure .input(z.object({ awardId: z.string(), projectIds: z.array(z.string()), shortlisted: z.boolean(), })) .mutation(async ({ ctx, input }) => { const result = await ctx.prisma.awardEligibility.updateMany({ where: { awardId: input.awardId, projectId: { in: input.projectIds }, eligible: true, }, data: { shortlisted: input.shortlisted }, }) return { updated: result.count, shortlisted: input.shortlisted } }), /** * Confirm shortlist — for SEPARATE_POOL awards, creates ProjectRoundState entries */ confirmShortlist: adminProcedure .input(z.object({ awardId: z.string() })) .mutation(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, select: { eligibilityMode: true, name: true, competitionId: true }, }) // Get shortlisted projects const shortlisted = await ctx.prisma.awardEligibility.findMany({ where: { awardId: input.awardId, shortlisted: true, eligible: true }, select: { projectId: true }, }) if (shortlisted.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No shortlisted projects to confirm', }) } const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id) // Mark all as confirmed await ctx.prisma.awardEligibility.updateMany({ where: { awardId: input.awardId, shortlisted: true, eligible: true }, data: { confirmedAt: new Date(), confirmedBy: verifiedUserId, }, }) // For SEPARATE_POOL: create ProjectRoundState entries in award rounds (if any exist) let routedCount = 0 if (award.eligibilityMode === 'SEPARATE_POOL') { const awardRounds = await ctx.prisma.round.findMany({ where: { specialAwardId: input.awardId }, select: { id: true }, orderBy: { sortOrder: 'asc' }, }) if (awardRounds.length > 0) { const firstRound = awardRounds[0] const projectIds = shortlisted.map((s) => s.projectId) // Create ProjectRoundState entries for confirmed projects in the first award round await ctx.prisma.projectRoundState.createMany({ data: projectIds.map((projectId) => ({ projectId, roundId: firstRound.id, state: 'PENDING' as const, })), skipDuplicates: true, }) routedCount = projectIds.length } } await logAudit({ userId: ctx.user.id, action: 'UPDATE', entityType: 'SpecialAward', entityId: input.awardId, detailsJson: { action: 'CONFIRM_SHORTLIST', confirmedCount: shortlisted.length, eligibilityMode: award.eligibilityMode, routedCount, }, }) return { confirmedCount: shortlisted.length, routedCount, eligibilityMode: award.eligibilityMode, } }), // ─── Award Rounds ──────────────────────────────────────────────────────── /** * List rounds belonging to an award */ listRounds: protectedProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.round.findMany({ where: { specialAwardId: input.awardId }, orderBy: { sortOrder: 'asc' }, include: { juryGroup: { select: { id: true, name: true } }, _count: { select: { projectRoundStates: true, assignments: true, }, }, }, }) }), /** * Create a round linked to an award */ createRound: adminProcedure .input(z.object({ awardId: z.string(), name: z.string().min(1), roundType: z.enum(['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION']).default('EVALUATION'), })) .mutation(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, select: { competitionId: true, name: true }, }) if (!award.competitionId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Award must be linked to a competition before creating rounds', }) } // Get max sort order for this award's rounds const maxOrder = await ctx.prisma.round.aggregate({ where: { specialAwardId: input.awardId }, _max: { sortOrder: true }, }) // Also need max sortOrder across the competition to avoid unique constraint conflicts const maxCompOrder = await ctx.prisma.round.aggregate({ where: { competitionId: award.competitionId }, _max: { sortOrder: true }, }) const sortOrder = Math.max( (maxOrder._max.sortOrder ?? 0) + 1, (maxCompOrder._max.sortOrder ?? 0) + 1 ) const slug = `${award.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` const round = await ctx.prisma.round.create({ data: { competitionId: award.competitionId, specialAwardId: input.awardId, name: input.name, slug, roundType: input.roundType, sortOrder, }, }) await logAudit({ userId: ctx.user.id, action: 'CREATE', entityType: 'Round', entityId: round.id, detailsJson: { awardId: input.awardId, awardName: award.name, roundType: input.roundType }, }) return round }), // ─── Pool Notifications ───────────────────────────────────────────────── /** * Get account stats for eligible projects (how many need invite vs have account) */ getNotificationStats: adminProcedure .input(z.object({ awardId: z.string() })) .query(async ({ ctx, input }) => { const eligibilities = await ctx.prisma.awardEligibility.findMany({ where: { awardId: input.awardId, eligible: true }, select: { project: { select: { submittedBy: { select: { id: true, passwordHash: true } }, teamMembers: { select: { user: { select: { id: true, passwordHash: true } } }, }, }, }, }, }) const seen = new Set() let needsInvite = 0 let hasAccount = 0 for (const e of eligibilities) { const submitter = e.project.submittedBy if (submitter && !seen.has(submitter.id)) { seen.add(submitter.id) if (submitter.passwordHash) hasAccount++ else needsInvite++ } for (const tm of e.project.teamMembers) { if (!seen.has(tm.user.id)) { seen.add(tm.user.id) if (tm.user.passwordHash) hasAccount++ else needsInvite++ } } } return { needsInvite, hasAccount, totalProjects: eligibilities.length } }), previewAwardSelectionEmail: adminProcedure .input(z.object({ awardId: z.string(), customMessage: z.string().optional(), })) .query(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, select: { name: true }, }) const eligibleCount = await ctx.prisma.awardEligibility.count({ where: { awardId: input.awardId, eligible: true }, }) const template = getAwardSelectionNotificationTemplate( 'Team Member', 'Your Project', award.name, input.customMessage || undefined, ) return { html: template.html, subject: template.subject, recipientCount: eligibleCount } }), /** * Notify eligible projects that they've been selected for an award. * Generates invite tokens for passwordless users. */ notifyEligibleProjects: adminProcedure .input(z.object({ awardId: z.string(), customMessage: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, select: { id: true, name: true, description: true, status: true }, }) // Get eligible projects that haven't been notified yet // Exclude projects that have been rejected at any stage const eligibilities = await ctx.prisma.awardEligibility.findMany({ where: { awardId: input.awardId, eligible: true, notifiedAt: null, project: { projectRoundStates: { none: { state: 'REJECTED' }, }, }, }, select: { id: true, projectId: true, project: { select: { id: true, title: true, submittedBy: { select: { id: true, email: true, name: true, passwordHash: true }, }, teamMembers: { select: { user: { select: { id: true, email: true, name: true, passwordHash: true }, }, }, }, }, }, }, }) if (eligibilities.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No eligible projects to notify', }) } // Pre-generate invite tokens for passwordless users const expiryMs = await getInviteExpiryMs(ctx.prisma) const expiresAt = new Date(Date.now() + expiryMs) const tokenMap = new Map() // userId -> token const allUsers: Array<{ id: string; passwordHash: string | null }> = [] for (const e of eligibilities) { if (e.project.submittedBy) allUsers.push(e.project.submittedBy) for (const tm of e.project.teamMembers) allUsers.push(tm.user) } const passwordlessUsers = allUsers.filter((u) => !u.passwordHash) const uniquePasswordless = [...new Map(passwordlessUsers.map((u) => [u.id, u])).values()] for (const user of uniquePasswordless) { const token = generateInviteToken() tokenMap.set(user.id, token) await ctx.prisma.user.update({ where: { id: user.id }, data: { inviteToken: token, inviteTokenExpiresAt: expiresAt, status: 'INVITED' }, }) } // Build notification items — track which eligibility each email belongs to const items: NotificationItem[] = [] const eligibilityEmailMap = new Map>() // eligibilityId → Set for (const e of eligibilities) { const recipients: Array<{ id: string; email: string; name: string | null }> = [] if (e.project.submittedBy) recipients.push(e.project.submittedBy) for (const tm of e.project.teamMembers) { if (!recipients.some((r) => r.id === tm.user.id)) { recipients.push(tm.user) } } const emails = new Set() for (const recipient of recipients) { const token = tokenMap.get(recipient.id) const accountUrl = token ? `/accept-invite?token=${token}` : undefined emails.add(recipient.email) items.push({ email: recipient.email, name: recipient.name || '', type: 'AWARD_SELECTION_NOTIFICATION', context: { title: `Under consideration for ${award.name}`, message: input.customMessage || '', metadata: { projectName: e.project.title, awardName: award.name, customMessage: input.customMessage, accountUrl, }, }, projectId: e.projectId, userId: recipient.id, }) } eligibilityEmailMap.set(e.id, emails) } const result = await sendBatchNotifications(items) // Determine which eligibilities had zero failures const failedEmails = new Set(result.errors.map((e) => e.email)) const successfulEligibilityIds: string[] = [] for (const [eligId, emails] of eligibilityEmailMap) { const hasFailure = [...emails].some((email) => failedEmails.has(email)) if (!hasFailure) successfulEligibilityIds.push(eligId) } if (successfulEligibilityIds.length > 0) { await ctx.prisma.awardEligibility.updateMany({ where: { id: { in: successfulEligibilityIds } }, data: { notifiedAt: new Date() }, }) } await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'SpecialAward', entityId: input.awardId, detailsJson: { action: 'NOTIFY_ELIGIBLE_PROJECTS', eligibleCount: eligibilities.length, emailsSent: result.sent, emailsFailed: result.failed, failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed } }), /** * Delete an award round (only if DRAFT) */ deleteRound: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { status: true, specialAwardId: true }, }) if (!round.specialAwardId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'This round is not an award round', }) } if (round.status !== 'ROUND_DRAFT') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Only draft rounds can be deleted', }) } await ctx.prisma.round.delete({ where: { id: input.roundId } }) await logAudit({ userId: ctx.user.id, action: 'DELETE', entityType: 'Round', entityId: input.roundId, detailsJson: { awardId: round.specialAwardId }, }) }), /** * Reorder award rounds via drag-and-drop. * Uses a two-phase transaction: first set all to negative temps (avoid unique constraint), * then set to final values. */ reorderAwardRounds: adminProcedure .input(z.object({ awardId: z.string(), roundIds: z.array(z.string()).min(1), })) .mutation(async ({ ctx, input }) => { const existingRounds = await ctx.prisma.round.findMany({ where: { specialAwardId: input.awardId }, select: { id: true, competitionId: true, sortOrder: true }, orderBy: { sortOrder: 'asc' }, }) if (existingRounds.length !== input.roundIds.length) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round list does not match existing award rounds', }) } const existingIds = new Set(existingRounds.map((r) => r.id)) for (const id of input.roundIds) { if (!existingIds.has(id)) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Round ${id} does not belong to this award`, }) } } // Collect the existing sortOrder values (in ascending order) and reassign them // to the new ordering. This keeps the same sortOrder slots, just remapped. const sortSlots = existingRounds.map((r) => r.sortOrder).sort((a, b) => a - b) const competitionId = existingRounds[0].competitionId await ctx.prisma.$transaction(async (tx) => { // Phase 1: set all to negative temps to avoid unique constraint for (let i = 0; i < existingRounds.length; i++) { await tx.round.update({ where: { id: existingRounds[i].id }, data: { sortOrder: -(i + 1000) }, }) } // Phase 2: assign final sort orders based on new ordering for (let i = 0; i < input.roundIds.length; i++) { await tx.round.update({ where: { id: input.roundIds[i] }, data: { sortOrder: sortSlots[i] }, }) } }) await logAudit({ userId: ctx.user.id, action: 'UPDATE', entityType: 'SpecialAward', entityId: input.awardId, detailsJson: { action: 'REORDER_ROUNDS', newOrder: input.roundIds }, }) }), /** * Assign (or reassign) eligible projects to the first award round. * Re-runnable: moves existing ProjectRoundState entries from other award rounds * to the first, and creates new PENDING entries for unassigned projects. */ assignToFirstRound: adminProcedure .input(z.object({ awardId: z.string() })) .mutation(async ({ ctx, input }) => { const award = await ctx.prisma.specialAward.findUniqueOrThrow({ where: { id: input.awardId }, select: { eligibilityMode: true, name: true }, }) if (award.eligibilityMode !== 'SEPARATE_POOL') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Assign to first round is only available for Separate Pool awards', }) } const awardRounds = await ctx.prisma.round.findMany({ where: { specialAwardId: input.awardId }, select: { id: true }, orderBy: { sortOrder: 'asc' }, }) if (awardRounds.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Create at least one round before assigning projects', }) } const firstRound = awardRounds[0] const otherRoundIds = awardRounds.slice(1).map((r) => r.id) // Get all eligible projects (confirmed or not — any eligible project) const eligible = await ctx.prisma.awardEligibility.findMany({ where: { awardId: input.awardId, eligible: true }, select: { projectId: true }, }) if (eligible.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No eligible projects to assign', }) } const projectIds = eligible.map((e) => e.projectId) // Move existing entries from other award rounds to the first round let movedCount = 0 if (otherRoundIds.length > 0) { const moved = await ctx.prisma.projectRoundState.updateMany({ where: { roundId: { in: otherRoundIds }, projectId: { in: projectIds }, }, data: { roundId: firstRound.id, state: 'PENDING' }, }) movedCount = moved.count } // Create PENDING entries for projects not yet in the first round const existing = await ctx.prisma.projectRoundState.findMany({ where: { roundId: firstRound.id, projectId: { in: projectIds } }, select: { projectId: true }, }) const existingSet = new Set(existing.map((e) => e.projectId)) const newProjectIds = projectIds.filter((id) => !existingSet.has(id)) let createdCount = 0 if (newProjectIds.length > 0) { await ctx.prisma.projectRoundState.createMany({ data: newProjectIds.map((projectId) => ({ projectId, roundId: firstRound.id, state: 'PENDING' as const, })), skipDuplicates: true, }) createdCount = newProjectIds.length } await logAudit({ userId: ctx.user.id, action: 'UPDATE', entityType: 'SpecialAward', entityId: input.awardId, detailsJson: { action: 'ASSIGN_TO_FIRST_ROUND', firstRoundId: firstRound.id, movedCount, createdCount, totalEligible: projectIds.length, }, }) return { movedCount, createdCount, totalAssigned: existingSet.size + createdCount } }), })