Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n

Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher

Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download

All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 23:31:41 +01:00
parent f038c95777
commit 59436ed67a
68 changed files with 14541 additions and 546 deletions

View File

@@ -32,7 +32,10 @@ import {
type Criterion,
} from '@/components/forms/evaluation-form-builder'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell } from 'lucide-react'
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Label } from '@/components/ui/label'
import { DateTimePicker } from '@/components/ui/datetime-picker'
import {
Select,
@@ -458,6 +461,321 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
{/* Jury Features */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<GitCompare className="h-5 w-5" />
Jury Features
</CardTitle>
<CardDescription>
Configure project comparison and peer review for jury members
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Comparison settings */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Enable Project Comparison</Label>
<p className="text-xs text-muted-foreground">
Allow jury members to compare projects side by side
</p>
</div>
<Switch
checked={Boolean(roundSettings.enable_comparison)}
onCheckedChange={(checked) =>
setRoundSettings((prev) => ({
...prev,
enable_comparison: checked,
}))
}
/>
</div>
{!!roundSettings.enable_comparison && (
<div className="space-y-2 pl-4 border-l-2 border-muted">
<Label className="text-sm">Max Projects to Compare</Label>
<Input
type="number"
min={2}
max={5}
value={Number(roundSettings.comparison_max_projects || 3)}
onChange={(e) =>
setRoundSettings((prev) => ({
...prev,
comparison_max_projects: parseInt(e.target.value) || 3,
}))
}
className="max-w-[120px]"
/>
</div>
)}
</div>
{/* Peer review settings */}
<div className="border-t pt-4 space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium flex items-center gap-1">
<MessageSquare className="h-4 w-4" />
Enable Peer Review / Discussion
</Label>
<p className="text-xs text-muted-foreground">
Allow jury members to discuss and see aggregated scores
</p>
</div>
<Switch
checked={Boolean(roundSettings.peer_review_enabled)}
onCheckedChange={(checked) =>
setRoundSettings((prev) => ({
...prev,
peer_review_enabled: checked,
}))
}
/>
</div>
{!!roundSettings.peer_review_enabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="space-y-2">
<Label className="text-sm">Divergence Threshold</Label>
<p className="text-xs text-muted-foreground">
Score divergence level that triggers a warning (0.0 - 1.0)
</p>
<Input
type="number"
min={0}
max={1}
step={0.05}
value={Number(roundSettings.divergence_threshold || 0.3)}
onChange={(e) =>
setRoundSettings((prev) => ({
...prev,
divergence_threshold: parseFloat(e.target.value) || 0.3,
}))
}
className="max-w-[120px]"
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Anonymization Level</Label>
<Select
value={String(roundSettings.anonymization_level || 'partial')}
onValueChange={(v) =>
setRoundSettings((prev) => ({
...prev,
anonymization_level: v,
}))
}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No anonymization</SelectItem>
<SelectItem value="partial">Partial (Juror 1, 2...)</SelectItem>
<SelectItem value="full">Full anonymization</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Discussion Window (hours)</Label>
<Input
type="number"
min={1}
max={720}
value={Number(roundSettings.discussion_window_hours || 48)}
onChange={(e) =>
setRoundSettings((prev) => ({
...prev,
discussion_window_hours: parseInt(e.target.value) || 48,
}))
}
className="max-w-[120px]"
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Max Comment Length</Label>
<Input
type="number"
min={100}
max={5000}
value={Number(roundSettings.max_comment_length || 2000)}
onChange={(e) =>
setRoundSettings((prev) => ({
...prev,
max_comment_length: parseInt(e.target.value) || 2000,
}))
}
className="max-w-[120px]"
/>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* File Settings */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
File Settings
</CardTitle>
<CardDescription>
Configure allowed file types and versioning for this round
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm">Allowed File Types</Label>
<p className="text-xs text-muted-foreground">
Comma-separated MIME types or extensions
</p>
<Input
placeholder="application/pdf, video/mp4, image/jpeg"
value={String(roundSettings.allowed_file_types || '')}
onChange={(e) =>
setRoundSettings((prev) => ({
...prev,
allowed_file_types: e.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Max File Size (MB)</Label>
<Input
type="number"
min={1}
max={2048}
value={Number(roundSettings.max_file_size_mb || 500)}
onChange={(e) =>
setRoundSettings((prev) => ({
...prev,
max_file_size_mb: parseInt(e.target.value) || 500,
}))
}
className="max-w-[150px]"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Enable File Versioning</Label>
<p className="text-xs text-muted-foreground">
Keep previous versions when files are replaced
</p>
</div>
<Switch
checked={Boolean(roundSettings.file_versioning)}
onCheckedChange={(checked) =>
setRoundSettings((prev) => ({
...prev,
file_versioning: checked,
}))
}
/>
</div>
{!!roundSettings.file_versioning && (
<div className="space-y-2 pl-4 border-l-2 border-muted">
<Label className="text-sm">Max Versions per File</Label>
<Input
type="number"
min={2}
max={20}
value={Number(roundSettings.max_file_versions || 5)}
onChange={(e) =>
setRoundSettings((prev) => ({
...prev,
max_file_versions: parseInt(e.target.value) || 5,
}))
}
className="max-w-[120px]"
/>
</div>
)}
</CardContent>
</Card>
{/* Availability Settings */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Calendar className="h-5 w-5" />
Jury Availability Settings
</CardTitle>
<CardDescription>
Configure how jury member availability affects assignments
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Require Availability</Label>
<p className="text-xs text-muted-foreground">
Jury members must set availability before receiving assignments
</p>
</div>
<Switch
checked={Boolean(roundSettings.require_availability)}
onCheckedChange={(checked) =>
setRoundSettings((prev) => ({
...prev,
require_availability: checked,
}))
}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Availability Mode</Label>
<Select
value={String(roundSettings.availability_mode || 'soft_penalty')}
onValueChange={(v) =>
setRoundSettings((prev) => ({
...prev,
availability_mode: v,
}))
}
>
<SelectTrigger className="max-w-[250px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hard_block">
Hard Block (unavailable jurors excluded)
</SelectItem>
<SelectItem value="soft_penalty">
Soft Penalty (reduce assignment priority)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">
Availability Weight ({Number(roundSettings.availability_weight || 50)}%)
</Label>
<p className="text-xs text-muted-foreground">
How much weight to give availability when using soft penalty mode
</p>
<Slider
value={[Number(roundSettings.availability_weight || 50)]}
min={0}
max={100}
step={5}
onValueChange={([value]) =>
setRoundSettings((prev) => ({
...prev,
availability_weight: value,
}))
}
className="max-w-xs"
/>
</div>
</CardContent>
</Card>
{/* Evaluation Criteria */}
<Card>
<CardHeader>