Round system redesign: criteria voting, audience voting, pipeline view, and admin UX improvements
- Schema: Extend LiveVotingSession with votingMode, criteriaJson, audience fields; add AudienceVoter model; make LiveVote.userId nullable for audience voters - Backend: Criteria-based voting with weighted scores, audience registration/voting with token-based dedup, configurable jury/audience weight in results - Jury UI: Criteria scoring with per-criterion sliders alongside simple 1-10 mode - Public audience voting page at /vote/[sessionId] with mobile-first design - Admin live voting: Tabbed layout (Session/Config/Results), criteria config, audience settings, weight-adjustable results with tie detection - Round type settings: Visual card selector replacing dropdown, feature tags - Round detail page: Live event status section, type-specific stats and actions - Round pipeline view: Horizontal visualization with bottleneck detection, List/Pipeline toggle on rounds page - SSE: Separate jury/audience vote events, audience vote tracking - Field visibility: Hide irrelevant fields per round type in create/edit forms Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import type { LiveVotingCriterion } from '@/types/round-settings'
|
||||
|
||||
export const liveVotingRouter = router({
|
||||
/**
|
||||
@@ -46,7 +48,7 @@ export const liveVotingRouter = router({
|
||||
}
|
||||
|
||||
// Get current votes if voting is in progress
|
||||
let currentVotes: { userId: string; score: number }[] = []
|
||||
let currentVotes: { userId: string | null; score: number }[] = []
|
||||
if (session.currentProjectId) {
|
||||
const votes = await ctx.prisma.liveVote.findMany({
|
||||
where: {
|
||||
@@ -58,9 +60,15 @@ export const liveVotingRouter = router({
|
||||
currentVotes = votes
|
||||
}
|
||||
|
||||
// Get audience voter count
|
||||
const audienceVoterCount = await ctx.prisma.audienceVoter.count({
|
||||
where: { sessionId: session.id },
|
||||
})
|
||||
|
||||
return {
|
||||
...session,
|
||||
currentVotes,
|
||||
audienceVoterCount,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -115,6 +123,8 @@ export const liveVotingRouter = router({
|
||||
status: session.status,
|
||||
votingStartedAt: session.votingStartedAt,
|
||||
votingEndsAt: session.votingEndsAt,
|
||||
votingMode: session.votingMode,
|
||||
criteriaJson: session.criteriaJson,
|
||||
},
|
||||
round: session.round,
|
||||
currentProject,
|
||||
@@ -202,6 +212,132 @@ export const liveVotingRouter = router({
|
||||
return session
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set voting mode (simple vs criteria)
|
||||
*/
|
||||
setVotingMode: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
votingMode: z.enum(['simple', 'criteria']),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.update({
|
||||
where: { id: input.sessionId },
|
||||
data: { votingMode: input.votingMode },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: 'SET_VOTING_MODE',
|
||||
entityType: 'LiveVotingSession',
|
||||
entityId: session.id,
|
||||
detailsJson: { votingMode: input.votingMode },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return session
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set criteria for criteria-based voting
|
||||
*/
|
||||
setCriteria: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
criteria: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
scale: z.number().int().min(1).max(100),
|
||||
weight: z.number().min(0).max(1),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate weights sum approximately to 1
|
||||
const weightSum = input.criteria.reduce((sum, c) => sum + c.weight, 0)
|
||||
if (Math.abs(weightSum - 1) > 0.01) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Criteria weights must sum to 1.0 (currently ${weightSum.toFixed(2)})`,
|
||||
})
|
||||
}
|
||||
|
||||
const session = await ctx.prisma.liveVotingSession.update({
|
||||
where: { id: input.sessionId },
|
||||
data: {
|
||||
criteriaJson: input.criteria,
|
||||
votingMode: 'criteria',
|
||||
},
|
||||
})
|
||||
|
||||
return session
|
||||
}),
|
||||
|
||||
/**
|
||||
* Import criteria from an existing evaluation form
|
||||
*/
|
||||
importCriteriaFromForm: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
formId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const form = await ctx.prisma.evaluationForm.findUniqueOrThrow({
|
||||
where: { id: input.formId },
|
||||
})
|
||||
|
||||
const formCriteria = form.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number
|
||||
weight: number
|
||||
type?: string
|
||||
}>
|
||||
|
||||
// Filter out section headers and convert
|
||||
const scoringCriteria = formCriteria.filter(
|
||||
(c) => !c.type || c.type === 'numeric'
|
||||
)
|
||||
|
||||
if (scoringCriteria.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No numeric criteria found in this evaluation form',
|
||||
})
|
||||
}
|
||||
|
||||
// Normalize weights to sum to 1
|
||||
const totalWeight = scoringCriteria.reduce((sum, c) => sum + (c.weight || 1), 0)
|
||||
const criteria: LiveVotingCriterion[] = scoringCriteria.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
scale: c.scale || 10,
|
||||
weight: (c.weight || 1) / totalWeight,
|
||||
}))
|
||||
|
||||
const session = await ctx.prisma.liveVotingSession.update({
|
||||
where: { id: input.sessionId },
|
||||
data: {
|
||||
criteriaJson: criteria as unknown as import('@prisma/client').Prisma.InputJsonValue,
|
||||
votingMode: 'criteria',
|
||||
},
|
||||
})
|
||||
|
||||
return session
|
||||
}),
|
||||
|
||||
/**
|
||||
* Start voting for a project
|
||||
*/
|
||||
@@ -288,7 +424,7 @@ export const liveVotingRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Submit a vote
|
||||
* Submit a vote (supports both simple and criteria modes)
|
||||
*/
|
||||
vote: protectedProcedure
|
||||
.input(
|
||||
@@ -296,6 +432,9 @@ export const liveVotingRouter = router({
|
||||
sessionId: z.string(),
|
||||
projectId: z.string(),
|
||||
score: z.number().int().min(1).max(10),
|
||||
criterionScores: z
|
||||
.record(z.string(), z.number())
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -326,6 +465,46 @@ export const liveVotingRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// For criteria mode, validate and compute weighted score
|
||||
let finalScore = input.score
|
||||
let criterionScoresJson = null
|
||||
|
||||
if (session.votingMode === 'criteria' && input.criterionScores) {
|
||||
const criteria = session.criteriaJson as LiveVotingCriterion[] | null
|
||||
if (!criteria || criteria.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No criteria configured for this session',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate all required criteria have scores
|
||||
for (const c of criteria) {
|
||||
if (input.criterionScores[c.id] === undefined) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Missing score for criterion: ${c.label}`,
|
||||
})
|
||||
}
|
||||
const cScore = input.criterionScores[c.id]
|
||||
if (cScore < 1 || cScore > c.scale) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Score for ${c.label} must be between 1 and ${c.scale}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compute weighted score normalized to 1-10
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const normalizedScore = (input.criterionScores[c.id] / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
finalScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
|
||||
criterionScoresJson = input.criterionScores
|
||||
}
|
||||
|
||||
// Upsert vote (allow vote change during window)
|
||||
const vote = await ctx.prisma.liveVote.upsert({
|
||||
where: {
|
||||
@@ -339,10 +518,12 @@ export const liveVotingRouter = router({
|
||||
sessionId: input.sessionId,
|
||||
projectId: input.projectId,
|
||||
userId: ctx.user.id,
|
||||
score: input.score,
|
||||
score: finalScore,
|
||||
criterionScoresJson: criterionScoresJson ?? undefined,
|
||||
},
|
||||
update: {
|
||||
score: input.score,
|
||||
score: finalScore,
|
||||
criterionScoresJson: criterionScoresJson ?? undefined,
|
||||
votedAt: new Date(),
|
||||
},
|
||||
})
|
||||
@@ -354,7 +535,13 @@ export const liveVotingRouter = router({
|
||||
* Get results for a session (with weighted jury + audience scoring)
|
||||
*/
|
||||
getResults: protectedProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
juryWeight: z.number().min(0).max(1).optional(),
|
||||
audienceWeight: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
@@ -367,8 +554,9 @@ export const liveVotingRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
const audienceWeight = session.audienceVoteWeight || 0
|
||||
const juryWeight = 1 - audienceWeight
|
||||
// Use custom weights if provided, else session defaults
|
||||
const audienceWeightVal = input.audienceWeight ?? session.audienceVoteWeight ?? 0
|
||||
const juryWeightVal = input.juryWeight ?? (1 - audienceWeightVal)
|
||||
|
||||
// Get jury votes grouped by project
|
||||
const juryScores = await ctx.prisma.liveVote.groupBy({
|
||||
@@ -400,6 +588,39 @@ export const liveVotingRouter = router({
|
||||
|
||||
const audienceMap = new Map(audienceScores.map((s) => [s.projectId, s]))
|
||||
|
||||
// For criteria mode, get per-criterion breakdowns
|
||||
let criteriaBreakdown: Record<string, Record<string, number>> | null = null
|
||||
if (session.votingMode === 'criteria') {
|
||||
const allJuryVotes = await ctx.prisma.liveVote.findMany({
|
||||
where: { sessionId: input.sessionId, isAudienceVote: false },
|
||||
select: { projectId: true, criterionScoresJson: true },
|
||||
})
|
||||
|
||||
criteriaBreakdown = {}
|
||||
for (const vote of allJuryVotes) {
|
||||
if (!vote.criterionScoresJson) continue
|
||||
const scores = vote.criterionScoresJson as Record<string, number>
|
||||
if (!criteriaBreakdown[vote.projectId]) {
|
||||
criteriaBreakdown[vote.projectId] = {}
|
||||
}
|
||||
for (const [criterionId, score] of Object.entries(scores)) {
|
||||
if (!criteriaBreakdown[vote.projectId][criterionId]) {
|
||||
criteriaBreakdown[vote.projectId][criterionId] = 0
|
||||
}
|
||||
criteriaBreakdown[vote.projectId][criterionId] += score
|
||||
}
|
||||
}
|
||||
// Average the scores
|
||||
for (const projectId of Object.keys(criteriaBreakdown)) {
|
||||
const projectVoteCount = allJuryVotes.filter((v) => v.projectId === projectId).length
|
||||
if (projectVoteCount > 0) {
|
||||
for (const criterionId of Object.keys(criteriaBreakdown[projectId])) {
|
||||
criteriaBreakdown[projectId][criterionId] /= projectVoteCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine and calculate weighted scores
|
||||
const results = juryScores
|
||||
.map((jurySc) => {
|
||||
@@ -407,8 +628,8 @@ export const liveVotingRouter = router({
|
||||
const audienceSc = audienceMap.get(jurySc.projectId)
|
||||
const juryAvg = jurySc._avg?.score || 0
|
||||
const audienceAvg = audienceSc?._avg?.score || 0
|
||||
const weightedTotal = audienceWeight > 0 && audienceSc
|
||||
? juryAvg * juryWeight + audienceAvg * audienceWeight
|
||||
const weightedTotal = audienceWeightVal > 0 && audienceSc
|
||||
? juryAvg * juryWeightVal + audienceAvg * audienceWeightVal
|
||||
: juryAvg
|
||||
|
||||
return {
|
||||
@@ -418,6 +639,7 @@ export const liveVotingRouter = router({
|
||||
audienceAverage: audienceAvg,
|
||||
audienceVoteCount: audienceSc?._count || 0,
|
||||
weightedTotal,
|
||||
criteriaAverages: criteriaBreakdown?.[jurySc.projectId] || null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.weightedTotal - a.weightedTotal)
|
||||
@@ -436,6 +658,9 @@ export const liveVotingRouter = router({
|
||||
results,
|
||||
ties,
|
||||
tieBreakerMethod: session.tieBreakerMethod,
|
||||
votingMode: session.votingMode,
|
||||
criteria: session.criteriaJson as LiveVotingCriterion[] | null,
|
||||
weights: { jury: juryWeightVal, audience: audienceWeightVal },
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -477,6 +702,10 @@ export const liveVotingRouter = router({
|
||||
allowAudienceVotes: z.boolean().optional(),
|
||||
audienceVoteWeight: z.number().min(0).max(1).optional(),
|
||||
tieBreakerMethod: z.enum(['admin_decides', 'highest_individual', 'revote']).optional(),
|
||||
audienceVotingMode: z.enum(['disabled', 'per_project', 'per_category', 'favorites']).optional(),
|
||||
audienceMaxFavorites: z.number().int().min(1).max(20).optional(),
|
||||
audienceRequireId: z.boolean().optional(),
|
||||
audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -507,17 +736,76 @@ export const liveVotingRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Cast an audience vote
|
||||
* Register an audience voter (public, no auth required)
|
||||
*/
|
||||
castAudienceVote: protectedProcedure
|
||||
registerAudienceVoter: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
identifier: z.string().optional(),
|
||||
identifierType: z.enum(['email', 'phone', 'name', 'anonymous']).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
})
|
||||
|
||||
if (!session.allowAudienceVotes) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Audience voting is not enabled for this session',
|
||||
})
|
||||
}
|
||||
|
||||
if (session.audienceRequireId && !input.identifier) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Identification is required for audience voting',
|
||||
})
|
||||
}
|
||||
|
||||
const token = randomUUID()
|
||||
|
||||
const voter = await ctx.prisma.audienceVoter.create({
|
||||
data: {
|
||||
sessionId: input.sessionId,
|
||||
token,
|
||||
identifier: input.identifier || null,
|
||||
identifierType: input.identifierType || 'anonymous',
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
})
|
||||
|
||||
return { token: voter.token, voterId: voter.id }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Cast an audience vote (token-based, no auth required)
|
||||
*/
|
||||
castAudienceVote: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
projectId: z.string(),
|
||||
score: z.number().int().min(1).max(10),
|
||||
token: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Verify voter token
|
||||
const voter = await ctx.prisma.audienceVoter.findUnique({
|
||||
where: { token: input.token },
|
||||
})
|
||||
|
||||
if (!voter || voter.sessionId !== input.sessionId) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid voting token',
|
||||
})
|
||||
}
|
||||
|
||||
// Verify session is in progress and allows audience votes
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
@@ -551,19 +839,19 @@ export const liveVotingRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Upsert audience vote
|
||||
// Upsert audience vote (dedup by audienceVoterId)
|
||||
const vote = await ctx.prisma.liveVote.upsert({
|
||||
where: {
|
||||
sessionId_projectId_userId: {
|
||||
sessionId_projectId_audienceVoterId: {
|
||||
sessionId: input.sessionId,
|
||||
projectId: input.projectId,
|
||||
userId: ctx.user.id,
|
||||
audienceVoterId: voter.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
sessionId: input.sessionId,
|
||||
projectId: input.projectId,
|
||||
userId: ctx.user.id,
|
||||
audienceVoterId: voter.id,
|
||||
score: input.score,
|
||||
isAudienceVote: true,
|
||||
},
|
||||
@@ -576,6 +864,70 @@ export const liveVotingRouter = router({
|
||||
return vote
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get audience voter stats (admin)
|
||||
*/
|
||||
getAudienceVoterStats: adminProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const voterCount = await ctx.prisma.audienceVoter.count({
|
||||
where: { sessionId: input.sessionId },
|
||||
})
|
||||
|
||||
const voteCount = await ctx.prisma.liveVote.count({
|
||||
where: { sessionId: input.sessionId, isAudienceVote: true },
|
||||
})
|
||||
|
||||
return { voterCount, voteCount }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get public session info for audience voting page
|
||||
*/
|
||||
getAudienceSession: publicProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||
where: { id: input.sessionId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentProjectId: true,
|
||||
votingEndsAt: true,
|
||||
allowAudienceVotes: true,
|
||||
audienceVotingMode: true,
|
||||
audienceRequireId: true,
|
||||
audienceMaxFavorites: true,
|
||||
round: {
|
||||
select: {
|
||||
name: true,
|
||||
program: { select: { name: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
currentProject,
|
||||
timeRemaining,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get public results for a live voting session (no auth required)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user