MOPC-App/src/server/routers/live-voting.ts

412 lines
11 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
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 } },
roundProjects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
include: {
project: {
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 } },
roundProjects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
include: {
project: {
select: { id: true, title: true, teamName: true },
},
},
},
},
},
},
})
}
// Get current votes if voting is in progress
let currentVotes: { userId: string; 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
}
return {
...session,
currentVotes,
}
}),
/**
* 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,
},
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
}),
/**
* 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 ctx.prisma.auditLog.create({
data: {
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 ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'END_SESSION',
entityType: 'LiveVotingSession',
entityId: session.id,
detailsJson: {},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return session
}),
/**
* Submit a vote
*/
vote: protectedProcedure
.input(
z.object({
sessionId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
})
)
.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',
})
}
// 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: input.score,
},
update: {
score: input.score,
votedAt: new Date(),
},
})
return vote
}),
/**
* Get results for a session
*/
getResults: 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 votes grouped by project
const projectScores = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: { sessionId: input.sessionId },
_avg: { score: true },
_count: true,
})
// Get project details
const projectIds = projectScores.map((s) => s.projectId)
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, title: true, teamName: true },
})
// Combine and sort by average score
const results = projectScores
.map((score) => {
const project = projects.find((p) => p.id === score.projectId)
return {
project,
averageScore: score._avg.score || 0,
voteCount: score._count,
}
})
.sort((a, b) => b.averageScore - a.averageScore)
return {
session,
results,
}
}),
})