feat(finale): audience favorite-vote windows with category gating + IP cap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-10 18:09:02 +02:00
parent 6eccfc694e
commit 6d2fa3369f
2 changed files with 613 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { randomUUID } from 'crypto'
import type { PrismaClient } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
interface LiveVotingCriterion {
@@ -11,6 +12,49 @@ interface LiveVotingCriterion {
weight: number
}
// ─── Grand-finale audience favorite-vote windows ─────────────────────────────
const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])
const MAX_FAVORITE_VOTERS_PER_IP = 3
/** Server-side window check — the source of truth even if no one closed the window. */
function windowIsOpen(
s: { audiencePhase: string; audienceWindowClosesAt: Date | null },
now = new Date()
) {
return s.audiencePhase === 'OPEN' && !!s.audienceWindowClosesAt && now <= s.audienceWindowClosesAt
}
function categoryForKey(key: string): 'STARTUP' | 'BUSINESS_CONCEPT' | null {
if (key === 'CATEGORY:STARTUP') return 'STARTUP'
if (key === 'CATEGORY:BUSINESS_CONCEPT') return 'BUSINESS_CONCEPT'
return null
}
/**
* Finale project order: the cursor system's round.configJson.projectOrder is
* the source of truth; session.projectOrderJson is the fallback.
*/
async function getOrderedFinaleProjects(
prisma: PrismaClient,
session: { roundId: string | null; projectOrderJson: unknown }
) {
let order: string[] = []
if (session.roundId) {
const round = await prisma.round.findUnique({ where: { id: session.roundId } })
order = (((round?.configJson as Record<string, unknown>) ?? {}).projectOrder as string[]) ?? []
}
if (order.length === 0) order = (session.projectOrderJson as string[]) ?? []
if (order.length === 0) return []
const projects = await prisma.project.findMany({
where: { id: { in: order } },
select: { id: true, title: true, teamName: true, competitionCategory: true },
})
const byId = new Map(projects.map((p) => [p.id, p]))
return order.map((id) => byId.get(id)).filter((p): p is NonNullable<typeof p> => !!p)
}
export const liveVotingRouter = router({
/**
* Get or create a live voting session for a round
@@ -759,6 +803,7 @@ export const liveVotingRouter = router({
audienceMaxFavorites: z.number().int().min(1).max(20).optional(),
audienceRequireId: z.boolean().optional(),
audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(),
allowOverallFavorite: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -917,6 +962,297 @@ export const liveVotingRouter = router({
return vote
}),
// ───────────────────────────────────────────────────────────────────────────
// Grand-finale audience favorite-vote windows
// ───────────────────────────────────────────────────────────────────────────
/**
* Open an audience voting window ("favorite Startup", "favorite Business
* Concept", or — if enabled — "overall favorite") for a fixed duration.
*/
openAudienceWindow: adminProcedure
.input(
z.object({
sessionId: z.string(),
windowKey: windowKeySchema,
durationMinutes: z.number().int().min(1).max(120).default(5),
})
)
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (windowIsOpen(session)) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An audience voting window is already open — close it first',
})
}
if (input.windowKey === 'OVERALL' && !session.allowOverallFavorite) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'The overall favorite vote is not enabled for this session',
})
}
const now = new Date()
const updated = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
audiencePhase: 'OPEN',
audienceWindowKey: input.windowKey,
audienceWindowOpenedAt: now,
audienceWindowClosesAt: new Date(now.getTime() + input.durationMinutes * 60_000),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'AUDIENCE_WINDOW_OPENED',
entityType: 'LiveVotingSession',
entityId: input.sessionId,
detailsJson: { windowKey: input.windowKey, durationMinutes: input.durationMinutes },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Close the audience voting window early (allowed at any time).
*/
closeAudienceWindow: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
const updated = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
audiencePhase: 'CLOSED',
audienceWindowKey: null,
audienceWindowOpenedAt: null,
audienceWindowClosesAt: null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'AUDIENCE_WINDOW_CLOSED',
entityType: 'LiveVotingSession',
entityId: input.sessionId,
detailsJson: { windowKey: session.audienceWindowKey },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Public window state for the audience voting page: open/closed, eligible
* projects (in run order), closing time, and the caller's current pick.
*/
getAudienceWindow: publicProcedure
.input(z.object({ sessionId: z.string(), token: z.string().optional() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
select: {
id: true,
roundId: true,
projectOrderJson: true,
audiencePhase: true,
audienceWindowKey: true,
audienceWindowClosesAt: true,
allowAudienceVotes: true,
},
})
const open = windowIsOpen(session)
const windowKey = open ? session.audienceWindowKey : null
let projects: Awaited<ReturnType<typeof getOrderedFinaleProjects>> = []
if (open && windowKey) {
const cat = categoryForKey(windowKey)
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
projects = cat ? ordered.filter((p) => p.competitionCategory === cat) : ordered
}
let myVoteProjectId: string | null = null
if (input.token && windowKey) {
const voter = await ctx.prisma.audienceVoter.findUnique({
where: { token: input.token },
})
if (voter && voter.sessionId === input.sessionId) {
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
where: {
sessionId_windowKey_audienceVoterId: {
sessionId: input.sessionId,
windowKey,
audienceVoterId: voter.id,
},
},
})
myVoteProjectId = existing?.projectId ?? null
}
}
return {
open,
windowKey,
closesAt: open ? session.audienceWindowClosesAt : null,
projects,
myVoteProjectId,
}
}),
/**
* Cast (or change) a pick-one-favorite vote in the open window.
* Gates, in order: token, window open, time, eligibility, category, IP cap.
*/
castFavoriteVote: publicProcedure
.input(z.object({ sessionId: z.string(), token: z.string(), projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const voter = await ctx.prisma.audienceVoter.findUnique({
where: { token: input.token },
})
if (!voter || voter.sessionId !== input.sessionId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid voting token' })
}
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (!windowIsOpen(session) || !session.audienceWindowKey) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting is not open right now',
})
}
const windowKey = session.audienceWindowKey
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
const project = ordered.find((p) => p.id === input.projectId)
if (!project) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not part of this vote' })
}
const cat = categoryForKey(windowKey)
if (cat && project.competitionCategory !== cat) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Project is not in the category currently open for voting',
})
}
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
where: {
sessionId_windowKey_audienceVoterId: {
sessionId: input.sessionId,
windowKey,
audienceVoterId: voter.id,
},
},
})
// IP cap only gates NEW voters — an existing voter may always update.
if (!existing && ctx.ip) {
const ipCount = await ctx.prisma.audienceFavoriteVote.count({
where: { sessionId: input.sessionId, windowKey, ipAddress: ctx.ip },
})
if (ipCount >= MAX_FAVORITE_VOTERS_PER_IP) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Vote limit reached for this network connection',
})
}
}
const vote = await ctx.prisma.audienceFavoriteVote.upsert({
where: {
sessionId_windowKey_audienceVoterId: {
sessionId: input.sessionId,
windowKey,
audienceVoterId: voter.id,
},
},
create: {
sessionId: input.sessionId,
windowKey,
projectId: input.projectId,
audienceVoterId: voter.id,
ipAddress: ctx.ip ?? null,
},
update: {
projectId: input.projectId,
ipAddress: ctx.ip ?? existing?.ipAddress ?? null,
},
})
return { projectId: vote.projectId, windowKey }
}),
/**
* Per-window per-project favorite-vote tallies (admin only — the big screen
* shows only the total count).
*/
getFavoriteTallies: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const grouped = await ctx.prisma.audienceFavoriteVote.groupBy({
by: ['windowKey', 'projectId'],
where: { sessionId: input.sessionId },
_count: { _all: true },
})
const projectIds = [...new Set(grouped.map((g) => g.projectId))]
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, title: true, teamName: true },
})
const byId = new Map(projects.map((p) => [p.id, p]))
const windowKeys = [...new Set(grouped.map((g) => g.windowKey))]
const windows = windowKeys.map((windowKey) => {
const rows = grouped.filter((g) => g.windowKey === windowKey)
return {
windowKey,
totalVotes: rows.reduce((sum, r) => sum + r._count._all, 0),
projects: rows
.map((r) => ({
projectId: r.projectId,
title: byId.get(r.projectId)?.title ?? 'Unknown',
teamName: byId.get(r.projectId)?.teamName ?? null,
count: r._count._all,
}))
.sort((a, b) => b.count - a.count),
}
})
return { windows }
}),
/**
* Resolve the live voting session for a round (public — the audience page
* only knows the roundId from the QR code URL).
*/
getAudienceContextByRound: publicProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
select: {
id: true,
allowAudienceVotes: true,
round: {
select: {
name: true,
competition: {
select: { program: { select: { name: true, year: true } } },
},
},
},
},
})
if (!session) return null
return {
sessionId: session.id,
allowAudienceVotes: session.allowAudienceVotes,
roundName: session.round?.name ?? null,
programName: session.round?.competition?.program?.name ?? null,
}
}),
/**
* Get audience voter stats (admin)
*/

View File

@@ -0,0 +1,277 @@
/**
* Grand-finale audience favorite-vote windows:
* - admin opens a per-category (or overall) window with a duration
* - votes are gated server-side: phase OPEN, before closesAt, category match
* - one vote per voter token per window (re-vote updates), max 3 voters per IP
* - no cron required: the closesAt check at vote/read time is the source of truth
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { liveVotingRouter } from '@/server/routers/live-voting'
let program: any
let round: any
let session: any
let startup1: any
let startup2: any
let concept1: any
let admin: any
let adminCaller: ReturnType<typeof createCaller>
let publicCaller: ReturnType<typeof createCaller>
let tokenA: string
let tokenB: string
async function makeVoter() {
const res = await publicCaller.registerAudienceVoter({ sessionId: session.id })
return res.token as string
}
beforeAll(async () => {
program = await createTestProgram()
const competition = await createTestCompetition(program.id)
round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
status: 'ROUND_ACTIVE',
})
startup1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
startup2 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
concept1 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' })
await prisma.round.update({
where: { id: round.id },
data: { configJson: { projectOrder: [concept1.id, startup1.id, startup2.id] } },
})
session = await prisma.liveVotingSession.create({
data: { roundId: round.id, allowAudienceVotes: true },
})
admin = await createTestUser('SUPER_ADMIN')
adminCaller = createCaller(liveVotingRouter, admin)
publicCaller = createCaller(liveVotingRouter, admin) // public procedures ignore the session user
tokenA = await makeVoter()
tokenB = await makeVoter()
})
afterAll(async () => {
await cleanupTestData(program.id, [admin.id])
})
describe('window lifecycle', () => {
it('opens a category window with a closing time', async () => {
const s = await adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'CATEGORY:STARTUP',
durationMinutes: 5,
})
expect(s.audiencePhase).toBe('OPEN')
expect(s.audienceWindowKey).toBe('CATEGORY:STARTUP')
const msLeft = new Date(s.audienceWindowClosesAt!).getTime() - Date.now()
expect(msLeft).toBeGreaterThan(4 * 60_000)
expect(msLeft).toBeLessThanOrEqual(5 * 60_000)
})
it('rejects opening a second window while one is open', async () => {
await expect(
adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
durationMinutes: 5,
})
).rejects.toThrow()
})
})
describe('casting favorite votes', () => {
it('accepts a vote for an in-category project and re-vote updates in place', async () => {
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenA,
projectId: startup1.id,
})
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenA,
projectId: startup2.id,
})
const rows = await prisma.audienceFavoriteVote.findMany({
where: { sessionId: session.id, windowKey: 'CATEGORY:STARTUP' },
})
expect(rows).toHaveLength(1)
expect(rows[0].projectId).toBe(startup2.id)
})
it('rejects a vote for a project outside the open category', async () => {
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: concept1.id,
})
).rejects.toThrow(/category/i)
})
it('rejects an invalid token', async () => {
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: 'bogus',
projectId: startup1.id,
})
).rejects.toThrow()
})
it('rejects votes after closesAt even without an explicit close (no cron)', async () => {
await prisma.liveVotingSession.update({
where: { id: session.id },
data: { audienceWindowClosesAt: new Date(Date.now() - 1000) },
})
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: startup1.id,
})
).rejects.toThrow()
// getAudienceWindow also reports closed
const win = await publicCaller.getAudienceWindow({ sessionId: session.id })
expect(win.open).toBe(false)
})
it('close + re-open works and votes flow again in the new category', async () => {
await adminCaller.closeAudienceWindow({ sessionId: session.id })
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: startup1.id,
})
).rejects.toThrow()
await adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
durationMinutes: 5,
})
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: concept1.id,
})
const rows = await prisma.audienceFavoriteVote.findMany({
where: { sessionId: session.id, windowKey: 'CATEGORY:BUSINESS_CONCEPT' },
})
expect(rows).toHaveLength(1)
})
})
describe('overall favorite window', () => {
it('requires the admin toggle before opening', async () => {
await adminCaller.closeAudienceWindow({ sessionId: session.id })
await expect(
adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'OVERALL', durationMinutes: 5 })
).rejects.toThrow()
await adminCaller.updateSessionConfig({ sessionId: session.id, allowOverallFavorite: true })
const s = await adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'OVERALL',
durationMinutes: 5,
})
expect(s.audienceWindowKey).toBe('OVERALL')
})
it('accepts any ordered project in OVERALL mode', async () => {
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenA,
projectId: concept1.id,
})
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: startup1.id,
})
const rows = await prisma.audienceFavoriteVote.findMany({
where: { sessionId: session.id, windowKey: 'OVERALL' },
})
expect(rows).toHaveLength(2)
})
})
describe('IP cap', () => {
it('rejects a 4th distinct voter from the same IP in one window', async () => {
// tokenA + tokenB already voted in OVERALL from ctx ip 127.0.0.1, but their
// stored ipAddress comes from ctx — normalize the rows to be explicit:
await prisma.audienceFavoriteVote.updateMany({
where: { sessionId: session.id, windowKey: 'OVERALL' },
data: { ipAddress: '127.0.0.1' },
})
const tokenC = await makeVoter()
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenC,
projectId: startup2.id,
})
const tokenD = await makeVoter()
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenD,
projectId: startup1.id,
})
).rejects.toThrow(/limit/i)
})
it('an existing voter on a capped IP can still change their vote', async () => {
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenA,
projectId: startup2.id,
})
const row = await prisma.audienceFavoriteVote.findFirst({
where: { sessionId: session.id, windowKey: 'OVERALL', audienceVoter: { token: tokenA } },
})
expect(row?.projectId).toBe(startup2.id)
})
})
describe('reads', () => {
it('getAudienceWindow lists eligible projects in order and the callers vote', async () => {
const win = await publicCaller.getAudienceWindow({ sessionId: session.id, token: tokenA })
expect(win.open).toBe(true)
expect(win.windowKey).toBe('OVERALL')
expect(win.projects.map((p: any) => p.id)).toEqual([concept1.id, startup1.id, startup2.id])
expect(win.myVoteProjectId).toBe(startup2.id)
})
it('getFavoriteTallies aggregates per window per project', async () => {
const tallies = await adminCaller.getFavoriteTallies({ sessionId: session.id })
const overall = tallies.windows.find((w: any) => w.windowKey === 'OVERALL')
expect(overall.totalVotes).toBe(3)
const startup2Count = overall.projects.find((p: any) => p.projectId === startup2.id)?.count
expect(startup2Count).toBe(2)
})
it('getAudienceContextByRound resolves the session publicly', async () => {
const ctx = await publicCaller.getAudienceContextByRound({ roundId: round.id })
expect(ctx?.sessionId).toBe(session.id)
expect(ctx?.allowAudienceVotes).toBe(true)
})
it('non-admins cannot open windows or read tallies', async () => {
const juror = await createTestUser('JURY_MEMBER')
const jurorCaller = createCaller(liveVotingRouter, juror)
await expect(
jurorCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'OVERALL', durationMinutes: 5 })
).rejects.toThrow()
await expect(jurorCaller.getFavoriteTallies({ sessionId: session.id })).rejects.toThrow()
await prisma.user.delete({ where: { id: juror.id } })
})
})