Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -51,12 +51,12 @@ import { TeamMemberRole } from '@prisma/client'
// ---------------------------------------------------------------------------
interface ApplyWizardDynamicProps {
mode: 'edition' | 'round'
mode: 'edition' | 'stage' | 'round'
config: WizardConfig
programName: string
programYear: number
programId?: string
roundId?: string
stageId?: string
isOpen: boolean
submissionDeadline?: Date | string | null
onSubmit: (data: Record<string, unknown>) => Promise<void>
@@ -390,7 +390,7 @@ export function ApplyWizardDynamic({
programName,
programYear,
programId,
roundId,
stageId,
isOpen,
submissionDeadline,
onSubmit,

View File

@@ -39,8 +39,7 @@ import { cn } from '@/lib/utils'
interface CSVImportFormProps {
programId: string
roundId?: string
roundName: string
stageName?: string
onSuccess?: () => void
}
@@ -73,7 +72,7 @@ interface MappedProject {
metadataJson?: Record<string, unknown>
}
export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVImportFormProps) {
export function CSVImportForm({ programId, stageName, onSuccess }: CSVImportFormProps) {
const router = useRouter()
const [step, setStep] = useState<Step>('upload')
const [file, setFile] = useState<File | null>(null)
@@ -226,7 +225,6 @@ export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVI
try {
await importMutation.mutateAsync({
programId,
roundId,
projects: valid,
})
setImportProgress(100)
@@ -257,7 +255,7 @@ export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVI
<CardTitle>Upload CSV File</CardTitle>
<CardDescription>
Upload a CSV file containing project data to import into{' '}
<strong>{roundName}</strong>
<strong>{stageName}</strong>
</CardDescription>
</CardHeader>
<CardContent>
@@ -559,7 +557,7 @@ export function CSVImportForm({ programId, roundId, roundName, onSuccess }: CSVI
<p className="mt-4 text-xl font-semibold">Import Complete!</p>
<p className="text-muted-foreground">
Successfully imported {validationSummary.valid} projects into{' '}
<strong>{roundName}</strong>
<strong>{stageName}</strong>
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" onClick={() => router.back()}>

View File

@@ -320,7 +320,7 @@ export function EvaluationForm({
toast.success('Evaluation submitted successfully!')
startTransition(() => {
router.push('/jury/assignments')
router.push('/jury/stages')
router.refresh()
})
} catch (error) {

View File

@@ -33,16 +33,16 @@ import {
} from 'lucide-react'
interface NotionImportFormProps {
roundId: string
roundName: string
programId: string
stageName?: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function NotionImportForm({
roundId,
roundName,
programId,
stageName,
onSuccess,
}: NotionImportFormProps) {
const [step, setStep] = useState<Step>('connect')
@@ -125,7 +125,7 @@ export function NotionImportForm({
const result = await importMutation.mutateAsync({
apiKey,
databaseId,
roundId,
programId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
@@ -419,7 +419,7 @@ export function NotionImportForm({
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all records from the Notion database into{' '}
<strong>{roundName}</strong>.
<strong>{stageName}</strong>.
</AlertDescription>
</Alert>

View File

@@ -1,699 +0,0 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Filter, ClipboardCheck, Zap, Info, Users, ListOrdered } from 'lucide-react'
import {
type FilteringRoundSettings,
type EvaluationRoundSettings,
type LiveEventRoundSettings,
defaultFilteringSettings,
defaultEvaluationSettings,
defaultLiveEventSettings,
roundTypeLabels,
roundTypeDescriptions,
} from '@/types/round-settings'
interface RoundTypeSettingsProps {
roundType: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'
onRoundTypeChange: (type: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT') => void
settings: Record<string, unknown>
onSettingsChange: (settings: Record<string, unknown>) => void
requiredReviewsField?: React.ReactNode
}
const roundTypeIcons = {
FILTERING: Filter,
EVALUATION: ClipboardCheck,
LIVE_EVENT: Zap,
}
const roundTypeFeatures: Record<string, string[]> = {
FILTERING: ['AI screening', 'Auto-elimination', 'Batch processing'],
EVALUATION: ['Jury reviews', 'Criteria scoring', 'Voting window'],
LIVE_EVENT: ['Real-time voting', 'Audience votes', 'Presentations'],
}
export function RoundTypeSettings({
roundType,
onRoundTypeChange,
settings,
onSettingsChange,
requiredReviewsField,
}: RoundTypeSettingsProps) {
const Icon = roundTypeIcons[roundType]
// Get typed settings with defaults
const getFilteringSettings = (): FilteringRoundSettings => ({
...defaultFilteringSettings,
...(settings as Partial<FilteringRoundSettings>),
})
const getEvaluationSettings = (): EvaluationRoundSettings => ({
...defaultEvaluationSettings,
...(settings as Partial<EvaluationRoundSettings>),
})
const getLiveEventSettings = (): LiveEventRoundSettings => ({
...defaultLiveEventSettings,
...(settings as Partial<LiveEventRoundSettings>),
})
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" />
Round Type & Configuration
</CardTitle>
<CardDescription>
Configure the type and behavior for this round
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Round Type Selector - Visual Cards */}
<div className="space-y-3">
<Label>Round Type</Label>
<div className="grid gap-3 sm:grid-cols-3">
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
const TypeIcon = roundTypeIcons[type]
const isSelected = roundType === type
const features = roundTypeFeatures[type]
return (
<button
key={type}
type="button"
onClick={() => onRoundTypeChange(type)}
className={`relative flex flex-col items-start gap-3 rounded-lg border-2 p-4 text-left transition-all duration-200 hover:shadow-md ${
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'border-muted hover:border-muted-foreground/30'
}`}
>
{isSelected && (
<div className="absolute top-2 right-2">
<div className="h-2 w-2 rounded-full bg-primary" />
</div>
)}
<div className={`rounded-lg p-2 ${isSelected ? 'bg-primary/10' : 'bg-muted'}`}>
<TypeIcon className={`h-5 w-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<div>
<p className={`font-medium ${isSelected ? 'text-primary' : ''}`}>
{roundTypeLabels[type]}
</p>
<p className="text-xs text-muted-foreground mt-1">
{roundTypeDescriptions[type]}
</p>
</div>
<div className="flex flex-wrap gap-1 mt-auto">
{features.map((f) => (
<span key={f} className="text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
{f}
</span>
))}
</div>
</button>
)
})}
</div>
</div>
{/* Type-specific settings */}
{roundType === 'FILTERING' && (
<FilteringSettings
settings={getFilteringSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
{roundType === 'EVALUATION' && (
<EvaluationSettings
settings={getEvaluationSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
requiredReviewsField={requiredReviewsField}
/>
)}
{roundType === 'LIVE_EVENT' && (
<LiveEventSettings
settings={getLiveEventSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
</CardContent>
</Card>
)
}
// Filtering Round Settings
function FilteringSettings({
settings,
onChange,
}: {
settings: FilteringRoundSettings
onChange: (settings: FilteringRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Filtering Settings</h4>
{/* Target Advancing */}
<div className="space-y-2">
<Label htmlFor="targetAdvancing">Target Projects to Advance</Label>
<Input
id="targetAdvancing"
type="number"
min="1"
value={settings.targetAdvancing}
onChange={(e) =>
onChange({ ...settings, targetAdvancing: parseInt(e.target.value) || 0 })
}
/>
<p className="text-xs text-muted-foreground">
The target number of projects to advance to the next round
</p>
</div>
{/* Auto-elimination */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto-Elimination</Label>
<p className="text-sm text-muted-foreground">
Automatically flag projects below threshold
</p>
</div>
<Switch
checked={settings.autoEliminationEnabled}
onCheckedChange={(v) =>
onChange({ ...settings, autoEliminationEnabled: v })
}
/>
</div>
{settings.autoEliminationEnabled && (
<div className="ml-6 space-y-4 border-l-2 pl-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="threshold">Score Threshold</Label>
<Input
id="threshold"
type="number"
min="1"
max="10"
step="0.5"
value={settings.autoEliminationThreshold}
onChange={(e) =>
onChange({
...settings,
autoEliminationThreshold: parseFloat(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Projects averaging below this score will be flagged
</p>
</div>
<div className="space-y-2">
<Label htmlFor="minReviews">Minimum Reviews</Label>
<Input
id="minReviews"
type="number"
min="0"
value={settings.autoEliminationMinReviews}
onChange={(e) =>
onChange({
...settings,
autoEliminationMinReviews: parseInt(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Min reviews before auto-elimination applies (0 for AI-only filtering)
</p>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Auto-elimination only flags projects for review. Final decisions require
admin approval.
</AlertDescription>
</Alert>
</div>
)}
</div>
{/* Auto-Filter on Close */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto-Run Filtering on Close</Label>
<p className="text-sm text-muted-foreground">
Automatically start filtering when this round is closed
</p>
</div>
<Switch
checked={settings.autoFilterOnClose}
onCheckedChange={(v) =>
onChange({ ...settings, autoFilterOnClose: v })
}
/>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
When enabled, closing this round will automatically run the configured filtering rules.
Results still require admin review before finalization.
</AlertDescription>
</Alert>
</div>
{/* Display Options */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display Options</h5>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center justify-between">
<Label htmlFor="showAverage">Show Average Score</Label>
<Switch
id="showAverage"
checked={settings.showAverageScore}
onCheckedChange={(v) =>
onChange({ ...settings, showAverageScore: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showRanking">Show Ranking</Label>
<Switch
id="showRanking"
checked={settings.showRanking}
onCheckedChange={(v) =>
onChange({ ...settings, showRanking: v })
}
/>
</div>
</div>
</div>
</div>
)
}
// Evaluation Round Settings
function EvaluationSettings({
settings,
onChange,
requiredReviewsField,
}: {
settings: EvaluationRoundSettings
onChange: (settings: EvaluationRoundSettings) => void
requiredReviewsField?: React.ReactNode
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Evaluation Settings</h4>
{/* Required Reviews (passed from parent form) */}
{requiredReviewsField}
{/* Target Finalists */}
<div className="space-y-2">
<Label htmlFor="targetFinalists">Target Finalists</Label>
<Input
id="targetFinalists"
type="number"
min="1"
value={settings.targetFinalists}
onChange={(e) =>
onChange({ ...settings, targetFinalists: parseInt(e.target.value) || 0 })
}
/>
<p className="text-xs text-muted-foreground">
The target number of finalists to select
</p>
</div>
{/* Requirements */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Requirements</h5>
<div className="flex items-center justify-between">
<div>
<Label>Require All Criteria</Label>
<p className="text-sm text-muted-foreground">
Jury must score all criteria before submission
</p>
</div>
<Switch
checked={settings.requireAllCriteria}
onCheckedChange={(v) =>
onChange({ ...settings, requireAllCriteria: v })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Detailed Criteria Required</Label>
<p className="text-sm text-muted-foreground">
Use detailed evaluation criteria
</p>
</div>
<Switch
checked={settings.detailedCriteriaRequired}
onCheckedChange={(v) =>
onChange({ ...settings, detailedCriteriaRequired: v })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="minFeedback">Minimum Feedback Length</Label>
<Input
id="minFeedback"
type="number"
min="0"
value={settings.minimumFeedbackLength}
onChange={(e) =>
onChange({
...settings,
minimumFeedbackLength: parseInt(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Minimum characters for feedback comments (0 = optional)
</p>
</div>
</div>
{/* Display Options */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display Options</h5>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center justify-between">
<Label htmlFor="showAverage">Show Average Score</Label>
<Switch
id="showAverage"
checked={settings.showAverageScore}
onCheckedChange={(v) =>
onChange({ ...settings, showAverageScore: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showRanking">Show Ranking</Label>
<Switch
id="showRanking"
checked={settings.showRanking}
onCheckedChange={(v) =>
onChange({ ...settings, showRanking: v })
}
/>
</div>
</div>
</div>
</div>
)
}
// Live Event Round Settings
function LiveEventSettings({
settings,
onChange,
}: {
settings: LiveEventRoundSettings
onChange: (settings: LiveEventRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Live Event Settings</h4>
{/* Presentation */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Presentation</h5>
<div className="space-y-2">
<Label htmlFor="duration">Presentation Duration (minutes)</Label>
<Input
id="duration"
type="number"
min="1"
max="60"
value={settings.presentationDurationMinutes}
onChange={(e) =>
onChange({
...settings,
presentationDurationMinutes: parseInt(e.target.value) || 5,
})
}
/>
</div>
</div>
{/* Voting */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Voting</h5>
<div className="space-y-2">
<Label htmlFor="votingWindow">Voting Window (seconds)</Label>
<Input
id="votingWindow"
type="number"
min="10"
max="300"
value={settings.votingWindowSeconds}
onChange={(e) =>
onChange({
...settings,
votingWindowSeconds: parseInt(e.target.value) || 30,
})
}
/>
<p className="text-xs text-muted-foreground">
Duration of the voting window after each presentation
</p>
</div>
<div className="space-y-2">
<Label>Voting Mode</Label>
<Select
value={settings.votingMode}
onValueChange={(v) =>
onChange({ ...settings, votingMode: v as 'simple' | 'criteria' })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="simple">
<div className="flex items-center gap-2">
<ListOrdered className="h-4 w-4" />
Simple (1-10 score)
</div>
</SelectItem>
<SelectItem value="criteria">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-4 w-4" />
Criteria-Based (per-criterion scoring)
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{settings.votingMode === 'simple'
? 'Jurors give a single 1-10 score per project'
: 'Jurors score each criterion separately, weighted into a final score'}
</p>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Allow Vote Change</Label>
<p className="text-sm text-muted-foreground">
Allow jury to change their vote during the window
</p>
</div>
<Switch
checked={settings.allowVoteChange}
onCheckedChange={(v) =>
onChange({ ...settings, allowVoteChange: v })
}
/>
</div>
</div>
{/* Audience Voting */}
<div className="space-y-4">
<h5 className="text-sm font-medium flex items-center gap-2">
<Users className="h-4 w-4" />
Audience Voting
</h5>
<div className="space-y-2">
<Label>Audience Voting Mode</Label>
<Select
value={settings.audienceVotingMode}
onValueChange={(v) =>
onChange({
...settings,
audienceVotingMode: v as 'disabled' | 'per_project' | 'per_category' | 'favorites',
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">Disabled</SelectItem>
<SelectItem value="per_project">Per Project (1-10 score)</SelectItem>
<SelectItem value="per_category">Per Category (vote best-in-category)</SelectItem>
<SelectItem value="favorites">Favorites (pick top N)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
How audience members can participate in voting
</p>
</div>
{settings.audienceVotingMode !== 'disabled' && (
<div className="ml-6 space-y-4 border-l-2 pl-4">
<div className="flex items-center justify-between">
<div>
<Label>Require Identification</Label>
<p className="text-sm text-muted-foreground">
Audience must provide email or name to vote
</p>
</div>
<Switch
checked={settings.audienceRequireId}
onCheckedChange={(v) =>
onChange({ ...settings, audienceRequireId: v })
}
/>
</div>
{settings.audienceVotingMode === 'favorites' && (
<div className="space-y-2">
<Label htmlFor="maxFavorites">Max Favorites</Label>
<Input
id="maxFavorites"
type="number"
min="1"
max="20"
value={settings.audienceMaxFavorites}
onChange={(e) =>
onChange({
...settings,
audienceMaxFavorites: parseInt(e.target.value) || 3,
})
}
/>
<p className="text-xs text-muted-foreground">
Number of favorites each audience member can select
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="audienceDuration">
Audience Voting Duration (minutes)
</Label>
<Input
id="audienceDuration"
type="number"
min="1"
max="600"
value={settings.audienceVotingDuration || ''}
placeholder="Same as jury"
onChange={(e) => {
const val = parseInt(e.target.value)
onChange({
...settings,
audienceVotingDuration: isNaN(val) ? null : val,
})
}}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the same window as jury voting
</p>
</div>
</div>
)}
</div>
{/* Display */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display</h5>
<div className="flex items-center justify-between">
<div>
<Label>Show Live Scores</Label>
<p className="text-sm text-muted-foreground">
Display scores in real-time during the event
</p>
</div>
<Switch
checked={settings.showLiveScores}
onCheckedChange={(v) =>
onChange({ ...settings, showLiveScores: v })
}
/>
</div>
<div className="space-y-2">
<Label>Display Mode</Label>
<Select
value={settings.displayMode}
onValueChange={(v) =>
onChange({
...settings,
displayMode: v as 'SCORES' | 'RANKING' | 'NONE',
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SCORES">Show Scores</SelectItem>
<SelectItem value="RANKING">Show Ranking</SelectItem>
<SelectItem value="NONE">Hide Until End</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
How results are displayed on the public screen
</p>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Presentation order and criteria can be configured in the Live Voting section once the round
is activated.
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -33,16 +33,16 @@ import {
} from 'lucide-react'
interface TypeformImportFormProps {
roundId: string
roundName: string
programId: string
stageName?: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function TypeformImportForm({
roundId,
roundName,
programId,
stageName,
onSuccess,
}: TypeformImportFormProps) {
const [step, setStep] = useState<Step>('connect')
@@ -126,7 +126,7 @@ export function TypeformImportForm({
const result = await importMutation.mutateAsync({
apiKey,
formId,
roundId,
programId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
@@ -446,7 +446,7 @@ export function TypeformImportForm({
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all responses from the Typeform into{' '}
<strong>{roundName}</strong>.
<strong>{stageName}</strong>.
</AlertDescription>
</Alert>