Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
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:
144
src/components/admin/deliberation/admin-override-dialog.tsx
Normal file
144
src/components/admin/deliberation/admin-override-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
src/components/admin/deliberation/results-panel.tsx
Normal file
179
src/components/admin/deliberation/results-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user