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

@@ -0,0 +1,397 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ArrowLeft,
GitCompare,
MapPin,
Users,
FileText,
CheckCircle2,
Clock,
} from 'lucide-react'
export default function CompareProjectsPage() {
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [comparing, setComparing] = useState(false)
// Fetch all assigned projects
const { data: assignments, isLoading: loadingAssignments } =
trpc.assignment.myAssignments.useQuery({})
// Derive unique rounds from assignments
const rounds = useMemo(() => {
if (!assignments) return []
const roundMap = new Map<string, { id: string; name: string }>()
for (const a of assignments as Array<{ round: { id: string; name: string } }>) {
if (a.round && !roundMap.has(a.round.id)) {
roundMap.set(a.round.id, { id: a.round.id, name: String(a.round.name) })
}
}
return Array.from(roundMap.values())
}, [assignments])
// Auto-select the first round if none selected
const activeRoundId = selectedRoundId || (rounds.length > 0 ? rounds[0].id : '')
// Filter assignments to current round
const roundProjects = useMemo(() => {
if (!assignments || !activeRoundId) return []
return (assignments as Array<{
project: Record<string, unknown>
round: { id: string; name: string }
evaluation?: Record<string, unknown>
}>)
.filter((a) => a.round.id === activeRoundId)
.map((a) => ({
...a.project,
roundName: a.round.name,
evaluation: a.evaluation,
}))
}, [assignments, activeRoundId])
// Fetch comparison data when comparing
const { data: comparisonData, isLoading: loadingComparison } =
trpc.evaluation.getMultipleForComparison.useQuery(
{ projectIds: selectedIds, roundId: activeRoundId },
{ enabled: comparing && selectedIds.length >= 2 && !!activeRoundId }
)
const toggleProject = (projectId: string) => {
setSelectedIds((prev) => {
if (prev.includes(projectId)) {
return prev.filter((id) => id !== projectId)
}
if (prev.length >= 3) return prev
return [...prev, projectId]
})
setComparing(false)
}
const handleCompare = () => {
if (selectedIds.length >= 2) {
setComparing(true)
}
}
const handleReset = () => {
setComparing(false)
setSelectedIds([])
}
const handleRoundChange = (roundId: string) => {
setSelectedRoundId(roundId)
setSelectedIds([])
setComparing(false)
}
if (loadingAssignments) {
return <CompareSkeleton />
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
</div>
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Compare Projects</h1>
<p className="text-muted-foreground">
Select 2-3 projects from the same round to compare side by side
</p>
</div>
<div className="flex items-center gap-2">
{comparing && (
<Button variant="outline" onClick={handleReset}>
Reset
</Button>
)}
{!comparing && (
<Button
onClick={handleCompare}
disabled={selectedIds.length < 2}
>
<GitCompare className="mr-2 h-4 w-4" />
Compare ({selectedIds.length})
</Button>
)}
</div>
</div>
{/* Round selector */}
{rounds.length > 1 && !comparing && (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">Round:</span>
<Select value={activeRoundId} onValueChange={handleRoundChange}>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Project selector */}
{!comparing && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{roundProjects.map((project: Record<string, unknown>) => {
const projectId = project.id as string
const isSelected = selectedIds.includes(projectId)
const isDisabled = !isSelected && selectedIds.length >= 3
return (
<Card
key={projectId}
className={`cursor-pointer transition-colors ${
isSelected
? 'border-primary ring-2 ring-primary/20'
: isDisabled
? 'opacity-50'
: 'hover:border-primary/50'
}`}
onClick={() => !isDisabled && toggleProject(projectId)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={() => toggleProject(projectId)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{String(project.title || 'Untitled')}
</p>
<p className="text-sm text-muted-foreground truncate">
{String(project.teamName || '')}
</p>
{!!project.roundName && (
<Badge variant="secondary" className="mt-1 text-xs">
{String(project.roundName)}
</Badge>
)}
</div>
{project.evaluation ? (
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
) : (
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{roundProjects.length === 0 && !loadingAssignments && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<GitCompare className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects assigned</p>
<p className="text-sm text-muted-foreground">
You need at least 2 assigned projects to use the comparison feature.
</p>
</CardContent>
</Card>
)}
{/* Comparison view */}
{comparing && loadingComparison && <CompareSkeleton />}
{comparing && comparisonData && (
<div
className={`grid gap-4 grid-cols-1 ${
selectedIds.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-2 lg:grid-cols-3'
}`}
>
{(comparisonData as Array<{
project: Record<string, unknown>
evaluation: Record<string, unknown> | null
assignmentId: string
}>).map((item) => (
<ComparisonCard
key={String(item.project.id)}
project={item.project}
evaluation={item.evaluation}
/>
))}
</div>
)}
</div>
)
}
function ComparisonCard({
project,
evaluation,
}: {
project: Record<string, unknown>
evaluation: Record<string, unknown> | null
}) {
const tags = Array.isArray(project.tags) ? project.tags : []
const files = Array.isArray(project.files) ? project.files : []
const scores = evaluation?.scores as Record<string, unknown> | undefined
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">{String(project.title || 'Untitled')}</CardTitle>
<CardDescription className="flex items-center gap-1">
<Users className="h-3 w-3" />
{String(project.teamName || 'N/A')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Country */}
{!!project.country && (
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 text-muted-foreground" />
{String(project.country)}
</div>
)}
{/* Description */}
{!!project.description && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Description</p>
<p className="text-sm line-clamp-4">{String(project.description)}</p>
</div>
)}
{/* Tags */}
{tags.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Tags</p>
<div className="flex flex-wrap gap-1">
{tags.map((tag: unknown, i: number) => (
<Badge key={i} variant="secondary" className="text-xs">
{String(tag)}
</Badge>
))}
</div>
</div>
)}
{/* Files */}
{files.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Files ({files.length})
</p>
<div className="space-y-1">
{files.map((file: unknown, i: number) => {
const f = file as Record<string, unknown>
return (
<div key={i} className="flex items-center gap-2 text-sm">
<FileText className="h-3 w-3 text-muted-foreground" />
<span className="truncate">{String(f.fileName || f.fileType || 'File')}</span>
</div>
)
})}
</div>
</div>
)}
{/* Evaluation scores */}
{evaluation && (
<div className="border-t pt-3">
<p className="text-xs font-medium text-muted-foreground mb-2">Your Evaluation</p>
{scores && typeof scores === 'object' ? (
<div className="space-y-1">
{Object.entries(scores).map(([criterion, score]) => (
<div key={criterion} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground truncate">{criterion}</span>
<span className="font-medium tabular-nums">{String(score)}</span>
</div>
))}
</div>
) : (
<Badge variant="outline">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
)}
{evaluation.globalScore != null && (
<div className="mt-2 flex items-center justify-between font-medium text-sm border-t pt-2">
<span>Overall Score</span>
<span className="text-primary">{String(evaluation.globalScore)}</span>
</div>
)}
</div>
)}
{!evaluation && (
<div className="border-t pt-3">
<Badge variant="outline">
<Clock className="mr-1 h-3 w-3" />
Not yet evaluated
</Badge>
</div>
)}
</CardContent>
</Card>
)
}
function CompareSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-36" />
</div>
<Skeleton className="h-8 w-64" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { use, useState, useEffect } from 'react'
import { use, useState, useEffect, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -15,7 +15,8 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { toast } from 'sonner'
import { Clock, CheckCircle, AlertCircle, Zap } from 'lucide-react'
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff } from 'lucide-react'
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
interface PageProps {
params: Promise<{ sessionId: string }>
@@ -27,12 +28,28 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [countdown, setCountdown] = useState<number | null>(null)
// Fetch session data with polling
// Fetch session data - reduced polling since SSE handles real-time
const { data, isLoading, refetch } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId },
{ refetchInterval: 2000 } // Poll every 2 seconds
{ refetchInterval: 10000 }
)
// SSE for real-time updates
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
setSelectedScore(null)
setCountdown(null)
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(sessionId, {
onSessionStatus,
onProjectChange,
})
// Vote mutation
const vote = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
@@ -207,9 +224,16 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
</Card>
{/* Mobile-friendly footer */}
<p className="text-white/60 text-sm mt-4">
MOPC Live Voting
</p>
<div className="flex items-center justify-center gap-2 mt-4">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-400" />
) : (
<WifiOff className="h-3 w-3 text-red-400" />
)}
<p className="text-white/60 text-sm">
MOPC Live Voting {isConnected ? '- Connected' : '- Reconnecting...'}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,366 @@
'use client'
import { useState } from 'react'
import { useParams, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
BarChart3,
MessageSquare,
Send,
Loader2,
Lock,
User,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate, cn, getInitials } from '@/lib/utils'
export default function DiscussionPage() {
const params = useParams()
const searchParams = useSearchParams()
const projectId = params.id as string
const roundId = searchParams.get('roundId') || ''
const [commentText, setCommentText] = useState('')
const utils = trpc.useUtils()
// Fetch peer summary
const { data: peerSummary, isLoading: loadingSummary } =
trpc.evaluation.getPeerSummary.useQuery(
{ projectId, roundId },
{ enabled: !!roundId }
)
// Fetch discussion thread
const { data: discussion, isLoading: loadingDiscussion } =
trpc.evaluation.getDiscussion.useQuery(
{ projectId, roundId },
{ enabled: !!roundId }
)
// Add comment mutation
const addCommentMutation = trpc.evaluation.addComment.useMutation({
onSuccess: () => {
utils.evaluation.getDiscussion.invalidate({ projectId, roundId })
toast.success('Comment added')
setCommentText('')
},
onError: (e) => toast.error(e.message),
})
const handleSubmitComment = () => {
if (!commentText.trim()) {
toast.error('Please enter a comment')
return
}
addCommentMutation.mutate({
projectId,
roundId,
content: commentText.trim(),
})
}
const isLoading = loadingSummary || loadingDiscussion
if (!roundId) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<MessageSquare className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No round specified</p>
<p className="text-sm text-muted-foreground">
Please access the discussion from your assignments page.
</p>
</CardContent>
</Card>
</div>
)
}
if (isLoading) {
return <DiscussionSkeleton />
}
// Parse peer summary data
const summary = peerSummary as Record<string, unknown> | undefined
const averageScore = summary ? Number(summary.averageScore || 0) : 0
const scoreRange = summary?.scoreRange as { min: number; max: number } | undefined
const evaluationCount = summary ? Number(summary.evaluationCount || 0) : 0
const individualScores = (summary?.scores || summary?.individualScores) as
| Array<number>
| undefined
// Parse discussion data
const discussionData = discussion as Record<string, unknown> | undefined
const comments = (discussionData?.comments || []) as Array<{
id: string
user: { id: string; name: string | null; email: string }
content: string
createdAt: string
}>
const discussionStatus = String(discussionData?.status || 'OPEN')
const isClosed = discussionStatus === 'CLOSED'
const closedAt = discussionData?.closedAt as string | undefined
const closedBy = discussionData?.closedBy as Record<string, unknown> | undefined
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Project Discussion
</h1>
<p className="text-muted-foreground">
Peer review discussion and anonymized score summary
</p>
</div>
{/* Peer Summary Card */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Peer Summary
</CardTitle>
<CardDescription>
Anonymized scoring overview across all evaluations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats row */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">{averageScore.toFixed(1)}</p>
<p className="text-xs text-muted-foreground">Average Score</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">
{scoreRange
? `${scoreRange.min.toFixed(1)} - ${scoreRange.max.toFixed(1)}`
: '--'}
</p>
<p className="text-xs text-muted-foreground">Score Range</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">{evaluationCount}</p>
<p className="text-xs text-muted-foreground">Evaluations</p>
</div>
</div>
{/* Anonymized score bars */}
{individualScores && individualScores.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Anonymized Individual Scores</p>
<div className="flex items-end gap-2 h-24">
{individualScores.map((score, i) => {
const maxPossible = scoreRange?.max || 10
const height =
maxPossible > 0
? Math.max((score / maxPossible) * 100, 4)
: 4
return (
<div
key={i}
className="flex-1 flex flex-col items-center gap-1"
>
<span className="text-[10px] font-medium tabular-nums">
{score.toFixed(1)}
</span>
<div
className={cn(
'w-full rounded-t transition-all',
score >= averageScore
? 'bg-primary/60'
: 'bg-muted-foreground/30'
)}
style={{ height: `${height}%` }}
/>
<span className="text-[10px] text-muted-foreground">
#{i + 1}
</span>
</div>
)
})}
</div>
</div>
)}
</CardContent>
</Card>
{/* Discussion Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Discussion
</CardTitle>
{isClosed && (
<Badge variant="secondary" className="flex items-center gap-1">
<Lock className="h-3 w-3" />
Closed
{closedAt && (
<span className="ml-1">- {formatDate(closedAt)}</span>
)}
</Badge>
)}
</div>
<CardDescription>
{isClosed
? 'This discussion has been closed.'
: 'Share your thoughts with fellow jurors about this project.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Comments */}
{comments.length > 0 ? (
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
{/* Avatar */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
{comment.user?.name
? getInitials(comment.user.name)
: <User className="h-4 w-4" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{comment.user?.name || 'Anonymous Juror'}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(comment.createdAt)}
</span>
</div>
<p className="text-sm mt-1 whitespace-pre-wrap">
{comment.content}
</p>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MessageSquare className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-2 text-sm text-muted-foreground">
No comments yet. Be the first to start the discussion.
</p>
</div>
)}
{/* Comment input */}
{!isClosed ? (
<div className="space-y-2 border-t pt-4">
<Textarea
placeholder="Write your comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={3}
/>
<div className="flex justify-end">
<Button
onClick={handleSubmitComment}
disabled={
addCommentMutation.isPending || !commentText.trim()
}
>
{addCommentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Post Comment
</Button>
</div>
</div>
) : (
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground text-center">
This discussion is closed and no longer accepts new comments.
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function DiscussionSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div>
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-40 mt-2" />
</div>
{/* Peer summary skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
{/* Discussion skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
</div>
)
}