Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
822
src/server/routers/live.ts
Normal file
822
src/server/routers/live.ts
Normal file
@@ -0,0 +1,822 @@
|
||||
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({
|
||||
stageId: z.string(),
|
||||
projectOrder: z.array(z.string()).min(1), // Ordered project IDs
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
|
||||
if (stage.stageType !== 'LIVE_FINAL') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Live sessions can only be started for LIVE_FINAL stages',
|
||||
})
|
||||
}
|
||||
|
||||
if (stage.status !== 'STAGE_ACTIVE') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Stage must be ACTIVE to start a live session',
|
||||
})
|
||||
}
|
||||
|
||||
// Check for existing active cursor
|
||||
const existingCursor = await ctx.prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
|
||||
if (existingCursor) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A live session already exists for this stage. 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 stage config
|
||||
await tx.stage.update({
|
||||
where: { id: input.stageId },
|
||||
data: {
|
||||
configJson: {
|
||||
...(stage.configJson as Record<string, unknown> ?? {}),
|
||||
projectOrder: input.projectOrder,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
|
||||
const created = await tx.liveProgressCursor.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
activeProjectId: input.projectOrder[0],
|
||||
activeOrderIndex: 0,
|
||||
isPaused: false,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'LIVE_SESSION_STARTED',
|
||||
entityType: 'Stage',
|
||||
entityId: input.stageId,
|
||||
detailsJson: {
|
||||
sessionId: created.sessionId,
|
||||
projectCount: input.projectOrder.length,
|
||||
firstProjectId: input.projectOrder[0],
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
return cursor
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set the active project in the live session
|
||||
*/
|
||||
setActiveProject: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
projectId: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
|
||||
// Get project order from stage config
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
const config = (stage.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.$transaction(async (tx) => {
|
||||
const result = await tx.liveProgressCursor.update({
|
||||
where: { id: cursor.id },
|
||||
data: {
|
||||
activeProjectId: input.projectId,
|
||||
activeOrderIndex: index,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
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 result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Jump to a specific index in the project order
|
||||
*/
|
||||
jump: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
index: z.number().int().min(0),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
const config = (stage.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.$transaction(async (tx) => {
|
||||
const result = await tx.liveProgressCursor.update({
|
||||
where: { id: cursor.id },
|
||||
data: {
|
||||
activeProjectId: targetProjectId,
|
||||
activeOrderIndex: input.index,
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
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 result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reorder the project presentation queue
|
||||
*/
|
||||
reorder: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
projectOrder: z.array(z.string()).min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
|
||||
// Update config with new order
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.stage.update({
|
||||
where: { id: input.stageId },
|
||||
data: {
|
||||
configJson: {
|
||||
...(stage.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),
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'LIVE_REORDER',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
detailsJson: {
|
||||
projectCount: input.projectOrder.length,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updatedCursor
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Pause the live session
|
||||
*/
|
||||
pause: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
|
||||
if (cursor.isPaused) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Session is already paused',
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.liveProgressCursor.update({
|
||||
where: { id: cursor.id },
|
||||
data: { isPaused: true },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'LIVE_PAUSED',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
detailsJson: { activeProjectId: cursor.activeProjectId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resume the live session
|
||||
*/
|
||||
resume: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
|
||||
if (!cursor.isPaused) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Session is not paused',
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await ctx.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.liveProgressCursor.update({
|
||||
where: { id: cursor.id },
|
||||
data: { isPaused: false },
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'LIVE_RESUMED',
|
||||
entityType: 'LiveProgressCursor',
|
||||
entityId: cursor.id,
|
||||
detailsJson: { activeProjectId: cursor.activeProjectId },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return updated
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get current cursor state (for all users, including audience)
|
||||
*/
|
||||
getCursor: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const cursor = await ctx.prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
|
||||
if (!cursor) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get stage config for project order
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
})
|
||||
const config = (stage.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 stage (if any)
|
||||
const openCohorts = await ctx.prisma.cohort.findMany({
|
||||
where: { stageId: input.stageId, 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({
|
||||
stageId: 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: { stageId: input.stageId },
|
||||
})
|
||||
|
||||
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: {
|
||||
stageId: input.stageId,
|
||||
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 stage's round
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { programId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Find or check existing LiveVotingSession for this stage
|
||||
// We look for any session linked to a round in this program
|
||||
const session = await ctx.prisma.liveVotingSession.findFirst({
|
||||
where: {
|
||||
stage: {
|
||||
track: { pipeline: { programId: stage.track.pipeline.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 stage info
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: cursor.stageId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
stageType: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: 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: { stageId: cursor.stageId, isOpen: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
votingMode: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
projects: {
|
||||
select: { projectId: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const config = (stage.configJson as Record<string, unknown>) ?? {}
|
||||
const projectOrder = (config.projectOrder as string[]) ?? []
|
||||
|
||||
const now = new Date()
|
||||
const isWindowOpen =
|
||||
stage.status === 'STAGE_ACTIVE' &&
|
||||
(!stage.windowOpenAt || now >= stage.windowOpenAt) &&
|
||||
(!stage.windowCloseAt || now <= stage.windowCloseAt)
|
||||
|
||||
// Aggregate project scores from LiveVote for the scoreboard
|
||||
// Find the active LiveVotingSession for this stage's program
|
||||
const stageWithTrack = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: cursor.stageId },
|
||||
select: { track: { select: { pipeline: { select: { programId: true } } } } },
|
||||
})
|
||||
|
||||
const votingSession = await ctx.prisma.liveVotingSession.findFirst({
|
||||
where: {
|
||||
stage: {
|
||||
track: { pipeline: { programId: stageWithTrack.track.pipeline.programId } },
|
||||
},
|
||||
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,
|
||||
stageInfo: {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
stageType: stage.stageType,
|
||||
},
|
||||
windowStatus: {
|
||||
isOpen: isWindowOpen,
|
||||
closesAt: stage.windowCloseAt,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
stageId: cursor.stageId,
|
||||
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 stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: cursor.stageId },
|
||||
include: {
|
||||
track: {
|
||||
include: {
|
||||
pipeline: { select: { programId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const session = await ctx.prisma.liveVotingSession.findFirst({
|
||||
where: {
|
||||
stage: {
|
||||
track: { pipeline: { programId: stage.track.pipeline.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 }
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user