import { z } from 'zod' import { router, observerProcedure } from '../trpc' import { normalizeCountryToCode } from '@/lib/countries' import { getUserAvatarUrl } from '../utils/avatar-url' import { aggregateVotes } from '../services/deliberation' const editionOrRoundInput = z.object({ roundId: z.string().optional(), programId: z.string().optional(), }).refine(data => data.roundId || data.programId, { message: 'Either roundId or programId is required', }) function projectWhere(input: { roundId?: string; programId?: string }) { if (input.roundId) return { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } } return { isTest: false, programId: input.programId! } } function assignmentWhere(input: { roundId?: string; programId?: string }) { if (input.roundId) return { roundId: input.roundId } return { round: { competition: { programId: input.programId! } } } } function evalWhere(input: { roundId?: string; programId?: string }, extra: Record = {}) { const base = input.roundId ? { assignment: { roundId: input.roundId } } : { assignment: { round: { competition: { programId: input.programId! } } } } return { ...base, ...extra } } export const analyticsRouter = router({ /** * Get score distribution (histogram data) */ getScoreDistribution: observerProcedure .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: evalWhere(input, { status: 'SUBMITTED' }), select: { criterionScoresJson: true, }, }) // Extract all scores and calculate distribution const allScores: number[] = [] evaluations.forEach((evaluation) => { const scores = evaluation.criterionScoresJson as Record | null if (scores) { Object.values(scores).forEach((score) => { if (typeof score === 'number') { allScores.push(score) } }) } }) // Count scores by bucket (1-10) const distribution = Array.from({ length: 10 }, (_, i) => ({ score: i + 1, count: allScores.filter((s) => Math.round(s) === i + 1).length, })) return { distribution, totalScores: allScores.length, averageScore: allScores.length > 0 ? allScores.reduce((a, b) => a + b, 0) / allScores.length : 0, } }), /** * Get evaluation completion over time (timeline data) */ getEvaluationTimeline: observerProcedure .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: evalWhere(input, { status: 'SUBMITTED' }), select: { submittedAt: true, }, orderBy: { submittedAt: 'asc' }, }) // Group by date const byDate: Record = {} let cumulative = 0 evaluations.forEach((evaluation) => { if (evaluation.submittedAt) { const date = evaluation.submittedAt.toISOString().split('T')[0] if (!byDate[date]) { byDate[date] = 0 } byDate[date]++ } }) // Convert to cumulative timeline const timeline = Object.entries(byDate) .sort(([a], [b]) => a.localeCompare(b)) .map(([date, count]) => { cumulative += count return { date, daily: count, cumulative, } }) return timeline }), /** * Get juror workload distribution */ getJurorWorkload: observerProcedure .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const assignments = await ctx.prisma.assignment.findMany({ where: assignmentWhere(input), include: { user: { select: { name: true } }, project: { select: { id: true, title: true } }, evaluation: { select: { id: true, status: true, globalScore: true }, }, }, }) // Group by user const byUser: Record< string, { name: string; assigned: number; completed: number; projects: { id: string; title: string; evalStatus: string; score: number | null }[] } > = {} assignments.forEach((assignment) => { const userId = assignment.userId if (!byUser[userId]) { byUser[userId] = { name: assignment.user.name || 'Unknown', assigned: 0, completed: 0, projects: [], } } byUser[userId].assigned++ const evalStatus = assignment.evaluation?.status if (evalStatus === 'SUBMITTED') { byUser[userId].completed++ } byUser[userId].projects.push({ id: assignment.project.id, title: assignment.project.title, evalStatus: evalStatus === 'SUBMITTED' ? 'REVIEWED' : evalStatus === 'DRAFT' ? 'UNDER_REVIEW' : 'NOT_REVIEWED', score: evalStatus === 'SUBMITTED' && assignment.evaluation?.globalScore != null ? Number(assignment.evaluation.globalScore) : null, }) }) return Object.entries(byUser) .map(([id, data]) => ({ id, ...data, completionRate: data.assigned > 0 ? Math.round((data.completed / data.assigned) * 100) : 0, })) .sort((a, b) => b.assigned - a.assigned) }), /** * Get project rankings with average scores */ getProjectRankings: observerProcedure .input(editionOrRoundInput.and(z.object({ limit: z.number().optional() }))) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.findMany({ where: projectWhere(input), select: { id: true, title: true, teamName: true, status: true, assignments: { select: { evaluation: { select: { criterionScoresJson: true, status: true }, }, }, }, }, }) // Calculate average scores const rankings = projects .map((project) => { const allScores: number[] = [] project.assignments.forEach((assignment) => { const evaluation = assignment.evaluation if (evaluation?.status === 'SUBMITTED') { const scores = evaluation.criterionScoresJson as Record< string, number > | null if (scores) { const scoreValues = Object.values(scores).filter( (s): s is number => typeof s === 'number' ) if (scoreValues.length > 0) { const average = scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length allScores.push(average) } } } }) const averageScore = allScores.length > 0 ? allScores.reduce((a, b) => a + b, 0) / allScores.length : null return { id: project.id, title: project.title, teamName: project.teamName, status: project.status, averageScore, evaluationCount: allScores.length, } }) .sort((a, b) => { // Evaluated projects first (sorted by score desc), unevaluated at bottom if (a.averageScore !== null && b.averageScore !== null) return b.averageScore - a.averageScore if (a.averageScore !== null) return -1 if (b.averageScore !== null) return 1 return 0 }) return input.limit ? rankings.slice(0, input.limit) : rankings }), /** * Get status breakdown (pie chart data) */ getStatusBreakdown: observerProcedure .input(editionOrRoundInput) .query(async ({ ctx, input }) => { if (input.roundId) { // Check if this is an evaluation round — show eval-level status breakdown const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { roundType: true }, }) if (round?.roundType === 'EVALUATION') { // For evaluation rounds, break down by evaluation status per project const projects = await ctx.prisma.projectRoundState.findMany({ where: { roundId: input.roundId, project: { isTest: false } }, select: { projectId: true, project: { select: { assignments: { where: { roundId: input.roundId }, select: { evaluation: { select: { status: true } }, }, }, }, }, }, }) let fullyReviewed = 0 let partiallyReviewed = 0 let notReviewed = 0 for (const p of projects) { const assignments = p.project.assignments if (assignments.length === 0) { notReviewed++ continue } const submitted = assignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length if (submitted === 0) { notReviewed++ } else if (submitted === assignments.length) { fullyReviewed++ } else { partiallyReviewed++ } } const result = [] if (fullyReviewed > 0) result.push({ status: 'FULLY_REVIEWED', count: fullyReviewed }) if (partiallyReviewed > 0) result.push({ status: 'PARTIALLY_REVIEWED', count: partiallyReviewed }) if (notReviewed > 0) result.push({ status: 'NOT_REVIEWED', count: notReviewed }) return result } // Non-evaluation rounds: use ProjectRoundState const states = await ctx.prisma.projectRoundState.groupBy({ by: ['state'], where: { roundId: input.roundId, project: { isTest: false } }, _count: true, }) return states.map((s) => ({ status: s.state, count: s._count, })) } // Edition-level: use global project status const projects = await ctx.prisma.project.groupBy({ by: ['status'], where: projectWhere(input), _count: true, }) return projects.map((p) => ({ status: p.status, count: p._count, })) }), /** * Get overview stats for dashboard */ getOverviewStats: observerProcedure .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const [ projectCount, assignmentCount, evaluationCount, jurorCount, statusCounts, ] = await Promise.all([ ctx.prisma.project.count({ where: projectWhere(input) }), ctx.prisma.assignment.count({ where: assignmentWhere(input) }), ctx.prisma.evaluation.count({ where: evalWhere(input, { status: 'SUBMITTED' }), }), ctx.prisma.assignment.groupBy({ by: ['userId'], where: assignmentWhere(input), }), ctx.prisma.project.groupBy({ by: ['status'], where: projectWhere(input), _count: true, }), ]) const completionRate = assignmentCount > 0 ? Math.round((evaluationCount / assignmentCount) * 100) : 0 return { projectCount, assignmentCount, evaluationCount, jurorCount: jurorCount.length, completionRate, statusBreakdown: statusCounts.map((s) => ({ status: s.status, count: s._count, })), } }), /** * Get criteria-level score distribution */ getCriteriaScores: observerProcedure .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const formWhere = input.roundId ? { roundId: input.roundId, isActive: true } : { round: { competition: { programId: input.programId! } }, isActive: true } const evaluationForms = await ctx.prisma.evaluationForm.findMany({ where: formWhere, }) if (!evaluationForms.length) { return [] } // Build label → Set map so program-level queries match all IDs for the same criterion label // Skip boolean and section_header criteria — they don't have numeric scores const labelToIds = new Map>() const labelToFirst = new Map() evaluationForms.forEach((form) => { const criteria = form.criteriaJson as Array<{ id: string; label: string; type?: string }> | null if (criteria) { criteria.forEach((c) => { if (c.type === 'boolean' || c.type === 'section_header') return if (!labelToIds.has(c.label)) { labelToIds.set(c.label, new Set()) labelToFirst.set(c.label, c) } labelToIds.get(c.label)!.add(c.id) }) } }) const criteriaLabels = Array.from(labelToFirst.values()) if (criteriaLabels.length === 0) { return [] } // Get all evaluations const evaluations = await ctx.prisma.evaluation.findMany({ where: evalWhere(input, { status: 'SUBMITTED' }), select: { criterionScoresJson: true }, }) // Calculate average score per criterion, checking ALL IDs that share the same label const criteriaScores = criteriaLabels.map((criterion) => { const scores: number[] = [] const ids = labelToIds.get(criterion.label) ?? new Set([criterion.id]) evaluations.forEach((evaluation) => { const criterionScoresJson = evaluation.criterionScoresJson as Record< string, number > | null if (criterionScoresJson) { for (const cid of ids) { if (typeof criterionScoresJson[cid] === 'number') { scores.push(criterionScoresJson[cid]) break // Only count one score per evaluation per criterion } } } }) return { id: criterion.id, name: criterion.label, averageScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0, count: scores.length, } }) return criteriaScores }), /** * Get geographic distribution of projects by country */ getGeographicDistribution: observerProcedure .input( z.object({ programId: z.string(), roundId: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = input.roundId ? { isTest: false, assignments: { some: { roundId: input.roundId } } } : { isTest: false, programId: input.programId } const distribution = await ctx.prisma.project.groupBy({ by: ['country'], where: { ...where, country: { not: null } }, _count: { id: true }, }) // Resolve country names to ISO codes (DB may store "France" instead of "FR") const codeMap = new Map() for (const d of distribution) { const resolved = normalizeCountryToCode(d.country) ?? d.country ?? 'UNKNOWN' codeMap.set(resolved, (codeMap.get(resolved) ?? 0) + d._count.id) } return Array.from(codeMap.entries()).map(([countryCode, count]) => ({ countryCode, count, })) }), // ========================================================================= // Advanced Analytics (F10) // ========================================================================= /** * Compare metrics across multiple rounds */ getCrossRoundComparison: observerProcedure .input(z.object({ roundIds: z.array(z.string()).min(2) })) .query(async ({ ctx, input }) => { const { roundIds } = input // Batch: fetch all rounds, assignments, and evaluations in 3 queries const [rounds, assignments, evaluations] = await Promise.all([ ctx.prisma.round.findMany({ where: { id: { in: roundIds } }, select: { id: true, name: true }, }), ctx.prisma.assignment.groupBy({ by: ['roundId'], where: { roundId: { in: roundIds } }, _count: true, }), ctx.prisma.evaluation.findMany({ where: { assignment: { roundId: { in: roundIds } }, status: 'SUBMITTED', }, select: { globalScore: true, assignment: { select: { roundId: true } } }, }), ]) const roundMap = new Map(rounds.map((r) => [r.id, r.name])) const assignmentCountMap = new Map(assignments.map((a) => [a.roundId, a._count])) // Group evaluations by round const evalsByRound = new Map() const projectsByRound = new Map>() for (const e of evaluations) { const rid = e.assignment.roundId if (!evalsByRound.has(rid)) evalsByRound.set(rid, []) if (e.globalScore !== null) evalsByRound.get(rid)!.push(e.globalScore) } // Count distinct projects per round via assignments const projectAssignments = await ctx.prisma.assignment.findMany({ where: { roundId: { in: roundIds }, project: { isTest: false } }, select: { roundId: true, projectId: true }, distinct: ['roundId', 'projectId'], }) for (const pa of projectAssignments) { if (!projectsByRound.has(pa.roundId)) projectsByRound.set(pa.roundId, new Set()) projectsByRound.get(pa.roundId)!.add(pa.projectId) } return roundIds.map((roundId) => { const globalScores = evalsByRound.get(roundId) ?? [] const assignmentCount = assignmentCountMap.get(roundId) ?? 0 const evaluationCount = globalScores.length const completionRate = assignmentCount > 0 ? Math.round((evaluationCount / assignmentCount) * 100) : 0 const averageScore = globalScores.length > 0 ? globalScores.reduce((a, b) => a + b, 0) / globalScores.length : null const distribution = Array.from({ length: 10 }, (_, i) => ({ score: i + 1, count: globalScores.filter((s) => Math.round(s) === i + 1).length, })) return { roundId, roundName: roundMap.get(roundId) ?? roundId, projectCount: projectsByRound.get(roundId)?.size ?? 0, evaluationCount, completionRate, averageScore, scoreDistribution: distribution, } }) }), /** * Get juror consistency metrics for a round */ getJurorConsistency: observerProcedure .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: evalWhere(input, { status: 'SUBMITTED' }), include: { assignment: { include: { user: { select: { id: true, name: true } }, }, }, }, }) // Group scores by juror const jurorScores: Record = {} evaluations.forEach((e) => { const userId = e.assignment.userId if (!jurorScores[userId]) { jurorScores[userId] = { name: e.assignment.user.name || 'Unknown', scores: [], } } if (e.globalScore !== null) { jurorScores[userId].scores.push(e.globalScore) } }) // Calculate overall average const allScores = Object.values(jurorScores).flatMap((j) => j.scores) const overallAverage = allScores.length > 0 ? allScores.reduce((a, b) => a + b, 0) / allScores.length : 0 // Calculate per-juror metrics const metrics = Object.entries(jurorScores).map(([userId, data]) => { const avg = data.scores.length > 0 ? data.scores.reduce((a, b) => a + b, 0) / data.scores.length : 0 const variance = data.scores.length > 1 ? data.scores.reduce((sum, s) => sum + Math.pow(s - avg, 2), 0) / data.scores.length : 0 const stddev = Math.sqrt(variance) const deviationFromOverall = Math.abs(avg - overallAverage) return { userId, name: data.name, evaluationCount: data.scores.length, averageScore: avg, stddev, deviationFromOverall, isOutlier: deviationFromOverall > 2, // Flag if 2+ points from mean } }) return { overallAverage, jurors: metrics.sort((a, b) => b.deviationFromOverall - a.deviationFromOverall), } }), /** * Get diversity metrics for projects in a round */ getDiversityMetrics: observerProcedure .input(editionOrRoundInput) .query(async ({ ctx, input }) => { const projects = await ctx.prisma.project.findMany({ where: projectWhere(input), select: { country: true, competitionCategory: true, oceanIssue: true, tags: true, }, }) const total = projects.length if (total === 0) { return { total: 0, byCountry: [], byCategory: [], byOceanIssue: [], byTag: [] } } // By country const countryCounts: Record = {} projects.forEach((p) => { const key = p.country || 'Unknown' countryCounts[key] = (countryCounts[key] || 0) + 1 }) const byCountry = Object.entries(countryCounts) .map(([country, count]) => ({ country, count, percentage: (count / total) * 100 })) .sort((a, b) => b.count - a.count) // By competition category const categoryCounts: Record = {} projects.forEach((p) => { const key = p.competitionCategory || 'Uncategorized' categoryCounts[key] = (categoryCounts[key] || 0) + 1 }) const byCategory = Object.entries(categoryCounts) .map(([category, count]) => ({ category, count, percentage: (count / total) * 100 })) .sort((a, b) => b.count - a.count) // By ocean issue const issueCounts: Record = {} projects.forEach((p) => { const key = p.oceanIssue || 'Unspecified' issueCounts[key] = (issueCounts[key] || 0) + 1 }) const byOceanIssue = Object.entries(issueCounts) .map(([issue, count]) => ({ issue, count, percentage: (count / total) * 100 })) .sort((a, b) => b.count - a.count) // By tag const tagCounts: Record = {} projects.forEach((p) => { (p.tags || []).forEach((tag) => { tagCounts[tag] = (tagCounts[tag] || 0) + 1 }) }) const byTag = Object.entries(tagCounts) .map(([tag, count]) => ({ tag, count, percentage: (count / total) * 100 })) .sort((a, b) => b.count - a.count) return { total, byCountry, byCategory, byOceanIssue, byTag } }), /** * Get dashboard stats (optionally scoped to a round) */ getDashboardStats: observerProcedure .input(z.object({ roundId: z.string().optional() }).optional()) .query(async ({ ctx, input }) => { const roundId = input?.roundId const projectFilter = roundId ? { isTest: false, projectRoundStates: { some: { roundId } } } : { isTest: false } const assignmentFilter = roundId ? { roundId } : { round: { competition: { isTest: false } } } const evalFilter = roundId ? { assignment: { roundId }, status: 'SUBMITTED' as const } : { assignment: { round: { competition: { isTest: false } } }, status: 'SUBMITTED' as const } const [ programCount, activeRounds, projectCount, jurorCount, submittedEvaluations, totalAssignments, evaluationScores, ] = await Promise.all([ ctx.prisma.program.count({ where: { isTest: false } }), ctx.prisma.round.findMany({ where: { status: 'ROUND_ACTIVE', competition: { isTest: false } }, select: { id: true, name: true }, take: 5, }), ctx.prisma.project.count({ where: projectFilter }), roundId ? ctx.prisma.assignment.findMany({ where: { roundId }, select: { userId: true }, distinct: ['userId'], }).then((rows) => rows.length) : ctx.prisma.user.count({ where: { isTest: false, role: 'JURY_MEMBER', status: 'ACTIVE' } }), ctx.prisma.evaluation.count({ where: evalFilter }), ctx.prisma.assignment.count({ where: assignmentFilter }), ctx.prisma.evaluation.findMany({ where: { ...evalFilter, globalScore: { not: null } }, select: { globalScore: true }, }), ]) const completionRate = totalAssignments > 0 ? Math.min(100, Math.round((submittedEvaluations / totalAssignments) * 100)) : 0 const scores = evaluationScores.map((e) => e.globalScore!).filter((s) => s != null) const scoreDistribution = [ { label: '9-10', min: 9, max: Infinity }, { label: '7-8', min: 7, max: 9 }, { label: '5-6', min: 5, max: 7 }, { label: '3-4', min: 3, max: 5 }, { label: '1-2', min: 1, max: 3 }, ].map((b) => ({ label: b.label, count: scores.filter((s) => s >= b.min && s < b.max).length, })) return { programCount, activeRoundCount: activeRounds.length, activeRoundName: activeRounds.length === 1 ? activeRounds[0].name : null, projectCount, jurorCount, submittedEvaluations, totalAssignments, completionRate, scoreDistribution, } }), // ========================================================================= // Stage-Scoped Analytics (Phase 4) // ========================================================================= /** * Get score distribution histogram for round evaluations */ getRoundScoreDistribution: observerProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const evaluations = await ctx.prisma.evaluation.findMany({ where: { status: 'SUBMITTED', assignment: { roundId: input.roundId }, }, select: { globalScore: true, criterionScoresJson: true, }, }) // Global score distribution (1-10 buckets) const globalScores = evaluations .map((e) => e.globalScore) .filter((s): s is number => s !== null) const globalDistribution = Array.from({ length: 10 }, (_, i) => ({ score: i + 1, count: globalScores.filter((s) => Math.round(s) === i + 1).length, })) // Per-criterion score distribution const criterionScores: Record = {} evaluations.forEach((e) => { const scores = e.criterionScoresJson as Record | null if (scores) { Object.entries(scores).forEach(([key, value]) => { if (typeof value === 'number') { if (!criterionScores[key]) criterionScores[key] = [] criterionScores[key].push(value) } }) } }) const criterionDistributions = Object.entries(criterionScores).map(([criterionId, scores]) => ({ criterionId, average: scores.reduce((a, b) => a + b, 0) / scores.length, count: scores.length, distribution: Array.from({ length: 10 }, (_, i) => ({ score: i + 1, count: scores.filter((s) => Math.round(s) === i + 1).length, })), })) return { globalDistribution, totalEvaluations: evaluations.length, averageGlobalScore: globalScores.length > 0 ? globalScores.reduce((a, b) => a + b, 0) / globalScores.length : 0, criterionDistributions, } }), /** * Get per-round completion summary for a competition * NOTE: This replaces the old pipeline-based getStageCompletionOverview */ getRoundCompletionOverview: observerProcedure .input(z.object({ competitionId: z.string() })) .query(async ({ ctx, input }) => { // Get all rounds in the competition const rounds = await ctx.prisma.round.findMany({ where: { competitionId: input.competitionId }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true, roundType: true, status: true, sortOrder: true, }, }) // Batch all queries by roundIds to avoid N+1 const roundIds = rounds.map((r) => r.id) const [ allProjectRoundStates, allAssignmentCounts, allCompletedEvals, allDistinctJurors, ] = await Promise.all([ ctx.prisma.projectRoundState.groupBy({ by: ['roundId', 'state'], where: { roundId: { in: roundIds } }, _count: true, }), ctx.prisma.assignment.groupBy({ by: ['roundId'], where: { roundId: { in: roundIds } }, _count: true, }), // groupBy on relation field not supported, use raw count per round ctx.prisma.$queryRaw<{ roundId: string; count: bigint }[]>` SELECT a."roundId", COUNT(e.id)::bigint as count FROM "Evaluation" e JOIN "Assignment" a ON e."assignmentId" = a.id WHERE a."roundId" = ANY(${roundIds}) AND e.status = 'SUBMITTED' GROUP BY a."roundId" `, ctx.prisma.assignment.groupBy({ by: ['roundId', 'userId'], where: { roundId: { in: roundIds } }, }), ]) // Build lookup maps const statesByRound = new Map() for (const ps of allProjectRoundStates) { const list = statesByRound.get(ps.roundId) || [] list.push({ state: ps.state, count: ps._count }) statesByRound.set(ps.roundId, list) } const assignmentCountByRound = new Map() for (const ac of allAssignmentCounts) { assignmentCountByRound.set(ac.roundId, ac._count) } const completedEvalsByRound = new Map() for (const ce of allCompletedEvals) { completedEvalsByRound.set(ce.roundId, Number(ce.count)) } const jurorCountByRound = new Map() for (const j of allDistinctJurors) { jurorCountByRound.set(j.roundId, (jurorCountByRound.get(j.roundId) || 0) + 1) } const roundOverviews = rounds.map((round) => { const stateBreakdown = statesByRound.get(round.id) || [] const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0) const totalAssignments = assignmentCountByRound.get(round.id) || 0 const completedEvaluations = completedEvalsByRound.get(round.id) || 0 const completionRate = (round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED') ? 100 : totalAssignments > 0 ? Math.min(100, Math.round((completedEvaluations / totalAssignments) * 100)) : 0 return { roundId: round.id, roundName: round.name, roundType: round.roundType, roundStatus: round.status, sortOrder: round.sortOrder, totalProjects, stateBreakdown, totalAssignments, completedEvaluations, pendingEvaluations: totalAssignments - completedEvaluations, completionRate, jurorCount: jurorCountByRound.get(round.id) || 0, } }) return { competitionId: input.competitionId, rounds: roundOverviews, summary: { totalRounds: rounds.length, totalProjects: roundOverviews.reduce((sum, s) => sum + s.totalProjects, 0), totalAssignments: roundOverviews.reduce((sum, s) => sum + s.totalAssignments, 0), totalCompleted: roundOverviews.reduce((sum, s) => sum + s.completedEvaluations, 0), }, } }), // ========================================================================= // Award Analytics (Phase 5) // ========================================================================= // NOTE: getAwardSummary procedure removed - depends on deleted Pipeline/Track/Stage/SpecialAward models // Will need to be reimplemented with new Competition/Round/Award architecture // NOTE: getAwardVoteDistribution procedure removed - depends on deleted Stage/Track/SpecialAward/AwardVote models // Will need to be reimplemented with new Competition/Round/Award architecture /** * Get all projects with pagination, filtering, and search (for observer dashboard) */ getAllProjects: observerProcedure .input( z.object({ roundId: z.string().optional(), search: z.string().optional(), status: z.string().optional(), sortBy: z.enum(['title', 'score', 'evaluations']).default('title'), sortDir: z.enum(['asc', 'desc']).default('asc'), page: z.number().min(1).default(1), perPage: z.number().min(1).max(100).default(20), }) ) .query(async ({ ctx, input }) => { const where: Record = { isTest: false } if (input.roundId) { where.projectRoundStates = { some: { roundId: input.roundId } } } const OBSERVER_DERIVED_STATUSES = ['NOT_REVIEWED', 'UNDER_REVIEW', 'REVIEWED'] if (input.status && !OBSERVER_DERIVED_STATUSES.includes(input.status)) { where.status = input.status } if (input.search) { where.OR = [ { title: { contains: input.search, mode: 'insensitive' } }, { teamName: { contains: input.search, mode: 'insensitive' } }, ] } // Prisma-level sort for title; score/evaluations sorted post-query const prismaOrderBy = input.sortBy === 'title' ? { title: input.sortDir as 'asc' | 'desc' } : { title: 'asc' as const } const [projects, total] = await Promise.all([ ctx.prisma.project.findMany({ where, select: { id: true, title: true, teamName: true, status: true, country: true, assignments: { select: { roundId: true, round: { select: { id: true, name: true, sortOrder: true } }, evaluation: { select: { globalScore: true, status: true }, }, }, }, projectRoundStates: { select: { roundId: true, state: true, round: { select: { id: true, name: true, sortOrder: true } }, }, orderBy: { round: { sortOrder: 'desc' } }, take: 1, }, }, orderBy: prismaOrderBy, // When sorting by computed fields or filtering by observer-derived status, fetch all then slice in JS ...(input.sortBy === 'title' && !OBSERVER_DERIVED_STATUSES.includes(input.status ?? '') ? { skip: (input.page - 1) * input.perPage, take: input.perPage } : {}), }), ctx.prisma.project.count({ where }), ]) const mapped = projects.map((p) => { const submitted = p.assignments .map((a) => a.evaluation) .filter((e) => e?.status === 'SUBMITTED') const drafts = p.assignments .map((a) => a.evaluation) .filter((e) => e?.status === 'DRAFT') const scores = submitted .map((e) => e?.globalScore) .filter((s): s is number => s !== null) const averageScore = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null // Show the furthest round the project reached (from projectRoundStates, ordered by sortOrder desc) const furthestRoundState = p.projectRoundStates[0] // Fallback to assignment round if no round states const roundAssignment = input.roundId ? p.assignments.find((a) => a.roundId === input.roundId) : p.assignments[0] // Derive observer-friendly status let observerStatus: string if (p.status === 'REJECTED') observerStatus = 'REJECTED' else if (p.status === 'SEMIFINALIST') observerStatus = 'SEMIFINALIST' else if (p.status === 'FINALIST') observerStatus = 'FINALIST' else if (p.status === 'SUBMITTED') observerStatus = 'SUBMITTED' else if (submitted.length > 0) observerStatus = 'REVIEWED' else if (drafts.length > 0) observerStatus = 'UNDER_REVIEW' else observerStatus = 'NOT_REVIEWED' return { id: p.id, title: p.title, teamName: p.teamName, status: p.status, observerStatus, country: p.country, roundId: furthestRoundState?.round?.id ?? roundAssignment?.round?.id ?? '', roundName: furthestRoundState?.round?.name ?? roundAssignment?.round?.name ?? '', averageScore, evaluationCount: submitted.length, } }) // Filter by observer-derived status in JS const observerStatusFilter = input.status && OBSERVER_DERIVED_STATUSES.includes(input.status) ? input.status : null const filtered = observerStatusFilter ? mapped.filter((p) => p.observerStatus === observerStatusFilter) : mapped const filteredTotal = observerStatusFilter ? filtered.length : total // Sort by computed fields (score, evaluations) in JS let sorted = filtered if (input.sortBy === 'score') { sorted = filtered.sort((a, b) => { const sa = a.averageScore ?? -1 const sb = b.averageScore ?? -1 return input.sortDir === 'asc' ? sa - sb : sb - sa }) } else if (input.sortBy === 'evaluations') { sorted = filtered.sort((a, b) => input.sortDir === 'asc' ? a.evaluationCount - b.evaluationCount : b.evaluationCount - a.evaluationCount ) } // Paginate in JS for computed-field sorts or observer status filter const needsJsPagination = input.sortBy !== 'title' || observerStatusFilter const paginated = needsJsPagination ? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage) : sorted return { projects: paginated, total: filteredTotal, page: input.page, perPage: input.perPage, totalPages: Math.ceil(filteredTotal / input.perPage), } }), /** * Get round-type-aware stats for a specific round. * Returns different metrics depending on the round type. */ getRoundTypeStats: observerProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId }, select: { id: true, roundType: true, competitionId: true }, }) const roundType = round.roundType switch (roundType) { case 'INTAKE': { const [total, byState, byCategory] = await Promise.all([ ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, project: { isTest: false } } }), ctx.prisma.projectRoundState.groupBy({ by: ['state'], where: { roundId: input.roundId, project: { isTest: false } }, _count: true, }), ctx.prisma.project.groupBy({ by: ['competitionCategory'], where: { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } }, _count: true, }), ]) return { roundType, stats: { totalProjects: total, byState: byState.map((s) => ({ state: s.state, count: s._count })), byCategory: byCategory.map((c) => ({ category: c.competitionCategory ?? 'Uncategorized', count: c._count, })), }, } } case 'FILTERING': { const [total, byOutcome] = await Promise.all([ ctx.prisma.filteringResult.count({ where: { roundId: input.roundId } }), ctx.prisma.filteringResult.groupBy({ by: ['outcome'], where: { roundId: input.roundId }, _count: true, }), ]) const passed = byOutcome.find((o) => o.outcome === 'PASSED')?._count ?? 0 return { roundType, stats: { totalScreened: total, passed, filteredOut: byOutcome.find((o) => o.outcome === 'FILTERED_OUT')?._count ?? 0, flagged: byOutcome.find((o) => o.outcome === 'FLAGGED')?._count ?? 0, passRate: total > 0 ? Math.round((passed / total) * 100) : 0, }, } } case 'EVALUATION': { const [assignmentCount, submittedCount, jurorCount] = await Promise.all([ ctx.prisma.assignment.count({ where: { roundId: input.roundId } }), ctx.prisma.evaluation.count({ where: { assignment: { roundId: input.roundId }, status: 'SUBMITTED' }, }), ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, select: { userId: true }, distinct: ['userId'], }).then((rows) => rows.length), ]) return { roundType, stats: { totalAssignments: assignmentCount, completedEvaluations: submittedCount, completionRate: assignmentCount > 0 ? Math.round((submittedCount / assignmentCount) * 100) : 0, activeJurors: jurorCount, }, } } case 'SUBMISSION': { const [fileCount, teamsWithFiles] = await Promise.all([ ctx.prisma.projectFile.count({ where: { roundId: input.roundId } }), ctx.prisma.projectFile.findMany({ where: { roundId: input.roundId }, select: { projectId: true }, distinct: ['projectId'], }).then((rows) => rows.length), ]) return { roundType, stats: { totalFiles: fileCount, teamsSubmitted: teamsWithFiles, }, } } case 'MENTORING': { const [assignmentCount, messageCount] = await Promise.all([ ctx.prisma.mentorAssignment.count({ where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } }, }), ctx.prisma.mentorMessage.count({ where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } }, }), ]) return { roundType, stats: { mentorAssignments: assignmentCount, totalMessages: messageCount, }, } } case 'LIVE_FINAL': { const session = await ctx.prisma.liveVotingSession.findUnique({ where: { roundId: input.roundId }, select: { id: true, status: true, _count: { select: { votes: true } } }, }) return { roundType, stats: { sessionStatus: session?.status ?? 'NOT_STARTED', voteCount: session?._count.votes ?? 0, }, } } case 'DELIBERATION': { const [sessions, votes, locks] = await Promise.all([ ctx.prisma.deliberationSession.count({ where: { roundId: input.roundId } }), ctx.prisma.deliberationVote.count({ where: { session: { roundId: input.roundId } }, }), ctx.prisma.resultLock.count({ where: { roundId: input.roundId } }), ]) return { roundType, stats: { totalSessions: sessions, totalVotes: votes, resultsLocked: locks, }, } } default: return { roundType, stats: {} } } }), /** * Observer-accessible project detail: project info + assignments with evaluations + competition rounds + files. * Read-only combined endpoint to avoid multiple round-trips. */ getProjectDetail: observerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([ ctx.prisma.project.findUniqueOrThrow({ where: { id: input.id }, include: { files: { select: { id: true, fileName: true, fileType: true, mimeType: true, size: true, bucket: true, objectKey: true, pageCount: true, textPreview: true, detectedLang: true, langConfidence: true, analyzedAt: true, requirementId: true, requirement: { select: { id: true, name: true, description: true, isRequired: true } }, }, }, teamMembers: { include: { user: { select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true }, }, }, orderBy: { joinedAt: 'asc' }, }, }, }), ctx.prisma.projectTag.findMany({ where: { projectId: input.id }, include: { tag: { select: { id: true, name: true, category: true, color: true } } }, orderBy: { confidence: 'desc' }, }).catch(() => [] as { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[]), ctx.prisma.assignment.findMany({ where: { projectId: input.id }, include: { user: { select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true } }, round: { select: { id: true, name: true } }, evaluation: { select: { id: true, status: true, submittedAt: true, globalScore: true, binaryDecision: true, criterionScoresJson: true, feedbackText: true, }, }, }, orderBy: { createdAt: 'desc' }, }), ctx.prisma.evaluation.findMany({ where: { status: 'SUBMITTED', assignment: { projectId: input.id }, }, }), ]) // Compute evaluation stats let stats = null if (submittedEvaluations.length > 0) { const globalScores = submittedEvaluations .map((e) => e.globalScore) .filter((s): s is number => s !== null) // Count recommendations: first check binaryDecision, then fall back to // boolean criteria in criterionScoresJson (when scoringMode isn't 'binary') const yesVotes = submittedEvaluations.filter((e) => { if (e.binaryDecision != null) return e.binaryDecision === true // Fall back: check if any boolean criterion is true const scores = e.criterionScoresJson as Record | null if (!scores) return false const boolValues = Object.values(scores).filter((v) => typeof v === 'boolean') return boolValues.length > 0 && boolValues.every((v) => v === true) }).length // Check if recommendation data exists at all const hasRecommendationData = submittedEvaluations.some((e) => { if (e.binaryDecision != null) return true const scores = e.criterionScoresJson as Record | null if (!scores) return false return Object.values(scores).some((v) => typeof v === 'boolean') }) stats = { totalEvaluations: submittedEvaluations.length, averageGlobalScore: globalScores.length > 0 ? globalScores.reduce((a, b) => a + b, 0) / globalScores.length : null, minScore: globalScores.length > 0 ? Math.min(...globalScores) : null, maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null, yesVotes, noVotes: submittedEvaluations.length - yesVotes, yesPercentage: hasRecommendationData ? (yesVotes / submittedEvaluations.length) * 100 : null, } } // Get competition rounds for file grouping let competitionRounds: { id: string; name: string; roundType: string }[] = [] const competition = await ctx.prisma.competition.findFirst({ where: { programId: projectRaw.programId, isTest: false }, include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } }, }) if (competition) { competitionRounds = competition.rounds } // Get project round states for round history const projectRoundStates = await ctx.prisma.projectRoundState.findMany({ where: { projectId: input.id }, select: { roundId: true, state: true, enteredAt: true, exitedAt: true }, }) // Get filtering result (AI screening) for rejected projects const filteringResult = projectRaw.status === 'REJECTED' ? await ctx.prisma.filteringResult.findFirst({ where: { projectId: input.id }, select: { outcome: true, finalOutcome: true, aiScreeningJson: true, overrideReason: true, round: { select: { id: true, name: true } }, }, orderBy: { createdAt: 'desc' }, }) : null // Get file requirements for all rounds let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = [] if (competitionRounds.length > 0) { allRequirements = await ctx.prisma.fileRequirement.findMany({ where: { roundId: { in: competitionRounds.map((r) => r.id) } }, select: { id: true, roundId: true, name: true, description: true, isRequired: true, acceptedMimeTypes: true, maxSizeMB: true }, orderBy: { sortOrder: 'asc' }, }) } // Attach avatar URLs const [teamMembersWithAvatars, assignmentsWithAvatars] = await Promise.all([ Promise.all( projectRaw.teamMembers.map(async (member) => ({ ...member, user: { ...member.user, avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider), }, })) ), Promise.all( assignments.map(async (a) => ({ ...a, user: { ...a.user, avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider), }, })) ), ]) return { project: { ...projectRaw, projectTags, teamMembers: teamMembersWithAvatars, }, assignments: assignmentsWithAvatars, stats, competitionRounds, projectRoundStates, allRequirements, filteringResult, } }), /** * Activity feed — recent audit log entries for observer dashboard */ getActivityFeed: observerProcedure .input(z.object({ limit: z.number().min(1).max(50).default(10) }).optional()) .query(async ({ ctx, input }) => { const limit = input?.limit ?? 10 // Exclude actions performed by test users const testUserIds = await ctx.prisma.user.findMany({ where: { isTest: true }, select: { id: true }, }).then((users) => users.map((u) => u.id)) const entries = await ctx.prisma.decisionAuditLog.findMany({ orderBy: { createdAt: 'desc' }, take: limit, ...(testUserIds.length > 0 && { where: { OR: [ { actorId: null }, { actorId: { notIn: testUserIds } }, ], }, }), select: { id: true, eventType: true, entityType: true, entityId: true, actorId: true, detailsJson: true, createdAt: true, }, }) // Batch-fetch actor names const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[] const actors = actorIds.length > 0 ? await ctx.prisma.user.findMany({ where: { id: { in: actorIds }, isTest: false }, select: { id: true, name: true }, }) : [] const actorMap = new Map(actors.map((a) => [a.id, a.name])) return entries.map((entry) => ({ id: entry.id, eventType: entry.eventType, entityType: entry.entityType, entityId: entry.entityId, actorName: entry.actorId ? actorMap.get(entry.actorId) ?? null : null, details: entry.detailsJson as Record | null, createdAt: entry.createdAt, })) }), // ========================================================================= // Round-Type-Specific Observer Reports // ========================================================================= /** * Get filtering result stats for a round (observer proxy of filtering.getResultStats) */ getFilteringResultStats: observerProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const [passed, filteredOut, flagged, overridden] = await Promise.all([ ctx.prisma.filteringResult.count({ where: { roundId: input.roundId, OR: [ { finalOutcome: 'PASSED' }, { finalOutcome: null, outcome: 'PASSED' }, ], }, }), ctx.prisma.filteringResult.count({ where: { roundId: input.roundId, OR: [ { finalOutcome: 'FILTERED_OUT' }, { finalOutcome: null, outcome: 'FILTERED_OUT' }, ], }, }), ctx.prisma.filteringResult.count({ where: { roundId: input.roundId, OR: [ { finalOutcome: 'FLAGGED' }, { finalOutcome: null, outcome: 'FLAGGED' }, ], }, }), ctx.prisma.filteringResult.count({ where: { roundId: input.roundId, overriddenBy: { not: null } }, }), ]) const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { competitionId: true }, }) let routedToAwards = 0 if (round?.competitionId) { routedToAwards = await ctx.prisma.awardEligibility.count({ where: { award: { competitionId: round.competitionId, eligibilityMode: 'SEPARATE_POOL', }, shortlisted: true, confirmedAt: { not: null }, }, }) } return { passed, filteredOut, flagged, overridden, routedToAwards, total: passed + filteredOut + flagged } }), /** * Get filtering results list for a round (observer proxy of filtering.getResults) */ getFilteringResults: observerProcedure .input(z.object({ roundId: z.string(), outcome: z.enum(['PASSED', 'FILTERED_OUT', 'FLAGGED']).optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(20), })) .query(async ({ ctx, input }) => { const { roundId, outcome, page, perPage } = input const skip = (page - 1) * perPage const where: Record = { roundId } if (outcome) { where.OR = [ { finalOutcome: outcome }, { finalOutcome: null, outcome }, ] } const [results, total] = await Promise.all([ ctx.prisma.filteringResult.findMany({ where, skip, take: perPage, orderBy: { createdAt: 'desc' }, select: { id: true, outcome: true, finalOutcome: true, aiScreeningJson: true, overrideReason: true, project: { select: { id: true, title: true, teamName: true, competitionCategory: true, country: true, awardEligibilities: { where: { shortlisted: true, confirmedAt: { not: null }, award: { eligibilityMode: 'SEPARATE_POOL' }, }, select: { award: { select: { name: true } }, }, }, }, }, }, }), ctx.prisma.filteringResult.count({ where }), ]) return { results, total, page, perPage, totalPages: Math.ceil(total / perPage), } }), /** * Get deliberation sessions for a round */ getDeliberationSessions: observerProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const sessions = await ctx.prisma.deliberationSession.findMany({ where: { roundId: input.roundId }, select: { id: true, category: true, status: true, mode: true, _count: { select: { votes: true, participants: true } }, }, orderBy: { createdAt: 'desc' }, }) return sessions }), /** * Get aggregated vote results for a deliberation session */ getDeliberationAggregate: observerProcedure .input(z.object({ sessionId: z.string() })) .query(async ({ ctx, input }) => { const agg = await aggregateVotes(input.sessionId, ctx.prisma) const projectIds = agg.rankings.map((r) => r.projectId) const projects = await ctx.prisma.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, title: true, teamName: true }, }) const projectMap = new Map(projects.map((p) => [p.id, p])) return { rankings: agg.rankings.map((r) => ({ ...r, projectTitle: projectMap.get(r.projectId)?.title ?? 'Unknown', teamName: projectMap.get(r.projectId)?.teamName ?? '', })), hasTies: agg.hasTies, tiedProjectIds: agg.tiedProjectIds, } }), /** * Get juror score matrix for a round (capped at 30 most-assigned projects) */ getJurorScoreMatrix: observerProcedure .input(z.object({ roundId: z.string() })) .query(async ({ ctx, input }) => { const assignments = await ctx.prisma.assignment.findMany({ where: { roundId: input.roundId }, include: { user: { select: { id: true, name: true } }, project: { select: { id: true, title: true } }, evaluation: { select: { globalScore: true, status: true }, }, }, }) const jurorMap = new Map() const projectMap = new Map() const cells: { jurorId: string; projectId: string; score: number | null }[] = [] for (const a of assignments) { jurorMap.set(a.user.id, a.user.name ?? 'Unknown') projectMap.set(a.project.id, a.project.title) if (a.evaluation?.status === 'SUBMITTED') { cells.push({ jurorId: a.user.id, projectId: a.project.id, score: a.evaluation.globalScore, }) } } const projectAssignCounts = new Map() for (const a of assignments) { projectAssignCounts.set(a.project.id, (projectAssignCounts.get(a.project.id) ?? 0) + 1) } const topProjectIds = [...projectAssignCounts.entries()] .sort(([, a], [, b]) => b - a) .slice(0, 30) .map(([id]) => id) const topProjectSet = new Set(topProjectIds) return { jurors: [...jurorMap.entries()].map(([id, name]) => ({ id, name })), projects: topProjectIds.map((id) => ({ id, title: projectMap.get(id) ?? 'Unknown' })), cells: cells.filter((c) => topProjectSet.has(c.projectId)), truncated: projectAssignCounts.size > 30, totalProjects: projectAssignCounts.size, } }), })