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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user