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

@@ -0,0 +1,159 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
type WizardState = {
programId: string
name: string
slug: string
categoryMode: string
startupFinalistCount: number
conceptFinalistCount: number
notifyOnRoundAdvance: boolean
notifyOnDeadlineApproach: boolean
deadlineReminderDays: number[]
}
type BasicsSectionProps = {
state: WizardState
onChange: (updates: Partial<WizardState>) => void
}
export function BasicsSection({ state, onChange }: BasicsSectionProps) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Competition Name *</Label>
<Input
id="name"
placeholder="e.g., 2026 Ocean Innovation Challenge"
value={state.name}
onChange={(e) => onChange({ name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
placeholder="e.g., 2026-ocean-innovation"
value={state.slug}
onChange={(e) => onChange({ slug: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
URL-safe identifier (lowercase, hyphens only)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="categoryMode">Category Mode</Label>
<Select value={state.categoryMode} onValueChange={(value) => onChange({ categoryMode: value })}>
<SelectTrigger id="categoryMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SHARED">Shared (all categories together)</SelectItem>
<SelectItem value="SEPARATE">Separate (categories evaluated independently)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="startupFinalists">Startup Finalists</Label>
<Input
id="startupFinalists"
type="number"
min={1}
value={state.startupFinalistCount}
onChange={(e) => onChange({ startupFinalistCount: parseInt(e.target.value, 10) })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="conceptFinalists">Concept Finalists</Label>
<Input
id="conceptFinalists"
type="number"
min={1}
value={state.conceptFinalistCount}
onChange={(e) => onChange({ conceptFinalistCount: parseInt(e.target.value, 10) })}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Notifications</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifyRoundAdvance">Round Advancement Notifications</Label>
<p className="text-xs text-muted-foreground">
Notify participants when they advance to the next round
</p>
</div>
<Switch
id="notifyRoundAdvance"
checked={state.notifyOnRoundAdvance}
onCheckedChange={(checked) => onChange({ notifyOnRoundAdvance: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifyDeadline">Deadline Reminders</Label>
<p className="text-xs text-muted-foreground">
Send reminders as deadlines approach
</p>
</div>
<Switch
id="notifyDeadline"
checked={state.notifyOnDeadlineApproach}
onCheckedChange={(checked) => onChange({ notifyOnDeadlineApproach: checked })}
/>
</div>
{state.notifyOnDeadlineApproach && (
<div className="space-y-2">
<Label>Reminder Days</Label>
<div className="flex flex-wrap gap-2">
{state.deadlineReminderDays.map((days, index) => (
<div key={index} className="flex items-center gap-1">
<Input
type="number"
min={1}
className="w-16"
value={days}
onChange={(e) => {
const newDays = [...state.deadlineReminderDays]
newDays[index] = parseInt(e.target.value, 10)
onChange({ deadlineReminderDays: newDays })
}}
/>
<span className="text-xs text-muted-foreground">days</span>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Days before deadline to send reminders
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
type WizardJuryGroup = {
tempId: string
name: string
slug: string
defaultMaxAssignments: number
defaultCapMode: string
sortOrder: number
}
type JuryGroupsSectionProps = {
juryGroups: WizardJuryGroup[]
onChange: (groups: WizardJuryGroup[]) => void
}
export function JuryGroupsSection({ juryGroups, onChange }: JuryGroupsSectionProps) {
const handleAddGroup = () => {
const newGroup: WizardJuryGroup = {
tempId: crypto.randomUUID(),
name: '',
slug: '',
defaultMaxAssignments: 5,
defaultCapMode: 'SOFT',
sortOrder: juryGroups.length,
}
onChange([...juryGroups, newGroup])
}
const handleRemoveGroup = (tempId: string) => {
const updated = juryGroups.filter((g) => g.tempId !== tempId)
const reordered = updated.map((g, index) => ({ ...g, sortOrder: index }))
onChange(reordered)
}
const handleUpdateGroup = (tempId: string, updates: Partial<WizardJuryGroup>) => {
const updated = juryGroups.map((g) =>
g.tempId === tempId ? { ...g, ...updates } : g
)
onChange(updated)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Groups</CardTitle>
<p className="text-sm text-muted-foreground">
Create jury groups for evaluation rounds (optional)
</p>
</CardHeader>
<CardContent className="space-y-3">
{juryGroups.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No jury groups yet. Add groups to assign evaluators to rounds.
</div>
) : (
juryGroups.map((group, index) => (
<div key={group.tempId} className="flex items-start gap-2 border rounded-lg p-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Group Name</Label>
<Input
placeholder="e.g., Technical Jury"
value={group.name}
onChange={(e) => {
const name = e.target.value
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
handleUpdateGroup(group.tempId, { name, slug })
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Slug</Label>
<Input
placeholder="e.g., technical-jury"
value={group.slug}
onChange={(e) => handleUpdateGroup(group.tempId, { slug: e.target.value })}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Max Assignments per Juror</Label>
<Input
type="number"
min={1}
value={group.defaultMaxAssignments}
onChange={(e) =>
handleUpdateGroup(group.tempId, {
defaultMaxAssignments: parseInt(e.target.value, 10),
})
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Cap Mode</Label>
<Select
value={group.defaultCapMode}
onValueChange={(value) => handleUpdateGroup(group.tempId, { defaultCapMode: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD">Hard (strict limit)</SelectItem>
<SelectItem value="SOFT">Soft (flexible)</SelectItem>
<SelectItem value="NONE">None (unlimited)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
onClick={() => handleRemoveGroup(group.tempId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
<Button variant="outline" className="w-full" onClick={handleAddGroup}>
<Plus className="h-4 w-4 mr-2" />
Add Jury Group
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
type WizardRound = {
tempId: string
name: string
slug: string
roundType: string
sortOrder: number
configJson: Record<string, unknown>
}
type WizardJuryGroup = {
tempId: string
name: string
slug: string
defaultMaxAssignments: number
defaultCapMode: string
sortOrder: number
}
type WizardState = {
programId: string
name: string
slug: string
categoryMode: string
startupFinalistCount: number
conceptFinalistCount: number
notifyOnRoundAdvance: boolean
notifyOnDeadlineApproach: boolean
deadlineReminderDays: number[]
rounds: WizardRound[]
juryGroups: WizardJuryGroup[]
}
type ReviewSectionProps = {
state: WizardState
}
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
export function ReviewSection({ state }: ReviewSectionProps) {
const warnings: string[] = []
if (!state.name) warnings.push('Competition name is required')
if (!state.slug) warnings.push('Competition slug is required')
if (state.rounds.length === 0) warnings.push('At least one round is required')
if (state.rounds.some((r) => !r.name)) warnings.push('All rounds must have a name')
if (state.rounds.some((r) => !r.slug)) warnings.push('All rounds must have a slug')
if (state.juryGroups.some((g) => !g.name)) warnings.push('All jury groups must have a name')
if (state.juryGroups.some((g) => !g.slug)) warnings.push('All jury groups must have a slug')
return (
<div className="space-y-6">
{/* Validation Status */}
{warnings.length > 0 ? (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">
Please fix the following issues:
</p>
<ul className="mt-2 space-y-1 text-sm text-destructive/90">
{warnings.map((warning, index) => (
<li key={index} className="ml-4 list-disc">
{warning}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
) : (
<Card className="border-emerald-500/50 bg-emerald-500/5">
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
<p className="text-sm font-medium text-emerald-700">
Ready to create competition
</p>
</div>
</CardContent>
</Card>
)}
{/* Competition Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Details</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Name</p>
<p className="text-sm">{state.name || <em className="text-muted-foreground">Not set</em>}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Slug</p>
<p className="text-sm font-mono">{state.slug || <em className="text-muted-foreground">Not set</em>}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs font-medium text-muted-foreground">Category Mode</p>
<p className="text-sm">{state.categoryMode}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Finalists</p>
<p className="text-sm">
{state.startupFinalistCount} Startup / {state.conceptFinalistCount} Concept
</p>
</div>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Notifications</p>
<div className="flex flex-wrap gap-1.5">
{state.notifyOnRoundAdvance && (
<Badge variant="secondary" className="text-[10px]">
Round Advance
</Badge>
)}
{state.notifyOnDeadlineApproach && (
<Badge variant="secondary" className="text-[10px]">
Deadline Reminders ({state.deadlineReminderDays.join(', ')} days)
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
{/* Rounds Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Rounds ({state.rounds.length})
</CardTitle>
</CardHeader>
<CardContent>
{state.rounds.length === 0 ? (
<p className="text-sm text-muted-foreground">No rounds configured</p>
) : (
<div className="space-y-2">
{state.rounds.map((round, index) => (
<div key={round.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{round.name || <em>Unnamed</em>}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{round.slug || <em>no-slug</em>}</p>
</div>
<Badge
variant="secondary"
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
>
{round.roundType.replace('_', ' ')}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Jury Groups Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Jury Groups ({state.juryGroups.length})
</CardTitle>
</CardHeader>
<CardContent>
{state.juryGroups.length === 0 ? (
<p className="text-sm text-muted-foreground">No jury groups configured</p>
) : (
<div className="space-y-2">
{state.juryGroups.map((group, index) => (
<div key={group.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{group.name || <em>Unnamed</em>}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{group.slug || <em>no-slug</em>}</p>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
<span>Max: {group.defaultMaxAssignments}</span>
<Badge variant="outline" className="text-[10px]">
{group.defaultCapMode}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,195 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
type WizardRound = {
tempId: string
name: string
slug: string
roundType: string
sortOrder: number
configJson: Record<string, unknown>
}
type RoundsSectionProps = {
rounds: WizardRound[]
onChange: (rounds: WizardRound[]) => void
}
const roundTypes = [
{ value: 'INTAKE', label: 'Intake', color: 'bg-gray-100 text-gray-700' },
{ value: 'FILTERING', label: 'Filtering', color: 'bg-amber-100 text-amber-700' },
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-blue-100 text-blue-700' },
{ value: 'SUBMISSION', label: 'Submission', color: 'bg-purple-100 text-purple-700' },
{ value: 'MENTORING', label: 'Mentoring', color: 'bg-teal-100 text-teal-700' },
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-red-100 text-red-700' },
{ value: 'DELIBERATION', label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700' },
]
export function RoundsSection({ rounds, onChange }: RoundsSectionProps) {
const handleAddRound = () => {
const newRound: WizardRound = {
tempId: crypto.randomUUID(),
name: '',
slug: '',
roundType: 'EVALUATION',
sortOrder: rounds.length,
configJson: {},
}
onChange([...rounds, newRound])
}
const handleRemoveRound = (tempId: string) => {
const updated = rounds.filter((r) => r.tempId !== tempId)
// Reorder
const reordered = updated.map((r, index) => ({ ...r, sortOrder: index }))
onChange(reordered)
}
const handleUpdateRound = (tempId: string, updates: Partial<WizardRound>) => {
const updated = rounds.map((r) =>
r.tempId === tempId ? { ...r, ...updates } : r
)
onChange(updated)
}
const handleMoveUp = (index: number) => {
if (index === 0) return
const updated = [...rounds]
;[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
onChange(reordered)
}
const handleMoveDown = (index: number) => {
if (index === rounds.length - 1) return
const updated = [...rounds]
;[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
onChange(reordered)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Rounds</CardTitle>
<p className="text-sm text-muted-foreground">
Define the stages of your competition workflow
</p>
</CardHeader>
<CardContent className="space-y-3">
{rounds.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No rounds yet. Add your first round to get started.
</div>
) : (
rounds.map((round, index) => {
const typeConfig = roundTypes.find((t) => t.value === round.roundType)
return (
<div key={round.tempId} className="flex items-start gap-2 border rounded-lg p-3">
<div className="flex flex-col gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMoveUp(index)}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMoveDown(index)}
disabled={index === rounds.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Round Name</Label>
<Input
placeholder="e.g., First Evaluation"
value={round.name}
onChange={(e) => {
const name = e.target.value
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
handleUpdateRound(round.tempId, { name, slug })
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Slug</Label>
<Input
placeholder="e.g., first-evaluation"
value={round.slug}
onChange={(e) => handleUpdateRound(round.tempId, { slug: e.target.value })}
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Label className="text-xs">Round Type</Label>
<Select
value={round.roundType}
onValueChange={(value) => handleUpdateRound(round.tempId, { roundType: value })}
>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roundTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{typeConfig && (
<Badge variant="secondary" className={typeConfig.color}>
{typeConfig.label}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
onClick={() => handleRemoveRound(round.tempId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})
)}
<Button variant="outline" className="w-full" onClick={handleAddRound}>
<Plus className="h-4 w-4 mr-2" />
Add Round
</Button>
</CardContent>
</Card>
</div>
)
}