import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' export const liveRouter = router({ /** * Start a live presentation session for a stage */ start: adminProcedure .input( z.object({ roundId: z.string(), projectOrder: z.array(z.string()).min(1), // Ordered project IDs }) ) .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, }) if (round.status !== 'ROUND_ACTIVE') { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Round must be ACTIVE to start a live session', }) } // Check for existing active cursor const existingCursor = await ctx.prisma.liveProgressCursor.findUnique({ where: { roundId: input.roundId }, }) if (existingCursor) { throw new TRPCError({ code: 'CONFLICT', message: 'A live session already exists for this round. Use jump/reorder to modify it.', }) } // Verify all projects exist const projects = await ctx.prisma.project.findMany({ where: { id: { in: input.projectOrder } }, select: { id: true }, }) if (projects.length !== input.projectOrder.length) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Some project IDs are invalid', }) } const cursor = await ctx.prisma.$transaction(async (tx) => { // Store the project order in round config await tx.round.update({ where: { id: input.roundId }, data: { configJson: { ...(round.configJson as Record ?? {}), projectOrder: input.projectOrder, } as Prisma.InputJsonValue, }, }) const created = await tx.liveProgressCursor.create({ data: { roundId: input.roundId, activeProjectId: input.projectOrder[0], activeOrderIndex: 0, isPaused: false, }, }) return created }) // Audit outside transaction so failures don't roll back the session start await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_SESSION_STARTED', entityType: 'Round', entityId: input.roundId, detailsJson: { sessionId: cursor.sessionId, projectCount: input.projectOrder.length, firstProjectId: input.projectOrder[0], }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return cursor }), /** * Set the active project in the live session */ setActiveProject: adminProcedure .input( z.object({ roundId: z.string(), projectId: z.string(), }) ) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) // Get project order from round config const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, }) const config = (round.configJson as Record) ?? {} const projectOrder = (config.projectOrder as string[]) ?? [] const index = projectOrder.indexOf(input.projectId) if (index === -1) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not in the session order', }) } const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { activeProjectId: input.projectId, activeOrderIndex: index, }, }) // Audit outside transaction so failures don't roll back the project set await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_ACTIVE_PROJECT_SET', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { projectId: input.projectId, orderIndex: index, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Jump to a specific index in the project order */ jump: adminProcedure .input( z.object({ roundId: z.string(), index: z.number().int().min(0), }) ) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, }) const config = (round.configJson as Record) ?? {} const projectOrder = (config.projectOrder as string[]) ?? [] if (input.index >= projectOrder.length) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Index ${input.index} is out of range (0-${projectOrder.length - 1})`, }) } const targetProjectId = projectOrder[input.index] const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { activeProjectId: targetProjectId, activeOrderIndex: input.index, }, }) // Audit outside transaction so failures don't roll back the jump await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_JUMP', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { fromIndex: cursor.activeOrderIndex, toIndex: input.index, projectId: targetProjectId, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Reorder the project presentation queue */ reorder: adminProcedure .input( z.object({ roundId: z.string(), projectOrder: z.array(z.string()).min(1), }) ) .mutation(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, }) const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) // Update config with new order const updated = await ctx.prisma.$transaction(async (tx) => { await tx.round.update({ where: { id: input.roundId }, data: { configJson: { ...(round.configJson as Record ?? {}), projectOrder: input.projectOrder, } as Prisma.InputJsonValue, }, }) // Recalculate active index const newIndex = cursor.activeProjectId ? input.projectOrder.indexOf(cursor.activeProjectId) : 0 const updatedCursor = await tx.liveProgressCursor.update({ where: { id: cursor.id }, data: { activeOrderIndex: Math.max(0, newIndex), }, }) return updatedCursor }) // Audit outside transaction so failures don't roll back the reorder await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_REORDER', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { projectCount: input.projectOrder.length, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Pause the live session */ pause: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) if (cursor.isPaused) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Session is already paused', }) } const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { isPaused: true }, }) // Audit outside transaction so failures don't roll back the pause await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_PAUSED', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { activeProjectId: cursor.activeProjectId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Resume the live session */ resume: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) if (!cursor.isPaused) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Session is not paused', }) } const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { isPaused: false }, }) // Audit outside transaction so failures don't roll back the resume await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_RESUMED', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { activeProjectId: cursor.activeProjectId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Get current cursor state (for all users, including audience) */ getCursor: protectedProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUnique({ where: { roundId: input.roundId }, }) if (!cursor) { return null } // Get round config for project order const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, }) const config = (round.configJson as Record) ?? {} const projectOrder = (config.projectOrder as string[]) ?? [] // Get current project details let activeProject = null if (cursor.activeProjectId) { activeProject = await ctx.prisma.project.findUnique({ where: { id: cursor.activeProjectId }, select: { id: true, title: true, teamName: true, description: true, tags: true, }, }) } // Get open cohorts for this round (if any) const openCohorts = await ctx.prisma.cohort.findMany({ where: { roundId: input.roundId, isOpen: true }, select: { id: true, name: true, votingMode: true, windowCloseAt: true, }, }) return { ...cursor, activeProject, projectOrder, totalProjects: projectOrder.length, openCohorts, } }), /** * Cast a vote during a live session (audience or jury) * Checks window is open and deduplicates votes */ castVote: audienceProcedure .input( z.object({ roundId: z.string(), projectId: z.string(), score: z.number().int().min(1).max(10), criterionScoresJson: z.record(z.number()).optional(), }) ) .mutation(async ({ ctx, input }) => { // Verify live session exists and is not paused const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) if (cursor.isPaused) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Voting is paused', }) } // Check if there's an open cohort containing this project const openCohort = await ctx.prisma.cohort.findFirst({ where: { roundId: input.roundId, isOpen: true, projects: { some: { projectId: input.projectId } }, }, }) // Check voting window if cohort has time limits if (openCohort?.windowCloseAt) { const now = new Date() if (now > openCohort.windowCloseAt) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Voting window has closed', }) } } // Find the LiveVotingSession linked to this round const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { competition: { select: { programId: true } } }, }) // Find or check existing LiveVotingSession for this round const session = await ctx.prisma.liveVotingSession.findFirst({ where: { round: { competition: { programId: round.competition.programId } }, status: 'IN_PROGRESS', }, }) if (!session) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No active voting session found', }) } // Deduplicate: check if user already voted on this project in this session const existingVote = await ctx.prisma.liveVote.findUnique({ where: { sessionId_projectId_userId: { sessionId: session.id, projectId: input.projectId, userId: ctx.user.id, }, }, }) if (existingVote) { // Update existing vote const updated = await ctx.prisma.liveVote.update({ where: { id: existingVote.id }, data: { score: input.score, criterionScoresJson: input.criterionScoresJson ? (input.criterionScoresJson as Prisma.InputJsonValue) : undefined, votedAt: new Date(), }, }) return { vote: updated, wasUpdate: true } } // Create new vote const vote = await ctx.prisma.liveVote.create({ data: { sessionId: session.id, projectId: input.projectId, userId: ctx.user.id, score: input.score, isAudienceVote: !['JURY_MEMBER', 'SUPER_ADMIN', 'PROGRAM_ADMIN'].includes( ctx.user.role ), criterionScoresJson: input.criterionScoresJson ? (input.criterionScoresJson as Prisma.InputJsonValue) : undefined, }, }) return { vote, wasUpdate: false } }), // ========================================================================= // Phase 4: Audience-native procedures // ========================================================================= /** * Get audience context for a live session (public-facing via sessionId) */ getAudienceContext: protectedProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUnique({ where: { sessionId: input.sessionId }, }) if (!cursor) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Live session not found', }) } // Get round info const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: cursor.roundId }, select: { id: true, name: true, status: true, configJson: true, }, }) // Get active project let activeProject = null if (cursor.activeProjectId) { activeProject = await ctx.prisma.project.findUnique({ where: { id: cursor.activeProjectId }, select: { id: true, title: true, teamName: true, description: true, tags: true, country: true, }, }) } // Get open cohorts const openCohorts = await ctx.prisma.cohort.findMany({ where: { roundId: cursor.roundId, isOpen: true }, select: { id: true, name: true, votingMode: true, windowOpenAt: true, windowCloseAt: true, projects: { select: { projectId: true }, }, }, }) const config = (round.configJson as Record) ?? {} const projectOrder = (config.projectOrder as string[]) ?? [] const now = new Date() const isWindowOpen = round.status === 'ROUND_ACTIVE' // Aggregate project scores from LiveVote for the scoreboard // Find the active LiveVotingSession for this round's program const votingSession = await ctx.prisma.liveVotingSession.findFirst({ where: { round: { competition: { programId: round.id } }, status: 'IN_PROGRESS', }, select: { id: true }, }) // Get all cohort project IDs for this stage const allCohortProjectIds = openCohorts.flatMap((c) => c.projects.map((p) => p.projectId) ) const uniqueProjectIds = [...new Set(allCohortProjectIds)] let projectScores: Array<{ projectId: string title: string teamName: string | null averageScore: number voteCount: number }> = [] if (votingSession && uniqueProjectIds.length > 0) { // Get vote aggregates const voteAggregates = await ctx.prisma.liveVote.groupBy({ by: ['projectId'], where: { sessionId: votingSession.id, projectId: { in: uniqueProjectIds }, }, _avg: { score: true }, _count: { score: true }, }) // Get project details const projects = await ctx.prisma.project.findMany({ where: { id: { in: uniqueProjectIds } }, select: { id: true, title: true, teamName: true }, }) const projectMap = new Map(projects.map((p) => [p.id, p])) projectScores = voteAggregates.map((agg) => { const project = projectMap.get(agg.projectId) return { projectId: agg.projectId, title: project?.title ?? 'Unknown', teamName: project?.teamName ?? null, averageScore: agg._avg.score ?? 0, voteCount: agg._count.score, } }) } return { cursor: { sessionId: cursor.sessionId, activeOrderIndex: cursor.activeOrderIndex, isPaused: cursor.isPaused, totalProjects: projectOrder.length, }, activeProject, openCohorts: openCohorts.map((c) => ({ id: c.id, name: c.name, votingMode: c.votingMode, windowCloseAt: c.windowCloseAt, projectIds: c.projects.map((p) => p.projectId), })), projectScores, roundInfo: { id: round.id, name: round.name, }, windowStatus: { isOpen: true, closesAt: null, }, } }), /** * Cast a vote in a stage-native live session */ castStageVote: audienceProcedure .input( z.object({ sessionId: z.string(), projectId: z.string(), score: z.number().int().min(1).max(10), dedupeKey: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { // Resolve cursor by sessionId const cursor = await ctx.prisma.liveProgressCursor.findUnique({ where: { sessionId: input.sessionId }, }) if (!cursor) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Live session not found', }) } if (cursor.isPaused) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Voting is paused', }) } // Check if there's an open cohort containing this project const openCohort = await ctx.prisma.cohort.findFirst({ where: { roundId: cursor.roundId, isOpen: true, projects: { some: { projectId: input.projectId } }, }, }) if (openCohort?.windowCloseAt) { const now = new Date() if (now > openCohort.windowCloseAt) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Voting window has closed', }) } } // Find an active LiveVotingSession const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: cursor.roundId }, select: { competition: { select: { programId: true } } }, }) const session = await ctx.prisma.liveVotingSession.findFirst({ where: { round: { competition: { programId: round.competition.programId } }, status: 'IN_PROGRESS', }, }) if (!session) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No active voting session found', }) } // Deduplicate: sessionId + projectId + userId const existingVote = await ctx.prisma.liveVote.findUnique({ where: { sessionId_projectId_userId: { sessionId: session.id, projectId: input.projectId, userId: ctx.user.id, }, }, }) if (existingVote) { const updated = await ctx.prisma.liveVote.update({ where: { id: existingVote.id }, data: { score: input.score, votedAt: new Date(), }, }) return { vote: updated, wasUpdate: true } } const vote = await ctx.prisma.liveVote.create({ data: { sessionId: session.id, projectId: input.projectId, userId: ctx.user.id, score: input.score, isAudienceVote: !['JURY_MEMBER', 'SUPER_ADMIN', 'PROGRAM_ADMIN'].includes( ctx.user.role ), }, }) return { vote, wasUpdate: false } }), })