From 45b007334e91aeee1a80db47d7c7dccd6b051e4d Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 10 Jun 2026 18:10:32 +0200 Subject: [PATCH] feat(finale): vote comments, by-round session lookup, my-finale-inputs query Co-Authored-By: Claude Opus 4.8 (1M context) --- src/server/routers/live-voting.ts | 151 ++++++++++++++++++++------- tests/unit/live-vote-comment.test.ts | 126 ++++++++++++++++++++++ 2 files changed, 240 insertions(+), 37 deletions(-) create mode 100644 tests/unit/live-vote-comment.test.ts diff --git a/src/server/routers/live-voting.ts b/src/server/routers/live-voting.ts index d0820e9..afd0579 100644 --- a/src/server/routers/live-voting.ts +++ b/src/server/routers/live-voting.ts @@ -55,6 +55,62 @@ async function getOrderedFinaleProjects( return order.map((id) => byId.get(id)).filter((p): p is NonNullable => !!p) } +/** Shared jury-voting payload for getSessionForVoting / getSessionForVotingByRound. */ +async function buildVotingPayload( + prisma: PrismaClient, + session: { + id: string + status: string + currentProjectId: string | null + votingStartedAt: Date | null + votingEndsAt: Date | null + votingMode: string + criteriaJson: unknown + round: unknown + }, + userId: string +) { + let currentProject = null + if (session.currentProjectId && session.status === 'IN_PROGRESS') { + currentProject = await prisma.project.findUnique({ + where: { id: session.currentProjectId }, + select: { id: true, title: true, teamName: true, description: true }, + }) + } + + let userVote = null + if (session.currentProjectId) { + userVote = await prisma.liveVote.findFirst({ + where: { + sessionId: session.id, + projectId: session.currentProjectId, + userId, + }, + }) + } + + let timeRemaining = null + if (session.votingEndsAt && session.status === 'IN_PROGRESS') { + const remaining = new Date(session.votingEndsAt).getTime() - Date.now() + timeRemaining = Math.max(0, Math.floor(remaining / 1000)) + } + + return { + session: { + id: session.id, + status: session.status, + votingStartedAt: session.votingStartedAt, + votingEndsAt: session.votingEndsAt, + votingMode: session.votingMode, + criteriaJson: session.criteriaJson, + }, + round: session.round, + currentProject, + userVote, + timeRemaining, + } +} + export const liveVotingRouter = router({ /** * Get or create a live voting session for a round @@ -141,46 +197,63 @@ export const liveVotingRouter = router({ }, }, }) + return buildVotingPayload(ctx.prisma, session, ctx.user.id) + }), - let currentProject = null - if (session.currentProjectId && session.status === 'IN_PROGRESS') { - currentProject = await ctx.prisma.project.findUnique({ - where: { id: session.currentProjectId }, - select: { id: true, title: true, teamName: true, description: true }, - }) - } - - let userVote = null - if (session.currentProjectId) { - userVote = await ctx.prisma.liveVote.findFirst({ - where: { - sessionId: session.id, - projectId: session.currentProjectId, - userId: ctx.user.id, + /** + * Same payload, resolved from a roundId (the jury live page only knows the + * round). Returns null — and creates nothing — when no session exists yet. + */ + getSessionForVotingByRound: protectedProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const session = await ctx.prisma.liveVotingSession.findUnique({ + where: { roundId: input.roundId }, + include: { + round: { + include: { + competition: { + include: { + program: { select: { name: true, year: true } }, + }, + }, + }, }, - }) - } - - let timeRemaining = null - if (session.votingEndsAt && session.status === 'IN_PROGRESS') { - const remaining = new Date(session.votingEndsAt).getTime() - Date.now() - timeRemaining = Math.max(0, Math.floor(remaining / 1000)) - } - - return { - session: { - id: session.id, - status: session.status, - votingStartedAt: session.votingStartedAt, - votingEndsAt: session.votingEndsAt, - votingMode: session.votingMode, - criteriaJson: session.criteriaJson, }, - round: session.round, - currentProject, - userVote, - timeRemaining, - } + }) + if (!session) return null + return buildVotingPayload(ctx.prisma, session, ctx.user.id) + }), + + /** + * A juror's own finale inputs (votes incl. comments + ceremony notes) for a + * round — resurfaced during deliberation so they can review or revise. + */ + getMyFinaleInputs: protectedProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const session = await ctx.prisma.liveVotingSession.findUnique({ + where: { roundId: input.roundId }, + select: { id: true, status: true, votingMode: true, criteriaJson: true }, + }) + const [votes, notes] = await Promise.all([ + session + ? ctx.prisma.liveVote.findMany({ + where: { sessionId: session.id, userId: ctx.user.id }, + select: { + projectId: true, + score: true, + criterionScoresJson: true, + comment: true, + votedAt: true, + }, + }) + : Promise.resolve([]), + ctx.prisma.liveNote.findMany({ + where: { roundId: input.roundId, userId: ctx.user.id }, + }), + ]) + return { session: session ?? null, votes, notes } }), /** @@ -491,6 +564,7 @@ export const liveVotingRouter = router({ criterionScores: z .record(z.string(), z.number()) .optional(), + comment: z.string().max(5000).optional(), }) ) .mutation(async ({ ctx, input }) => { @@ -612,11 +686,14 @@ export const liveVotingRouter = router({ score: finalScore, isAudienceVote: false, criterionScoresJson: criterionScoresJson ?? undefined, + comment: input.comment ?? undefined, }, update: { score: finalScore, isAudienceVote: false, criterionScoresJson: criterionScoresJson ?? undefined, + // An omitted comment leaves the existing one untouched + ...(input.comment !== undefined ? { comment: input.comment } : {}), votedAt: new Date(), }, }) diff --git a/tests/unit/live-vote-comment.test.ts b/tests/unit/live-vote-comment.test.ts new file mode 100644 index 0000000..7094885 --- /dev/null +++ b/tests/unit/live-vote-comment.test.ts @@ -0,0 +1,126 @@ +/** + * Grand-finale juror voting extras: + * - optional overall comment stored with the LiveVote (and updatable) + * - getSessionForVotingByRound: the jury page only knows the roundId + * - getMyFinaleInputs: a juror's own finale votes + ceremony notes, + * resurfaced during deliberation + */ +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 emptyRound: any +let session: any +let project: any +let juror: any +let jurorCaller: ReturnType + +beforeAll(async () => { + program = await createTestProgram() + const competition = await createTestCompetition(program.id) + const juryGroup = await prisma.juryGroup.create({ + data: { competitionId: competition.id, name: 'Finals Jury', slug: uid('jg') }, + }) + round = await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + status: 'ROUND_ACTIVE', + }) + await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: juryGroup.id } }) + emptyRound = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', sortOrder: 1 }) + project = await createTestProject(program.id, { competitionCategory: 'STARTUP' }) + juror = await createTestUser('JURY_MEMBER') + await prisma.juryGroupMember.create({ + data: { juryGroupId: juryGroup.id, userId: juror.id, role: 'MEMBER' }, + }) + session = await prisma.liveVotingSession.create({ + data: { + roundId: round.id, + status: 'IN_PROGRESS', + currentProjectId: project.id, + votingMode: 'simple', + }, + }) + jurorCaller = createCaller(liveVotingRouter, juror) +}) + +afterAll(async () => { + await cleanupTestData(program.id, [juror.id]) +}) + +describe('vote comments', () => { + it('persists an optional comment with the vote', async () => { + const vote = await jurorCaller.vote({ + sessionId: session.id, + projectId: project.id, + score: 8, + comment: 'Strong pitch, weak unit economics', + }) + expect(vote.comment).toBe('Strong pitch, weak unit economics') + }) + + it('re-voting updates the comment and keeps it when omitted', async () => { + const updated = await jurorCaller.vote({ + sessionId: session.id, + projectId: project.id, + score: 9, + comment: 'Revised after Q&A', + }) + expect(updated.score).toBe(9) + expect(updated.comment).toBe('Revised after Q&A') + + const again = await jurorCaller.vote({ + sessionId: session.id, + projectId: project.id, + score: 7, + }) + expect(again.comment).toBe('Revised after Q&A') // omitted comment is not erased + }) +}) + +describe('getSessionForVotingByRound', () => { + it('resolves the session from a roundId', async () => { + const data = await jurorCaller.getSessionForVotingByRound({ roundId: round.id }) + expect(data?.session.id).toBe(session.id) + expect(data?.currentProject?.id).toBe(project.id) + expect(data?.userVote?.score).toBe(7) + }) + + it('returns null when the round has no session (creates nothing)', async () => { + const data = await jurorCaller.getSessionForVotingByRound({ roundId: emptyRound.id }) + expect(data).toBeNull() + const count = await prisma.liveVotingSession.count({ where: { roundId: emptyRound.id } }) + expect(count).toBe(0) + }) +}) + +describe('getMyFinaleInputs', () => { + it('returns the caller’s votes and notes for the round', async () => { + await prisma.liveNote.create({ + data: { roundId: round.id, projectId: project.id, userId: juror.id, content: 'ceremony note' }, + }) + const inputs = await jurorCaller.getMyFinaleInputs({ roundId: round.id }) + expect(inputs.session?.id).toBe(session.id) + expect(inputs.votes).toHaveLength(1) + expect(inputs.votes[0].projectId).toBe(project.id) + expect(inputs.votes[0].comment).toBe('Revised after Q&A') + expect(inputs.notes).toHaveLength(1) + expect(inputs.notes[0].content).toBe('ceremony note') + }) + + it('is empty-safe for a round without a session', async () => { + const inputs = await jurorCaller.getMyFinaleInputs({ roundId: emptyRound.id }) + expect(inputs.session).toBeNull() + expect(inputs.votes).toHaveLength(0) + }) +})