Files
MOPC-Portal/src/server/routers/analytics.ts
Matt 3e70de3a5a Add Anthropic API, test environment, remove locale settings
Feature 1: Anthropic API Integration
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option

Feature 2: Remove Locale Settings UI
- Strip Localization tab from admin settings
- Remove i18n settings from router inferCategory and getFeatureFlags
- Keep franc document language detection intact

Feature 3: Test Environment with Role Impersonation
- Add isTest field to User, Program, Project, Competition models
- Test environment service: create/teardown with realistic dummy data
- JWT-based impersonation for test users (@test.local emails)
- Impersonation banner with quick-switch between test roles
- Test environment panel in admin settings (SUPER_ADMIN only)
- Email redirect: @test.local emails routed to admin with [TEST] prefix
- Complete data isolation: 45+ isTest:false filters across platform
  - All global queries on User/Project/Program/Competition
  - AI services blocked from processing test data
  - Cron jobs skip test rounds/users
  - Analytics/exports exclude test data
  - Admin layout/pickers hide test programs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:28:07 +01:00

1764 lines
62 KiB
TypeScript

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<string, unknown> = {}) {
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<string, number> | 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<string, number> = {}
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<id> 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<string, Set<string>>()
const labelToFirst = new Map<string, { id: string; label: string }>()
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<string, number>()
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<string, number[]>()
const projectsByRound = new Map<string, Set<string>>()
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<string, { name: string; scores: number[] }> = {}
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<string, number> = {}
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<string, number> = {}
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<string, number> = {}
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<string, number> = {}
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<string, number[]> = {}
evaluations.forEach((e) => {
const scores = e.criterionScoresJson as Record<string, number> | 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<string, { state: string; count: number }[]>()
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<string, number>()
for (const ac of allAssignmentCounts) {
assignmentCountByRound.set(ac.roundId, ac._count)
}
const completedEvalsByRound = new Map<string, number>()
for (const ce of allCompletedEvals) {
completedEvalsByRound.set(ce.roundId, Number(ce.count))
}
const jurorCountByRound = new Map<string, number>()
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<string, unknown> = { 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> = { 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<string, string>()
const projectMap = new Map<string, string>()
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<string, number>()
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,
}
}),
})