From 4e6904fa1227c49d81e39427c95b7c02b9125419 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 10 Jun 2026 18:12:00 +0200 Subject: [PATCH] feat(finale): reveal controller + public ceremony-state endpoint (no-leak guaranteed) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/server/routers/live-voting.ts | 218 ++++++++++++++++++++++++++++++ tests/unit/reveal.test.ts | 160 ++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 tests/unit/reveal.test.ts diff --git a/src/server/routers/live-voting.ts b/src/server/routers/live-voting.ts index afd0579..62ab598 100644 --- a/src/server/routers/live-voting.ts +++ b/src/server/routers/live-voting.ts @@ -16,6 +16,15 @@ interface LiveVotingCriterion { const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL']) +const revealStepSchema = z.object({ + kind: z.enum(['category-intro', 'place', 'audience-award', 'overall-favorite', 'thanks']), + category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(), + place: z.number().int().min(1).max(10).optional(), + projectId: z.string().optional(), + title: z.string().max(200).optional(), + subtitle: z.string().max(300).optional(), +}) + const MAX_FAVORITE_VOTERS_PER_IP = 3 /** Server-side window check — the source of truth even if no one closed the window. */ @@ -1330,6 +1339,215 @@ export const liveVotingRouter = router({ } }), + // ─────────────────────────────────────────────────────────────────────────── + // Results reveal controller (big screen) + // ─────────────────────────────────────────────────────────────────────────── + + /** + * Save (or replace) the reveal step list as a private DRAFT. + * Display strings are denormalized into the steps at compose time so the + * public endpoint needs no joins. + */ + saveReveal: adminProcedure + .input( + z.object({ + sessionId: z.string(), + steps: z.array(revealStepSchema).max(50), + }) + ) + .mutation(async ({ ctx, input }) => { + return ctx.prisma.revealState.upsert({ + where: { sessionId: input.sessionId }, + create: { + sessionId: input.sessionId, + stepsJson: input.steps, + status: 'DRAFT', + currentStepIndex: -1, + }, + update: { stepsJson: input.steps, status: 'DRAFT', currentStepIndex: -1 }, + }) + }), + + /** + * Arm the reveal: the big screen switches to the Results splash, nothing + * is revealed yet. + */ + armReveal: adminProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ ctx, input }) => { + const reveal = await ctx.prisma.revealState.findUniqueOrThrow({ + where: { sessionId: input.sessionId }, + }) + const steps = (reveal.stepsJson as unknown[]) ?? [] + if (steps.length === 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No reveal steps composed' }) + } + const updated = await ctx.prisma.revealState.update({ + where: { sessionId: input.sessionId }, + data: { status: 'ARMED', currentStepIndex: -1 }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'REVEAL_ARMED', + entityType: 'RevealState', + entityId: updated.id, + detailsJson: { stepCount: steps.length }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + return updated + }), + + /** + * Reveal the next step. Clamps at the final step and flips to DONE. + */ + revealNext: adminProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ ctx, input }) => { + const reveal = await ctx.prisma.revealState.findUniqueOrThrow({ + where: { sessionId: input.sessionId }, + }) + if (reveal.status !== 'ARMED' && reveal.status !== 'REVEALING' && reveal.status !== 'DONE') { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Reveal is not armed' }) + } + const steps = (reveal.stepsJson as unknown[]) ?? [] + const newIndex = Math.min(reveal.currentStepIndex + 1, steps.length - 1) + const updated = await ctx.prisma.revealState.update({ + where: { sessionId: input.sessionId }, + data: { + currentStepIndex: newIndex, + status: newIndex >= steps.length - 1 ? 'DONE' : 'REVEALING', + }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'REVEAL_ADVANCED', + entityType: 'RevealState', + entityId: updated.id, + detailsJson: { stepIndex: newIndex }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + return updated + }), + + /** + * Reset the reveal to DRAFT — the big screen leaves reveal mode. + */ + resetReveal: adminProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ ctx, input }) => { + const updated = await ctx.prisma.revealState.update({ + where: { sessionId: input.sessionId }, + data: { status: 'DRAFT', currentStepIndex: -1 }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'REVEAL_RESET', + entityType: 'RevealState', + entityId: updated.id, + detailsJson: {}, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + return updated + }), + + /** Full reveal state incl. all steps — admin preview only. */ + getRevealAdmin: adminProcedure + .input(z.object({ sessionId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.revealState.findUnique({ where: { sessionId: input.sessionId } }) + }), + + /** + * Everything the big-screen ceremony view needs, derived from existing + * state. Public. NEVER includes scores or un-revealed steps. + */ + getCeremonyState: publicProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const [cursor, session] = await Promise.all([ + ctx.prisma.liveProgressCursor.findUnique({ where: { roundId: input.roundId } }), + ctx.prisma.liveVotingSession.findUnique({ + where: { roundId: input.roundId }, + select: { + id: true, + audiencePhase: true, + audienceWindowKey: true, + audienceWindowClosesAt: true, + round: { + select: { + name: true, + competition: { + select: { program: { select: { name: true, year: true } } }, + }, + }, + }, + }, + }), + ]) + + let activeProject = null + if (cursor?.activeProjectId) { + activeProject = await ctx.prisma.project.findUnique({ + where: { id: cursor.activeProjectId }, + select: { id: true, title: true, teamName: true, competitionCategory: true }, + }) + } + + const audienceOpen = session ? windowIsOpen(session) : false + let voteCount = 0 + if (session && audienceOpen && session.audienceWindowKey) { + voteCount = await ctx.prisma.audienceFavoriteVote.count({ + where: { sessionId: session.id, windowKey: session.audienceWindowKey }, + }) + } + + let reveal: { status: string; currentStepIndex: number; steps: unknown[] } | null = null + if (session) { + const revealState = await ctx.prisma.revealState.findUnique({ + where: { sessionId: session.id }, + }) + if (revealState && revealState.status !== 'DRAFT') { + const steps = (revealState.stepsJson as unknown[]) ?? [] + reveal = { + status: revealState.status, + currentStepIndex: revealState.currentStepIndex, + // ONLY steps revealed so far — never the full list + steps: + revealState.status === 'ARMED' ? [] : steps.slice(0, revealState.currentStepIndex + 1), + } + } + } + + return { + overrideSlide: cursor?.overrideSlide ?? null, + phase: cursor + ? { + projectPhase: cursor.projectPhase, + phaseStartedAt: cursor.phaseStartedAt, + phaseDurationSeconds: cursor.phaseDurationSeconds, + phasePausedAt: cursor.phasePausedAt, + phasePausedAccumMs: cursor.phasePausedAccumMs, + } + : null, + activeProject, + audience: { + open: audienceOpen, + windowKey: audienceOpen ? session?.audienceWindowKey ?? null : null, + closesAt: audienceOpen ? session?.audienceWindowClosesAt ?? null : null, + voteCount, + }, + reveal, + programName: session?.round?.competition?.program?.name ?? null, + roundName: session?.round?.name ?? null, + } + }), + /** * Get audience voter stats (admin) */ diff --git a/tests/unit/reveal.test.ts b/tests/unit/reveal.test.ts new file mode 100644 index 0000000..acf9a94 --- /dev/null +++ b/tests/unit/reveal.test.ts @@ -0,0 +1,160 @@ +/** + * Results reveal controller: admin composes steps privately (DRAFT), arms the + * big screen (ARMED), then steps through one reveal at a time (REVEALING → + * DONE). The public ceremony-state endpoint must NEVER leak un-revealed steps. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestCompetition, + createTestRound, + createTestProject, + cleanupTestData, +} from '../helpers' +import { liveVotingRouter } from '@/server/routers/live-voting' +import { liveRouter } from '@/server/routers/live' + +let program: any +let round: any +let session: any +let project: any +let admin: any +let adminCaller: ReturnType +let publicCaller: ReturnType + +const steps = [ + { kind: 'category-intro' as const, category: 'STARTUP' as const, title: 'Startups' }, + { kind: 'place' as const, category: 'STARTUP' as const, place: 3, title: 'Team Gamma', subtitle: '3rd place — Startups' }, + { kind: 'place' as const, category: 'STARTUP' as const, place: 2, title: 'Team Beta', subtitle: '2nd place — Startups' }, + { kind: 'place' as const, category: 'STARTUP' as const, place: 1, title: 'Team Alpha', subtitle: 'Winner — Startups' }, + { kind: 'thanks' as const, title: 'Thank you' }, +] + +beforeAll(async () => { + program = await createTestProgram() + const competition = await createTestCompetition(program.id) + round = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' }) + project = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) + await prisma.round.update({ + where: { id: round.id }, + data: { configJson: { projectOrder: [project.id] } }, + }) + session = await prisma.liveVotingSession.create({ data: { roundId: round.id } }) + admin = await createTestUser('SUPER_ADMIN') + adminCaller = createCaller(liveVotingRouter, admin) + publicCaller = createCaller(liveVotingRouter, admin) + // a cursor so getCeremonyState has phase data + const liveCaller = createCaller(liveRouter, admin) + await liveCaller.start({ roundId: round.id, projectOrder: [project.id] }) +}) + +afterAll(async () => { + await cleanupTestData(program.id, [admin.id]) +}) + +describe('reveal lifecycle', () => { + it('saveReveal upserts a DRAFT', async () => { + const r1 = await adminCaller.saveReveal({ sessionId: session.id, steps: steps.slice(0, 2) }) + expect(r1.status).toBe('DRAFT') + const r2 = await adminCaller.saveReveal({ sessionId: session.id, steps }) + expect((r2.stepsJson as any[]).length).toBe(5) + expect(r2.currentStepIndex).toBe(-1) + }) + + it('armReveal requires steps and moves DRAFT → ARMED', async () => { + const r = await adminCaller.armReveal({ sessionId: session.id }) + expect(r.status).toBe('ARMED') + }) + + it('revealNext steps through and lands on DONE at the last step', async () => { + let r = await adminCaller.revealNext({ sessionId: session.id }) + expect(r.status).toBe('REVEALING') + expect(r.currentStepIndex).toBe(0) + + r = await adminCaller.revealNext({ sessionId: session.id }) + expect(r.currentStepIndex).toBe(1) + + r = await adminCaller.revealNext({ sessionId: session.id }) + r = await adminCaller.revealNext({ sessionId: session.id }) + r = await adminCaller.revealNext({ sessionId: session.id }) + expect(r.currentStepIndex).toBe(4) + expect(r.status).toBe('DONE') + + // advancing past the end stays clamped at the final step + r = await adminCaller.revealNext({ sessionId: session.id }) + expect(r.currentStepIndex).toBe(4) + expect(r.status).toBe('DONE') + }) + + it('public ceremony state only exposes revealed steps', async () => { + // wind back mid-reveal + await prisma.revealState.update({ + where: { sessionId: session.id }, + data: { status: 'REVEALING', currentStepIndex: 1 }, + }) + const state = await publicCaller.getCeremonyState({ roundId: round.id }) + expect(state.reveal?.status).toBe('REVEALING') + expect(state.reveal?.steps).toHaveLength(2) // steps 0 and 1 only + expect(state.reveal?.steps?.some((s: any) => s.title === 'Team Alpha')).toBe(false) + }) + + it('public ceremony state shows no steps when ARMED and no reveal when DRAFT', async () => { + await prisma.revealState.update({ + where: { sessionId: session.id }, + data: { status: 'ARMED', currentStepIndex: -1 }, + }) + let state = await publicCaller.getCeremonyState({ roundId: round.id }) + expect(state.reveal?.status).toBe('ARMED') + expect(state.reveal?.steps).toHaveLength(0) + + await adminCaller.resetReveal({ sessionId: session.id }) + state = await publicCaller.getCeremonyState({ roundId: round.id }) + expect(state.reveal).toBeNull() + }) + + it('reveal mutations are admin-only', async () => { + const juror = await createTestUser('JURY_MEMBER') + const jurorCaller = createCaller(liveVotingRouter, juror) + await expect(jurorCaller.armReveal({ sessionId: session.id })).rejects.toThrow() + await expect(jurorCaller.revealNext({ sessionId: session.id })).rejects.toThrow() + await prisma.user.delete({ where: { id: juror.id } }) + }) +}) + +describe('ceremony state composition', () => { + it('exposes phase, active project, audience window and never any scores', async () => { + const state = await publicCaller.getCeremonyState({ roundId: round.id }) + expect(state.phase?.projectPhase).toBe('ON_DECK') + expect(state.activeProject?.title).toBe(project.title) + expect(state.audience.open).toBe(false) + expect(JSON.stringify(state)).not.toMatch(/score/i) + }) + + it('includes the live vote count while a window is open', async () => { + await adminCaller.updateSessionConfig({ sessionId: session.id, allowAudienceVotes: true }) + await adminCaller.openAudienceWindow({ + sessionId: session.id, + windowKey: 'CATEGORY:STARTUP', + durationMinutes: 5, + }) + const reg = await publicCaller.registerAudienceVoter({ sessionId: session.id }) + await publicCaller.castFavoriteVote({ + sessionId: session.id, + token: reg.token, + projectId: project.id, + }) + const state = await publicCaller.getCeremonyState({ roundId: round.id }) + expect(state.audience.open).toBe(true) + expect(state.audience.windowKey).toBe('CATEGORY:STARTUP') + expect(state.audience.voteCount).toBe(1) + }) + + it('reports the override slide', async () => { + const liveCaller = createCaller(liveRouter, admin) + await liveCaller.setOverrideSlide({ roundId: round.id, slide: 'break' }) + const state = await publicCaller.getCeremonyState({ roundId: round.id }) + expect(state.overrideSlide).toBe('break') + }) +})