diff --git a/src/server/routers/live.ts b/src/server/routers/live.ts index 4bb8936..eec3564 100644 --- a/src/server/routers/live.ts +++ b/src/server/routers/live.ts @@ -1,9 +1,61 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' -import { Prisma } from '@prisma/client' +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 @@ -344,6 +396,318 @@ export const liveRouter = router({ 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) */ @@ -376,10 +740,22 @@ export const liveRouter = router({ 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 }, @@ -395,6 +771,7 @@ export const liveRouter = router({ ...cursor, activeProject, projectOrder, + orderedProjects, totalProjects: projectOrder.length, openCohorts, } diff --git a/tests/unit/live-phase.test.ts b/tests/unit/live-phase.test.ts new file mode 100644 index 0000000..2ed8c81 --- /dev/null +++ b/tests/unit/live-phase.test.ts @@ -0,0 +1,167 @@ +/** + * Grand-finale per-project phase machine on LiveProgressCursor: + * ON_DECK → PRESENTING → QA → SCORING, with server-stamped timers, + * pause/resume accumulator math, an overtime timing log, big-screen + * override slides, and persisted juror notes. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestCompetition, + createTestRound, + createTestProject, + cleanupTestData, +} from '../helpers' +import { liveRouter } from '@/server/routers/live' + +let program: any +let round: any +let p1: any +let p2: any +let admin: any +let juror: any +let adminCaller: ReturnType +let jurorCaller: ReturnType + +beforeAll(async () => { + program = await createTestProgram() + const competition = await createTestCompetition(program.id) + round = await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + status: 'ROUND_ACTIVE', + configJson: { presentationDurationMinutes: 2, qaDurationMinutes: 1 }, + }) + p1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) + p2 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' }) + admin = await createTestUser('SUPER_ADMIN') + juror = await createTestUser('JURY_MEMBER') + adminCaller = createCaller(liveRouter, admin) + jurorCaller = createCaller(liveRouter, juror) + + await adminCaller.start({ roundId: round.id, projectOrder: [p1.id, p2.id] }) +}) + +afterAll(async () => { + await cleanupTestData(program.id, [admin.id, juror.id]) +}) + +describe('phase transitions', () => { + it('sendToScreens puts a project ON_DECK with no timer', async () => { + const cursor = await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id }) + expect(cursor.projectPhase).toBe('ON_DECK') + expect(cursor.activeProjectId).toBe(p1.id) + expect(cursor.phaseStartedAt).toBeNull() + expect(cursor.phaseDurationSeconds).toBeNull() + expect(cursor.overrideSlide).toBeNull() + }) + + it('rejects sendToScreens for a project outside the order', async () => { + await expect( + adminCaller.sendToScreens({ roundId: round.id, projectId: 'nope' }) + ).rejects.toThrow() + }) + + it('startPresentation stamps the timer with config default duration', async () => { + const cursor = await adminCaller.startPresentation({ roundId: round.id }) + expect(cursor.projectPhase).toBe('PRESENTING') + expect(cursor.phaseStartedAt).not.toBeNull() + expect(cursor.phaseDurationSeconds).toBe(120) // presentationDurationMinutes: 2 + expect(cursor.phasePausedAccumMs).toBe(0) + }) + + it('pause/resume folds pause time into the accumulator', async () => { + const paused = await adminCaller.pausePhase({ roundId: round.id }) + expect(paused.phasePausedAt).not.toBeNull() + + // pausing twice errors + await expect(adminCaller.pausePhase({ roundId: round.id })).rejects.toThrow() + + // backdate the pause so the accumulator visibly grows + await prisma.liveProgressCursor.update({ + where: { roundId: round.id }, + data: { phasePausedAt: new Date(Date.now() - 5_000) }, + }) + const resumed = await adminCaller.resumePhase({ roundId: round.id }) + expect(resumed.phasePausedAt).toBeNull() + expect(resumed.phasePausedAccumMs).toBeGreaterThanOrEqual(5_000) + + // resuming while not paused errors + await expect(adminCaller.resumePhase({ roundId: round.id })).rejects.toThrow() + }) + + it('startQA logs the presentation with overtime and starts the QA timer', async () => { + // Backdate the presentation start so it overran its 120s budget + await prisma.liveProgressCursor.update({ + where: { roundId: round.id }, + data: { phaseStartedAt: new Date(Date.now() - 200_000), phasePausedAccumMs: 0 }, + }) + const cursor = await adminCaller.startQA({ roundId: round.id, durationSeconds: 30 }) + expect(cursor.projectPhase).toBe('QA') + expect(cursor.phaseDurationSeconds).toBe(30) + + const log = cursor.timingLogJson as Array + expect(log).toHaveLength(1) + expect(log[0].projectId).toBe(p1.id) + expect(log[0].phase).toBe('PRESENTING') + expect(log[0].configuredSeconds).toBe(120) + expect(log[0].overranSeconds).toBeGreaterThanOrEqual(79) // ~200s elapsed vs 120s budget + }) + + it('openScoring logs the QA phase and clears the timer', async () => { + const cursor = await adminCaller.openScoring({ roundId: round.id }) + expect(cursor.projectPhase).toBe('SCORING') + expect(cursor.phaseStartedAt).toBeNull() + const log = cursor.timingLogJson as Array + expect(log).toHaveLength(2) + expect(log[1].phase).toBe('QA') + expect(log[1].configuredSeconds).toBe(30) + }) + + it('sending the next project keeps the timing log', async () => { + const cursor = await adminCaller.sendToScreens({ roundId: round.id, projectId: p2.id }) + expect(cursor.activeProjectId).toBe(p2.id) + expect(cursor.projectPhase).toBe('ON_DECK') + expect((cursor.timingLogJson as Array).length).toBe(2) + }) + + it('setOverrideSlide sets and clears the big-screen override', async () => { + const set = await adminCaller.setOverrideSlide({ roundId: round.id, slide: 'break' }) + expect(set.overrideSlide).toBe('break') + const cleared = await adminCaller.setOverrideSlide({ roundId: round.id, slide: null }) + expect(cleared.overrideSlide).toBeNull() + }) + + it('getCursor exposes phase fields and ordered projects with categories', async () => { + const cursor = await jurorCaller.getCursor({ roundId: round.id }) + expect(cursor?.projectPhase).toBe('ON_DECK') + expect(cursor?.orderedProjects?.map((p: any) => p.id)).toEqual([p1.id, p2.id]) + expect(cursor?.orderedProjects?.[0]?.competitionCategory).toBe('STARTUP') + expect(cursor?.activeProject?.competitionCategory).toBe('BUSINESS_CONCEPT') + }) + + it('phase mutations are admin-only', async () => { + await expect( + jurorCaller.startPresentation({ roundId: round.id }) + ).rejects.toThrow() + }) +}) + +describe('juror notes', () => { + it('saveNote upserts one note per (round, project, juror)', async () => { + await jurorCaller.saveNote({ roundId: round.id, projectId: p1.id, content: 'first draft' }) + await jurorCaller.saveNote({ roundId: round.id, projectId: p1.id, content: 'revised' }) + await jurorCaller.saveNote({ roundId: round.id, projectId: p2.id, content: 'other project' }) + + const notes = await jurorCaller.getMyNotes({ roundId: round.id }) + expect(notes).toHaveLength(2) + const n1 = notes.find((n: any) => n.projectId === p1.id) + expect(n1?.content).toBe('revised') + }) + + it('getMyNotes only returns the caller’s notes', async () => { + const adminNotes = await adminCaller.getMyNotes({ roundId: round.id }) + expect(adminNotes).toHaveLength(0) + }) +})