import { z } from 'zod' import { TRPCError } from '@trpc/server' import { Prisma, type PrismaClient, type LiveProgressCursor } from '@prisma/client' import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' // ─── Grand-finale phase machine helpers ───────────────────────────────────── type TimingEntry = { projectId: string phase: 'PRESENTING' | 'QA' startedAt: string endedAt: string configuredSeconds: number | null overranSeconds: number } /** * When leaving a timed phase (PRESENTING/QA), compute the actual elapsed time * (pause-adjusted) and append it to the cursor's timing log. Overtime is * recorded as fact — never as a penalty. */ function closedOutTiming(cursor: LiveProgressCursor, now: Date): Prisma.InputJsonValue | undefined { if (!cursor.phaseStartedAt || !cursor.activeProjectId) return undefined if (cursor.projectPhase !== 'PRESENTING' && cursor.projectPhase !== 'QA') return undefined const end = cursor.phasePausedAt ?? now const elapsedSec = Math.max( 0, Math.floor((end.getTime() - cursor.phaseStartedAt.getTime() - cursor.phasePausedAccumMs) / 1000) ) const entry: TimingEntry = { projectId: cursor.activeProjectId, phase: cursor.projectPhase, startedAt: cursor.phaseStartedAt.toISOString(), endedAt: now.toISOString(), configuredSeconds: cursor.phaseDurationSeconds, overranSeconds: cursor.phaseDurationSeconds == null ? 0 : Math.max(0, elapsedSec - cursor.phaseDurationSeconds), } const log = Array.isArray(cursor.timingLogJson) ? (cursor.timingLogJson as unknown as TimingEntry[]) : [] return [...log, entry] as unknown as Prisma.InputJsonValue } async function getRoundCeremonyConfig(prisma: PrismaClient, roundId: string) { const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId } }) const cfg = (round.configJson as Record) ?? {} return { presentationSeconds: typeof cfg.presentationDurationMinutes === 'number' ? cfg.presentationDurationMinutes * 60 : 300, qaSeconds: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300, projectOrder: (cfg.projectOrder as string[]) ?? [], } } 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 }), // ─────────────────────────────────────────────────────────────────────────── // Grand-finale phase machine (ON_DECK → PRESENTING → QA → SCORING) // ─────────────────────────────────────────────────────────────────────────── /** * Put a project on every screen as "Up next" — the grace period before the * presentation actually starts. Clears any timer and override slide. */ sendToScreens: 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 }, }) const { projectOrder } = await getRoundCeremonyConfig(ctx.prisma, input.roundId) const index = projectOrder.indexOf(input.projectId) if (index === -1) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not in the session order' }) } const now = new Date() const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { activeProjectId: input.projectId, activeOrderIndex: index, projectPhase: 'ON_DECK', phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0, overrideSlide: null, ...(closedOutTiming(cursor, now) !== undefined ? { timingLogJson: closedOutTiming(cursor, now) } : {}), }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_SEND_TO_SCREENS', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { projectId: input.projectId, orderIndex: index }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Start the presentation timer for the on-deck project. */ startPresentation: adminProcedure .input( z.object({ roundId: z.string(), durationSeconds: z.number().int().min(10).max(7200).optional(), }) ) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) if (!cursor.activeProjectId) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' }) } const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId) const now = new Date() const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { projectPhase: 'PRESENTING', phaseStartedAt: now, phaseDurationSeconds: input.durationSeconds ?? cfg.presentationSeconds, phasePausedAt: null, phasePausedAccumMs: 0, ...(closedOutTiming(cursor, now) !== undefined ? { timingLogJson: closedOutTiming(cursor, now) } : {}), }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_PHASE_STARTED', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { phase: 'PRESENTING', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Close out the presentation (logging overtime) and start the Q&A timer. */ startQA: adminProcedure .input( z.object({ roundId: z.string(), durationSeconds: z.number().int().min(10).max(7200).optional(), }) ) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) if (!cursor.activeProjectId) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' }) } const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId) const now = new Date() const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { projectPhase: 'QA', phaseStartedAt: now, phaseDurationSeconds: input.durationSeconds ?? cfg.qaSeconds, phasePausedAt: null, phasePausedAccumMs: 0, ...(closedOutTiming(cursor, now) !== undefined ? { timingLogJson: closedOutTiming(cursor, now) } : {}), }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_PHASE_STARTED', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { phase: 'QA', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Close out Q&A (logging overtime) and open jury scoring (no timer). */ openScoring: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) const now = new Date() const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { projectPhase: 'SCORING', phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0, ...(closedOutTiming(cursor, now) !== undefined ? { timingLogJson: closedOutTiming(cursor, now) } : {}), }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_PHASE_STARTED', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { phase: 'SCORING', projectId: cursor.activeProjectId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Pause the running phase timer (e.g. technical difficulties). */ pausePhase: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) if (!cursor.phaseStartedAt || cursor.phasePausedAt) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: cursor.phasePausedAt ? 'Timer is already paused' : 'No timer is running', }) } const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { phasePausedAt: new Date() }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_PHASE_PAUSED', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Resume the paused phase timer, folding paused time into the accumulator. */ resumePhase: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) if (!cursor.phasePausedAt) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Timer is not paused' }) } const now = new Date() const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { phasePausedAccumMs: cursor.phasePausedAccumMs + (now.getTime() - cursor.phasePausedAt.getTime()), phasePausedAt: null, }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_PHASE_RESUMED', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Force a static slide on the big screen (or clear it). */ setOverrideSlide: adminProcedure .input( z.object({ roundId: z.string(), slide: z.enum(['welcome', 'break', 'deliberation', 'thanks']).nullable(), }) ) .mutation(async ({ ctx, input }) => { const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({ where: { roundId: input.roundId }, }) const updated = await ctx.prisma.liveProgressCursor.update({ where: { id: cursor.id }, data: { overrideSlide: input.slide }, }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'LIVE_OVERRIDE_SLIDE', entityType: 'LiveProgressCursor', entityId: cursor.id, detailsJson: { slide: input.slide }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return updated }), /** * Persisted per-juror per-project ceremony notes (autosaved by the UI; * resurfaced during deliberation). */ saveNote: protectedProcedure .input( z.object({ roundId: z.string(), projectId: z.string(), content: z.string().max(20_000), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.liveNote.upsert({ where: { roundId_projectId_userId: { roundId: input.roundId, projectId: input.projectId, userId: ctx.user.id, }, }, create: { roundId: input.roundId, projectId: input.projectId, userId: ctx.user.id, content: input.content, }, update: { content: input.content }, }) }), getMyNotes: protectedProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.liveNote.findMany({ where: { roundId: input.roundId, userId: ctx.user.id }, orderBy: { updatedAt: 'desc' }, }) }), /** * 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, competitionCategory: true, }, }) } // Full run order with categories (for the admin run-order list and // category grouping on every surface) const orderProjects = await ctx.prisma.project.findMany({ where: { id: { in: projectOrder } }, select: { id: true, title: true, teamName: true, competitionCategory: true }, }) const orderById = new Map(orderProjects.map((p) => [p.id, p])) const orderedProjects = projectOrder .map((id) => orderById.get(id)) .filter((p): p is NonNullable => !!p) // 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, orderedProjects, 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 } }), })