feat(finale): reveal controller + public ceremony-state endpoint (no-leak guaranteed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,15 @@ interface LiveVotingCriterion {
|
|||||||
|
|
||||||
const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])
|
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
|
const MAX_FAVORITE_VOTERS_PER_IP = 3
|
||||||
|
|
||||||
/** Server-side window check — the source of truth even if no one closed the window. */
|
/** 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)
|
* Get audience voter stats (admin)
|
||||||
*/
|
*/
|
||||||
|
|||||||
160
tests/unit/reveal.test.ts
Normal file
160
tests/unit/reveal.test.ts
Normal file
@@ -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<typeof createCaller>
|
||||||
|
let publicCaller: ReturnType<typeof createCaller>
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user