import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure } from '../trpc' 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: { program: { select: { name: true, year: true } }, roundProjects: { where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } }, include: { project: { select: { id: true, title: true, teamName: true }, }, }, }, }, }, }, }) if (!session) { // Create session session = await ctx.prisma.liveVotingSession.create({ data: { roundId: input.roundId, }, include: { round: { include: { program: { select: { name: true, year: true } }, roundProjects: { where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } }, include: { project: { select: { id: true, title: true, teamName: true }, }, }, }, }, }, }, }) } // Get current votes if voting is in progress let currentVotes: { userId: string; 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 } return { ...session, currentVotes, } }), /** * 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: { program: { select: { name: true, year: true } }, }, }, }, }) // Get current project if in progress 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 }, }) } // Get user's vote for current project let userVote = null if (session.currentProjectId) { userVote = await ctx.prisma.liveVote.findFirst({ where: { sessionId: session.id, projectId: session.currentProjectId, userId: ctx.user.id, }, }) } // Calculate time remaining 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, }, 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: { program: { select: { name: true, year: true } }, }, }, }, }) // Get all projects in order const projectOrder = (session.projectOrderJson as string[]) || [] const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectOrder } }, select: { id: true, title: true, teamName: true }, }) // Sort by order const sortedProjects = projectOrder .map((id) => projects.find((p) => p.id === id)) .filter(Boolean) // Get scores for each project 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 }), /** * 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 ctx.prisma.auditLog.create({ data: { 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 ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'END_SESSION', entityType: 'LiveVotingSession', entityId: session.id, detailsJson: {}, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return session }), /** * Submit a vote */ vote: protectedProcedure .input( z.object({ sessionId: z.string(), projectId: z.string(), score: z.number().int().min(1).max(10), }) ) .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', }) } // 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: input.score, }, update: { score: input.score, votedAt: new Date(), }, }) return vote }), /** * Get results for a session */ getResults: 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: { program: { select: { name: true, year: true } }, }, }, }, }) // Get all votes grouped by project const projectScores = await ctx.prisma.liveVote.groupBy({ by: ['projectId'], where: { sessionId: input.sessionId }, _avg: { score: true }, _count: true, }) // Get project details const projectIds = projectScores.map((s) => s.projectId) const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, title: true, teamName: true }, }) // Combine and sort by average score const results = projectScores .map((score) => { const project = projects.find((p) => p.id === score.projectId) return { project, averageScore: score._avg.score || 0, voteCount: score._count, } }) .sort((a, b) => b.averageScore - a.averageScore) return { session, results, } }), })