import { z } from 'zod' import { TRPCError } from '@trpc/server' import { randomUUID } from 'crypto' import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc' import { logAudit } from '../utils/audit' interface LiveVotingCriterion { id: string label: string description?: string scale: number weight: number } export const liveVotingRouter = router({ /** * Get or create a live voting session for a round */ getSession: adminProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { let session = await ctx.prisma.liveVotingSession.findUnique({ where: { roundId: input.roundId }, include: { round: { include: { competition: { include: { program: { select: { name: true, year: true } }, }, }, }, }, }, }) if (!session) { session = await ctx.prisma.liveVotingSession.create({ data: { roundId: input.roundId, }, include: { round: { include: { competition: { include: { program: { select: { name: true, year: true } }, }, }, }, }, }, }) } // Get current votes if voting is in progress let currentVotes: { userId: string | null; score: number }[] = [] if (session.currentProjectId) { const votes = await ctx.prisma.liveVote.findMany({ where: { sessionId: session.id, projectId: session.currentProjectId, }, select: { userId: true, score: true }, }) currentVotes = votes } // Get audience voter count const audienceVoterCount = await ctx.prisma.audienceVoter.count({ where: { sessionId: session.id }, }) return { ...session, currentVotes, audienceVoterCount, } }), /** * Get session for jury member voting */ getSessionForVoting: protectedProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ where: { id: input.sessionId }, include: { round: { include: { competition: { include: { program: { select: { name: true, year: true } }, }, }, }, }, }, }) let currentProject = null if (session.currentProjectId && session.status === 'IN_PROGRESS') { currentProject = await ctx.prisma.project.findUnique({ where: { id: session.currentProjectId }, select: { id: true, title: true, teamName: true, description: true }, }) } let userVote = null if (session.currentProjectId) { userVote = await ctx.prisma.liveVote.findFirst({ where: { sessionId: session.id, projectId: session.currentProjectId, userId: ctx.user.id, }, }) } let timeRemaining = null if (session.votingEndsAt && session.status === 'IN_PROGRESS') { const remaining = new Date(session.votingEndsAt).getTime() - Date.now() timeRemaining = Math.max(0, Math.floor(remaining / 1000)) } return { session: { id: session.id, status: session.status, votingStartedAt: session.votingStartedAt, votingEndsAt: session.votingEndsAt, votingMode: session.votingMode, criteriaJson: session.criteriaJson, }, round: session.round, currentProject, userVote, timeRemaining, } }), /** * Get public session info for display */ getPublicSession: protectedProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ where: { id: input.sessionId }, include: { round: { include: { competition: { include: { program: { select: { name: true, year: true } }, }, }, }, }, }, }) const projectOrder = (session.projectOrderJson as string[]) || [] const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectOrder } }, select: { id: true, title: true, teamName: true }, }) const sortedProjects = projectOrder .map((id) => projects.find((p) => p.id === id)) .filter(Boolean) const scores = await ctx.prisma.liveVote.groupBy({ by: ['projectId'], where: { sessionId: session.id }, _avg: { score: true }, _count: true, }) const projectsWithScores = sortedProjects.map((project) => { const projectScore = scores.find((s) => s.projectId === project!.id) return { ...project, averageScore: projectScore?._avg.score || null, voteCount: projectScore?._count || 0, } }) return { session: { id: session.id, status: session.status, currentProjectId: session.currentProjectId, votingEndsAt: session.votingEndsAt, }, round: session.round, projects: projectsWithScores, } }), /** * Set project order for voting */ setProjectOrder: adminProcedure .input( z.object({ sessionId: z.string(), projectIds: z.array(z.string()), }) ) .mutation(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.update({ where: { id: input.sessionId }, data: { projectOrderJson: input.projectIds, }, }) return session }), /** * Set voting mode (simple vs criteria) */ setVotingMode: adminProcedure .input( z.object({ sessionId: z.string(), votingMode: z.enum(['simple', 'criteria']), }) ) .mutation(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.update({ where: { id: input.sessionId }, data: { votingMode: input.votingMode }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'SET_VOTING_MODE', entityType: 'LiveVotingSession', entityId: session.id, detailsJson: { votingMode: input.votingMode }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return session }), /** * Set criteria for criteria-based voting */ setCriteria: adminProcedure .input( z.object({ sessionId: z.string(), criteria: z.array( z.object({ id: z.string(), label: z.string(), description: z.string().optional(), scale: z.number().int().min(1).max(100), weight: z.number().min(0).max(1), }) ), }) ) .mutation(async ({ ctx, input }) => { // Validate weights sum approximately to 1 const weightSum = input.criteria.reduce((sum, c) => sum + c.weight, 0) if (Math.abs(weightSum - 1) > 0.01) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Criteria weights must sum to 1.0 (currently ${weightSum.toFixed(2)})`, }) } const session = await ctx.prisma.liveVotingSession.update({ where: { id: input.sessionId }, data: { criteriaJson: input.criteria, votingMode: 'criteria', }, }) return session }), /** * Import criteria from an existing evaluation form */ importCriteriaFromForm: adminProcedure .input( z.object({ sessionId: z.string(), formId: z.string(), }) ) .mutation(async ({ ctx, input }) => { const form = await ctx.prisma.evaluationForm.findUniqueOrThrow({ where: { id: input.formId }, }) const formCriteria = form.criteriaJson as Array<{ id: string label: string description?: string scale: number weight: number type?: string }> // Filter out section headers and convert const scoringCriteria = formCriteria.filter( (c) => !c.type || c.type === 'numeric' ) if (scoringCriteria.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No numeric criteria found in this evaluation form', }) } // Normalize weights to sum to 1 const totalWeight = scoringCriteria.reduce((sum, c) => sum + (c.weight || 1), 0) const criteria: LiveVotingCriterion[] = scoringCriteria.map((c) => ({ id: c.id, label: c.label, description: c.description, scale: c.scale || 10, weight: (c.weight || 1) / totalWeight, })) const session = await ctx.prisma.liveVotingSession.update({ where: { id: input.sessionId }, data: { criteriaJson: criteria as unknown as import('@prisma/client').Prisma.InputJsonValue, votingMode: 'criteria', }, }) return session }), /** * Start voting for a project */ startVoting: adminProcedure .input( z.object({ sessionId: z.string(), projectId: z.string(), durationSeconds: z.number().int().min(10).max(300).default(30), }) ) .mutation(async ({ ctx, input }) => { const now = new Date() const votingEndsAt = new Date(now.getTime() + input.durationSeconds * 1000) const session = await ctx.prisma.liveVotingSession.update({ where: { id: input.sessionId }, data: { status: 'IN_PROGRESS', currentProjectId: input.projectId, votingStartedAt: now, votingEndsAt, }, }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'START_VOTING', entityType: 'LiveVotingSession', entityId: session.id, detailsJson: { projectId: input.projectId, durationSeconds: input.durationSeconds }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return session }), /** * Stop voting */ stopVoting: adminProcedure .input(z.object({ sessionId: z.string() })) .mutation(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.update({ where: { id: input.sessionId }, data: { status: 'PAUSED', votingEndsAt: new Date(), }, }) return session }), /** * End session */ endSession: adminProcedure .input(z.object({ sessionId: z.string() })) .mutation(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.update({ where: { id: input.sessionId }, data: { status: 'COMPLETED', }, }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'END_SESSION', entityType: 'LiveVotingSession', entityId: session.id, detailsJson: {}, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return session }), /** * Submit a vote (supports both simple and criteria modes) */ vote: protectedProcedure .input( z.object({ sessionId: z.string(), projectId: z.string(), score: z.number().int().min(1).max(10), criterionScores: z .record(z.string(), z.number()) .optional(), }) ) .mutation(async ({ ctx, input }) => { // Verify session is in progress const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ where: { id: input.sessionId }, }) if (session.status !== 'IN_PROGRESS') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting is not currently active', }) } if (session.currentProjectId !== input.projectId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot vote for this project right now', }) } // Check if voting window is still open if (session.votingEndsAt && new Date() > session.votingEndsAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting window has closed', }) } // For criteria mode, validate and compute weighted score let finalScore = input.score let criterionScoresJson = null if (session.votingMode === 'criteria' && input.criterionScores) { const criteria = session.criteriaJson as LiveVotingCriterion[] | null if (!criteria || criteria.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No criteria configured for this session', }) } // Validate all required criteria have scores for (const c of criteria) { if (input.criterionScores[c.id] === undefined) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Missing score for criterion: ${c.label}`, }) } const cScore = input.criterionScores[c.id] if (cScore < 1 || cScore > c.scale) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Score for ${c.label} must be between 1 and ${c.scale}`, }) } } // Compute weighted score normalized to 1-10 let weightedSum = 0 for (const c of criteria) { const normalizedScore = (input.criterionScores[c.id] / c.scale) * 10 weightedSum += normalizedScore * c.weight } finalScore = Math.round(Math.min(10, Math.max(1, weightedSum))) criterionScoresJson = input.criterionScores } // Upsert vote (allow vote change during window) const vote = await ctx.prisma.liveVote.upsert({ where: { sessionId_projectId_userId: { sessionId: input.sessionId, projectId: input.projectId, userId: ctx.user.id, }, }, create: { sessionId: input.sessionId, projectId: input.projectId, userId: ctx.user.id, score: finalScore, criterionScoresJson: criterionScoresJson ?? undefined, }, update: { score: finalScore, criterionScoresJson: criterionScoresJson ?? undefined, votedAt: new Date(), }, }) return vote }), /** * Get results for a session (with weighted jury + audience scoring) */ getResults: protectedProcedure .input( z.object({ sessionId: z.string(), juryWeight: z.number().min(0).max(1).optional(), audienceWeight: z.number().min(0).max(1).optional(), }) ) .query(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ where: { id: input.sessionId }, include: { round: { include: { competition: { include: { program: { select: { name: true, year: true } }, }, }, }, }, }, }) // Use custom weights if provided, else session defaults const audienceWeightVal = input.audienceWeight ?? session.audienceVoteWeight ?? 0 const juryWeightVal = input.juryWeight ?? (1 - audienceWeightVal) // Get jury votes grouped by project const juryScores = await ctx.prisma.liveVote.groupBy({ by: ['projectId'], where: { sessionId: input.sessionId, isAudienceVote: false }, _avg: { score: true }, _count: true, }) // Get audience votes grouped by project const audienceScores = await ctx.prisma.liveVote.groupBy({ by: ['projectId'], where: { sessionId: input.sessionId, isAudienceVote: true }, _avg: { score: true }, _count: true, }) // Get project details const allProjectIds = [ ...new Set([ ...juryScores.map((s) => s.projectId), ...audienceScores.map((s) => s.projectId), ]), ] const projects = await ctx.prisma.project.findMany({ where: { id: { in: allProjectIds } }, select: { id: true, title: true, teamName: true }, }) const audienceMap = new Map(audienceScores.map((s) => [s.projectId, s])) // For criteria mode, get per-criterion breakdowns let criteriaBreakdown: Record> | null = null if (session.votingMode === 'criteria') { const allJuryVotes = await ctx.prisma.liveVote.findMany({ where: { sessionId: input.sessionId, isAudienceVote: false }, select: { projectId: true, criterionScoresJson: true }, }) criteriaBreakdown = {} for (const vote of allJuryVotes) { if (!vote.criterionScoresJson) continue const scores = vote.criterionScoresJson as Record if (!criteriaBreakdown[vote.projectId]) { criteriaBreakdown[vote.projectId] = {} } for (const [criterionId, score] of Object.entries(scores)) { if (!criteriaBreakdown[vote.projectId][criterionId]) { criteriaBreakdown[vote.projectId][criterionId] = 0 } criteriaBreakdown[vote.projectId][criterionId] += score } } // Average the scores for (const projectId of Object.keys(criteriaBreakdown)) { const projectVoteCount = allJuryVotes.filter((v) => v.projectId === projectId).length if (projectVoteCount > 0) { for (const criterionId of Object.keys(criteriaBreakdown[projectId])) { criteriaBreakdown[projectId][criterionId] /= projectVoteCount } } } } // Combine and calculate weighted scores const results = juryScores .map((jurySc) => { const project = projects.find((p) => p.id === jurySc.projectId) const audienceSc = audienceMap.get(jurySc.projectId) const juryAvg = jurySc._avg?.score || 0 const audienceAvg = audienceSc?._avg?.score || 0 const weightedTotal = audienceWeightVal > 0 && audienceSc ? juryAvg * juryWeightVal + audienceAvg * audienceWeightVal : juryAvg return { project, juryAverage: juryAvg, juryVoteCount: jurySc._count, audienceAverage: audienceAvg, audienceVoteCount: audienceSc?._count || 0, weightedTotal, criteriaAverages: criteriaBreakdown?.[jurySc.projectId] || null, } }) .sort((a, b) => b.weightedTotal - a.weightedTotal) // Detect ties const ties: string[][] = [] for (let i = 0; i < results.length - 1; i++) { if (Math.abs(results[i].weightedTotal - results[i + 1].weightedTotal) < 0.001) { const tieGroup = [results[i].project?.id, results[i + 1].project?.id].filter(Boolean) as string[] ties.push(tieGroup) } } return { session, results, ties, tieBreakerMethod: session.tieBreakerMethod, votingMode: session.votingMode, criteria: session.criteriaJson as LiveVotingCriterion[] | null, weights: { jury: juryWeightVal, audience: audienceWeightVal }, } }), /** * Update presentation settings for a live voting session */ updatePresentationSettings: adminProcedure .input( z.object({ sessionId: z.string(), presentationSettingsJson: z.object({ theme: z.string().optional(), autoAdvance: z.boolean().optional(), autoAdvanceDelay: z.number().int().min(5).max(120).optional(), scoreDisplayFormat: z.enum(['bar', 'number', 'radial']).optional(), showVoteCount: z.boolean().optional(), brandingOverlay: z.string().optional(), }), }) ) .mutation(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.update({ where: { id: input.sessionId }, data: { presentationSettingsJson: input.presentationSettingsJson, }, }) return session }), /** * Update session config (audience voting, tie-breaker) */ updateSessionConfig: adminProcedure .input( z.object({ sessionId: z.string(), allowAudienceVotes: z.boolean().optional(), audienceVoteWeight: z.number().min(0).max(1).optional(), tieBreakerMethod: z.enum(['admin_decides', 'highest_individual', 'revote']).optional(), audienceVotingMode: z.enum(['disabled', 'per_project', 'per_category', 'favorites']).optional(), audienceMaxFavorites: z.number().int().min(1).max(20).optional(), audienceRequireId: z.boolean().optional(), audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(), }) ) .mutation(async ({ ctx, input }) => { const { sessionId, ...data } = input const session = await ctx.prisma.liveVotingSession.update({ where: { id: sessionId }, data, }) // Audit log try { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE_SESSION_CONFIG', entityType: 'LiveVotingSession', entityId: sessionId, detailsJson: data, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) } catch { // Audit log errors should never break the operation } return session }), /** * Register an audience voter (public, no auth required) */ registerAudienceVoter: publicProcedure .input( z.object({ sessionId: z.string(), identifier: z.string().optional(), identifierType: z.enum(['email', 'phone', 'name', 'anonymous']).optional(), }) ) .mutation(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ where: { id: input.sessionId }, }) if (!session.allowAudienceVotes) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Audience voting is not enabled for this session', }) } if (session.audienceRequireId && !input.identifier) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Identification is required for audience voting', }) } const token = randomUUID() const voter = await ctx.prisma.audienceVoter.create({ data: { sessionId: input.sessionId, token, identifier: input.identifier || null, identifierType: input.identifierType || 'anonymous', ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { token: voter.token, voterId: voter.id } }), /** * Cast an audience vote (token-based, no auth required) */ castAudienceVote: publicProcedure .input( z.object({ sessionId: z.string(), projectId: z.string(), score: z.number().int().min(1).max(10), token: z.string(), }) ) .mutation(async ({ ctx, input }) => { // Verify voter token const voter = await ctx.prisma.audienceVoter.findUnique({ where: { token: input.token }, }) if (!voter || voter.sessionId !== input.sessionId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid voting token', }) } // Verify session is in progress and allows audience votes const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ where: { id: input.sessionId }, }) if (session.status !== 'IN_PROGRESS') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting is not currently active', }) } if (!session.allowAudienceVotes) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Audience voting is not enabled for this session', }) } if (session.currentProjectId !== input.projectId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot vote for this project right now', }) } if (session.votingEndsAt && new Date() > session.votingEndsAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting window has closed', }) } // Upsert audience vote (dedup by audienceVoterId) const vote = await ctx.prisma.liveVote.upsert({ where: { sessionId_projectId_audienceVoterId: { sessionId: input.sessionId, projectId: input.projectId, audienceVoterId: voter.id, }, }, create: { sessionId: input.sessionId, projectId: input.projectId, audienceVoterId: voter.id, score: input.score, isAudienceVote: true, }, update: { score: input.score, votedAt: new Date(), }, }) return vote }), /** * Get audience voter stats (admin) */ getAudienceVoterStats: adminProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const voterCount = await ctx.prisma.audienceVoter.count({ where: { sessionId: input.sessionId }, }) const voteCount = await ctx.prisma.liveVote.count({ where: { sessionId: input.sessionId, isAudienceVote: true }, }) return { voterCount, voteCount } }), /** * Get public session info for audience voting page */ getAudienceSession: publicProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ where: { id: input.sessionId }, select: { id: true, status: true, currentProjectId: true, votingEndsAt: true, allowAudienceVotes: true, audienceVotingMode: true, audienceRequireId: true, audienceMaxFavorites: true, round: { select: { name: true, competition: { select: { program: { select: { name: true, year: true } }, }, }, }, }, }, }) let currentProject = null if (session.currentProjectId && session.status === 'IN_PROGRESS') { currentProject = await ctx.prisma.project.findUnique({ where: { id: session.currentProjectId }, select: { id: true, title: true, teamName: true }, }) } let timeRemaining = null if (session.votingEndsAt && session.status === 'IN_PROGRESS') { const remaining = new Date(session.votingEndsAt).getTime() - Date.now() timeRemaining = Math.max(0, Math.floor(remaining / 1000)) } return { session, currentProject, timeRemaining, } }), /** * Get public results for a live voting session (no auth required) */ getPublicResults: publicProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ where: { id: input.sessionId }, select: { id: true, status: true, currentProjectId: true, votingEndsAt: true, presentationSettingsJson: true, allowAudienceVotes: true, audienceVoteWeight: true, }, }) // Only return data if session is in progress or completed if (session.status !== 'IN_PROGRESS' && session.status !== 'COMPLETED') { return { session: { id: session.id, status: session.status, presentationSettings: session.presentationSettingsJson, }, projects: [], } } // Get all votes grouped by project (anonymized - no user data) const scores = await ctx.prisma.liveVote.groupBy({ by: ['projectId'], where: { sessionId: input.sessionId }, _avg: { score: true }, _count: true, }) const projectIds = scores.map((s) => s.projectId) const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, title: true, teamName: true }, }) const projectsWithScores = scores.map((score) => { const project = projects.find((p) => p.id === score.projectId) return { id: project?.id, title: project?.title, teamName: project?.teamName, averageScore: score._avg.score || 0, voteCount: score._count, } }).sort((a, b) => b.averageScore - a.averageScore) return { session: { id: session.id, status: session.status, currentProjectId: session.currentProjectId, votingEndsAt: session.votingEndsAt, presentationSettings: session.presentationSettingsJson, allowAudienceVotes: session.allowAudienceVotes, }, projects: projectsWithScores, } }), })