diff --git a/src/server/routers/live-voting.ts b/src/server/routers/live-voting.ts index adda44a..deb69a5 100644 --- a/src/server/routers/live-voting.ts +++ b/src/server/routers/live-voting.ts @@ -432,6 +432,11 @@ export const liveVotingRouter = router({ /** * Submit a vote (supports both simple and criteria modes) + * + * SECURITY: This endpoint records JURY votes only (isAudienceVote=false). + * Audience voters use `castAudienceVote` (publicProcedure with separate + * AudienceVoter records). Caller must be either an admin or a member of + * the round's jury group; otherwise FORBIDDEN. */ vote: protectedProcedure .input( @@ -448,8 +453,42 @@ export const liveVotingRouter = router({ // Verify session is in progress const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ where: { id: input.sessionId }, + include: { + round: { + select: { id: true, juryGroupId: true }, + }, + }, }) + // Authorization: admins always allowed; otherwise the caller must be a + // JuryGroupMember of the round's jury group. JURY_MEMBER role alone is + // not enough — we verify the actual membership row. + const isAdmin = + ctx.user.role === 'SUPER_ADMIN' || ctx.user.role === 'PROGRAM_ADMIN' + if (!isAdmin) { + const juryGroupId = session.round?.juryGroupId + if (!juryGroupId) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'No jury group is associated with this voting session', + }) + } + const membership = await ctx.prisma.juryGroupMember.findUnique({ + where: { + juryGroupId_userId: { + juryGroupId, + userId: ctx.user.id, + }, + }, + }) + if (!membership) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not a member of this jury group', + }) + } + } + if (session.status !== 'IN_PROGRESS') { throw new TRPCError({ code: 'BAD_REQUEST', @@ -512,7 +551,8 @@ export const liveVotingRouter = router({ criterionScoresJson = input.criterionScores } - // Upsert vote (allow vote change during window) + // Upsert vote (allow vote change during window). Explicitly recorded + // as a jury vote — audience votes go through castAudienceVote. const vote = await ctx.prisma.liveVote.upsert({ where: { sessionId_projectId_userId: { @@ -526,10 +566,12 @@ export const liveVotingRouter = router({ projectId: input.projectId, userId: ctx.user.id, score: finalScore, + isAudienceVote: false, criterionScoresJson: criterionScoresJson ?? undefined, }, update: { score: finalScore, + isAudienceVote: false, criterionScoresJson: criterionScoresJson ?? undefined, votedAt: new Date(), },