feat(finale): deliberation jury identity resolution, rankable projects, score-revision path, session sync

- submitVote resolves the caller's JuryGroupMember participant row server-side
  (was comparing JuryGroupMember id to User id — every juror got FORBIDDEN)
- getSessionWithVotes now includes category projects so the ranking form has
  data before finalize
- liveVoting.vote accepts any finale-ordered project (revision during
  deliberation); timed window still applies to the live project
- live.sendToScreens keeps LiveVotingSession.currentProjectId/status in sync

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-10 18:15:45 +02:00
parent 4e6904fa12
commit c9dc1bfabd
7 changed files with 229 additions and 18 deletions

View File

@@ -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,

View File

@@ -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',

View File

@@ -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,

View File

@@ -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 ───────────────────────────────────────────────────────