'use client' import { useState, useMemo, useCallback } from 'react' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Download, Filter, ChevronDown, ChevronUp, Clock, User, Activity, Database, Globe, ChevronLeft, ChevronRight, RefreshCw, RotateCcw, AlertTriangle, Layers, ArrowLeftRight, } from 'lucide-react' import { Switch } from '@/components/ui/switch' import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' // Action type options (manual audit actions + auto-generated mutation audit actions) const ACTION_TYPES = [ // Manual audit actions 'CREATE', 'UPDATE', 'DELETE', 'IMPORT', 'EXPORT', 'LOGIN', 'LOGIN_SUCCESS', 'LOGIN_FAILED', 'INVITATION_ACCEPTED', 'SUBMIT_EVALUATION', 'EVALUATION_SUBMITTED', 'UPDATE_STATUS', 'ROUND_ACTIVATED', 'ROUND_CLOSED', 'ROUND_ARCHIVED', 'UPLOAD_FILE', 'DELETE_FILE', 'FILE_DOWNLOADED', 'BULK_CREATE', 'BULK_UPDATE_STATUS', 'UPDATE_EVALUATION_FORM', 'ROLE_CHANGED', 'PASSWORD_SET', 'PASSWORD_CHANGED', 'JUROR_DROPOUT_RESHUFFLE', 'COI_REASSIGNMENT', 'APPLY_AI_SUGGESTIONS', 'APPLY_SUGGESTIONS', 'NOTIFY_JURORS_OF_ASSIGNMENTS', 'IMPERSONATION_START', 'IMPERSONATION_END', // Auto-generated mutation audit actions (non-super-admin) 'EVALUATION_START', 'EVALUATION_SUBMIT', 'EVALUATION_AUTOSAVE', 'EVALUATION_DECLARE_COI', 'EVALUATION_ADD_COMMENT', 'APPLICANT_SAVE_SUBMISSION', 'APPLICANT_SAVE_FILE_METADATA', 'APPLICANT_DELETE_FILE', 'APPLICANT_REQUEST_MENTORING', 'APPLICANT_WITHDRAW_FROM_COMPETITION', 'APPLICANT_INVITE_TEAM_MEMBER', 'APPLICANT_REMOVE_TEAM_MEMBER', 'APPLICANT_SEND_MENTOR_MESSAGE', 'APPLICATION_SUBMIT', 'APPLICATION_SAVE_DRAFT', 'APPLICATION_SUBMIT_DRAFT', 'MENTOR_SEND_MESSAGE', 'MENTOR_CREATE_NOTE', 'MENTOR_DELETE_NOTE', 'MENTOR_COMPLETE_MILESTONE', 'LIVE_CAST_VOTE', 'LIVE_CAST_STAGE_VOTE', 'LIVE_VOTING_VOTE', 'LIVE_VOTING_CAST_AUDIENCE_VOTE', 'DELIBERATION_SUBMIT_VOTE', 'NOTIFICATION_MARK_AS_READ', 'NOTIFICATION_MARK_ALL_AS_READ', 'USER_UPDATE_PROFILE', 'USER_SET_PASSWORD', 'USER_CHANGE_PASSWORD', 'USER_COMPLETE_ONBOARDING', 'SPECIAL_AWARD_SUBMIT_VOTE', ] // Entity type options const ENTITY_TYPES = [ 'User', 'Program', 'Competition', 'Round', 'Project', 'Assignment', 'Evaluation', 'EvaluationForm', 'ProjectFile', 'GracePeriod', 'Applicant', 'Application', 'Mentor', 'Live', 'LiveVoting', 'Deliberation', 'Notification', 'SpecialAward', 'File', 'Tag', 'Message', 'Settings', 'Ranking', 'Filtering', 'RoundEngine', ] // Color map for action types const actionColors: Record = { CREATE: 'default', UPDATE: 'secondary', DELETE: 'destructive', IMPORT: 'default', EXPORT: 'outline', LOGIN: 'outline', LOGIN_SUCCESS: 'outline', LOGIN_FAILED: 'destructive', INVITATION_ACCEPTED: 'default', SUBMIT_EVALUATION: 'default', EVALUATION_SUBMITTED: 'default', ROUND_ACTIVATED: 'default', ROUND_CLOSED: 'secondary', ROUND_ARCHIVED: 'secondary', FILE_DOWNLOADED: 'outline', ROLE_CHANGED: 'secondary', PASSWORD_SET: 'outline', PASSWORD_CHANGED: 'outline', JUROR_DROPOUT_RESHUFFLE: 'destructive', COI_REASSIGNMENT: 'secondary', APPLY_AI_SUGGESTIONS: 'default', APPLY_SUGGESTIONS: 'default', NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline', IMPERSONATION_START: 'destructive', IMPERSONATION_END: 'secondary', // Auto-generated mutation audit actions EVALUATION_START: 'default', EVALUATION_SUBMIT: 'default', EVALUATION_AUTOSAVE: 'outline', EVALUATION_DECLARE_COI: 'secondary', EVALUATION_ADD_COMMENT: 'outline', APPLICANT_SAVE_SUBMISSION: 'default', APPLICANT_DELETE_FILE: 'destructive', APPLICANT_WITHDRAW_FROM_COMPETITION: 'destructive', APPLICANT_INVITE_TEAM_MEMBER: 'default', APPLICANT_REMOVE_TEAM_MEMBER: 'destructive', APPLICATION_SUBMIT: 'default', MENTOR_SEND_MESSAGE: 'outline', MENTOR_CREATE_NOTE: 'default', MENTOR_DELETE_NOTE: 'destructive', LIVE_CAST_VOTE: 'default', LIVE_CAST_STAGE_VOTE: 'default', LIVE_VOTING_CAST_AUDIENCE_VOTE: 'default', DELIBERATION_SUBMIT_VOTE: 'default', SPECIAL_AWARD_SUBMIT_VOTE: 'default', USER_UPDATE_PROFILE: 'secondary', USER_SET_PASSWORD: 'outline', USER_CHANGE_PASSWORD: 'outline', USER_COMPLETE_ONBOARDING: 'default', } export default function AuditLogPage() { // Filter state const [filters, setFilters] = useState({ userId: '', action: '', entityType: '', startDate: '', endDate: '', }) const [page, setPage] = useState(1) const [expandedRows, setExpandedRows] = useState>(new Set()) const [showFilters, setShowFilters] = useState(true) const [groupBySession, setGroupBySession] = useState(false) // Build query input const queryInput = useMemo( () => ({ userId: filters.userId || undefined, action: filters.action || undefined, entityType: filters.entityType || undefined, startDate: filters.startDate ? new Date(filters.startDate) : undefined, endDate: filters.endDate ? new Date(filters.endDate + 'T23:59:59') : undefined, page, perPage: 50, }), [filters, page] ) // Fetch audit logs const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput, { refetchInterval: 30_000 }) // Fetch users for filter dropdown const { data: usersData } = trpc.user.list.useQuery({ page: 1, perPage: 100, }) // Fetch anomalies const { data: anomalyData } = trpc.audit.getAnomalies.useQuery({}, { retry: false, }) // Export query const exportLogs = trpc.export.auditLogs.useQuery( { userId: filters.userId || undefined, action: filters.action || undefined, entityType: filters.entityType || undefined, startDate: filters.startDate ? new Date(filters.startDate) : undefined, endDate: filters.endDate ? new Date(filters.endDate + 'T23:59:59') : undefined, }, { enabled: false } ) const [showExportDialog, setShowExportDialog] = useState(false) // Handle export const handleExport = () => { setShowExportDialog(true) } const handleRequestExportData = useCallback(async () => { const result = await exportLogs.refetch() return result.data ?? undefined }, [exportLogs]) // Reset filters const resetFilters = () => { setFilters({ userId: '', action: '', entityType: '', startDate: '', endDate: '', }) setPage(1) } // Toggle row expansion const toggleRow = (id: string) => { const newExpanded = new Set(expandedRows) if (newExpanded.has(id)) { newExpanded.delete(id) } else { newExpanded.add(id) } setExpandedRows(newExpanded) } const hasFilters = Object.values(filters).some((v) => v !== '') return (
{/* Header */}

Audit Logs

View system activity and user actions

{/* Filters */}
Filters {hasFilters && ( Active )}
{showFilters ? ( ) : ( )}
{/* User Filter */}
{/* Action Filter */}
{/* Entity Type Filter */}
{/* Start Date */}
setFilters({ ...filters, startDate: e.target.value }) } />
{/* End Date */}
setFilters({ ...filters, endDate: e.target.value }) } />
{hasFilters && (
)}
{/* Anomaly Alerts */} {anomalyData && anomalyData.anomalies.length > 0 && ( Anomaly Alerts ({anomalyData.anomalies.length})
{anomalyData.anomalies.slice(0, 5).map((anomaly, i) => (

{anomaly.isRapid ? 'Rapid Activity' : 'Bulk Operations'}

{String(anomaly.actionCount)} actions in {String(anomaly.timeWindowMinutes)} min ({anomaly.actionsPerMinute.toFixed(1)}/min)

{anomaly.userId && (

User: {String(anomaly.user?.name || anomaly.userId)}

)}
{String(anomaly.actionCount)} actions
))}
)} {/* Session Grouping Toggle */}
{/* Results */} {isLoading ? ( ) : data && data.logs.length > 0 ? ( <> {/* Desktop Table View */} Timestamp User Action Entity IP Address {data.logs.map((log) => { const isExpanded = expandedRows.has(log.id) return ( <> toggleRow(log.id)} > {formatDate(log.timestamp)}

{log.user?.name || 'System'}

{log.user?.email}

{log.action.replace(/_/g, ' ')}

{log.entityType}

{log.entityId && (

{log.entityId.slice(0, 8)}...

)}
{log.ipAddress || '-'} {isExpanded ? ( ) : ( )}
{isExpanded && (

Entity ID

{log.entityId || 'N/A'}

User Agent

{log.userAgent || 'N/A'}

{log.detailsJson && (

Details

{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? ( } /> ) : log.action === 'COI_REASSIGNMENT' ? ( } /> ) : (
                                      {JSON.stringify(log.detailsJson, null, 2)}
                                    
)}
)} {!!(log as Record).previousDataJson && (

Changes (Before / After)

).previousDataJson} after={log.detailsJson} />
)} {groupBySession && !!(log as Record).sessionId && (

Session ID

{String((log as Record).sessionId)}

)}
)} ) })}
{/* Mobile Card View */}
{data.logs.map((log) => { const isExpanded = expandedRows.has(log.id) return ( toggleRow(log.id)} >
{log.action.replace(/_/g, ' ')} {log.entityType}
{isExpanded ? ( ) : ( )}
{formatDate(log.timestamp)}
{log.user?.name || 'System'}
{isExpanded && (

Entity ID

{log.entityId || 'N/A'}

IP Address

{log.ipAddress || 'N/A'}

{log.detailsJson && (

Details

{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? ( } /> ) : log.action === 'COI_REASSIGNMENT' ? ( } /> ) : (
                                {JSON.stringify(log.detailsJson, null, 2)}
                              
)}
)}
)}
) })}
{/* Pagination */}

Showing {(page - 1) * 50 + 1} to{' '} {Math.min(page * 50, data.total)} of {data.total} results

Page {page} of {data.totalPages}
) : (

No audit logs found

{hasFilters ? 'Try adjusting your filters' : 'Activity will appear here as users interact with the system'}

)} {/* CSV Export Dialog with Column Selection */}
) } function ReshuffleDetailView({ details }: { details: Record }) { const reassignedTo = (details.reassignedTo ?? {}) as Record const jurorIds = Object.keys(reassignedTo) const moves = (details.moves ?? []) as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[] // Resolve juror IDs to names const { data: nameMap } = trpc.user.resolveNames.useQuery( { ids: [...jurorIds, details.droppedJurorId as string].filter(Boolean) }, { enabled: jurorIds.length > 0 }, ) const droppedName = (details.droppedJurorName as string) || (nameMap && details.droppedJurorId ? nameMap[details.droppedJurorId as string] : null) || (details.droppedJurorId as string) return (
{/* Summary */}
Juror Dropout {droppedName}

{String(details.movedCount)} project(s) reassigned, {String(details.failedCount)} failed {details.removedFromGroup ? ' — removed from jury group' : ''}

{/* Per-project moves (new format) */} {moves.length > 0 && (

Project → New Juror

{moves.map((move, i) => ( ))}
Project Reassigned To
{move.projectTitle} {move.newJurorName}
)} {/* Fallback: count-based view (old format, no per-project detail) */} {moves.length === 0 && jurorIds.length > 0 && (

Reassignment Summary (project detail not available)

{jurorIds.map((id) => ( ))}
Juror Projects Received
{nameMap?.[id] || id} {reassignedTo[id]}
)} {/* Failed projects */} {Array.isArray(details.failedProjects) && (details.failedProjects as string[]).length > 0 && (

Could not reassign:

    {(details.failedProjects as string[]).map((p, i) => (
  • {p}
  • ))}
)}
) } function COIReassignmentDetailView({ details }: { details: Record }) { const ids = [details.oldJurorId, details.newJurorId].filter(Boolean) as string[] const { data: nameMap } = trpc.user.resolveNames.useQuery( { ids }, { enabled: ids.length > 0 }, ) const oldName = nameMap?.[details.oldJurorId as string] || (details.oldJurorId as string) const newName = nameMap?.[details.newJurorId as string] || (details.newJurorId as string) return (
COI Reassignment

From

{oldName}

To

{newName}

Project: {(details.projectId as string)?.slice(0, 12)}... {' | '}Round: {(details.roundId as string)?.slice(0, 12)}...
) } function DiffViewer({ before, after }: { before: unknown; after: unknown }) { const beforeObj = typeof before === 'object' && before !== null ? before as Record : {} const afterObj = typeof after === 'object' && after !== null ? after as Record : {} 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 (

No differences detected

) } return (
Field Before After
{changedKeys.map((key) => (
{key} {beforeObj[key] !== undefined ? JSON.stringify(beforeObj[key]) : '--'} {afterObj[key] !== undefined ? JSON.stringify(afterObj[key]) : '--'}
))}
) } function AuditLogSkeleton() { return (
{[...Array(10)].map((_, i) => (
))}
) }