diff --git a/src/server/routers/deliberation.ts b/src/server/routers/deliberation.ts index f37c967..5168c20 100644 --- a/src/server/routers/deliberation.ts +++ b/src/server/routers/deliberation.ts @@ -110,7 +110,6 @@ export const deliberationRouter = router({ .input( z.object({ sessionId: z.string(), - juryMemberId: z.string(), projectId: z.string(), rank: z.number().int().min(1).optional(), isWinnerPick: z.boolean().optional(), @@ -118,15 +117,26 @@ export const deliberationRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - // Enforce that jury members can only vote as themselves - if (input.juryMemberId !== ctx.user.id) { + // Resolve the caller's participant row server-side: DeliberationParticipant + // and DeliberationVote both reference JuryGroupMember ids, which the + // client has no business knowing. A juror can only ever vote as themself. + const participant = await ctx.prisma.deliberationParticipant.findFirst({ + where: { + sessionId: input.sessionId, + user: { userId: ctx.user.id }, + }, + }) + if (!participant) { throw new TRPCError({ code: 'FORBIDDEN', - message: 'You can only submit votes as yourself', + message: 'You are not a participant in this deliberation', }) } - const vote = await submitVote(input, ctx.prisma) + const vote = await submitVote( + { ...input, juryMemberId: participant.userId }, + ctx.prisma + ) await logAudit({ prisma: ctx.prisma, diff --git a/src/server/routers/live-voting.ts b/src/server/routers/live-voting.ts index 62ab598..7c57ab3 100644 --- a/src/server/routers/live-voting.ts +++ b/src/server/routers/live-voting.ts @@ -616,22 +616,29 @@ export const liveVotingRouter = router({ } } - if (session.status !== 'IN_PROGRESS') { + if (session.status !== 'IN_PROGRESS' && session.status !== 'PAUSED') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting is not currently active', }) } - if (session.currentProjectId !== input.projectId) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Cannot vote for this project right now', - }) + const isCurrentProject = session.currentProjectId === input.projectId + if (!isCurrentProject) { + // Revision path (deliberation / catching up): any project in the + // finale run order may be (re)scored while the session is open. + const ordered = await getOrderedFinaleProjects(ctx.prisma, session) + if (!ordered.some((p) => p.id === input.projectId)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Cannot vote for this project right now', + }) + } } - // Check if voting window is still open - if (session.votingEndsAt && new Date() > session.votingEndsAt) { + // The timed voting window only applies to the live flow for the + // currently presented project + if (isCurrentProject && session.votingEndsAt && new Date() > session.votingEndsAt) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting window has closed', diff --git a/src/server/routers/live.ts b/src/server/routers/live.ts index eec3564..2597d0d 100644 --- a/src/server/routers/live.ts +++ b/src/server/routers/live.ts @@ -432,6 +432,12 @@ export const liveRouter = router({ : {}), }, }) + // Keep the voting session in lockstep so jury votes target this project + // (updateMany: a session may not exist yet — that's fine). + await ctx.prisma.liveVotingSession.updateMany({ + where: { roundId: input.roundId }, + data: { currentProjectId: input.projectId, status: 'IN_PROGRESS' }, + }) await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, diff --git a/src/server/services/deliberation.ts b/src/server/services/deliberation.ts index b83d9a9..6ef540e 100644 --- a/src/server/services/deliberation.ts +++ b/src/server/services/deliberation.ts @@ -615,7 +615,7 @@ export async function getSessionWithVotes( sessionId: string, prisma: PrismaClient, ) { - return prisma.deliberationSession.findUnique({ + const session = await prisma.deliberationSession.findUnique({ where: { id: sessionId }, include: { votes: { @@ -648,6 +648,23 @@ export async function getSessionWithVotes( round: { select: { id: true, name: true, roundType: true } }, }, }) + if (!session) return null + + // Rankable projects: the round's projects in this session's category. + // (results are empty until finalize — the jury ranking form needs this list.) + const roundStates = await prisma.projectRoundState.findMany({ + where: { + roundId: session.roundId, + project: { competitionCategory: session.category }, + }, + include: { + project: { + select: { id: true, title: true, teamName: true, competitionCategory: true }, + }, + }, + }) + + return { ...session, projects: roundStates.map((rs) => rs.project) } } // ─── Internal Helpers ─────────────────────────────────────────────────────── diff --git a/tests/unit/deliberation-jury-wiring.test.ts b/tests/unit/deliberation-jury-wiring.test.ts new file mode 100644 index 0000000..649f573 --- /dev/null +++ b/tests/unit/deliberation-jury-wiring.test.ts @@ -0,0 +1,113 @@ +/** + * Deliberation jury wiring — regression for two launch blockers: + * 1. submitVote compared the JuryGroupMember id against User.id (FORBIDDEN for + * every legitimate juror). The server now resolves the caller's participant + * row itself; the client never sends an identity. + * 2. getSession had no project list before finalize (the ranking form rendered + * empty). It now includes the round's projects filtered by session category. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestCompetition, + createTestRound, + createTestProject, + createTestProjectRoundState, + cleanupTestData, + uid, +} from '../helpers' +import { deliberationRouter } from '@/server/routers/deliberation' + +let program: any +let competition: any +let delibRound: any +let startup1: any +let startup2: any +let concept1: any +let juror: any +let outsiderJuror: any +let admin: any +let sessionId: string +let adminCaller: ReturnType +let jurorCaller: ReturnType + +beforeAll(async () => { + program = await createTestProgram() + competition = await createTestCompetition(program.id) + delibRound = await createTestRound(competition.id, { + roundType: 'DELIBERATION', + 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 createTestProjectRoundState(startup1.id, delibRound.id) + await createTestProjectRoundState(startup2.id, delibRound.id) + await createTestProjectRoundState(concept1.id, delibRound.id) + + const juryGroup = await prisma.juryGroup.create({ + data: { competitionId: competition.id, name: 'Finals Jury', slug: uid('jg') }, + }) + juror = await createTestUser('JURY_MEMBER') + outsiderJuror = await createTestUser('JURY_MEMBER') + admin = await createTestUser('SUPER_ADMIN') + const member = await prisma.juryGroupMember.create({ + data: { juryGroupId: juryGroup.id, userId: juror.id, role: 'MEMBER' }, + }) + + adminCaller = createCaller(deliberationRouter, admin) + jurorCaller = createCaller(deliberationRouter, juror) + + const session = await adminCaller.createSession({ + competitionId: competition.id, + roundId: delibRound.id, + category: 'STARTUP', + mode: 'FULL_RANKING', + tieBreakMethod: 'TIE_ADMIN_DECIDES', + participantUserIds: [member.id], // JuryGroupMember IDs + }) + sessionId = session.id + await adminCaller.openVoting({ sessionId }) +}) + +afterAll(async () => { + await cleanupTestData(program.id, [juror.id, outsiderJuror.id, admin.id]) +}) + +describe('getSession projects', () => { + it('exposes the category projects before any results exist', async () => { + const session = await jurorCaller.getSession({ sessionId }) + const ids = (session.projects ?? []).map((p: any) => p.id).sort() + expect(ids).toEqual([startup1.id, startup2.id].sort()) + // off-category project excluded + expect(ids).not.toContain(concept1.id) + }) +}) + +describe('submitVote identity resolution', () => { + it('lets a participant juror vote without sending any identity', async () => { + await jurorCaller.submitVote({ sessionId, projectId: startup1.id, rank: 1 }) + await jurorCaller.submitVote({ sessionId, projectId: startup2.id, rank: 2 }) + + const session = await jurorCaller.getSession({ sessionId }) + const myVotes = (session.votes ?? []).filter( + (v: any) => v.juryMember?.user?.id === juror.id + ) + expect(myVotes).toHaveLength(2) + }) + + it('rejects a juror who is not a participant', async () => { + const outsiderCaller = createCaller(deliberationRouter, outsiderJuror) + await expect( + outsiderCaller.submitVote({ sessionId, projectId: startup1.id, rank: 1 }) + ).rejects.toThrow(/participant/i) + }) + + it('aggregates the resolved votes (sanity end-to-end)', async () => { + const agg = await adminCaller.aggregate({ sessionId }) + expect(agg.rankings.length).toBeGreaterThan(0) + expect(agg.rankings[0].projectId).toBe(startup1.id) // rank 1 → top Borda points + }) +}) diff --git a/tests/unit/live-phase.test.ts b/tests/unit/live-phase.test.ts index 2ed8c81..7db6b4e 100644 --- a/tests/unit/live-phase.test.ts +++ b/tests/unit/live-phase.test.ts @@ -148,6 +148,20 @@ describe('phase transitions', () => { }) }) +describe('voting session sync', () => { + it('sendToScreens points the voting session at the project and activates it', async () => { + const session = await prisma.liveVotingSession.create({ + data: { roundId: round.id, status: 'NOT_STARTED' }, + }) + await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id }) + const updated = await prisma.liveVotingSession.findUniqueOrThrow({ + where: { id: session.id }, + }) + expect(updated.currentProjectId).toBe(p1.id) + expect(updated.status).toBe('IN_PROGRESS') + }) +}) + describe('juror notes', () => { it('saveNote upserts one note per (round, project, juror)', async () => { await jurorCaller.saveNote({ roundId: round.id, projectId: p1.id, content: 'first draft' }) diff --git a/tests/unit/live-vote-comment.test.ts b/tests/unit/live-vote-comment.test.ts index 7094885..0af63b0 100644 --- a/tests/unit/live-vote-comment.test.ts +++ b/tests/unit/live-vote-comment.test.ts @@ -88,12 +88,56 @@ describe('vote comments', () => { }) }) +describe('deliberation-time revision', () => { + it('allows voting for an ordered project that is not currently presenting', async () => { + const otherProject = await createTestProject(program.id, { + competitionCategory: 'BUSINESS_CONCEPT', + }) + await prisma.round.update({ + where: { id: round.id }, + data: { configJson: { projectOrder: [project.id, otherProject.id] } }, + }) + // otherProject is NOT currentProjectId — revision path must accept it + const vote = await jurorCaller.vote({ + sessionId: session.id, + projectId: otherProject.id, + score: 6, + comment: 'revised during deliberation', + }) + expect(vote.projectId).toBe(otherProject.id) + }) + + it('still rejects projects outside the finale order', async () => { + const stranger = await createTestProject(program.id) + await expect( + jurorCaller.vote({ sessionId: session.id, projectId: stranger.id, score: 5 }) + ).rejects.toThrow() + }) + + it('allows revision while the session is PAUSED', async () => { + await prisma.liveVotingSession.update({ + where: { id: session.id }, + data: { status: 'PAUSED' }, + }) + const vote = await jurorCaller.vote({ + sessionId: session.id, + projectId: project.id, + score: 10, + }) + expect(vote.score).toBe(10) + await prisma.liveVotingSession.update({ + where: { id: session.id }, + data: { status: 'IN_PROGRESS' }, + }) + }) +}) + 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) + expect(data?.userVote?.score).toBe(10) // last revision in the PAUSED test }) it('returns null when the round has no session (creates nothing)', async () => { @@ -111,9 +155,9 @@ describe('getMyFinaleInputs', () => { }) 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.votes).toHaveLength(2) // original + deliberation-time revision + const mainVote = inputs.votes.find((v: any) => v.projectId === project.id) + expect(mainVote?.comment).toBe('Revised after Q&A') expect(inputs.notes).toHaveLength(1) expect(inputs.notes[0].content).toBe('ceremony note') })