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:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user