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

@@ -290,21 +290,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
const {
edition,
activeStageCount,
totalStageCount,
activeRoundCount,
totalRoundCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentStages,
recentRounds,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftStages,
draftRounds,
unassignedProjects,
} = data
@@ -318,25 +318,25 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
const invitedJurors = totalJurors - activeJurors
// Compute per-stage eval stats
const stagesWithEvalStats = recentStages.map((stage: typeof recentStages[number]) => {
const submitted = stage.assignments.filter(
// Compute per-round eval stats
const roundsWithEvalStats = recentRounds.map((round: typeof recentRounds[number]) => {
const submitted = round.assignments.filter(
(a: { evaluation: { status: string } | null }) => a.evaluation?.status === 'SUBMITTED'
).length
const total = stage._count.assignments
const total = round._count.assignments
const percent = total > 0 ? Math.round((submitted / total) * 100) : 0
return { ...stage, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
})
// Upcoming deadlines from stages
// Upcoming deadlines from rounds
const now = new Date()
const deadlines: { label: string; stageName: string; date: Date }[] = []
for (const stage of recentStages) {
if (stage.windowCloseAt && new Date(stage.windowCloseAt) > now) {
const deadlines: { label: string; roundName: string; date: Date }[] = []
for (const round of recentRounds) {
if (round.windowCloseAt && new Date(round.windowCloseAt) > now) {
deadlines.push({
label: 'Window closes',
stageName: stage.name,
date: new Date(stage.windowCloseAt),
roundName: round.name,
date: new Date(round.windowCloseAt),
})
}
}
@@ -381,10 +381,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Stages</p>
<p className="text-2xl font-bold mt-1">{totalStageCount}</p>
<p className="text-sm font-medium text-muted-foreground">Rounds</p>
<p className="text-2xl font-bold mt-1">{totalRoundCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeStageCount} active stage{activeStageCount !== 1 ? 's' : ''}
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
@@ -467,13 +467,13 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Quick Actions */}
<div className="grid gap-3 sm:grid-cols-3">
<Link href="/admin/rounds/pipelines" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<Link href="/admin/competitions" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
<Plus className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium">Pipelines</p>
<p className="text-xs text-muted-foreground">Manage stages & pipelines</p>
<p className="text-sm font-medium">Competitions</p>
<p className="text-xs text-muted-foreground">Manage rounds & competitions</p>
</div>
</Link>
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
@@ -500,7 +500,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<div className="grid gap-6 lg:grid-cols-12">
{/* Left Column */}
<div className="space-y-6 lg:col-span-7">
{/* Stages Card (enhanced) */}
{/* Rounds Card (enhanced) */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
@@ -510,14 +510,14 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<div className="rounded-lg bg-blue-500/10 p-1.5">
<CircleDot className="h-4 w-4 text-blue-500" />
</div>
Stages
Rounds
</CardTitle>
<CardDescription>
Pipeline stages in {edition.name}
Competition rounds in {edition.name}
</CardDescription>
</div>
<Link
href="/admin/rounds/pipelines"
href="/admin/competitions"
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
@@ -525,48 +525,48 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
</CardHeader>
<CardContent>
{stagesWithEvalStats.length === 0 ? (
{roundsWithEvalStats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No stages created yet
No rounds created yet
</p>
<Link
href="/admin/rounds/pipelines"
href="/admin/competitions"
className="mt-4 text-sm font-medium text-primary hover:underline"
>
Set up your pipeline
Set up your competition
</Link>
</div>
) : (
<div className="space-y-3">
{stagesWithEvalStats.map((stage) => (
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
<div
key={stage.id}
key={round.id}
className="block"
>
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{stage.name}</p>
<StatusBadge status={stage.status} />
<p className="font-medium">{round.name}</p>
<StatusBadge status={round.status} />
</div>
<p className="text-sm text-muted-foreground">
{stage._count.projectStageStates} projects &middot; {stage._count.assignments} assignments
{stage.totalEvals > 0 && (
<> &middot; {stage.evalPercent}% evaluated</>
{round._count.projectRoundStates} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
</p>
{stage.windowOpenAt && stage.windowCloseAt && (
{round.windowOpenAt && round.windowCloseAt && (
<p className="text-xs text-muted-foreground">
Window: {formatDateOnly(stage.windowOpenAt)} &ndash; {formatDateOnly(stage.windowCloseAt)}
Window: {formatDateOnly(round.windowOpenAt)} &ndash; {formatDateOnly(round.windowCloseAt)}
</p>
)}
</div>
</div>
{stage.totalEvals > 0 && (
<Progress value={stage.evalPercent} className="mt-3 h-1.5" gradient />
{round.totalEvals > 0 && (
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
)}
</div>
</div>
@@ -682,7 +682,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<CardContent>
<div className="space-y-2">
{pendingCOIs > 0 && (
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<Link href="/admin/competitions" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-amber-500" />
<span className="text-sm">COI declarations to review</span>
@@ -699,16 +699,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<Badge variant="warning">{unassignedProjects}</Badge>
</Link>
)}
{draftStages > 0 && (
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
{draftRounds > 0 && (
<Link href="/admin/competitions" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<CircleDot className="h-4 w-4 text-blue-500" />
<span className="text-sm">Draft stages to activate</span>
<span className="text-sm">Draft rounds to activate</span>
</div>
<Badge variant="secondary">{draftStages}</Badge>
<Badge variant="secondary">{draftRounds}</Badge>
</Link>
)}
{pendingCOIs === 0 && unassignedProjects === 0 && draftStages === 0 && (
{pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && (
<div className="flex flex-col items-center py-4 text-center">
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
<p className="mt-1.5 text-sm text-muted-foreground">All caught up!</p>
@@ -731,7 +731,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</CardTitle>
</CardHeader>
<CardContent>
{stagesWithEvalStats.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0).length === 0 ? (
{roundsWithEvalStats.filter((s: typeof roundsWithEvalStats[number]) => s.status !== 'ROUND_DRAFT' && s.totalEvals > 0).length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<TrendingUp className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
@@ -740,19 +740,19 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
) : (
<div className="space-y-5">
{stagesWithEvalStats
.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0)
.map((stage: typeof stagesWithEvalStats[number]) => (
<div key={stage.id} className="space-y-2">
{roundsWithEvalStats
.filter((r: typeof roundsWithEvalStats[number]) => r.status !== 'ROUND_DRAFT' && r.totalEvals > 0)
.map((round: typeof roundsWithEvalStats[number]) => (
<div key={round.id} className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium truncate">{stage.name}</p>
<p className="text-sm font-medium truncate">{round.name}</p>
<span className="text-sm font-semibold tabular-nums">
{stage.evalPercent}%
{round.evalPercent}%
</span>
</div>
<Progress value={stage.evalPercent} className="h-2" gradient />
<Progress value={round.evalPercent} className="h-2" gradient />
<p className="text-xs text-muted-foreground">
{stage.submittedEvals} of {stage.totalEvals} evaluations submitted
{round.submittedEvals} of {round.totalEvals} evaluations submitted
</p>
</div>
))}
@@ -905,7 +905,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{deadline.label} &mdash; {deadline.stageName}
{deadline.label} &mdash; {deadline.roundName}
</p>
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
{formatDateOnly(deadline.date)} &middot; in {days} day{days !== 1 ? 's' : ''}