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,168 @@
'use client'
import { useState } from 'react'
import { Search } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface AddMemberDialogProps {
juryGroupId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string>('')
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [maxAssignments, setMaxAssignments] = useState<string>('')
const utils = trpc.useUtils()
const { data: userResponse, isLoading: isSearching } = trpc.user.list.useQuery(
{ search: searchQuery, perPage: 20 },
{ enabled: searchQuery.length > 0 }
)
const users = userResponse?.users || []
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member added successfully')
onOpenChange(false)
resetForm()
},
onError: (err) => {
toast.error(err.message)
},
})
const resetForm = () => {
setSearchQuery('')
setSelectedUserId('')
setRole('MEMBER')
setMaxAssignments('')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedUserId) {
toast.error('Please select a user')
return
}
addMember({
juryGroupId,
userId: selectedUserId,
role,
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Member to Jury Group</DialogTitle>
<DialogDescription>
Search for a user and assign them to this jury group
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="search">Search User</Label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Search by name or email..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isSearching && (
<p className="text-sm text-muted-foreground">Searching...</p>
)}
{users && users.length > 0 && (
<div className="max-h-40 overflow-y-auto border rounded-md">
{users.map((user) => (
<button
key={user.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-accent ${
selectedUserId === user.id ? 'bg-accent' : ''
}`}
onClick={() => {
setSelectedUserId(user.id)
setSearchQuery(user.email)
}}
>
<div className="font-medium">{user.name || 'Unnamed User'}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</button>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={(val) => setRole(val as any)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
<Input
id="maxAssignments"
type="number"
min="1"
placeholder="Leave empty to use group default"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !selectedUserId}>
{isPending ? 'Adding...' : 'Add Member'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import { useState } from 'react'
import { Trash2, UserPlus } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { AddMemberDialog } from './add-member-dialog'
interface JuryMember {
id: string
userId: string
role: string
user: {
id: string
name: string | null
email: string
}
maxAssignmentsOverride: number | null
preferredStartupRatio: number | null
}
interface JuryMembersTableProps {
juryGroupId: string
members: JuryMember[]
}
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
const utils = trpc.useUtils()
const { mutate: removeMember, isPending: isRemoving } = trpc.juryGroup.removeMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member removed successfully')
setRemovingMemberId(null)
},
onError: (err) => {
toast.error(err.message)
setRemovingMemberId(null)
},
})
const handleRemove = (memberId: string) => {
removeMember({ id: memberId })
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => setAddDialogOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" />
Add Member
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead>
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No members yet. Add members to get started.
</TableCell>
</TableRow>
) : (
members.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-medium">
{member.user.name || 'Unnamed User'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{member.user.email}
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant={member.role === 'CHAIR' ? 'default' : 'secondary'}>
{member.role}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
{member.maxAssignmentsOverride ?? '—'}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setRemovingMemberId(member.id)}
disabled={isRemoving}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AddMemberDialog
juryGroupId={juryGroupId}
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
/>
<AlertDialog open={!!removingMemberId} onOpenChange={() => setRemovingMemberId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove this member from the jury group? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removingMemberId && handleRemove(removingMemberId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}