import { Router, Request, Response } from 'express'; import { getSession, getTeams, getJurors, getScores, addScore, updateSession, getStore, } from './store.js'; import { resolveConflict } from './conflict.js'; import { calculateAllResults, getCompletionStatus } from './aggregation.js'; import { broadcastUpdate } from './sse.js'; import { Score } from '../shared/types.js'; export const apiRouter = Router(); // ─── Session Endpoints ─────────────────────────────────────────────── apiRouter.get('/api/session', (_req: Request, res: Response) => { const session = getSession(); res.json(session); }); apiRouter.post('/api/session/start', (_req: Request, res: Response) => { const session = getSession(); if (session.status === 'active') { res.status(400).json({ error: 'Session is already active' }); return; } const updated = updateSession({ status: 'active', startedAt: new Date().toISOString(), }); res.json(updated); }); apiRouter.post('/api/session/end', (_req: Request, res: Response) => { const session = getSession(); if (session.status !== 'active') { res.status(400).json({ error: 'Session is not active' }); return; } const updated = updateSession({ status: 'ended', endedAt: new Date().toISOString(), }); res.json(updated); }); // ─── Team and Juror Endpoints ──────────────────────────────────────── apiRouter.get('/api/teams', (_req: Request, res: Response) => { const teams = getTeams(); res.json(teams); }); apiRouter.get('/api/jurors', (_req: Request, res: Response) => { const jurors = getJurors(); res.json(jurors); }); // ─── Score Endpoints ───────────────────────────────────────────────── apiRouter.post('/api/scores', (req: Request, res: Response) => { const session = getSession(); if (session.status !== 'active') { res.status(403).json({ error: 'Session is not active' }); return; } const { jurorId, teamId, criterionId, value, force } = req.body; // Validate required fields if (!jurorId || !teamId || !criterionId || value === undefined) { res.status(400).json({ error: 'Missing required fields: jurorId, teamId, criterionId, value' }); return; } // Validate value is within scale range const { scaleMin, scaleMax } = session.config; if (typeof value !== 'number' || value < scaleMin || value > scaleMax) { res.status(400).json({ error: `Value must be between ${scaleMin} and ${scaleMax}` }); return; } // Validate jurorId exists const jurors = getJurors(); if (!jurors.find((j) => j.id === jurorId)) { res.status(400).json({ error: `Unknown jurorId: ${jurorId}` }); return; } // Validate teamId exists const teams = getTeams(); if (!teams.find((t) => t.id === teamId)) { res.status(400).json({ error: `Unknown teamId: ${teamId}` }); return; } // Validate criterionId exists const criteria = session.config.criteria; if (!criteria.find((c) => c.id === criterionId)) { res.status(400).json({ error: `Unknown criterionId: ${criterionId}` }); return; } const incoming: Score = { jurorId, teamId, criterionId, value, updatedAt: new Date().toISOString(), }; // Check for existing score and resolve conflict const existingScores = getScores(); const existing = existingScores.find( (s) => s.jurorId === jurorId && s.teamId === teamId && s.criterionId === criterionId ); const conflict = resolveConflict(incoming, existing, force); if (!conflict.accept) { res.status(409).json({ error: conflict.reason, existing }); return; } addScore(incoming); broadcastUpdate(); res.json(incoming); }); apiRouter.get('/api/scores/:jurorId', (req: Request, res: Response) => { const { jurorId } = req.params; const scores = getScores(); const jurorScores = scores.filter((s) => s.jurorId === jurorId); res.json(jurorScores); }); apiRouter.get('/api/scores', (_req: Request, res: Response) => { const scores = getScores(); res.json(scores); }); // ─── Results and Progress Endpoints ────────────────────────────────── apiRouter.get('/api/results', (_req: Request, res: Response) => { const store = getStore(); const criteriaCount = store.session.config.criteria.length; const results = calculateAllResults(store.scores, store.teams, criteriaCount, store.session.config.criteria); res.json(results); }); apiRouter.get('/api/progress', (_req: Request, res: Response) => { const store = getStore(); const criteriaCount = store.session.config.criteria.length; const progress: Record = {}; for (const juror of store.jurors) { let completedTeams = 0; for (const team of store.teams) { const status = getCompletionStatus(juror.id, team.id, store.scores, criteriaCount); if (status === 'complete') { completedTeams++; } } progress[juror.id] = { completed: completedTeams, total: store.teams.length }; } res.json(progress); }); apiRouter.get('/api/export/csv', (_req: Request, res: Response) => { const store = getStore(); const criteriaCount = store.session.config.criteria.length; const results = calculateAllResults(store.scores, store.teams, criteriaCount); // Build header row: Team, then for each juror: juror_criterion1, juror_criterion2, ..., then Total, Average, Rank const criteria = store.session.config.criteria; const headerParts = ['Team']; for (const juror of store.jurors) { for (const criterion of criteria) { headerParts.push(`${juror.name}_${criterion.nameDE}`); } } headerParts.push('Total', 'Average', 'Rank'); const rows: string[] = [headerParts.join(',')]; // One row per team (sorted by rank) for (const result of results) { const rowParts: string[] = [`"${result.teamName}"`]; for (const juror of store.jurors) { for (const criterion of criteria) { const score = store.scores.find( (s) => s.jurorId === juror.id && s.teamId === result.teamId && s.criterionId === criterion.id ); rowParts.push(score ? String(score.value) : ''); } } rowParts.push(String(result.total), result.average.toFixed(2), String(result.rank)); rows.push(rowParts.join(',')); } const csv = rows.join('\n'); res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', 'attachment; filename="jury-results.csv"'); res.send(csv); });