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

@@ -53,6 +53,7 @@ import {
Plus,
MoreHorizontal,
ClipboardList,
Bot,
Eye,
Pencil,
FileUp,
@@ -122,7 +123,7 @@ function parseFiltersFromParams(
statuses: searchParams.get('status')
? searchParams.get('status')!.split(',')
: [],
stageId: searchParams.get('stage') || '',
roundId: searchParams.get('round') || '',
competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '',
country: searchParams.get('country') || '',
@@ -156,7 +157,7 @@ function filtersToParams(
if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(','))
if (filters.stageId) params.set('stage', filters.stageId)
if (filters.roundId) params.set('round', filters.roundId)
if (filters.competitionCategory)
params.set('category', filters.competitionCategory)
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
@@ -181,7 +182,7 @@ export default function ProjectsPage() {
const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search,
statuses: parsed.statuses,
stageId: parsed.stageId,
roundId: parsed.roundId,
competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue,
country: parsed.country,
@@ -252,7 +253,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
stageId: filters.stageId || undefined,
roundId: filters.roundId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -284,14 +285,14 @@ export default function ProjectsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
// Assign to stage dialog state
// Assign to round dialog state
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
const [assignStageId, setAssignStageId] = useState('')
const [assignRoundId, setAssignRoundId] = useState('')
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'stage' | 'program'>('stage')
const [selectedStageForTagging, setSelectedStageForTagging] = useState<string>('')
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
@@ -351,10 +352,10 @@ export default function ProjectsPage() {
: null
const handleStartTagging = () => {
if (taggingScope === 'stage' && selectedStageForTagging) {
// Router only accepts programId; resolve from the selected stage's parent program
if (taggingScope === 'round' && selectedRoundForTagging) {
// Router only accepts programId; resolve from the selected round's parent program
const parentProgram = programs?.find((p) =>
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedStageForTagging)
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedRoundForTagging)
)
if (parentProgram) {
startTaggingJob.mutate({ programId: parentProgram.id })
@@ -368,19 +369,19 @@ export default function ProjectsPage() {
if (!taggingInProgress) {
setAiTagDialogOpen(false)
setActiveTaggingJobId(null)
setSelectedStageForTagging('')
setSelectedRoundForTagging('')
setSelectedProgramForTagging('')
}
}
// Get selected program's stages (flattened from pipelines -> tracks -> stages)
// Get selected program's rounds (flattened from pipelines -> tracks -> stages)
const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging)
const programStages = selectedProgram?.stages ?? []
const programRounds = selectedProgram?.stages ?? []
// Calculate stats for display
const displayProgram = taggingScope === 'program'
? selectedProgram
: (selectedStageForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedStageForTagging)) : null)
: (selectedRoundForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedRoundForTagging)) : null)
// Calculate progress percentage
const taggingProgressPercent = jobStatus && jobStatus.totalProjects > 0
@@ -394,7 +395,7 @@ export default function ProjectsPage() {
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkNotificationsConfirmed, setBulkNotificationsConfirmed] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
const [bulkAssignStageId, setBulkAssignStageId] = useState('')
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
@@ -415,7 +416,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
stageId: filters.stageId || undefined,
roundId: filters.roundId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -475,12 +476,12 @@ export default function ProjectsPage() {
}
)
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to round`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkAssignStageId('')
setBulkAssignRoundId('')
setBulkAssignDialogOpen(false)
utils.project.list.invalidate()
},
@@ -576,13 +577,13 @@ export default function ProjectsPage() {
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
: false
const assignToStage = trpc.projectPool.assignToStage.useMutation({
const assignToRound = trpc.projectPool.assignToRound.useMutation({
onSuccess: () => {
toast.success('Project assigned to stage')
toast.success('Project assigned to round')
utils.project.list.invalidate()
setAssignDialogOpen(false)
setProjectToAssign(null)
setAssignStageId('')
setAssignRoundId('')
},
onError: (error) => {
toast.error(error.message || 'Failed to assign project')
@@ -618,7 +619,7 @@ export default function ProjectsPage() {
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
<Tags className="mr-2 h-4 w-4" />
<Bot className="mr-2 h-4 w-4" />
AI Tags
</Button>
<Button variant="outline" asChild>
@@ -668,7 +669,7 @@ export default function ProjectsPage() {
filters={filters}
filterOptions={filterOptions ? {
...filterOptions,
stages: programs?.flatMap(p =>
rounds: programs?.flatMap(p =>
(p.stages as Array<{ id: string; name: string }>)?.map(s => ({
id: s.id,
name: s.name,
@@ -799,7 +800,7 @@ export default function ProjectsPage() {
<p className="text-sm text-muted-foreground">
{filters.search ||
filters.statuses.length > 0 ||
filters.stageId ||
filters.roundId ||
filters.competitionCategory ||
filters.oceanIssue ||
filters.country
@@ -992,7 +993,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Stage
Assign to Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1132,7 +1133,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Stage
Assign to Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1258,10 +1259,10 @@ export default function ProjectsPage() {
onClick={() => setBulkAssignDialogOpen(true)}
>
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
Assign to Stage
Assign to Round
</Button>
{/* Change Status (only when filtered by stage) */}
{filters.stageId && (
{/* Change Status (only when filtered by round) */}
{filters.roundId && (
<>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-[160px] h-9 text-sm">
@@ -1442,22 +1443,22 @@ export default function ProjectsPage() {
</AlertDialogContent>
</AlertDialog>
{/* Assign to Stage Dialog */}
{/* Assign to Round Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={(open) => {
setAssignDialogOpen(open)
if (!open) { setProjectToAssign(null); setAssignStageId('') }
if (!open) { setProjectToAssign(null); setAssignRoundId('') }
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogTitle>Assign to Round</DialogTitle>
<DialogDescription>
Assign &quot;{projectToAssign?.title}&quot; to a stage.
Assign &quot;{projectToAssign?.title}&quot; to a round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Stage</Label>
<Select value={assignStageId} onValueChange={setAssignStageId}>
<Label>Select Round</Label>
<Select value={assignRoundId} onValueChange={setAssignRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
@@ -1479,40 +1480,40 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (projectToAssign && assignStageId) {
assignToStage.mutate({
if (projectToAssign && assignRoundId) {
assignToRound.mutate({
projectIds: [projectToAssign.id],
stageId: assignStageId,
roundId: assignRoundId,
})
}
}}
disabled={!assignStageId || assignToStage.isPending}
disabled={!assignRoundId || assignToRound.isPending}
>
{assignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{assignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</div>
</DialogContent>
</Dialog>
{/* Bulk Assign to Stage Dialog */}
{/* Bulk Assign to Round Dialog */}
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
setBulkAssignDialogOpen(open)
if (!open) setBulkAssignStageId('')
if (!open) setBulkAssignRoundId('')
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogTitle>Assign to Round</DialogTitle>
<DialogDescription>
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a stage. Projects will have their status set to &quot;Assigned&quot;.
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. 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 Stage</Label>
<Select value={bulkAssignStageId} onValueChange={setBulkAssignStageId}>
<Label>Select Round</Label>
<Select value={bulkAssignRoundId} onValueChange={setBulkAssignRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a stage..." />
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap((p) =>
@@ -1532,16 +1533,16 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (bulkAssignStageId && selectedIds.size > 0) {
bulkAssignToStage.mutate({
if (bulkAssignRoundId && selectedIds.size > 0) {
bulkAssignToRound.mutate({
projectIds: Array.from(selectedIds),
stageId: bulkAssignStageId,
roundId: bulkAssignRoundId,
})
}
}}
disabled={!bulkAssignStageId || bulkAssignToStage.isPending}
disabled={!bulkAssignRoundId || bulkAssignToRound.isPending}
>
{bulkAssignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{bulkAssignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</Button>
</div>
@@ -1718,19 +1719,19 @@ export default function ProjectsPage() {
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setTaggingScope('stage')}
onClick={() => setTaggingScope('round')}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
taggingScope === 'stage'
taggingScope === 'round'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30'
}`}
>
<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
<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
</span>
<span className="text-xs text-muted-foreground text-center">
Tag projects in one specific stage
Tag projects in one specific round
</span>
</button>
<button
@@ -1747,7 +1748,7 @@ export default function ProjectsPage() {
Entire Edition
</span>
<span className="text-xs text-muted-foreground text-center">
Tag all projects across all stages
Tag all projects across all rounds
</span>
</button>
</div>
@@ -1755,15 +1756,15 @@ export default function ProjectsPage() {
{/* Selection */}
<div className="space-y-2">
{taggingScope === 'stage' ? (
{taggingScope === 'round' ? (
<>
<Label htmlFor="stage-select">Select Stage</Label>
<Label htmlFor="round-select">Select Round</Label>
<Select
value={selectedStageForTagging}
onValueChange={setSelectedStageForTagging}
value={selectedRoundForTagging}
onValueChange={setSelectedRoundForTagging}
>
<SelectTrigger id="stage-select">
<SelectValue placeholder="Choose a stage..." />
<SelectTrigger id="round-select">
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap(p =>
@@ -1828,7 +1829,7 @@ export default function ProjectsPage() {
onClick={handleStartTagging}
disabled={
taggingInProgress ||
(taggingScope === 'stage' && !selectedStageForTagging) ||
(taggingScope === 'round' && !selectedRoundForTagging) ||
(taggingScope === 'program' && !selectedProgramForTagging)
}
>