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:
2026-02-12 14:27:49 +01:00
parent b5d90d3c26
commit 2a5fa463b3
14 changed files with 2518 additions and 456 deletions

View File

@@ -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)
*/