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

@@ -96,6 +96,7 @@ import { CountryFlagImg } from '@/components/ui/country-select'
import {
ProjectFiltersBar,
type ProjectFilters,
type FilterOptions,
} from './project-filters'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -121,7 +122,7 @@ function parseFiltersFromParams(
statuses: searchParams.get('status')
? searchParams.get('status')!.split(',')
: [],
roundId: searchParams.get('round') || '',
stageId: searchParams.get('stage') || '',
competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '',
country: searchParams.get('country') || '',
@@ -155,7 +156,7 @@ function filtersToParams(
if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(','))
if (filters.roundId) params.set('round', filters.roundId)
if (filters.stageId) params.set('stage', filters.stageId)
if (filters.competitionCategory)
params.set('category', filters.competitionCategory)
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
@@ -180,7 +181,7 @@ export default function ProjectsPage() {
const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search,
statuses: parsed.statuses,
roundId: parsed.roundId,
stageId: parsed.stageId,
competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue,
country: parsed.country,
@@ -251,7 +252,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
roundId: filters.roundId || undefined,
stageId: filters.stageId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -283,14 +284,14 @@ export default function ProjectsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
// Assign to round dialog state
// Assign to stage dialog state
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
const [assignRoundId, setAssignRoundId] = useState('')
const [assignStageId, setAssignStageId] = useState('')
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
const [taggingScope, setTaggingScope] = useState<'stage' | 'program'>('stage')
const [selectedStageForTagging, setSelectedStageForTagging] = useState<string>('')
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
@@ -350,8 +351,14 @@ export default function ProjectsPage() {
: null
const handleStartTagging = () => {
if (taggingScope === 'round' && selectedRoundForTagging) {
startTaggingJob.mutate({ roundId: selectedRoundForTagging })
if (taggingScope === 'stage' && selectedStageForTagging) {
// Router only accepts programId; resolve from the selected stage's parent program
const parentProgram = programs?.find((p) =>
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedStageForTagging)
)
if (parentProgram) {
startTaggingJob.mutate({ programId: parentProgram.id })
}
} else if (taggingScope === 'program' && selectedProgramForTagging) {
startTaggingJob.mutate({ programId: selectedProgramForTagging })
}
@@ -361,20 +368,19 @@ export default function ProjectsPage() {
if (!taggingInProgress) {
setAiTagDialogOpen(false)
setActiveTaggingJobId(null)
setSelectedRoundForTagging('')
setSelectedStageForTagging('')
setSelectedProgramForTagging('')
}
}
// Get selected program's rounds
// Get selected program's stages (flattened from pipelines -> tracks -> stages)
const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging)
const programRounds = filterOptions?.rounds?.filter(r => r.program?.id === selectedProgramForTagging) ?? []
const programStages = selectedProgram?.stages ?? []
// Calculate stats for display
const selectedRound = filterOptions?.rounds?.find(r => r.id === selectedRoundForTagging)
const displayProgram = taggingScope === 'program'
? selectedProgram
: (selectedRound ? programs?.find(p => p.id === selectedRound.program?.id) : null)
: (selectedStageForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedStageForTagging)) : null)
// Calculate progress percentage
const taggingProgressPercent = jobStatus && jobStatus.totalProjects > 0
@@ -387,7 +393,7 @@ export default function ProjectsPage() {
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
const [bulkAssignStageId, setBulkAssignStageId] = useState('')
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
@@ -406,7 +412,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
roundId: filters.roundId || undefined,
stageId: filters.stageId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -446,12 +452,12 @@ export default function ProjectsPage() {
},
})
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkAssignRoundId('')
setBulkAssignStageId('')
setBulkAssignDialogOpen(false)
utils.project.list.invalidate()
},
@@ -526,10 +532,9 @@ export default function ProjectsPage() {
}
const handleBulkConfirm = () => {
if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return
if (!bulkStatus || selectedIds.size === 0) return
bulkUpdateStatus.mutate({
ids: Array.from(selectedIds),
roundId: filters.roundId,
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
})
}
@@ -542,13 +547,13 @@ export default function ProjectsPage() {
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
: false
const assignToRound = trpc.projectPool.assignToRound.useMutation({
const assignToStage = trpc.projectPool.assignToStage.useMutation({
onSuccess: () => {
toast.success('Project assigned to round')
toast.success('Project assigned to stage')
utils.project.list.invalidate()
setAssignDialogOpen(false)
setProjectToAssign(null)
setAssignRoundId('')
setAssignStageId('')
},
onError: (error) => {
toast.error(error.message || 'Failed to assign project')
@@ -579,7 +584,7 @@ export default function ProjectsPage() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
{data ? `${data.total} projects across all rounds` : 'Manage submitted projects across all rounds'}
{data ? `${data.total} projects across all stages` : 'Manage submitted projects across all stages'}
</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -632,7 +637,17 @@ export default function ProjectsPage() {
{/* Filters */}
<ProjectFiltersBar
filters={filters}
filterOptions={filterOptions}
filterOptions={filterOptions ? {
...filterOptions,
stages: programs?.flatMap(p =>
(p.stages as Array<{ id: string; name: string }>)?.map(s => ({
id: s.id,
name: s.name,
programName: p.name,
programYear: p.year,
})) ?? []
) ?? [],
} satisfies FilterOptions : undefined}
onChange={handleFiltersChange}
/>
@@ -755,7 +770,7 @@ export default function ProjectsPage() {
<p className="text-sm text-muted-foreground">
{filters.search ||
filters.statuses.length > 0 ||
filters.roundId ||
filters.stageId ||
filters.competitionCategory ||
filters.oceanIssue ||
filters.country
@@ -799,7 +814,7 @@ export default function ProjectsPage() {
</TableHead>
<TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Round</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
@@ -871,8 +886,8 @@ export default function ProjectsPage() {
<TableCell>
<div>
<div className="flex items-center gap-2">
{project.round ? (
<p>{project.round.name}</p>
{project.program ? (
<p>{project.program.name}</p>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
Unassigned
@@ -880,7 +895,7 @@ export default function ProjectsPage() {
)}
</div>
<p className="text-sm text-muted-foreground">
{project.round?.program?.name}
{project.program?.year}
</p>
</div>
</TableCell>
@@ -939,7 +954,7 @@ export default function ProjectsPage() {
Edit
</Link>
</DropdownMenuItem>
{!project.round && (
{project._count.assignments === 0 && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
@@ -948,7 +963,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Round
Assign to Stage
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1000,8 +1015,8 @@ export default function ProjectsPage() {
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{project.round?.name ?? 'Unassigned'}</span>
<span className="text-muted-foreground">Stage</span>
<span>{project.program?.name ?? 'Unassigned'}</span>
</div>
{project.competitionCategory && (
<div className="flex items-center justify-between text-sm">
@@ -1079,7 +1094,7 @@ export default function ProjectsPage() {
Edit
</Link>
</DropdownMenuItem>
{!project.round && (
{project._count.assignments === 0 && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
@@ -1088,7 +1103,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Round
Assign to Stage
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1138,10 +1153,10 @@ export default function ProjectsPage() {
)}
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span className="text-muted-foreground">Program</span>
<span className="text-right">
{project.round ? (
<>{project.round.name}</>
{project.program ? (
<>{project.program.name}</>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
Unassigned
@@ -1207,17 +1222,17 @@ export default function ProjectsPage() {
{selectedIds.size} selected
</Badge>
<div className="flex flex-wrap gap-2 flex-1">
{/* Assign to Round */}
{/* Assign to Stage */}
<Button
size="sm"
variant="outline"
onClick={() => setBulkAssignDialogOpen(true)}
>
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
Assign to Round
Assign to Stage
</Button>
{/* Change Status (only when filtered by round) */}
{filters.roundId && (
{/* Change Status (only when filtered by stage) */}
{filters.stageId && (
<>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-[160px] h-9 text-sm">
@@ -1332,30 +1347,30 @@ export default function ProjectsPage() {
</AlertDialogContent>
</AlertDialog>
{/* Assign to Round Dialog */}
{/* Assign to Stage Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={(open) => {
setAssignDialogOpen(open)
if (!open) { setProjectToAssign(null); setAssignRoundId('') }
if (!open) { setProjectToAssign(null); setAssignStageId('') }
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Round</DialogTitle>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogDescription>
Assign &quot;{projectToAssign?.title}&quot; to a round.
Assign &quot;{projectToAssign?.title}&quot; to a stage.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={assignRoundId} onValueChange={setAssignRoundId}>
<Label>Select Stage</Label>
<Select value={assignStageId} onValueChange={setAssignStageId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap((p) =>
(p.rounds || []).map((r: { id: string; name: string }) => (
<SelectItem key={r.id} value={r.id}>
{p.name} {p.year} - {r.name}
((p.stages || []) as Array<{ id: string; name: string }>).map((s) => (
<SelectItem key={s.id} value={s.id}>
{p.name} {p.year} - {s.name}
</SelectItem>
))
)}
@@ -1369,46 +1384,46 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (projectToAssign && assignRoundId) {
assignToRound.mutate({
if (projectToAssign && assignStageId) {
assignToStage.mutate({
projectIds: [projectToAssign.id],
roundId: assignRoundId,
stageId: assignStageId,
})
}
}}
disabled={!assignRoundId || assignToRound.isPending}
disabled={!assignStageId || assignToStage.isPending}
>
{assignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{assignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</div>
</DialogContent>
</Dialog>
{/* Bulk Assign to Round Dialog */}
{/* Bulk Assign to Stage Dialog */}
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
setBulkAssignDialogOpen(open)
if (!open) setBulkAssignRoundId('')
if (!open) setBulkAssignStageId('')
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Round</DialogTitle>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogDescription>
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to &quot;Assigned&quot;.
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a stage. Projects will have their status set to &quot;Assigned&quot;.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={bulkAssignRoundId} onValueChange={setBulkAssignRoundId}>
<Label>Select Stage</Label>
<Select value={bulkAssignStageId} onValueChange={setBulkAssignStageId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
<SelectValue placeholder="Choose a stage..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap((p) =>
(p.rounds || []).map((r: { id: string; name: string }) => (
<SelectItem key={r.id} value={r.id}>
{p.name} {p.year} - {r.name}
((p.stages || []) as Array<{ id: string; name: string }>).map((s) => (
<SelectItem key={s.id} value={s.id}>
{p.name} {p.year} - {s.name}
</SelectItem>
))
)}
@@ -1422,16 +1437,16 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (bulkAssignRoundId && selectedIds.size > 0) {
bulkAssignToRound.mutate({
if (bulkAssignStageId && selectedIds.size > 0) {
bulkAssignToStage.mutate({
projectIds: Array.from(selectedIds),
roundId: bulkAssignRoundId,
stageId: bulkAssignStageId,
})
}
}}
disabled={!bulkAssignRoundId || bulkAssignToRound.isPending}
disabled={!bulkAssignStageId || bulkAssignToStage.isPending}
>
{bulkAssignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{bulkAssignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</Button>
</div>
@@ -1608,19 +1623,19 @@ export default function ProjectsPage() {
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setTaggingScope('round')}
onClick={() => setTaggingScope('stage')}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
taggingScope === 'round'
taggingScope === 'stage'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30'
}`}
>
<FolderOpen className={`h-6 w-6 ${taggingScope === 'round' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'round' ? 'text-primary' : ''}`}>
Single Round
<FolderOpen className={`h-6 w-6 ${taggingScope === 'stage' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'stage' ? 'text-primary' : ''}`}>
Single Stage
</span>
<span className="text-xs text-muted-foreground text-center">
Tag projects in one specific round
Tag projects in one specific stage
</span>
</button>
<button
@@ -1637,7 +1652,7 @@ export default function ProjectsPage() {
Entire Edition
</span>
<span className="text-xs text-muted-foreground text-center">
Tag all projects across all rounds
Tag all projects across all stages
</span>
</button>
</div>
@@ -1645,22 +1660,24 @@ export default function ProjectsPage() {
{/* Selection */}
<div className="space-y-2">
{taggingScope === 'round' ? (
{taggingScope === 'stage' ? (
<>
<Label htmlFor="round-select">Select Round</Label>
<Label htmlFor="stage-select">Select Stage</Label>
<Select
value={selectedRoundForTagging}
onValueChange={setSelectedRoundForTagging}
value={selectedStageForTagging}
onValueChange={setSelectedStageForTagging}
>
<SelectTrigger id="round-select">
<SelectValue placeholder="Choose a round..." />
<SelectTrigger id="stage-select">
<SelectValue placeholder="Choose a stage..." />
</SelectTrigger>
<SelectContent>
{filterOptions?.rounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.program?.name})
</SelectItem>
))}
{programs?.flatMap(p =>
(p.stages as Array<{ id: string; name: string }>)?.map(s => (
<SelectItem key={s.id} value={s.id}>
{s.name} ({p.name})
</SelectItem>
)) ?? []
)}
</SelectContent>
</Select>
</>
@@ -1716,7 +1733,7 @@ export default function ProjectsPage() {
onClick={handleStartTagging}
disabled={
taggingInProgress ||
(taggingScope === 'round' && !selectedRoundForTagging) ||
(taggingScope === 'stage' && !selectedStageForTagging) ||
(taggingScope === 'program' && !selectedProgramForTagging)
}
>