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 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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user