2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
2026-02-05 21:09:06 +01:00
|
|
|
import { logAudit } from '../utils/audit'
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
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 } },
|
2026-02-04 14:15:06 +01:00
|
|
|
projects: {
|
2026-01-30 13:41:32 +01:00
|
|
|
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
2026-02-04 14:15:06 +01:00
|
|
|
select: { id: true, title: true, teamName: true },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!session) {
|
|
|
|
|
// Create session
|
|
|
|
|
session = await ctx.prisma.liveVotingSession.create({
|
|
|
|
|
data: {
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
round: {
|
|
|
|
|
include: {
|
|
|
|
|
program: { select: { name: true, year: true } },
|
2026-02-04 14:15:06 +01:00
|
|
|
projects: {
|
2026-01-30 13:41:32 +01:00
|
|
|
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
|
2026-02-04 14:15:06 +01:00
|
|
|
select: { id: true, title: true, teamName: true },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-02-05 21:09:06 +01:00
|
|
|
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,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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
|
2026-02-05 21:09:06 +01:00
|
|
|
await logAudit({
|
|
|
|
|
prisma: ctx.prisma,
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'END_SESSION',
|
|
|
|
|
entityType: 'LiveVotingSession',
|
|
|
|
|
entityId: session.id,
|
|
|
|
|
detailsJson: {},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
})
|