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' 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 const eligibleCount = await ctx.prisma.awardEligibility.count({ where: { awardId: input.id, eligible: true }, }) return { ...award, competition, 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(), 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(), }) ) .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 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 }) => { 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 }) => { 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 }), // ─── 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 void processEligibilityJob( input.awardId, true, // include submitted ctx.user.id, input.roundId ) 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 }), /** * 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 }, }) }), })