Files
MOPC-Portal/src/server/routers/live.ts

775 lines
22 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
export const liveRouter = router({
/**
* Start a live presentation session for a stage
*/
start: adminProcedure
.input(
z.object({
roundId: z.string(),
projectOrder: z.array(z.string()).min(1), // Ordered project IDs
})
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
if (round.status !== 'ROUND_ACTIVE') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Round must be ACTIVE to start a live session',
})
}
// Check for existing active cursor
const existingCursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { roundId: input.roundId },
})
if (existingCursor) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A live session already exists for this round. Use jump/reorder to modify it.',
})
}
// Verify all projects exist
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectOrder } },
select: { id: true },
})
if (projects.length !== input.projectOrder.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Some project IDs are invalid',
})
}
const cursor = await ctx.prisma.$transaction(async (tx) => {
// Store the project order in round config
await tx.round.update({
where: { id: input.roundId },
data: {
configJson: {
...(round.configJson as Record<string, unknown> ?? {}),
projectOrder: input.projectOrder,
} as Prisma.InputJsonValue,
},
})
const created = await tx.liveProgressCursor.create({
data: {
roundId: input.roundId,
activeProjectId: input.projectOrder[0],
activeOrderIndex: 0,
isPaused: false,
},
})
return created
})
// Audit outside transaction so failures don't roll back the session start
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_SESSION_STARTED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
sessionId: cursor.sessionId,
projectCount: input.projectOrder.length,
firstProjectId: input.projectOrder[0],
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return cursor
}),
/**
* Set the active project in the live session
*/
setActiveProject: adminProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
// Get project order from round config
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const config = (round.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
const index = projectOrder.indexOf(input.projectId)
if (index === -1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Project is not in the session order',
})
}
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: input.projectId,
activeOrderIndex: index,
},
})
// Audit outside transaction so failures don't roll back the project set
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_ACTIVE_PROJECT_SET',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
projectId: input.projectId,
orderIndex: index,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Jump to a specific index in the project order
*/
jump: adminProcedure
.input(
z.object({
roundId: z.string(),
index: z.number().int().min(0),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const config = (round.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
if (input.index >= projectOrder.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Index ${input.index} is out of range (0-${projectOrder.length - 1})`,
})
}
const targetProjectId = projectOrder[input.index]
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: targetProjectId,
activeOrderIndex: input.index,
},
})
// Audit outside transaction so failures don't roll back the jump
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_JUMP',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
fromIndex: cursor.activeOrderIndex,
toIndex: input.index,
projectId: targetProjectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Reorder the project presentation queue
*/
reorder: adminProcedure
.input(
z.object({
roundId: z.string(),
projectOrder: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
// Update config with new order
const updated = await ctx.prisma.$transaction(async (tx) => {
await tx.round.update({
where: { id: input.roundId },
data: {
configJson: {
...(round.configJson as Record<string, unknown> ?? {}),
projectOrder: input.projectOrder,
} as Prisma.InputJsonValue,
},
})
// Recalculate active index
const newIndex = cursor.activeProjectId
? input.projectOrder.indexOf(cursor.activeProjectId)
: 0
const updatedCursor = await tx.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeOrderIndex: Math.max(0, newIndex),
},
})
return updatedCursor
})
// Audit outside transaction so failures don't roll back the reorder
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_REORDER',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: {
projectCount: input.projectOrder.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Pause the live session
*/
pause: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (cursor.isPaused) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session is already paused',
})
}
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: { isPaused: true },
})
// Audit outside transaction so failures don't roll back the pause
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PAUSED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { activeProjectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Resume the live session
*/
resume: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (!cursor.isPaused) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Session is not paused',
})
}
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: { isPaused: false },
})
// Audit outside transaction so failures don't roll back the resume
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_RESUMED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { activeProjectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Get current cursor state (for all users, including audience)
*/
getCursor: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { roundId: input.roundId },
})
if (!cursor) {
return null
}
// Get round config for project order
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const config = (round.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
// Get current project details
let activeProject = null
if (cursor.activeProjectId) {
activeProject = await ctx.prisma.project.findUnique({
where: { id: cursor.activeProjectId },
select: {
id: true,
title: true,
teamName: true,
description: true,
tags: true,
},
})
}
// Get open cohorts for this round (if any)
const openCohorts = await ctx.prisma.cohort.findMany({
where: { roundId: input.roundId, isOpen: true },
select: {
id: true,
name: true,
votingMode: true,
windowCloseAt: true,
},
})
return {
...cursor,
activeProject,
projectOrder,
totalProjects: projectOrder.length,
openCohorts,
}
}),
/**
* Cast a vote during a live session (audience or jury)
* Checks window is open and deduplicates votes
*/
castVote: audienceProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
criterionScoresJson: z.record(z.number()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify live session exists and is not paused
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (cursor.isPaused) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting is paused',
})
}
// Check if there's an open cohort containing this project
const openCohort = await ctx.prisma.cohort.findFirst({
where: {
roundId: input.roundId,
isOpen: true,
projects: { some: { projectId: input.projectId } },
},
})
// Check voting window if cohort has time limits
if (openCohort?.windowCloseAt) {
const now = new Date()
if (now > openCohort.windowCloseAt) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting window has closed',
})
}
}
// Find the LiveVotingSession linked to this round
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { competition: { select: { programId: true } } },
})
// Find or check existing LiveVotingSession for this round
const session = await ctx.prisma.liveVotingSession.findFirst({
where: {
round: { competition: { programId: round.competition.programId } },
status: 'IN_PROGRESS',
},
})
if (!session) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'No active voting session found',
})
}
// Deduplicate: check if user already voted on this project in this session
const existingVote = await ctx.prisma.liveVote.findUnique({
where: {
sessionId_projectId_userId: {
sessionId: session.id,
projectId: input.projectId,
userId: ctx.user.id,
},
},
})
if (existingVote) {
// Update existing vote
const updated = await ctx.prisma.liveVote.update({
where: { id: existingVote.id },
data: {
score: input.score,
criterionScoresJson: input.criterionScoresJson
? (input.criterionScoresJson as Prisma.InputJsonValue)
: undefined,
votedAt: new Date(),
},
})
return { vote: updated, wasUpdate: true }
}
// Create new vote
const vote = await ctx.prisma.liveVote.create({
data: {
sessionId: session.id,
projectId: input.projectId,
userId: ctx.user.id,
score: input.score,
isAudienceVote: !['JURY_MEMBER', 'SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(
ctx.user.role
),
criterionScoresJson: input.criterionScoresJson
? (input.criterionScoresJson as Prisma.InputJsonValue)
: undefined,
},
})
return { vote, wasUpdate: false }
}),
// =========================================================================
// Phase 4: Audience-native procedures
// =========================================================================
/**
* Get audience context for a live session (public-facing via sessionId)
*/
getAudienceContext: protectedProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { sessionId: input.sessionId },
})
if (!cursor) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Live session not found',
})
}
// Get round info
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: cursor.roundId },
select: {
id: true,
name: true,
status: true,
configJson: true,
},
})
// Get active project
let activeProject = null
if (cursor.activeProjectId) {
activeProject = await ctx.prisma.project.findUnique({
where: { id: cursor.activeProjectId },
select: {
id: true,
title: true,
teamName: true,
description: true,
tags: true,
country: true,
},
})
}
// Get open cohorts
const openCohorts = await ctx.prisma.cohort.findMany({
where: { roundId: cursor.roundId, isOpen: true },
select: {
id: true,
name: true,
votingMode: true,
windowOpenAt: true,
windowCloseAt: true,
projects: {
select: { projectId: true },
},
},
})
const config = (round.configJson as Record<string, unknown>) ?? {}
const projectOrder = (config.projectOrder as string[]) ?? []
const now = new Date()
const isWindowOpen = round.status === 'ROUND_ACTIVE'
// Aggregate project scores from LiveVote for the scoreboard
// Find the active LiveVotingSession for this round's program
const votingSession = await ctx.prisma.liveVotingSession.findFirst({
where: {
round: { competition: { programId: round.id } },
status: 'IN_PROGRESS',
},
select: { id: true },
})
// Get all cohort project IDs for this stage
const allCohortProjectIds = openCohorts.flatMap((c) =>
c.projects.map((p) => p.projectId)
)
const uniqueProjectIds = [...new Set(allCohortProjectIds)]
let projectScores: Array<{
projectId: string
title: string
teamName: string | null
averageScore: number
voteCount: number
}> = []
if (votingSession && uniqueProjectIds.length > 0) {
// Get vote aggregates
const voteAggregates = await ctx.prisma.liveVote.groupBy({
by: ['projectId'],
where: {
sessionId: votingSession.id,
projectId: { in: uniqueProjectIds },
},
_avg: { score: true },
_count: { score: true },
})
// Get project details
const projects = await ctx.prisma.project.findMany({
where: { id: { in: uniqueProjectIds } },
select: { id: true, title: true, teamName: true },
})
const projectMap = new Map(projects.map((p) => [p.id, p]))
projectScores = voteAggregates.map((agg) => {
const project = projectMap.get(agg.projectId)
return {
projectId: agg.projectId,
title: project?.title ?? 'Unknown',
teamName: project?.teamName ?? null,
averageScore: agg._avg.score ?? 0,
voteCount: agg._count.score,
}
})
}
return {
cursor: {
sessionId: cursor.sessionId,
activeOrderIndex: cursor.activeOrderIndex,
isPaused: cursor.isPaused,
totalProjects: projectOrder.length,
},
activeProject,
openCohorts: openCohorts.map((c) => ({
id: c.id,
name: c.name,
votingMode: c.votingMode,
windowCloseAt: c.windowCloseAt,
projectIds: c.projects.map((p) => p.projectId),
})),
projectScores,
roundInfo: {
id: round.id,
name: round.name,
},
windowStatus: {
isOpen: true,
closesAt: null,
},
}
}),
/**
* Cast a vote in a stage-native live session
*/
castStageVote: audienceProcedure
.input(
z.object({
sessionId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
dedupeKey: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Resolve cursor by sessionId
const cursor = await ctx.prisma.liveProgressCursor.findUnique({
where: { sessionId: input.sessionId },
})
if (!cursor) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Live session not found',
})
}
if (cursor.isPaused) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting is paused',
})
}
// Check if there's an open cohort containing this project
const openCohort = await ctx.prisma.cohort.findFirst({
where: {
roundId: cursor.roundId,
isOpen: true,
projects: { some: { projectId: input.projectId } },
},
})
if (openCohort?.windowCloseAt) {
const now = new Date()
if (now > openCohort.windowCloseAt) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting window has closed',
})
}
}
// Find an active LiveVotingSession
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: cursor.roundId },
select: { competition: { select: { programId: true } } },
})
const session = await ctx.prisma.liveVotingSession.findFirst({
where: {
round: { competition: { programId: round.competition.programId } },
status: 'IN_PROGRESS',
},
})
if (!session) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'No active voting session found',
})
}
// Deduplicate: sessionId + projectId + userId
const existingVote = await ctx.prisma.liveVote.findUnique({
where: {
sessionId_projectId_userId: {
sessionId: session.id,
projectId: input.projectId,
userId: ctx.user.id,
},
},
})
if (existingVote) {
const updated = await ctx.prisma.liveVote.update({
where: { id: existingVote.id },
data: {
score: input.score,
votedAt: new Date(),
},
})
return { vote: updated, wasUpdate: true }
}
const vote = await ctx.prisma.liveVote.create({
data: {
sessionId: session.id,
projectId: input.projectId,
userId: ctx.user.id,
score: input.score,
isAudienceVote: !['JURY_MEMBER', 'SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(
ctx.user.role
),
},
})
return { vote, wasUpdate: false }
}),
})