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

@@ -1,6 +1,6 @@
'use client'
import { Suspense, use, useState, useEffect } from 'react'
import { Suspense, use, useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
@@ -15,6 +15,15 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from 'sonner'
import {
ArrowLeft,
@@ -28,6 +37,10 @@ import {
AlertCircle,
ExternalLink,
RefreshCw,
QrCode,
Settings2,
Scale,
UserCheck,
} from 'lucide-react'
import {
DndContext,
@@ -46,6 +59,8 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse'
import { QRCodeDisplay } from '@/components/shared/qr-code-display'
interface PageProps {
params: Promise<{ id: string }>
@@ -119,11 +134,38 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
const [projectOrder, setProjectOrder] = useState<string[]>([])
const [countdown, setCountdown] = useState<number | null>(null)
const [votingDuration, setVotingDuration] = useState(30)
const [liveVoteCount, setLiveVoteCount] = useState<number | null>(null)
const [liveAvgScore, setLiveAvgScore] = useState<number | null>(null)
// Fetch session data
// Fetch session data - reduced polling since SSE handles real-time
const { data: sessionData, isLoading, refetch } = trpc.liveVoting.getSession.useQuery(
{ roundId },
{ refetchInterval: 2000 } // Poll every 2 seconds
{ refetchInterval: 5000 }
)
// SSE for real-time vote updates
const onVoteUpdate = useCallback((data: VoteUpdate) => {
setLiveVoteCount(data.totalVotes)
setLiveAvgScore(data.averageScore)
}, [])
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
setLiveVoteCount(null)
setLiveAvgScore(null)
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(
sessionData?.id || null,
{
onVoteUpdate,
onSessionStatus,
onProjectChange,
}
)
// Mutations
@@ -166,6 +208,26 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
},
})
const updateSessionConfig = trpc.liveVoting.updateSessionConfig.useMutation({
onSuccess: () => {
toast.success('Session config updated')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const updatePresentationSettings = trpc.liveVoting.updatePresentationSettings.useMutation({
onSuccess: () => {
toast.success('Presentation settings updated')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
@@ -446,61 +508,185 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Current Votes
{isConnected && (
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
)}
</CardTitle>
</CardHeader>
<CardContent>
{sessionData.currentVotes.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No votes yet
</p>
) : (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total votes</span>
<span className="font-medium">
{sessionData.currentVotes.length}
</span>
{(() => {
const voteCount = liveVoteCount ?? sessionData.currentVotes.length
const avgScore = liveAvgScore ?? (
sessionData.currentVotes.length > 0
? sessionData.currentVotes.reduce((sum, v) => sum + v.score, 0) / sessionData.currentVotes.length
: null
)
if (voteCount === 0) {
return (
<p className="text-muted-foreground text-center py-4">
No votes yet
</p>
)
}
return (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total votes</span>
<span className="font-medium">{voteCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Average score</span>
<span className="font-medium">
{avgScore !== null ? avgScore.toFixed(1) : '--'}
</span>
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Average score</span>
<span className="font-medium">
{(
sessionData.currentVotes.reduce(
(sum, v) => sum + v.score,
0
) / sessionData.currentVotes.length
).toFixed(1)}
)
})()}
</CardContent>
</Card>
{/* Session Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
Session Config
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="audience-votes" className="text-sm">
Audience Voting
</Label>
<Switch
id="audience-votes"
checked={!!sessionData.allowAudienceVotes}
onCheckedChange={(checked) => {
updateSessionConfig.mutate({
sessionId: sessionData.id,
allowAudienceVotes: checked,
})
}}
disabled={isCompleted}
/>
</div>
{sessionData.allowAudienceVotes && (
<div className="space-y-2">
<Label className="text-sm">Audience Weight</Label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="50"
value={(sessionData.audienceVoteWeight || 0) * 100}
onChange={(e) => {
updateSessionConfig.mutate({
sessionId: sessionData.id,
audienceVoteWeight: parseInt(e.target.value) / 100,
})
}}
className="flex-1"
disabled={isCompleted}
/>
<span className="text-sm font-medium w-12 text-right">
{Math.round((sessionData.audienceVoteWeight || 0) * 100)}%
</span>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-sm">Tie-Breaker Method</Label>
<Select
value={sessionData.tieBreakerMethod || 'admin_decides'}
onValueChange={(v) => {
updateSessionConfig.mutate({
sessionId: sessionData.id,
tieBreakerMethod: v as 'admin_decides' | 'highest_individual' | 'revote',
})
}}
disabled={isCompleted}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin_decides">Admin Decides</SelectItem>
<SelectItem value="highest_individual">Highest Individual Score</SelectItem>
<SelectItem value="revote">Revote</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Score Display Format</Label>
<Select
value={
(sessionData.presentationSettingsJson as Record<string, unknown>)?.scoreDisplayFormat as string || 'bar'
}
onValueChange={(v) => {
const existing = (sessionData.presentationSettingsJson as Record<string, unknown>) || {}
updatePresentationSettings.mutate({
sessionId: sessionData.id,
presentationSettingsJson: {
...existing,
scoreDisplayFormat: v as 'bar' | 'number' | 'radial',
},
})
}}
disabled={isCompleted}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bar">Bar Chart</SelectItem>
<SelectItem value="number">Number Only</SelectItem>
<SelectItem value="radial">Radial</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Links */}
{/* QR Codes & Links */}
<Card>
<CardHeader>
<CardTitle>Voting Links</CardTitle>
<CardTitle className="flex items-center gap-2">
<QrCode className="h-5 w-5" />
Voting Links
</CardTitle>
<CardDescription>
Share these links with participants
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={`/jury/live/${sessionData.id}`} target="_blank">
<ExternalLink className="mr-2 h-4 w-4" />
Jury Voting Page
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link
href={`/live-scores/${sessionData.id}`}
target="_blank"
>
<ExternalLink className="mr-2 h-4 w-4" />
Public Score Display
</Link>
</Button>
<CardContent className="space-y-4">
<QRCodeDisplay
url={`${typeof window !== 'undefined' ? window.location.origin : ''}/jury/live/${sessionData.id}`}
title="Jury Voting"
size={160}
/>
<QRCodeDisplay
url={`${typeof window !== 'undefined' ? window.location.origin : ''}/live-scores/${sessionData.id}`}
title="Public Scoreboard"
size={160}
/>
<div className="flex flex-col gap-2 pt-2 border-t">
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={`/jury/live/${sessionData.id}`} target="_blank">
<ExternalLink className="mr-2 h-4 w-4" />
Open Jury Page
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={`/live-scores/${sessionData.id}`} target="_blank">
<ExternalLink className="mr-2 h-4 w-4" />
Open Scoreboard
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>