Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -2,27 +2,27 @@ import { z } from 'zod'
import { router, observerProcedure } from '../trpc'
import { normalizeCountryToCode } from '@/lib/countries'
const editionOrStageInput = z.object({
stageId: z.string().optional(),
const editionOrRoundInput = z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
}).refine(data => data.stageId || data.programId, {
message: 'Either stageId or programId is required',
}).refine(data => data.roundId || data.programId, {
message: 'Either roundId or programId is required',
})
function projectWhere(input: { stageId?: string; programId?: string }) {
if (input.stageId) return { assignments: { some: { stageId: input.stageId } } }
function projectWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { assignments: { some: { roundId: input.roundId } } }
return { programId: input.programId! }
}
function assignmentWhere(input: { stageId?: string; programId?: string }) {
if (input.stageId) return { stageId: input.stageId }
return { stage: { track: { pipeline: { 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: { stageId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
const base = input.stageId
? { assignment: { stageId: input.stageId } }
: { assignment: { stage: { track: { pipeline: { 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 }
}
@@ -31,7 +31,7 @@ export const analyticsRouter = router({
* Get score distribution (histogram data)
*/
getScoreDistribution: observerProcedure
.input(editionOrStageInput)
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: evalWhere(input, { status: 'SUBMITTED' }),
@@ -73,7 +73,7 @@ export const analyticsRouter = router({
* Get evaluation completion over time (timeline data)
*/
getEvaluationTimeline: observerProcedure
.input(editionOrStageInput)
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: evalWhere(input, { status: 'SUBMITTED' }),
@@ -116,7 +116,7 @@ export const analyticsRouter = router({
* Get juror workload distribution
*/
getJurorWorkload: observerProcedure
.input(editionOrStageInput)
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
where: assignmentWhere(input),
@@ -165,7 +165,7 @@ export const analyticsRouter = router({
* Get project rankings with average scores
*/
getProjectRankings: observerProcedure
.input(editionOrStageInput.and(z.object({ limit: z.number().optional() })))
.input(editionOrRoundInput.and(z.object({ limit: z.number().optional() })))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: projectWhere(input),
@@ -233,7 +233,7 @@ export const analyticsRouter = router({
* Get status breakdown (pie chart data)
*/
getStatusBreakdown: observerProcedure
.input(editionOrStageInput)
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({
by: ['status'],
@@ -251,7 +251,7 @@ export const analyticsRouter = router({
* Get overview stats for dashboard
*/
getOverviewStats: observerProcedure
.input(editionOrStageInput)
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const [
projectCount,
@@ -298,11 +298,11 @@ export const analyticsRouter = router({
* Get criteria-level score distribution
*/
getCriteriaScores: observerProcedure
.input(editionOrStageInput)
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const formWhere = input.stageId
? { stageId: input.stageId, isActive: true }
: { stage: { track: { pipeline: { programId: input.programId! } } }, isActive: true }
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,
@@ -317,7 +317,7 @@ export const analyticsRouter = router({
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
if (criteria) {
criteria.forEach((c) => {
const key = input.stageId ? c.id : c.label
const key = input.roundId ? c.id : c.label
if (!criteriaMap.has(key)) {
criteriaMap.set(key, c)
}
@@ -371,12 +371,12 @@ export const analyticsRouter = router({
.input(
z.object({
programId: z.string(),
stageId: z.string().optional(),
roundId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const where = input.stageId
? { assignments: { some: { stageId: input.stageId } } }
const where = input.roundId
? { assignments: { some: { roundId: input.roundId } } }
: { programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({
@@ -403,26 +403,26 @@ export const analyticsRouter = router({
// =========================================================================
/**
* Compare metrics across multiple stages
* Compare metrics across multiple rounds
*/
getCrossStageComparison: observerProcedure
.input(z.object({ stageIds: z.array(z.string()).min(2) }))
getCrossRoundComparison: observerProcedure
.input(z.object({ roundIds: z.array(z.string()).min(2) }))
.query(async ({ ctx, input }) => {
const comparisons = await Promise.all(
input.stageIds.map(async (stageId) => {
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: stageId },
input.roundIds.map(async (roundId) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true },
})
const [projectCount, assignmentCount, evaluationCount] = await Promise.all([
ctx.prisma.project.count({
where: { assignments: { some: { stageId } } },
where: { assignments: { some: { roundId } } },
}),
ctx.prisma.assignment.count({ where: { stageId } }),
ctx.prisma.assignment.count({ where: { roundId } }),
ctx.prisma.evaluation.count({
where: {
assignment: { stageId },
assignment: { roundId },
status: 'SUBMITTED',
},
}),
@@ -434,7 +434,7 @@ export const analyticsRouter = router({
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { stageId },
assignment: { roundId },
status: 'SUBMITTED',
},
select: { globalScore: true },
@@ -454,8 +454,8 @@ export const analyticsRouter = router({
}))
return {
stageId,
stageName: stage.name,
roundId,
roundName: round.name,
projectCount,
evaluationCount,
completionRate,
@@ -469,10 +469,10 @@ export const analyticsRouter = router({
}),
/**
* Get juror consistency metrics for a stage
* Get juror consistency metrics for a round
*/
getJurorConsistency: observerProcedure
.input(editionOrStageInput)
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: evalWhere(input, { status: 'SUBMITTED' }),
@@ -538,10 +538,10 @@ export const analyticsRouter = router({
}),
/**
* Get diversity metrics for projects in a stage
* Get diversity metrics for projects in a round
*/
getDiversityMetrics: observerProcedure
.input(editionOrStageInput)
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: projectWhere(input),
@@ -603,30 +603,37 @@ export const analyticsRouter = router({
}),
/**
* Get year-over-year stats across all stages in a program
* Get year-over-year stats across all rounds in a program
*/
getYearOverYear: observerProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const stages = await ctx.prisma.stage.findMany({
where: { track: { pipeline: { programId: input.programId } } },
select: { id: true, name: true, createdAt: true },
const competitions = await ctx.prisma.competition.findMany({
where: { programId: input.programId },
include: {
rounds: {
select: { id: true, name: true, createdAt: true },
orderBy: { createdAt: 'asc' },
},
},
orderBy: { createdAt: 'asc' },
})
const allRounds = competitions.flatMap((c) => c.rounds)
const stats = await Promise.all(
stages.map(async (stage) => {
allRounds.map(async (round) => {
const [projectCount, evaluationCount, assignmentCount] = await Promise.all([
ctx.prisma.project.count({
where: { assignments: { some: { stageId: stage.id } } },
where: { assignments: { some: { roundId: round.id } } },
}),
ctx.prisma.evaluation.count({
where: {
assignment: { stageId: stage.id },
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.count({ where: { stageId: stage.id } }),
ctx.prisma.assignment.count({ where: { roundId: round.id } }),
])
const completionRate = assignmentCount > 0
@@ -635,7 +642,7 @@ export const analyticsRouter = router({
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { stageId: stage.id },
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
select: { globalScore: true },
@@ -650,9 +657,9 @@ export const analyticsRouter = router({
: null
return {
stageId: stage.id,
stageName: stage.name,
createdAt: stage.createdAt,
roundId: round.id,
roundName: round.name,
createdAt: round.createdAt,
projectCount,
evaluationCount,
completionRate,
@@ -665,24 +672,24 @@ export const analyticsRouter = router({
}),
/**
* Get dashboard stats (optionally scoped to a stage)
* Get dashboard stats (optionally scoped to a round)
*/
getDashboardStats: observerProcedure
.input(z.object({ stageId: z.string().optional() }).optional())
.input(z.object({ roundId: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const stageId = input?.stageId
const roundId = input?.roundId
const projectFilter = stageId
? { assignments: { some: { stageId } } }
const projectFilter = roundId
? { assignments: { some: { roundId } } }
: {}
const assignmentFilter = stageId ? { stageId } : {}
const evalFilter = stageId
? { assignment: { stageId }, status: 'SUBMITTED' as const }
const assignmentFilter = roundId ? { roundId } : {}
const evalFilter = roundId
? { assignment: { roundId }, status: 'SUBMITTED' as const }
: { status: 'SUBMITTED' as const }
const [
programCount,
activeStageCount,
activeRoundCount,
projectCount,
jurorCount,
submittedEvaluations,
@@ -690,7 +697,7 @@ export const analyticsRouter = router({
evaluationScores,
] = await Promise.all([
ctx.prisma.program.count(),
ctx.prisma.stage.count({ where: { status: 'STAGE_ACTIVE' } }),
ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }),
ctx.prisma.project.count({ where: projectFilter }),
ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
ctx.prisma.evaluation.count({ where: evalFilter }),
@@ -719,7 +726,7 @@ export const analyticsRouter = router({
return {
programCount,
activeStageCount,
activeRoundCount,
projectCount,
jurorCount,
submittedEvaluations,
@@ -734,15 +741,15 @@ export const analyticsRouter = router({
// =========================================================================
/**
* Get score distribution histogram for stage evaluations
* Get score distribution histogram for round evaluations
*/
getStageScoreDistribution: observerProcedure
.input(z.object({ stageId: z.string() }))
getRoundScoreDistribution: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { stageId: input.stageId },
assignment: { roundId: input.roundId },
},
select: {
globalScore: true,
@@ -796,80 +803,70 @@ export const analyticsRouter = router({
}),
/**
* Get per-stage completion summary for a pipeline
* Get per-round completion summary for a competition
* NOTE: This replaces the old pipeline-based getStageCompletionOverview
*/
getStageCompletionOverview: observerProcedure
.input(z.object({ pipelineId: z.string() }))
getRoundCompletionOverview: observerProcedure
.input(z.object({ competitionId: z.string() }))
.query(async ({ ctx, input }) => {
// Get all stages in the pipeline via tracks
const tracks = await ctx.prisma.track.findMany({
where: { pipelineId: input.pipelineId },
include: {
stages: {
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
stageType: true,
status: true,
sortOrder: true,
},
},
},
// 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,
},
})
const stages = tracks.flatMap((t) =>
t.stages.map((s) => ({ ...s, trackName: t.name, trackId: t.id }))
)
// For each stage, get project counts, assignment coverage, evaluation completion
const stageOverviews = await Promise.all(
stages.map(async (stage) => {
// For each round, get assignment coverage and evaluation completion
const roundOverviews = await Promise.all(
rounds.map(async (round) => {
const [
projectStates,
projectRoundStates,
totalAssignments,
completedEvaluations,
distinctJurors,
] = await Promise.all([
ctx.prisma.projectStageState.groupBy({
ctx.prisma.projectRoundState.groupBy({
by: ['state'],
where: { stageId: stage.id },
where: { roundId: round.id },
_count: true,
}),
ctx.prisma.assignment.count({
where: { stageId: stage.id },
where: { roundId: round.id },
}),
ctx.prisma.evaluation.count({
where: {
assignment: { stageId: stage.id },
assignment: { roundId: round.id },
status: 'SUBMITTED',
},
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { stageId: stage.id },
where: { roundId: round.id },
}),
])
const stateBreakdown = projectStates.map((ps) => ({
const stateBreakdown = projectRoundStates.map((ps) => ({
state: ps.state,
count: ps._count,
}))
const totalProjects = projectStates.reduce((sum, ps) => sum + ps._count, 0)
const totalProjects = projectRoundStates.reduce((sum, ps) => sum + ps._count, 0)
const completionRate = totalAssignments > 0
? Math.round((completedEvaluations / totalAssignments) * 100)
: 0
return {
stageId: stage.id,
stageName: stage.name,
stageType: stage.stageType,
stageStatus: stage.status,
trackName: stage.trackName,
trackId: stage.trackId,
sortOrder: stage.sortOrder,
roundId: round.id,
roundName: round.name,
roundType: round.roundType,
roundStatus: round.status,
sortOrder: round.sortOrder,
totalProjects,
stateBreakdown,
totalAssignments,
@@ -882,13 +879,13 @@ export const analyticsRouter = router({
)
return {
pipelineId: input.pipelineId,
stages: stageOverviews,
competitionId: input.competitionId,
rounds: roundOverviews,
summary: {
totalStages: stages.length,
totalProjects: stageOverviews.reduce((sum, s) => sum + s.totalProjects, 0),
totalAssignments: stageOverviews.reduce((sum, s) => sum + s.totalAssignments, 0),
totalCompleted: stageOverviews.reduce((sum, s) => sum + s.completedEvaluations, 0),
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),
},
}
}),
@@ -897,204 +894,11 @@ export const analyticsRouter = router({
// Award Analytics (Phase 5)
// =========================================================================
/**
* Get per-award-track summary for a pipeline
*/
getAwardSummary: observerProcedure
.input(z.object({ pipelineId: z.string() }))
.query(async ({ ctx, input }) => {
// Find all AWARD tracks in the pipeline
const awardTracks = await ctx.prisma.track.findMany({
where: { pipelineId: input.pipelineId, kind: 'AWARD' },
include: {
specialAward: {
include: {
winnerProject: { select: { id: true, title: true, teamName: true } },
},
},
stages: {
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true, stageType: true, status: true },
},
},
orderBy: { sortOrder: 'asc' },
})
// NOTE: getAwardSummary procedure removed - depends on deleted Pipeline/Track/Stage/SpecialAward models
// Will need to be reimplemented with new Competition/Round/Award architecture
const awards = await Promise.all(
awardTracks.map(async (track) => {
const award = track.specialAward
// Count projects in this track (active PSS)
const projectCount = await ctx.prisma.projectStageState.count({
where: { trackId: track.id },
})
// Count evaluations in this track's stages
const stageIds = track.stages.map((s) => s.id)
const [totalAssignments, completedEvals] = await Promise.all([
ctx.prisma.assignment.count({
where: { stageId: { in: stageIds } },
}),
ctx.prisma.evaluation.count({
where: {
assignment: { stageId: { in: stageIds } },
status: 'SUBMITTED',
},
}),
])
const completionRate = totalAssignments > 0
? Math.round((completedEvals / totalAssignments) * 100)
: 0
return {
trackId: track.id,
trackName: track.name,
routingMode: track.routingMode,
awardId: award?.id ?? null,
awardName: award?.name ?? track.name,
awardStatus: award?.status ?? null,
scoringMode: award?.scoringMode ?? null,
projectCount,
totalAssignments,
completedEvaluations: completedEvals,
completionRate,
winner: award?.winnerProject
? {
projectId: award.winnerProject.id,
title: award.winnerProject.title,
teamName: award.winnerProject.teamName,
overridden: award.winnerOverridden,
}
: null,
stages: track.stages,
}
})
)
return awards
}),
/**
* Get per-project vote/score distribution for an award stage
*/
getAwardVoteDistribution: observerProcedure
.input(z.object({ stageId: z.string() }))
.query(async ({ ctx, input }) => {
// Get all evaluations for this stage
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { stageId: input.stageId },
},
select: {
globalScore: true,
assignment: {
select: {
projectId: true,
project: { select: { title: true, teamName: true } },
},
},
},
})
// Also get any AwardVotes linked to the stage's track's award
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: {
track: {
select: {
specialAward: {
select: { id: true },
},
},
},
},
})
const awardId = stage?.track?.specialAward?.id
let awardVotes: Array<{ projectId: string; rank: number | null }> = []
if (awardId) {
awardVotes = await ctx.prisma.awardVote.findMany({
where: { awardId },
select: { projectId: true, rank: true },
})
}
// Group evaluation scores by project
const projectMap = new Map<string, {
title: string
teamName: string | null
scores: number[]
voteCount: number
avgRank: number | null
}>()
for (const ev of evaluations) {
const pid = ev.assignment.projectId
if (!projectMap.has(pid)) {
projectMap.set(pid, {
title: ev.assignment.project.title,
teamName: ev.assignment.project.teamName,
scores: [],
voteCount: 0,
avgRank: null,
})
}
if (ev.globalScore !== null) {
projectMap.get(pid)!.scores.push(ev.globalScore)
}
}
// Merge award votes
const ranksByProject = new Map<string, number[]>()
for (const vote of awardVotes) {
const entry = projectMap.get(vote.projectId)
if (entry) {
entry.voteCount++
}
if (vote.rank !== null) {
if (!ranksByProject.has(vote.projectId)) ranksByProject.set(vote.projectId, [])
ranksByProject.get(vote.projectId)!.push(vote.rank)
}
}
// Calculate averages
const results = Array.from(projectMap.entries()).map(([projectId, data]) => {
const avgScore = data.scores.length > 0
? data.scores.reduce((a, b) => a + b, 0) / data.scores.length
: null
const minScore = data.scores.length > 0 ? Math.min(...data.scores) : null
const maxScore = data.scores.length > 0 ? Math.max(...data.scores) : null
const ranks = ranksByProject.get(projectId)
const avgRank = ranks?.length
? ranks.reduce((a, b) => a + b, 0) / ranks.length
: null
return {
projectId,
title: data.title,
teamName: data.teamName,
evaluationCount: data.scores.length,
voteCount: data.voteCount,
avgScore,
minScore,
maxScore,
avgRank,
}
})
// Sort by avgScore descending
results.sort((a, b) => (b.avgScore ?? 0) - (a.avgScore ?? 0))
return {
stageId: input.stageId,
projects: results,
totalEvaluations: evaluations.length,
totalVotes: awardVotes.length,
}
}),
// 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)
@@ -1102,7 +906,7 @@ export const analyticsRouter = router({
getAllProjects: observerProcedure
.input(
z.object({
stageId: z.string().optional(),
roundId: z.string().optional(),
search: z.string().optional(),
status: z.string().optional(),
page: z.number().min(1).default(1),
@@ -1112,8 +916,8 @@ export const analyticsRouter = router({
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.stageId) {
where.assignments = { some: { stageId: input.stageId } }
if (input.roundId) {
where.assignments = { some: { roundId: input.roundId } }
}
if (input.status) {
@@ -1138,8 +942,8 @@ export const analyticsRouter = router({
country: true,
assignments: {
select: {
stageId: true,
stage: { select: { id: true, name: true } },
roundId: true,
round: { select: { id: true, name: true } },
evaluation: {
select: { globalScore: true, status: true },
},
@@ -1173,8 +977,8 @@ export const analyticsRouter = router({
teamName: p.teamName,
status: p.status,
country: p.country,
stageId: firstAssignment?.stage?.id ?? '',
stageName: firstAssignment?.stage?.name ?? '',
roundId: firstAssignment?.round?.id ?? '',
roundName: firstAssignment?.round?.name ?? '',
averageScore,
evaluationCount: submitted.length,
}