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,224 @@
'use client';
import { use } from 'react';
import { useRouter } from 'next/navigation';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft } from 'lucide-react';
import { toast } from 'sonner';
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
import type { Route } from 'next';
export default function DeliberationSessionPage({
params: paramsPromise
}: {
params: Promise<{ competitionId: string; sessionId: string }>;
}) {
const params = use(paramsPromise);
const router = useRouter();
const utils = trpc.useUtils();
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
sessionId: params.sessionId
});
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Voting opened');
},
onError: (err) => {
toast.error(err.message);
}
});
const closeVotingMutation = trpc.deliberation.closeVoting.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Voting closed');
},
onError: (err) => {
toast.error(err.message);
}
});
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Loading...</h1>
</div>
</div>
</div>
);
}
if (!session) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Session Not Found</h1>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() =>
router.push(`/admin/competitions/${params.competitionId}/deliberation` as Route)
}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">Deliberation Session</h1>
<Badge>{session.status}</Badge>
</div>
<p className="text-muted-foreground">
{session.round?.name} - {session.category}
</p>
</div>
</div>
<Tabs defaultValue="setup" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="setup">Setup</TabsTrigger>
<TabsTrigger value="voting">Voting Control</TabsTrigger>
<TabsTrigger value="results">Results</TabsTrigger>
</TabsList>
<TabsContent value="setup" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Session Configuration</CardTitle>
<CardDescription>Deliberation settings and participants</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Mode</p>
<p className="mt-1">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
<p className="mt-1">{session.tieBreakMethod}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Show Collective Rankings
</p>
<p className="mt-1">{session.showCollectiveRankings ? 'Yes' : 'No'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Show Prior Jury Data</p>
<p className="mt-1">{session.showPriorJuryData ? 'Yes' : 'No'}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Participants ({session.participants?.length || 0})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{session.participants?.map((participant: any) => (
<div
key={participant.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{participant.user?.name}</p>
<p className="text-sm text-muted-foreground">{participant.user?.email}</p>
</div>
<Badge variant={participant.hasVoted ? 'default' : 'outline'}>
{participant.hasVoted ? 'Voted' : 'Pending'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="voting" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Voting Controls</CardTitle>
<CardDescription>Manage the voting window for jury members</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => openVotingMutation.mutate({ sessionId: params.sessionId })}
disabled={
openVotingMutation.isPending || session.status !== 'DELIB_OPEN'
}
className="flex-1"
>
Open Voting
</Button>
<Button
variant="destructive"
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
disabled={
closeVotingMutation.isPending || session.status !== 'DELIB_VOTING'
}
className="flex-1"
>
Close Voting
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Voting Status</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{session.participants?.map((participant: any) => (
<div
key={participant.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<span>{participant.user?.name}</span>
<Badge variant={participant.hasVoted ? 'default' : 'secondary'}>
{participant.hasVoted ? 'Submitted' : 'Not Voted'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="results" className="space-y-4">
<ResultsPanel sessionId={params.sessionId} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,317 @@
'use client';
import { use, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ArrowLeft, Plus } from 'lucide-react';
import { toast } from 'sonner';
import type { Route } from 'next';
export default function DeliberationListPage({
params: paramsPromise
}: {
params: Promise<{ competitionId: string }>;
}) {
const params = use(paramsPromise);
const router = useRouter();
const utils = trpc.useUtils();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [formData, setFormData] = useState({
roundId: '',
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
mode: 'SINGLE_WINNER_VOTE' as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING',
tieBreakMethod: 'TIE_RUNOFF' as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK',
showCollectiveRankings: false,
showPriorJuryData: false,
participantUserIds: [] as string[]
});
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
{ competitionId: params.competitionId },
{ enabled: !!params.competitionId }
);
// Get rounds for this competition
const { data: competition } = trpc.competition.getById.useQuery(
{ id: params.competitionId },
{ enabled: !!params.competitionId }
);
const rounds = competition?.rounds || [];
// TODO: Add getJuryMembers endpoint if needed for participant selection
const juryMembers: any[] = [];
const createSessionMutation = trpc.deliberation.createSession.useMutation({
onSuccess: (data) => {
utils.deliberation.listSessions.invalidate({ competitionId: params.competitionId });
toast.success('Deliberation session created');
setCreateDialogOpen(false);
router.push(
`/admin/competitions/${params.competitionId}/deliberation/${data.id}` as Route
);
},
onError: (err) => {
toast.error(err.message);
}
});
const handleCreateSession = () => {
if (!formData.roundId) {
toast.error('Please select a round');
return;
}
createSessionMutation.mutate({
competitionId: params.competitionId,
roundId: formData.roundId,
category: formData.category,
mode: formData.mode,
tieBreakMethod: formData.tieBreakMethod,
showCollectiveRankings: formData.showCollectiveRankings,
showPriorJuryData: formData.showPriorJuryData,
participantUserIds: formData.participantUserIds
});
};
const getStatusBadge = (status: string) => {
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DELIB_OPEN: 'outline',
DELIB_VOTING: 'default',
DELIB_TALLYING: 'secondary',
DELIB_LOCKED: 'destructive'
};
return <Badge variant={variants[status] || 'outline'}>{status}</Badge>;
};
if (isLoading) {
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-4">
<Skeleton className="h-8 w-8 shrink-0" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96 max-w-full" />
</div>
</div>
<div className="grid grid-cols-1 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Back to competition details">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Deliberation Sessions</h1>
<p className="text-muted-foreground">
Manage final jury deliberations and winner selection
</p>
</div>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Session
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{sessions?.map((session: any) => (
<Link
key={session.id}
href={
`/admin/competitions/${params.competitionId}/deliberation/${session.id}` as Route
}
>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>
{session.round?.name} - {session.category}
</CardTitle>
<CardDescription className="mt-1">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
</CardDescription>
</div>
{getStatusBadge(session.status)}
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{session.participants?.length || 0} participants</span>
<span></span>
<span>Tie break: {session.tieBreakMethod}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{sessions?.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">No deliberation sessions yet</p>
<Button variant="link" onClick={() => setCreateDialogOpen(true)}>
Create your first session
</Button>
</CardContent>
</Card>
)}
{/* Create Session Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Deliberation Session</DialogTitle>
<DialogDescription>
Set up a new deliberation session for final winner selection
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="round">Round *</Label>
<Select value={formData.roundId} onValueChange={(value) => setFormData({ ...formData, roundId: value })}>
<SelectTrigger id="round">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: any) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="category">Category *</Label>
<Select
value={formData.category}
onValueChange={(value) =>
setFormData({ ...formData, category: value as 'STARTUP' | 'BUSINESS_CONCEPT' })
}
>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="mode">Voting Mode *</Label>
<Select
value={formData.mode}
onValueChange={(value) =>
setFormData({
...formData,
mode: value as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING'
})
}
>
<SelectTrigger id="mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SINGLE_WINNER_VOTE">Single Winner Vote</SelectItem>
<SelectItem value="FULL_RANKING">Full Ranking</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tieBreak">Tie Break Method *</Label>
<Select
value={formData.tieBreakMethod}
onValueChange={(value) =>
setFormData({
...formData,
tieBreakMethod: value as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK'
})
}
>
<SelectTrigger id="tieBreak">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="TIE_RUNOFF">Runoff Vote</SelectItem>
<SelectItem value="TIE_ADMIN_DECIDES">Admin Decides</SelectItem>
<SelectItem value="SCORE_FALLBACK">Score Fallback</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="showCollective"
checked={formData.showCollectiveRankings}
onCheckedChange={(checked) =>
setFormData({ ...formData, showCollectiveRankings: checked as boolean })
}
/>
<Label htmlFor="showCollective" className="font-normal">
Show collective rankings during voting
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showPrior"
checked={formData.showPriorJuryData}
onCheckedChange={(checked) =>
setFormData({ ...formData, showPriorJuryData: checked as boolean })
}
/>
<Label htmlFor="showPrior" className="font-normal">
Show prior jury evaluation data
</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateSession} disabled={createSessionMutation.isPending}>
{createSessionMutation.isPending ? 'Creating...' : 'Create Session'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}