diff --git a/src/server/routers/live-voting.ts b/src/server/routers/live-voting.ts index deb69a5..d0820e9 100644 --- a/src/server/routers/live-voting.ts +++ b/src/server/routers/live-voting.ts @@ -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) ?? {}).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 => !!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> = [] + 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) */ diff --git a/tests/unit/audience-window.test.ts b/tests/unit/audience-window.test.ts new file mode 100644 index 0000000..bcd797a --- /dev/null +++ b/tests/unit/audience-window.test.ts @@ -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 +let publicCaller: ReturnType +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 caller’s 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 } }) + }) +})