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

@@ -48,7 +48,11 @@ import {
ChevronRight,
RefreshCw,
RotateCcw,
AlertTriangle,
Layers,
ArrowLeftRight,
} from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
@@ -127,6 +131,7 @@ export default function AuditLogPage() {
const [page, setPage] = useState(1)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [showFilters, setShowFilters] = useState(true)
const [groupBySession, setGroupBySession] = useState(false)
// Build query input
const queryInput = useMemo(
@@ -153,6 +158,11 @@ export default function AuditLogPage() {
perPage: 100,
})
// Fetch anomalies
const { data: anomalyData } = trpc.audit.getAnomalies.useQuery({}, {
retry: false,
})
// Export mutation
const exportLogs = trpc.export.auditLogs.useQuery(
{
@@ -384,6 +394,54 @@ export default function AuditLogPage() {
</Card>
</Collapsible>
{/* Anomaly Alerts */}
{anomalyData && anomalyData.anomalies.length > 0 && (
<Card className="border-amber-500/50 bg-amber-500/5">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2 text-amber-700">
<AlertTriangle className="h-5 w-5" />
Anomaly Alerts ({anomalyData.anomalies.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{anomalyData.anomalies.slice(0, 5).map((anomaly, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg border border-amber-200 bg-white p-3 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium">{anomaly.isRapid ? 'Rapid Activity' : 'Bulk Operations'}</p>
<p className="text-xs text-muted-foreground">{String(anomaly.actionCount)} actions in {String(anomaly.timeWindowMinutes)} min ({anomaly.actionsPerMinute.toFixed(1)}/min)</p>
{anomaly.userId && (
<p className="text-xs text-muted-foreground mt-1">
User: {String(anomaly.user?.name || anomaly.userId)}
</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
{String(anomaly.actionCount)} actions
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Session Grouping Toggle */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch
id="session-grouping"
checked={groupBySession}
onCheckedChange={setGroupBySession}
/>
<label htmlFor="session-grouping" className="text-sm cursor-pointer flex items-center gap-1">
<Layers className="h-4 w-4" />
Group by Session
</label>
</div>
</div>
{/* Results */}
{isLoading ? (
<AuditLogSkeleton />
@@ -485,6 +543,28 @@ export default function AuditLogPage() {
</pre>
</div>
)}
{!!(log as Record<string, unknown>).previousDataJson && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1 flex items-center gap-1">
<ArrowLeftRight className="h-3 w-3" />
Changes (Before / After)
</p>
<DiffViewer
before={(log as Record<string, unknown>).previousDataJson}
after={log.detailsJson}
/>
</div>
)}
{groupBySession && !!(log as Record<string, unknown>).sessionId && (
<div>
<p className="text-xs font-medium text-muted-foreground">
Session ID
</p>
<p className="font-mono text-xs">
{String((log as Record<string, unknown>).sessionId)}
</p>
</div>
)}
</div>
</TableCell>
</TableRow>
@@ -625,6 +705,42 @@ export default function AuditLogPage() {
)
}
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {}
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}
const allKeys = Array.from(new Set([...Object.keys(beforeObj), ...Object.keys(afterObj)]))
const changedKeys = allKeys.filter(
(key) => JSON.stringify(beforeObj[key]) !== JSON.stringify(afterObj[key])
)
if (changedKeys.length === 0) {
return (
<p className="text-xs text-muted-foreground italic">No differences detected</p>
)
}
return (
<div className="rounded-lg border overflow-hidden text-xs font-mono">
<div className="grid grid-cols-3 bg-muted p-2 font-medium">
<span>Field</span>
<span>Before</span>
<span>After</span>
</div>
{changedKeys.map((key) => (
<div key={key} className="grid grid-cols-3 p-2 border-t">
<span className="font-medium text-muted-foreground">{key}</span>
<span className="text-red-600 break-all">
{beforeObj[key] !== undefined ? JSON.stringify(beforeObj[key]) : '--'}
</span>
<span className="text-green-600 break-all">
{afterObj[key] !== undefined ? JSON.stringify(afterObj[key]) : '--'}
</span>
</div>
))}
</div>
)
}
function AuditLogSkeleton() {
return (
<Card>