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

@@ -19,7 +19,7 @@ import {
} from '@/lib/pdf-generator'
interface ExportPdfButtonProps {
stageId: string
roundId: string
roundName?: string
programName?: string
chartRefs?: Record<string, RefObject<HTMLDivElement | null>>
@@ -28,7 +28,7 @@ interface ExportPdfButtonProps {
}
export function ExportPdfButton({
stageId,
roundId,
roundName,
programName,
chartRefs,
@@ -38,7 +38,7 @@ export function ExportPdfButton({
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ stageId, sections: [] },
{ roundId, sections: [] },
{ enabled: false }
)

View File

@@ -46,8 +46,8 @@ interface FileUploadProps {
allowedTypes?: string[]
multiple?: boolean
className?: string
stageId?: string
availableStages?: Array<{ id: string; name: string }>
roundId?: string
availableRounds?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
@@ -85,12 +85,12 @@ export function FileUpload({
allowedTypes,
multiple = true,
className,
stageId,
availableStages,
roundId,
availableRounds,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedStageId, setSelectedStageId] = useState<string | null>(stageId ?? null)
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(roundId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
@@ -129,7 +129,7 @@ export function FileUpload({
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
stageId: selectedStageId ?? undefined,
roundId: selectedRoundId ?? undefined,
})
// Store the DB file ID
@@ -309,24 +309,24 @@ export function FileUpload({
return (
<div className={cn('space-y-4', className)}>
{/* Stage selector */}
{availableStages && availableStages.length > 0 && (
{/* Round selector */}
{availableRounds && availableRounds.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Stage
Upload for Round
</label>
<Select
value={selectedStageId ?? 'null'}
onValueChange={(value) => setSelectedStageId(value === 'null' ? null : value)}
value={selectedRoundId ?? 'null'}
onValueChange={(value) => setSelectedRoundId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a stage" />
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific stage)</SelectItem>
{availableStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
<SelectItem value="null">General (no specific round)</SelectItem>
{availableRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>

View File

@@ -67,18 +67,18 @@ interface ProjectFile {
requirement?: FileRequirementInfo | null
}
interface StageGroup {
stageId: string | null
stageName: string
interface RoundGroup {
roundId: string | null
roundName: string
sortOrder: number
files: Array<ProjectFile & { isLate?: boolean }>
}
interface FileViewerProps {
files?: ProjectFile[]
groupedFiles?: StageGroup[]
groupedFiles?: RoundGroup[]
projectId?: string
stageId?: string
roundId?: string
className?: string
}
@@ -118,7 +118,7 @@ function getFileTypeLabel(fileType: string) {
}
}
export function FileViewer({ files, groupedFiles, projectId, stageId, className }: FileViewerProps) {
export function FileViewer({ files, groupedFiles, projectId, roundId, className }: FileViewerProps) {
// Render grouped view if groupedFiles is provided
if (groupedFiles) {
return <GroupedFileViewer groupedFiles={groupedFiles} className={className} />
@@ -148,7 +148,7 @@ export function FileViewer({ files, groupedFiles, projectId, stageId, className
return (
<div className={cn('space-y-4', className)}>
{/* Requirement Fulfillment Checklist */}
{stageId && <RequirementChecklist stageId={stageId} files={files} />}
{roundId && <RequirementChecklist roundId={roundId} files={files} />}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
@@ -167,7 +167,7 @@ export function FileViewer({ files, groupedFiles, projectId, stageId, className
)
}
function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: StageGroup[], className?: string }) {
function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: RoundGroup[], className?: string }) {
const hasAnyFiles = groupedFiles.some(group => group.files.length > 0)
if (!hasAnyFiles) {
@@ -204,11 +204,11 @@ function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: StageGro
)
return (
<div key={group.stageId || 'no-stage'} className="space-y-3">
{/* Stage header */}
<div key={group.roundId || 'no-round'} className="space-y-3">
{/* Round header */}
<div className="flex items-center justify-between border-b pb-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.stageName}
{group.roundName}
</h3>
<Badge variant="outline" className="text-xs">
{group.files.length} {group.files.length === 1 ? 'file' : 'files'}
@@ -739,8 +739,8 @@ function CompactFileItem({ file }: { file: ProjectFile }) {
* Displays a checklist of file requirements and their fulfillment status.
* Used by admins/jury to see which required files have been uploaded.
*/
function RequirementChecklist({ stageId, files }: { stageId: string; files: ProjectFile[] }) {
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({ stageId })
function RequirementChecklist({ roundId, files }: { roundId: string; files: ProjectFile[] }) {
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({ roundId })
if (requirements.length === 0) return null

View File

@@ -50,7 +50,7 @@ interface RequirementUploadSlotProps {
requirement: FileRequirement
existingFile?: UploadedFile | null
projectId: string
stageId: string
roundId: string
onFileChange?: () => void
disabled?: boolean
}
@@ -59,7 +59,7 @@ export function RequirementUploadSlot({
requirement,
existingFile,
projectId,
stageId,
roundId,
onFileChange,
disabled = false,
}: RequirementUploadSlotProps) {
@@ -110,13 +110,13 @@ export function RequirementUploadSlot({
try {
// Get presigned URL
const { url, bucket, objectKey, isLate, stageId: uploadStageId } =
const { url, bucket, objectKey, isLate, roundId: uploadRoundId } =
await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
mimeType: file.type,
fileType: 'OTHER',
stageId,
roundId,
requirementId: requirement.id,
})
@@ -150,7 +150,7 @@ export function RequirementUploadSlot({
fileType: 'OTHER',
bucket,
objectKey,
stageId: uploadStageId || stageId,
roundId: uploadRoundId || roundId,
isLate: isLate || false,
requirementId: requirement.id,
})
@@ -164,7 +164,7 @@ export function RequirementUploadSlot({
setProgress(0)
}
},
[projectId, stageId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
[projectId, roundId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
)
const handleDelete = useCallback(async () => {
@@ -309,22 +309,22 @@ export function RequirementUploadSlot({
interface RequirementUploadListProps {
projectId: string
stageId: string
roundId: string
disabled?: boolean
}
export function RequirementUploadList({ projectId, stageId, disabled }: RequirementUploadListProps) {
export function RequirementUploadList({ projectId, roundId, disabled }: RequirementUploadListProps) {
const utils = trpc.useUtils()
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
stageId,
roundId,
})
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, stageId })
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, roundId })
if (requirements.length === 0) return null
const handleFileChange = () => {
utils.file.listByProject.invalidate({ projectId, stageId })
utils.file.listByProject.invalidate({ projectId, roundId })
}
return (
@@ -353,7 +353,7 @@ export function RequirementUploadList({ projectId, stageId, disabled }: Requirem
: null
}
projectId={projectId}
stageId={stageId}
roundId={roundId}
onFileChange={handleFileChange}
disabled={disabled}
/>

View File

@@ -1,47 +0,0 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface StageBreadcrumbProps {
pipelineName: string
trackName: string
stageName: string
stageId?: string
pipelineId?: string
className?: string
basePath?: string // e.g. '/jury/stages' or '/admin/reports/stages'
}
export function StageBreadcrumb({
pipelineName,
trackName,
stageName,
stageId,
pipelineId,
className,
basePath = '/jury/stages',
}: StageBreadcrumbProps) {
return (
<nav className={cn('flex items-center gap-1 text-sm text-muted-foreground', className)}>
<Link href={basePath as Route} className="hover:text-foreground transition-colors truncate max-w-[150px]">
{pipelineName}
</Link>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span className="truncate max-w-[120px]">{trackName}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
{stageId ? (
<Link
href={`${basePath}/${stageId}/assignments` as Route}
className="hover:text-foreground transition-colors font-medium text-foreground truncate max-w-[150px]"
>
{stageName}
</Link>
) : (
<span className="font-medium text-foreground truncate max-w-[150px]">{stageName}</span>
)}
</nav>
)
}

View File

@@ -1,205 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import {
CheckCircle,
Circle,
Clock,
XCircle,
FileText,
Users,
Vote,
ArrowRightLeft,
Presentation,
Award,
} from 'lucide-react'
interface StageTimelineItem {
id: string
name: string
stageType: string
isCurrent: boolean
state: string // PENDING, IN_PROGRESS, PASSED, REJECTED, etc.
enteredAt?: Date | string | null
}
interface StageTimelineProps {
stages: StageTimelineItem[]
orientation?: 'horizontal' | 'vertical'
className?: string
}
const stageTypeIcons: Record<string, typeof Circle> = {
INTAKE: FileText,
EVALUATION: Users,
VOTING: Vote,
DELIBERATION: ArrowRightLeft,
LIVE_PRESENTATION: Presentation,
AWARD: Award,
}
function getStateColor(state: string, isCurrent: boolean) {
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive text-destructive-foreground'
if (state === 'PASSED' || state === 'COMPLETED')
return 'bg-green-600 text-white dark:bg-green-700'
if (state === 'IN_PROGRESS' || isCurrent)
return 'bg-primary text-primary-foreground'
return 'border-2 border-muted bg-background text-muted-foreground'
}
function getConnectorColor(state: string) {
if (state === 'PASSED' || state === 'COMPLETED' || state === 'IN_PROGRESS')
return 'bg-primary'
if (state === 'REJECTED' || state === 'ELIMINATED')
return 'bg-destructive/30'
return 'bg-muted'
}
function formatDate(date: Date | string | null | undefined) {
if (!date) return null
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function StageTimeline({
stages,
orientation = 'horizontal',
className,
}: StageTimelineProps) {
if (stages.length === 0) return null
if (orientation === 'vertical') {
return (
<div className={cn('relative', className)}>
<div className="space-y-0">
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="relative flex gap-4">
{index < stages.length - 1 && (
<div
className={cn(
'absolute left-[15px] top-[32px] h-full w-0.5',
getConnectorColor(stage.state)
)}
/>
)}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1 pb-8">
<div className="flex items-center gap-2">
<p
className={cn(
'font-medium text-sm',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground'
)}
>
{stage.name}
</p>
{stage.isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Current
</span>
)}
</div>
<p className="text-xs text-muted-foreground capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</p>
{stage.enteredAt && (
<p className="text-xs text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// Horizontal orientation
return (
<div className={cn('flex items-center gap-0 overflow-x-auto pb-2', className)}>
{stages.map((stage, index) => {
const Icon = stageTypeIcons[stage.stageType] || Circle
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
const isPending = !isPassed && !isRejected && !stage.isCurrent
return (
<div key={stage.id} className="flex items-center">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8 lg:w-12 shrink-0',
getConnectorColor(stages[index - 1].state)
)}
/>
)}
<div className="flex flex-col items-center gap-1 shrink-0">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
getStateColor(stage.state, stage.isCurrent)
)}
>
{isRejected ? (
<XCircle className="h-4 w-4" />
) : isPassed ? (
<CheckCircle className="h-4 w-4" />
) : stage.isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
<div className="text-center max-w-[80px]">
<p
className={cn(
'text-xs font-medium leading-tight',
isRejected && 'text-destructive',
isPending && 'text-muted-foreground',
stage.isCurrent && 'text-primary'
)}
>
{stage.name}
</p>
{stage.enteredAt && (
<p className="text-[10px] text-muted-foreground">
{formatDate(stage.enteredAt)}
</p>
)}
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,133 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { Clock, CheckCircle, XCircle, Timer } from 'lucide-react'
import { CountdownTimer } from '@/components/shared/countdown-timer'
interface StageWindowBadgeProps {
windowOpenAt?: Date | string | null
windowCloseAt?: Date | string | null
status?: string
className?: string
}
function toDate(v: Date | string | null | undefined): Date | null {
if (!v) return null
return typeof v === 'string' ? new Date(v) : v
}
export function StageWindowBadge({
windowOpenAt,
windowCloseAt,
status,
className,
}: StageWindowBadgeProps) {
const now = new Date()
const openAt = toDate(windowOpenAt)
const closeAt = toDate(windowCloseAt)
// Determine window state
const isBeforeOpen = openAt && now < openAt
const isOpenEnded = openAt && !closeAt && now >= openAt
const isOpen = openAt && closeAt && now >= openAt && now <= closeAt
const isClosed = closeAt && now > closeAt
if (status === 'COMPLETED' || status === 'CLOSED') {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<CheckCircle className="h-3 w-3 shrink-0" />
<span>Completed</span>
</div>
)
}
if (isClosed) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground bg-muted',
className
)}
>
<XCircle className="h-3 w-3 shrink-0" />
<span>Closed</span>
</div>
)
}
if (isOpenEnded) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isOpen && closeAt) {
const remainingMs = closeAt.getTime() - now.getTime()
const isUrgent = remainingMs < 24 * 60 * 60 * 1000 // < 24 hours
if (isUrgent) {
return <CountdownTimer deadline={closeAt} label="Closes in" className={className} />
}
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>Open</span>
</div>
)
}
if (isBeforeOpen && openAt) {
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Timer className="h-3 w-3 shrink-0" />
<span>
Opens{' '}
{openAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
)
}
// No window configured
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
'text-muted-foreground border-dashed',
className
)}
>
<Clock className="h-3 w-3 shrink-0" />
<span>No window set</span>
</div>
)
}