Competition/Round architecture: full platform rewrite (Phases 1-9)

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,239 @@
'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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Lock, Unlock, History } from 'lucide-react';
import { toast } from 'sonner';
import type { CompetitionCategory } from '@prisma/client';
interface ResultLockControlsProps {
competitionId: string;
roundId: string;
category: CompetitionCategory;
}
export function ResultLockControls({ competitionId, roundId, category }: ResultLockControlsProps) {
const utils = trpc.useUtils();
const [lockDialogOpen, setLockDialogOpen] = useState(false);
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
const [unlockReason, setUnlockReason] = useState('');
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery({
competitionId,
roundId,
category
});
const { data: history } = trpc.resultLock.history.useQuery({
competitionId
});
const lockMutation = trpc.resultLock.lock.useMutation({
onSuccess: () => {
utils.resultLock.isLocked.invalidate();
utils.resultLock.history.invalidate();
toast.success('Results locked successfully');
setLockDialogOpen(false);
},
onError: (err) => {
toast.error(err.message);
}
});
const unlockMutation = trpc.resultLock.unlock.useMutation({
onSuccess: () => {
utils.resultLock.isLocked.invalidate();
utils.resultLock.history.invalidate();
toast.success('Results unlocked');
setUnlockDialogOpen(false);
setUnlockReason('');
},
onError: (err) => {
toast.error(err.message);
}
});
const handleLock = () => {
lockMutation.mutate({
competitionId,
roundId,
category,
resultSnapshot: {} // This would contain the actual results snapshot
});
};
const handleUnlock = () => {
if (!unlockReason.trim()) {
toast.error('Reason is required to unlock results');
return;
}
if (!lockStatus?.lock?.id) {
toast.error('No active lock found');
return;
}
unlockMutation.mutate({
resultLockId: lockStatus.lock.id,
reason: unlockReason.trim()
});
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Result Lock Status</CardTitle>
<CardDescription>Prevent changes to finalized results</CardDescription>
</div>
<Badge variant={lockStatus?.locked ? 'destructive' : 'outline'}>
{lockStatus?.locked ? 'Locked' : 'Unlocked'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{lockStatus?.locked ? (
<div className="space-y-3">
<div className="rounded-lg bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<Lock className="h-5 w-5 text-destructive" />
<div className="flex-1">
<p className="font-medium text-destructive">Results are locked</p>
{lockStatus.lock?.lockedAt && (
<p className="mt-1 text-sm text-muted-foreground">
Locked on {new Date(lockStatus.lock.lockedAt).toLocaleString()}
</p>
)}
</div>
</div>
</div>
<Button
variant="outline"
onClick={() => setUnlockDialogOpen(true)}
className="w-full"
>
<Unlock className="mr-2 h-4 w-4" />
Unlock Results (Super Admin Only)
</Button>
</div>
) : (
<Button onClick={() => setLockDialogOpen(true)} className="w-full">
<Lock className="mr-2 h-4 w-4" />
Lock Results
</Button>
)}
</CardContent>
</Card>
{/* Lock History */}
{history && history.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Lock History
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{history.map((entry: any) => (
<div key={entry.id} className="rounded-lg border p-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
{entry.action === 'LOCK' ? (
<Lock className="h-4 w-4 text-destructive" />
) : (
<Unlock className="h-4 w-4 text-green-600" />
)}
<span className="font-medium">{entry.action}</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{entry.adminUser?.name} - {new Date(entry.timestamp).toLocaleString()}
</p>
{entry.reason && (
<p className="mt-2 text-sm italic">&ldquo;{entry.reason}&rdquo;</p>
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Lock Confirmation Dialog */}
<Dialog open={lockDialogOpen} onOpenChange={setLockDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Lock Results</DialogTitle>
<DialogDescription>
This will prevent any further changes to the results. This action can only be
reversed by a super admin.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setLockDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleLock} disabled={lockMutation.isPending}>
{lockMutation.isPending ? 'Locking...' : 'Confirm Lock'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Unlock Dialog */}
<Dialog open={unlockDialogOpen} onOpenChange={setUnlockDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Unlock Results</DialogTitle>
<DialogDescription>
Unlocking results will allow modifications. This action will be audited.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="unlockReason">Reason (Required) *</Label>
<Textarea
id="unlockReason"
value={unlockReason}
onChange={(e) => setUnlockReason(e.target.value)}
placeholder="Explain why results need to be unlocked..."
rows={3}
required
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUnlockDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleUnlock}
disabled={unlockMutation.isPending || !unlockReason.trim()}
>
{unlockMutation.isPending ? 'Unlocking...' : 'Confirm Unlock'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}