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

@@ -46,16 +46,16 @@ export default function StageApplyPage() {
return (
<ApplyWizardDynamic
mode="stage"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
config={{...DEFAULT_WIZARD_CONFIG, ...config.wizardConfig}}
programName={config.program.name}
programYear={config.program.year}
stageId={config.stage.id}
roundId={config.stage.id}
isOpen={config.stage.isOpen}
submissionDeadline={config.stage.submissionEndDate}
submissionDeadline={config.stage.submissionDeadline}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'stage',
stageId: config.stage.id,
roundId: config.stage.id,
data: data as any,
})
}}

View File

@@ -46,7 +46,7 @@ export default function EditionApplyPage() {
return (
<ApplyWizardDynamic
mode="edition"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
config={{...DEFAULT_WIZARD_CONFIG, ...config.wizardConfig}}
programName={config.program.name}
programYear={config.program.year}
programId={config.program.id}

View File

@@ -1,267 +0,0 @@
'use client'
import { use, useState, useCallback } from 'react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Trophy,
Star,
RefreshCw,
Waves,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { trpc } from '@/lib/trpc/client'
export default function StageScoreboardPage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
// Fetch audience context for stage info and cohort data
const { data: context } = trpc.live.getAudienceContext.useQuery(
{ sessionId },
{ refetchInterval: 5000 }
)
const stageInfo = context?.stageInfo
// Fetch scores by querying cohort projects + their votes
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
const openCohorts = context?.openCohorts ?? []
const allProjectIds = openCohorts.flatMap(
(c: { projectIds?: string[] }) => c.projectIds ?? []
)
const uniqueProjectIds = [...new Set(allProjectIds)]
// For live scores, we poll the audience context and compute from the cursor data
// The getAudienceContext returns projects with vote data when available
const projectScores = (context as Record<string, unknown>)?.projectScores as
| Array<{
projectId: string
title: string
teamName?: string | null
averageScore: number
voteCount: number
}>
| undefined
// Sort projects by average score descending
const sortedProjects = [...(projectScores ?? [])].sort(
(a, b) => b.averageScore - a.averageScore
)
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-3">
<Waves className="h-10 w-10 text-brand-blue" />
<div>
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Scores
</h1>
{stageInfo && (
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
)}
</div>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<div className="flex items-center gap-2">
<Badge variant="destructive">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Reconnect
</Button>
</div>
)}
</div>
</div>
{/* Paused state */}
{isPaused && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex items-center justify-center gap-3 py-6">
<Pause className="h-8 w-8 text-amber-600" />
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
Session Paused
</p>
</CardContent>
</Card>
)}
{/* Current project highlight */}
{activeProject && !isPaused && (
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center pb-2">
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
<Clock className="h-3 w-3" />
Now Presenting
</div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent className="text-center">
<p className="text-sm">{activeProject.description}</p>
</CardContent>
)}
</Card>
)}
{/* Leaderboard / Rankings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
Rankings
</CardTitle>
</CardHeader>
<CardContent>
{sortedProjects.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">
{uniqueProjectIds.length === 0
? 'Waiting for presentations to begin...'
: 'No scores yet. Votes will appear here in real-time.'}
</p>
</div>
) : (
<div className="space-y-3">
{sortedProjects.map((project, index) => {
const isCurrent = project.projectId === activeProject?.id
return (
<div
key={project.projectId}
className={`rounded-lg p-4 transition-all duration-300 ${
isCurrent
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
: 'bg-muted/50'
}`}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
{index === 0 ? (
<Trophy className="h-4 w-4 text-amber-500" />
) : index === 1 ? (
<span className="font-bold text-gray-400">2</span>
) : index === 2 ? (
<span className="font-bold text-amber-600">3</span>
) : (
<span className="font-bold text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{/* Score */}
<div className="shrink-0 text-right">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500" />
<span className="text-xl font-bold tabular-nums">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Waiting state */}
{!activeProject && !isPaused && sortedProjects.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
<p className="text-xl font-semibold">Waiting for presentations</p>
<p className="text-sm text-muted-foreground mt-2">
Scores will appear here as projects are presented.
</p>
</CardContent>
</Card>
)}
{/* SSE error */}
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
{sseError}
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge &middot; Live Scoreboard
</p>
</div>
</div>
)
}

View File

@@ -337,7 +337,7 @@ export function SubmissionDetailClient() {
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
return (
<div

View File

@@ -194,7 +194,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
{data.session.round?.competition.program.name} - {data.session.round?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -257,7 +257,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
{data.session.round?.competition.program.name} - {data.session.round?.name}
</CardDescription>
</CardHeader>

View File

@@ -0,0 +1,88 @@
'use client';
import { use, useEffect, useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Card, CardContent } from '@/components/ui/card';
import { AudienceVoteCard } from '@/components/public/audience-vote-card';
import { toast } from 'sonner';
export default function AudienceVotePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
const params = use(paramsPromise);
const utils = trpc.useUtils();
const [hasVoted, setHasVoted] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
const submitVoteMutation = trpc.liveVoting.castAudienceVote.useMutation({
onSuccess: () => {
setHasVoted(true);
// Store in localStorage to prevent duplicate votes
if (cursor?.activeProject?.id) {
localStorage.setItem(`voted-${params.roundId}-${cursor.activeProject.id}`, 'true');
}
toast.success('Vote submitted! Thank you for participating.');
},
onError: (err) => {
toast.error(err.message);
}
});
// Check localStorage on mount
useEffect(() => {
if (cursor?.activeProject?.id) {
const voted = localStorage.getItem(`voted-${params.roundId}-${cursor.activeProject.id}`);
if (voted === 'true') {
setHasVoted(true);
}
}
}, [cursor?.activeProject?.id, params.roundId]);
const handleVote = () => {
if (!cursor?.activeProject?.id) return;
submitVoteMutation.mutate({
projectId: cursor.activeProject.id,
sessionId: params.roundId,
score: 1,
token: `audience-${Date.now()}`
});
};
if (!cursor?.activeProject) {
return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-center text-lg text-muted-foreground">
No project is currently being presented
</p>
<p className="mt-2 text-center text-sm text-muted-foreground">
Please wait for the ceremony to begin
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
<div className="w-full">
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold text-[#053d57]">Monaco Ocean Protection Challenge</h1>
<p className="mt-2 text-lg text-muted-foreground">Live Audience Voting</p>
</div>
<AudienceVoteCard
project={cursor.activeProject}
onVote={handleVote}
hasVoted={hasVoted}
/>
<p className="mt-6 text-center text-sm text-muted-foreground">
Live voting in progress
</p>
</div>
</div>
);
}

View File

@@ -1,215 +0,0 @@
'use client'
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Star,
CheckCircle2,
AlertCircle,
RefreshCw,
Waves,
} from 'lucide-react'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export default function StageAudienceVotePage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [hasVoted, setHasVoted] = useState(false)
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
const castVoteMutation = trpc.live.castStageVote.useMutation({
onSuccess: () => {
toast.success('Your vote has been recorded!')
setHasVoted(true)
setLastVotedProjectId(activeProject?.id ?? null)
setSelectedScore(null)
},
onError: (err) => {
toast.error(err.message)
},
})
// Reset vote state when project changes
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
if (hasVoted) {
setHasVoted(false)
setSelectedScore(null)
}
}
const handleVote = () => {
if (!activeProject || selectedScore === null) return
castVoteMutation.mutate({
sessionId,
projectId: activeProject.id,
score: selectedScore,
})
}
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
{/* MOPC branding header */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<Waves className="h-8 w-8 text-brand-blue" />
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Vote
</h1>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success" className="text-xs">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
)}
</div>
</div>
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{sseError}</p>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</CardContent>
</Card>
)}
{/* Paused state */}
{isPaused ? (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Pause className="h-16 w-16 text-amber-600 mb-4" />
<p className="text-xl font-semibold">Voting Paused</p>
<p className="text-sm text-muted-foreground mt-2">
Please wait for the next project...
</p>
</CardContent>
</Card>
) : activeProject ? (
<>
{/* Active project card */}
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm text-center">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Voting controls */}
<Card>
<CardContent className="py-6 space-y-6">
{hasVoted ? (
<div className="flex flex-col items-center py-8 text-center">
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
<p className="text-xl font-semibold">Thank you!</p>
<p className="text-sm text-muted-foreground mt-2">
Your vote has been recorded. Waiting for the next project...
</p>
</div>
) : (
<>
<p className="text-center text-sm font-medium text-muted-foreground">
Rate this project from 1 to 10
</p>
<div className="grid grid-cols-5 gap-3">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
className={cn(
'h-14 text-xl font-bold tabular-nums',
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
)}
onClick={() => setSelectedScore(score)}
>
{score}
</Button>
))}
</div>
<Button
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
disabled={selectedScore === null || castVoteMutation.isPending}
onClick={handleVote}
>
{castVoteMutation.isPending ? (
'Submitting...'
) : selectedScore !== null ? (
<>
<Star className="mr-2 h-5 w-5" />
Vote {selectedScore}/10
</>
) : (
'Select a score to vote'
)}
</Button>
</>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
<p className="text-xl font-semibold">Waiting...</p>
<p className="text-sm text-muted-foreground mt-2">
The next project will appear here shortly.
</p>
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge
</p>
</div>
</div>
)
}