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,144 @@
'use client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
interface AdminOverrideDialogProps {
sessionId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
projectIds: string[];
}
export function AdminOverrideDialog({
sessionId,
open,
onOpenChange,
projectIds
}: AdminOverrideDialogProps) {
const utils = trpc.useUtils();
const [rankings, setRankings] = useState<Record<string, number>>({});
const [reason, setReason] = useState('');
const { data: session } = trpc.deliberation.getSession.useQuery(
{ sessionId },
{ enabled: open }
);
const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
utils.deliberation.aggregate.invalidate();
toast.success('Admin override applied successfully');
onOpenChange(false);
setRankings({});
setReason('');
},
onError: (err) => {
toast.error(err.message);
}
});
const handleSubmit = () => {
if (!reason.trim()) {
toast.error('Reason is required for admin override');
return;
}
const rankingsArray = Object.entries(rankings).map(([projectId, rank]) => ({
projectId,
rank
}));
if (rankingsArray.length === 0) {
toast.error('Please assign at least one rank');
return;
}
adminDecideMutation.mutate({
sessionId,
rankings: rankingsArray,
reason: reason.trim()
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Admin Override</DialogTitle>
<DialogDescription>
Manually set the final rankings for this deliberation session. This action will be
audited.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<Label>Project Rankings</Label>
<div className="space-y-2">
{projectIds.map((projectId) => {
const project = session?.projects?.find((p: any) => p.id === projectId);
return (
<div key={projectId} className="flex items-center gap-3">
<Input
type="number"
min="1"
placeholder="Rank"
value={rankings[projectId] || ''}
onChange={(e) =>
setRankings({
...rankings,
[projectId]: parseInt(e.target.value) || 0
})
}
className="w-20"
/>
<span className="flex-1 text-sm">{project?.title || projectId}</span>
</div>
);
})}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="reason">Reason (Required) *</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Explain why this admin override is necessary..."
rows={3}
required
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={adminDecideMutation.isPending || !reason.trim()}
>
{adminDecideMutation.isPending ? 'Applying...' : 'Apply Override'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
import { AdminOverrideDialog } from './admin-override-dialog';
interface ResultsPanelProps {
sessionId: string;
}
export function ResultsPanel({ sessionId }: ResultsPanelProps) {
const utils = trpc.useUtils();
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
const { data: session } = trpc.deliberation.getSession.useQuery({ sessionId });
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery({ sessionId });
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
utils.deliberation.aggregate.invalidate();
toast.success('Runoff voting initiated');
},
onError: (err) => {
toast.error(err.message);
}
});
const finalizeMutation = trpc.deliberation.finalize.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Results finalized successfully');
},
onError: (err) => {
toast.error(err.message);
}
});
if (!aggregatedResults) {
return (
<Card>
<CardContent className="p-12 text-center">
<p className="text-muted-foreground">No voting results yet</p>
</CardContent>
</Card>
);
}
// Detect ties: check if two or more top-ranked candidates share the same totalScore
const hasTie = (() => {
const rankings = aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }> | undefined;
if (!rankings || rankings.length < 2) return false;
// Group projects by totalScore
const scoreGroups = new Map<number, string[]>();
for (const r of rankings) {
const score = r.totalScore ?? 0;
const group = scoreGroups.get(score) || [];
group.push(r.projectId);
scoreGroups.set(score, group);
}
// A tie exists if the highest score is shared by 2+ projects
const topScore = Math.max(...scoreGroups.keys());
const topGroup = scoreGroups.get(topScore);
return (topGroup?.length ?? 0) >= 2;
})();
const tiedProjectIds = hasTie
? (aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }>)
.filter((r) => r.totalScore === (aggregatedResults.rankings as Array<{ totalScore?: number }>)[0]?.totalScore)
.map((r) => r.projectId)
: [];
const canFinalize = session?.status === 'DELIB_TALLYING' && !hasTie;
return (
<div className="space-y-4">
{/* Results Table */}
<Card>
<CardHeader>
<CardTitle>Voting Results</CardTitle>
<CardDescription>
Aggregated voting results
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{aggregatedResults.rankings?.map((result: any, index: number) => (
<div
key={result.projectId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
#{index + 1}
</div>
<div>
<p className="font-medium">{result.projectTitle}</p>
<p className="text-sm text-muted-foreground">
{result.votes} votes {result.averageRank?.toFixed(2)} avg rank
</p>
</div>
</div>
<Badge variant="outline" className="text-lg">
{result.totalScore?.toFixed(1) || 0}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
{/* Tie Warning */}
{hasTie && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Tie Detected</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Multiple projects have the same score. You must resolve this before finalizing
results.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="outline"
size="sm"
onClick={() => initRunoffMutation.mutate({ sessionId, tiedProjectIds })}
disabled={initRunoffMutation.isPending}
>
Initiate Runoff Vote
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setOverrideDialogOpen(true)}
>
Admin Override
</Button>
</div>
</AlertDescription>
</Alert>
)}
{/* Finalize Button */}
{canFinalize && (
<Card>
<CardContent className="flex items-center justify-between p-6">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<div>
<p className="font-medium">Ready to Finalize</p>
<p className="text-sm text-muted-foreground">
All votes counted, no ties detected
</p>
</div>
</div>
<Button
onClick={() => finalizeMutation.mutate({ sessionId })}
disabled={finalizeMutation.isPending}
>
{finalizeMutation.isPending ? 'Finalizing...' : 'Finalize Results'}
</Button>
</CardContent>
</Card>
)}
{/* Admin Override Dialog */}
<AdminOverrideDialog
sessionId={sessionId}
open={overrideDialogOpen}
onOpenChange={setOverrideDialogOpen}
projectIds={aggregatedResults.rankings?.map((r: any) => r.projectId) || []}
/>
</div>
);
}