- 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>
1000 lines
29 KiB
TypeScript
1000 lines
29 KiB
TypeScript
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({
|
|
/**
|
|
* Get or create a live voting session for a round
|
|
*/
|
|
getSession: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
let session = await ctx.prisma.liveVotingSession.findUnique({
|
|
where: { roundId: input.roundId },
|
|
include: {
|
|
round: {
|
|
include: {
|
|
program: { select: { name: true, year: true } },
|
|
projects: {
|
|
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
|
select: { id: true, title: true, teamName: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!session) {
|
|
// Create session
|
|
session = await ctx.prisma.liveVotingSession.create({
|
|
data: {
|
|
roundId: input.roundId,
|
|
},
|
|
include: {
|
|
round: {
|
|
include: {
|
|
program: { select: { name: true, year: true } },
|
|
projects: {
|
|
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
|
select: { id: true, title: true, teamName: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// Get current votes if voting is in progress
|
|
let currentVotes: { userId: string | null; score: number }[] = []
|
|
if (session.currentProjectId) {
|
|
const votes = await ctx.prisma.liveVote.findMany({
|
|
where: {
|
|
sessionId: session.id,
|
|
projectId: session.currentProjectId,
|
|
},
|
|
select: { userId: true, score: true },
|
|
})
|
|
currentVotes = votes
|
|
}
|
|
|
|
// Get audience voter count
|
|
const audienceVoterCount = await ctx.prisma.audienceVoter.count({
|
|
where: { sessionId: session.id },
|
|
})
|
|
|
|
return {
|
|
...session,
|
|
currentVotes,
|
|
audienceVoterCount,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get session for jury member voting
|
|
*/
|
|
getSessionForVoting: protectedProcedure
|
|
.input(z.object({ sessionId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
|
where: { id: input.sessionId },
|
|
include: {
|
|
round: {
|
|
include: {
|
|
program: { select: { name: true, year: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Get current project if in progress
|
|
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, description: true },
|
|
})
|
|
}
|
|
|
|
// Get user's vote for current project
|
|
let userVote = null
|
|
if (session.currentProjectId) {
|
|
userVote = await ctx.prisma.liveVote.findFirst({
|
|
where: {
|
|
sessionId: session.id,
|
|
projectId: session.currentProjectId,
|
|
userId: ctx.user.id,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Calculate time remaining
|
|
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: {
|
|
id: session.id,
|
|
status: session.status,
|
|
votingStartedAt: session.votingStartedAt,
|
|
votingEndsAt: session.votingEndsAt,
|
|
votingMode: session.votingMode,
|
|
criteriaJson: session.criteriaJson,
|
|
},
|
|
round: session.round,
|
|
currentProject,
|
|
userVote,
|
|
timeRemaining,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Get public session info for display
|
|
*/
|
|
getPublicSession: protectedProcedure
|
|
.input(z.object({ sessionId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
|
where: { id: input.sessionId },
|
|
include: {
|
|
round: {
|
|
include: {
|
|
program: { select: { name: true, year: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Get all projects in order
|
|
const projectOrder = (session.projectOrderJson as string[]) || []
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: { id: { in: projectOrder } },
|
|
select: { id: true, title: true, teamName: true },
|
|
})
|
|
|
|
// Sort by order
|
|
const sortedProjects = projectOrder
|
|
.map((id) => projects.find((p) => p.id === id))
|
|
.filter(Boolean)
|
|
|
|
// Get scores for each project
|
|
const scores = await ctx.prisma.liveVote.groupBy({
|
|
by: ['projectId'],
|
|
where: { sessionId: session.id },
|
|
_avg: { score: true },
|
|
_count: true,
|
|
})
|
|
|
|
const projectsWithScores = sortedProjects.map((project) => {
|
|
const projectScore = scores.find((s) => s.projectId === project!.id)
|
|
return {
|
|
...project,
|
|
averageScore: projectScore?._avg.score || null,
|
|
voteCount: projectScore?._count || 0,
|
|
}
|
|
})
|
|
|
|
return {
|
|
session: {
|
|
id: session.id,
|
|
status: session.status,
|
|
currentProjectId: session.currentProjectId,
|
|
votingEndsAt: session.votingEndsAt,
|
|
},
|
|
round: session.round,
|
|
projects: projectsWithScores,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Set project order for voting
|
|
*/
|
|
setProjectOrder: adminProcedure
|
|
.input(
|
|
z.object({
|
|
sessionId: z.string(),
|
|
projectIds: z.array(z.string()),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
where: { id: input.sessionId },
|
|
data: {
|
|
projectOrderJson: input.projectIds,
|
|
},
|
|
})
|
|
|
|
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
|
|
*/
|
|
startVoting: adminProcedure
|
|
.input(
|
|
z.object({
|
|
sessionId: z.string(),
|
|
projectId: z.string(),
|
|
durationSeconds: z.number().int().min(10).max(300).default(30),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const now = new Date()
|
|
const votingEndsAt = new Date(now.getTime() + input.durationSeconds * 1000)
|
|
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
where: { id: input.sessionId },
|
|
data: {
|
|
status: 'IN_PROGRESS',
|
|
currentProjectId: input.projectId,
|
|
votingStartedAt: now,
|
|
votingEndsAt,
|
|
},
|
|
})
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'START_VOTING',
|
|
entityType: 'LiveVotingSession',
|
|
entityId: session.id,
|
|
detailsJson: { projectId: input.projectId, durationSeconds: input.durationSeconds },
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return session
|
|
}),
|
|
|
|
/**
|
|
* Stop voting
|
|
*/
|
|
stopVoting: adminProcedure
|
|
.input(z.object({ sessionId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
where: { id: input.sessionId },
|
|
data: {
|
|
status: 'PAUSED',
|
|
votingEndsAt: new Date(),
|
|
},
|
|
})
|
|
|
|
return session
|
|
}),
|
|
|
|
/**
|
|
* End session
|
|
*/
|
|
endSession: adminProcedure
|
|
.input(z.object({ sessionId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
where: { id: input.sessionId },
|
|
data: {
|
|
status: 'COMPLETED',
|
|
},
|
|
})
|
|
|
|
// Audit log
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'END_SESSION',
|
|
entityType: 'LiveVotingSession',
|
|
entityId: session.id,
|
|
detailsJson: {},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return session
|
|
}),
|
|
|
|
/**
|
|
* Submit a vote (supports both simple and criteria modes)
|
|
*/
|
|
vote: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
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 }) => {
|
|
// Verify session is in progress
|
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
|
where: { id: input.sessionId },
|
|
})
|
|
|
|
if (session.status !== 'IN_PROGRESS') {
|
|
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',
|
|
})
|
|
}
|
|
|
|
// Check if voting window is still open
|
|
if (session.votingEndsAt && new Date() > session.votingEndsAt) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Voting window has closed',
|
|
})
|
|
}
|
|
|
|
// 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: {
|
|
sessionId_projectId_userId: {
|
|
sessionId: input.sessionId,
|
|
projectId: input.projectId,
|
|
userId: ctx.user.id,
|
|
},
|
|
},
|
|
create: {
|
|
sessionId: input.sessionId,
|
|
projectId: input.projectId,
|
|
userId: ctx.user.id,
|
|
score: finalScore,
|
|
criterionScoresJson: criterionScoresJson ?? undefined,
|
|
},
|
|
update: {
|
|
score: finalScore,
|
|
criterionScoresJson: criterionScoresJson ?? undefined,
|
|
votedAt: new Date(),
|
|
},
|
|
})
|
|
|
|
return vote
|
|
}),
|
|
|
|
/**
|
|
* Get results for a session (with weighted jury + audience scoring)
|
|
*/
|
|
getResults: protectedProcedure
|
|
.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 },
|
|
include: {
|
|
round: {
|
|
include: {
|
|
program: { select: { name: true, year: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// 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({
|
|
by: ['projectId'],
|
|
where: { sessionId: input.sessionId, isAudienceVote: false },
|
|
_avg: { score: true },
|
|
_count: true,
|
|
})
|
|
|
|
// Get audience votes grouped by project
|
|
const audienceScores = await ctx.prisma.liveVote.groupBy({
|
|
by: ['projectId'],
|
|
where: { sessionId: input.sessionId, isAudienceVote: true },
|
|
_avg: { score: true },
|
|
_count: true,
|
|
})
|
|
|
|
// Get project details
|
|
const allProjectIds = [
|
|
...new Set([
|
|
...juryScores.map((s) => s.projectId),
|
|
...audienceScores.map((s) => s.projectId),
|
|
]),
|
|
]
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: { id: { in: allProjectIds } },
|
|
select: { id: true, title: true, teamName: true },
|
|
})
|
|
|
|
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) => {
|
|
const project = projects.find((p) => p.id === jurySc.projectId)
|
|
const audienceSc = audienceMap.get(jurySc.projectId)
|
|
const juryAvg = jurySc._avg?.score || 0
|
|
const audienceAvg = audienceSc?._avg?.score || 0
|
|
const weightedTotal = audienceWeightVal > 0 && audienceSc
|
|
? juryAvg * juryWeightVal + audienceAvg * audienceWeightVal
|
|
: juryAvg
|
|
|
|
return {
|
|
project,
|
|
juryAverage: juryAvg,
|
|
juryVoteCount: jurySc._count,
|
|
audienceAverage: audienceAvg,
|
|
audienceVoteCount: audienceSc?._count || 0,
|
|
weightedTotal,
|
|
criteriaAverages: criteriaBreakdown?.[jurySc.projectId] || null,
|
|
}
|
|
})
|
|
.sort((a, b) => b.weightedTotal - a.weightedTotal)
|
|
|
|
// Detect ties
|
|
const ties: string[][] = []
|
|
for (let i = 0; i < results.length - 1; i++) {
|
|
if (Math.abs(results[i].weightedTotal - results[i + 1].weightedTotal) < 0.001) {
|
|
const tieGroup = [results[i].project?.id, results[i + 1].project?.id].filter(Boolean) as string[]
|
|
ties.push(tieGroup)
|
|
}
|
|
}
|
|
|
|
return {
|
|
session,
|
|
results,
|
|
ties,
|
|
tieBreakerMethod: session.tieBreakerMethod,
|
|
votingMode: session.votingMode,
|
|
criteria: session.criteriaJson as LiveVotingCriterion[] | null,
|
|
weights: { jury: juryWeightVal, audience: audienceWeightVal },
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Update presentation settings for a live voting session
|
|
*/
|
|
updatePresentationSettings: adminProcedure
|
|
.input(
|
|
z.object({
|
|
sessionId: z.string(),
|
|
presentationSettingsJson: z.object({
|
|
theme: z.string().optional(),
|
|
autoAdvance: z.boolean().optional(),
|
|
autoAdvanceDelay: z.number().int().min(5).max(120).optional(),
|
|
scoreDisplayFormat: z.enum(['bar', 'number', 'radial']).optional(),
|
|
showVoteCount: z.boolean().optional(),
|
|
brandingOverlay: z.string().optional(),
|
|
}),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
where: { id: input.sessionId },
|
|
data: {
|
|
presentationSettingsJson: input.presentationSettingsJson,
|
|
},
|
|
})
|
|
|
|
return session
|
|
}),
|
|
|
|
/**
|
|
* Update session config (audience voting, tie-breaker)
|
|
*/
|
|
updateSessionConfig: adminProcedure
|
|
.input(
|
|
z.object({
|
|
sessionId: z.string(),
|
|
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 }) => {
|
|
const { sessionId, ...data } = input
|
|
|
|
const session = await ctx.prisma.liveVotingSession.update({
|
|
where: { id: sessionId },
|
|
data,
|
|
})
|
|
|
|
// Audit log
|
|
try {
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE_SESSION_CONFIG',
|
|
entityType: 'LiveVotingSession',
|
|
entityId: sessionId,
|
|
detailsJson: data,
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
} catch {
|
|
// Audit log errors should never break the operation
|
|
}
|
|
|
|
return session
|
|
}),
|
|
|
|
/**
|
|
* Register an audience voter (public, no auth required)
|
|
*/
|
|
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 },
|
|
})
|
|
|
|
if (session.status !== 'IN_PROGRESS') {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Voting is not currently active',
|
|
})
|
|
}
|
|
|
|
if (!session.allowAudienceVotes) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Audience voting is not enabled for this session',
|
|
})
|
|
}
|
|
|
|
if (session.currentProjectId !== input.projectId) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Cannot vote for this project right now',
|
|
})
|
|
}
|
|
|
|
if (session.votingEndsAt && new Date() > session.votingEndsAt) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Voting window has closed',
|
|
})
|
|
}
|
|
|
|
// Upsert audience vote (dedup by audienceVoterId)
|
|
const vote = await ctx.prisma.liveVote.upsert({
|
|
where: {
|
|
sessionId_projectId_audienceVoterId: {
|
|
sessionId: input.sessionId,
|
|
projectId: input.projectId,
|
|
audienceVoterId: voter.id,
|
|
},
|
|
},
|
|
create: {
|
|
sessionId: input.sessionId,
|
|
projectId: input.projectId,
|
|
audienceVoterId: voter.id,
|
|
score: input.score,
|
|
isAudienceVote: true,
|
|
},
|
|
update: {
|
|
score: input.score,
|
|
votedAt: new Date(),
|
|
},
|
|
})
|
|
|
|
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)
|
|
*/
|
|
getPublicResults: 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,
|
|
presentationSettingsJson: true,
|
|
allowAudienceVotes: true,
|
|
audienceVoteWeight: true,
|
|
},
|
|
})
|
|
|
|
// Only return data if session is in progress or completed
|
|
if (session.status !== 'IN_PROGRESS' && session.status !== 'COMPLETED') {
|
|
return {
|
|
session: {
|
|
id: session.id,
|
|
status: session.status,
|
|
presentationSettings: session.presentationSettingsJson,
|
|
},
|
|
projects: [],
|
|
}
|
|
}
|
|
|
|
// Get all votes grouped by project (anonymized - no user data)
|
|
const scores = await ctx.prisma.liveVote.groupBy({
|
|
by: ['projectId'],
|
|
where: { sessionId: input.sessionId },
|
|
_avg: { score: true },
|
|
_count: true,
|
|
})
|
|
|
|
const projectIds = scores.map((s) => s.projectId)
|
|
const projects = await ctx.prisma.project.findMany({
|
|
where: { id: { in: projectIds } },
|
|
select: { id: true, title: true, teamName: true },
|
|
})
|
|
|
|
const projectsWithScores = scores.map((score) => {
|
|
const project = projects.find((p) => p.id === score.projectId)
|
|
return {
|
|
id: project?.id,
|
|
title: project?.title,
|
|
teamName: project?.teamName,
|
|
averageScore: score._avg.score || 0,
|
|
voteCount: score._count,
|
|
}
|
|
}).sort((a, b) => b.averageScore - a.averageScore)
|
|
|
|
return {
|
|
session: {
|
|
id: session.id,
|
|
status: session.status,
|
|
currentProjectId: session.currentProjectId,
|
|
votingEndsAt: session.votingEndsAt,
|
|
presentationSettings: session.presentationSettingsJson,
|
|
allowAudienceVotes: session.allowAudienceVotes,
|
|
},
|
|
projects: projectsWithScores,
|
|
}
|
|
}),
|
|
})
|