Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
403
src/server/routers/live-voting.ts
Normal file
403
src/server/routers/live-voting.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
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 } },
|
||||
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; 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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user