Files
MOPC-Portal/src/app/api/live-voting/stream/route.ts
Matt 875c2e8f48
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
fix: security hardening — block self-registration, SSE auth, audit logging fixes
Security fixes:
- Block self-registration via magic link (PrismaAdapter createUser throws)
- Magic links only sent to existing ACTIVE users (prevents enumeration)
- signIn callback rejects non-existent users (defense-in-depth)
- Change schema default role from JURY_MEMBER to APPLICANT
- Add authentication to live-voting SSE stream endpoint
- Fix false FILE_OPENED/FILE_DOWNLOADED audit events on page load
  (remove purpose from eagerly pre-fetched URL queries)

Bug fixes:
- Fix impersonation skeleton screen on applicant dashboard
- Fix onboarding redirect loop in auth layout

Observer dashboard redesign (Steps 1-6):
- Clickable round pipeline with selected round highlighting
- Round-type-specific dashboard panels (intake, filtering, evaluation,
  submission, mentoring, live final, deliberation)
- Enhanced activity feed with server-side humanization
- Previous round comparison section
- New backend queries for round-specific analytics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:18:50 +01:00

226 lines
7.2 KiB
TypeScript

import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest): Promise<Response> {
// Require authentication — prevent unauthenticated access to live vote data
const userSession = await auth()
if (!userSession?.user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
if (!sessionId) {
return new Response(JSON.stringify({ error: 'sessionId is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
// Verify the session exists
const session = await prisma.liveVotingSession.findUnique({
where: { id: sessionId },
select: { id: true, status: true },
})
if (!session) {
return new Response(JSON.stringify({ error: 'Session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
}
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// Track state for change detection
let lastVoteCount = -1
let lastAudienceVoteCount = -1
let lastProjectId: string | null = null
let lastStatus: string | null = null
const sendEvent = (event: string, data: unknown) => {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
controller.enqueue(encoder.encode(payload))
}
// Send initial connection event
sendEvent('connected', { sessionId, timestamp: new Date().toISOString() })
const poll = async () => {
try {
const currentSession = await prisma.liveVotingSession.findUnique({
where: { id: sessionId },
select: {
status: true,
currentProjectId: true,
currentProjectIndex: true,
votingEndsAt: true,
allowAudienceVotes: true,
},
})
if (!currentSession) {
sendEvent('session_status', { status: 'DELETED' })
controller.close()
return false
}
// Check for status changes
if (lastStatus !== null && currentSession.status !== lastStatus) {
sendEvent('session_status', {
status: currentSession.status,
timestamp: new Date().toISOString(),
})
}
lastStatus = currentSession.status
// Check for project changes
if (
lastProjectId !== null &&
currentSession.currentProjectId !== lastProjectId
) {
sendEvent('project_change', {
projectId: currentSession.currentProjectId,
projectIndex: currentSession.currentProjectIndex,
timestamp: new Date().toISOString(),
})
}
lastProjectId = currentSession.currentProjectId
// Check for vote updates on the current project
if (currentSession.currentProjectId) {
// Jury votes
const juryVoteCount = await prisma.liveVote.count({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: false,
},
})
if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
const latestVotes = await prisma.liveVote.findMany({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: false,
},
select: {
score: true,
isAudienceVote: true,
votedAt: true,
},
orderBy: { votedAt: 'desc' },
take: 1,
})
const avgScore = await prisma.liveVote.aggregate({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: false,
},
_avg: { score: true },
_count: true,
})
sendEvent('vote_update', {
projectId: currentSession.currentProjectId,
totalVotes: juryVoteCount,
averageScore: avgScore._avg.score,
latestVote: latestVotes[0] || null,
timestamp: new Date().toISOString(),
})
}
lastVoteCount = juryVoteCount
// Audience votes (separate event)
if (currentSession.allowAudienceVotes) {
const audienceVoteCount = await prisma.liveVote.count({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: true,
},
})
if (lastAudienceVoteCount !== -1 && audienceVoteCount !== lastAudienceVoteCount) {
const audienceAvg = await prisma.liveVote.aggregate({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: true,
},
_avg: { score: true },
})
sendEvent('audience_vote', {
projectId: currentSession.currentProjectId,
audienceVotes: audienceVoteCount,
audienceAverage: audienceAvg._avg.score,
timestamp: new Date().toISOString(),
})
}
lastAudienceVoteCount = audienceVoteCount
}
}
// Stop polling if session is completed
if (currentSession.status === 'COMPLETED') {
sendEvent('session_status', {
status: 'COMPLETED',
timestamp: new Date().toISOString(),
})
controller.close()
return false
}
return true
} catch (error) {
console.error('[SSE] Poll error:', error)
return true // Keep trying
}
}
// Initial poll to set baseline state
const shouldContinue = await poll()
if (!shouldContinue) return
// Poll every 2 seconds
const interval = setInterval(async () => {
const cont = await poll()
if (!cont) {
clearInterval(interval)
}
}, 2000)
// Clean up on abort
request.signal.addEventListener('abort', () => {
clearInterval(interval)
try {
controller.close()
} catch {
// Stream may already be closed
}
})
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}