diff --git a/src/components/admin/SystemActivityLog.tsx b/src/components/admin/SystemActivityLog.tsx new file mode 100644 index 00000000..e5176a62 --- /dev/null +++ b/src/components/admin/SystemActivityLog.tsx @@ -0,0 +1,368 @@ +import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + FileEdit, + Plus, + History, + Shield, + UserCog, + FileCheck, + FileX, + Flag, + AlertCircle, + Star, + AlertTriangle, + Image as ImageIcon, + CheckCircle, + ChevronDown, + ChevronUp, + Trash2 +} from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { + fetchSystemActivities, + SystemActivity, + ActivityType, + EntityChangeDetails, + AdminActionDetails, + SubmissionReviewDetails, + ReportResolutionDetails, + ReviewModerationDetails, + PhotoApprovalDetails +} from '@/lib/systemActivityService'; + +export interface SystemActivityLogRef { + refresh: () => Promise; +} + +interface SystemActivityLogProps { + limit?: number; + showFilters?: boolean; +} + +const activityTypeConfig = { + entity_change: { + icon: FileEdit, + color: 'text-blue-500', + bgColor: 'bg-blue-500/10', + label: 'Entity Change', + }, + admin_action: { + icon: Shield, + color: 'text-red-500', + bgColor: 'bg-red-500/10', + label: 'Admin Action', + }, + submission_review: { + icon: FileCheck, + color: 'text-green-500', + bgColor: 'bg-green-500/10', + label: 'Submission Review', + }, + report_resolution: { + icon: Flag, + color: 'text-orange-500', + bgColor: 'bg-orange-500/10', + label: 'Report Resolution', + }, + review_moderation: { + icon: Star, + color: 'text-purple-500', + bgColor: 'bg-purple-500/10', + label: 'Review Moderation', + }, + photo_approval: { + icon: ImageIcon, + color: 'text-teal-500', + bgColor: 'bg-teal-500/10', + label: 'Photo Approval', + }, +}; + +export const SystemActivityLog = forwardRef( + ({ limit = 50, showFilters = true }, ref) => { + const [activities, setActivities] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [filterType, setFilterType] = useState('all'); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const loadActivities = async () => { + setIsLoading(true); + try { + const data = await fetchSystemActivities(limit, { + type: filterType === 'all' ? undefined : filterType, + }); + setActivities(data); + } catch (error) { + console.error('Error loading system activities:', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadActivities(); + }, [limit, filterType]); + + useImperativeHandle(ref, () => ({ + refresh: loadActivities, + })); + + const toggleExpanded = (id: string) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const renderActivityDetails = (activity: SystemActivity) => { + const isExpanded = expandedIds.has(activity.id); + + switch (activity.type) { + case 'entity_change': { + const details = activity.details as EntityChangeDetails; + return ( +
+
+ + {details.change_type} + + {details.entity_type} + {details.entity_name && ( + {details.entity_name} + )} +
+ {isExpanded && details.change_reason && ( +

+ Reason: {details.change_reason} +

+ )} + {isExpanded && details.version_number && ( +

+ Version #{details.version_number} +

+ )} +
+ ); + } + + case 'admin_action': { + const details = activity.details as AdminActionDetails; + return ( +
+
+ + {details.action.replace(/_/g, ' ')} + + {details.target_username && ( + + → @{details.target_username} + + )} +
+ {isExpanded && details.details && ( +
+                  {JSON.stringify(details.details, null, 2)}
+                
+ )} +
+ ); + } + + case 'submission_review': { + const details = activity.details as SubmissionReviewDetails; + const statusColor = details.status === 'approved' ? 'bg-green-500/10 text-green-500' : 'bg-red-500/10 text-red-500'; + return ( +
+
+ + {details.status} + + {details.submission_type} + {details.entity_name && ( + {details.entity_name} + )} +
+
+ ); + } + + case 'report_resolution': { + const details = activity.details as ReportResolutionDetails; + return ( +
+
+ {details.status} + {details.reported_entity_type} +
+ {isExpanded && details.resolution_notes && ( +

+ {details.resolution_notes} +

+ )} +
+ ); + } + + case 'review_moderation': { + const details = activity.details as ReviewModerationDetails; + return ( +
+
+ {details.moderation_status} + {details.entity_type} review +
+
+ ); + } + + case 'photo_approval': { + const details = activity.details as PhotoApprovalDetails; + return ( +
+
+ Approved + {details.entity_type} photo +
+
+ ); + } + + default: + return null; + } + }; + + if (isLoading) { + return ( + + + System Activity Log + Loading recent system activities... + + + {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ); + } + + return ( + + +
+
+ System Activity Log + + Complete audit trail of all system changes and actions + +
+ {showFilters && ( + + )} +
+
+ + {activities.length === 0 ? ( +
+ No activities found +
+ ) : ( +
+ {activities.map((activity) => { + const config = activityTypeConfig[activity.type]; + const Icon = config.icon; + const isExpanded = expandedIds.has(activity.id); + + return ( +
+
+ +
+
+
+
+ {activity.actor ? ( + <> + + + + {activity.actor.username.slice(0, 2).toUpperCase()} + + + + {activity.actor.display_name || activity.actor.username} + + + ) : ( + System + )} + + {activity.action} + +
+
+ + {formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true })} + + +
+
+ {renderActivityDetails(activity)} +
+
+ ); + })} +
+ )} +
+
+ ); + } +); + +SystemActivityLog.displayName = 'SystemActivityLog'; diff --git a/src/lib/systemActivityService.ts b/src/lib/systemActivityService.ts new file mode 100644 index 00000000..630e6a76 --- /dev/null +++ b/src/lib/systemActivityService.ts @@ -0,0 +1,333 @@ +import { supabase } from '@/integrations/supabase/client'; + +export type ActivityType = + | 'entity_change' + | 'admin_action' + | 'submission_review' + | 'report_resolution' + | 'review_moderation' + | 'photo_approval'; + +export interface ActivityActor { + id: string; + username: string; + display_name?: string; + avatar_url?: string; +} + +export interface EntityChangeDetails { + entity_type: string; + entity_id: string; + entity_name?: string; + change_type: string; + change_reason?: string; + version_number?: number; +} + +export interface AdminActionDetails { + action: string; + target_user_id?: string; + target_username?: string; + details?: Record; +} + +export interface SubmissionReviewDetails { + submission_id: string; + submission_type: string; + status: string; + entity_name?: string; +} + +export interface ReportResolutionDetails { + report_id: string; + reported_entity_type: string; + reported_entity_id: string; + status: string; + resolution_notes?: string; +} + +export interface ReviewModerationDetails { + review_id: string; + entity_type: string; + entity_id: string; + moderation_status: string; + entity_name?: string; +} + +export interface PhotoApprovalDetails { + photo_id: string; + entity_type: string; + entity_id: string; + entity_name?: string; +} + +export type ActivityDetails = + | EntityChangeDetails + | AdminActionDetails + | SubmissionReviewDetails + | ReportResolutionDetails + | ReviewModerationDetails + | PhotoApprovalDetails; + +export interface SystemActivity { + id: string; + type: ActivityType; + timestamp: string; + actor_id: string | null; + actor?: ActivityActor; + action: string; + details: ActivityDetails; +} + +export interface ActivityFilters { + type?: ActivityType; + userId?: string; + entityType?: string; + dateFrom?: string; + dateTo?: string; +} + +/** + * Fetch unified system activity log from multiple sources + */ +export async function fetchSystemActivities( + limit: number = 50, + filters?: ActivityFilters +): Promise { + const activities: SystemActivity[] = []; + + // Fetch entity versions (entity changes) + const { data: versions, error: versionsError } = await supabase + .from('entity_versions') + .select('id, entity_type, entity_id, version_number, version_data, changed_by, changed_at, change_type, change_reason') + .order('changed_at', { ascending: false }) + .limit(limit * 2); // Fetch more to account for filtering + + if (!versionsError && versions) { + for (const version of versions) { + const versionData = version.version_data as any; + activities.push({ + id: version.id, + type: 'entity_change', + timestamp: version.changed_at, + actor_id: version.changed_by, + action: `${version.change_type} ${version.entity_type}`, + details: { + entity_type: version.entity_type, + entity_id: version.entity_id, + entity_name: versionData?.name || versionData?.title, + change_type: version.change_type, + change_reason: version.change_reason, + version_number: version.version_number, + } as EntityChangeDetails, + }); + } + } + + // Fetch admin audit log (admin actions) + const { data: auditLogs, error: auditError } = await supabase + .from('admin_audit_log') + .select('id, admin_user_id, target_user_id, action, details, created_at') + .order('created_at', { ascending: false }) + .limit(limit); + + if (!auditError && auditLogs) { + for (const log of auditLogs) { + activities.push({ + id: log.id, + type: 'admin_action', + timestamp: log.created_at, + actor_id: log.admin_user_id, + action: log.action, + details: { + action: log.action, + target_user_id: log.target_user_id, + details: log.details, + } as AdminActionDetails, + }); + } + } + + // Fetch submission reviews (approved/rejected submissions) + const { data: submissions, error: submissionsError } = await supabase + .from('content_submissions') + .select('id, submission_type, status, reviewer_id, reviewed_at, content') + .not('reviewed_at', 'is', null) + .in('status', ['approved', 'rejected', 'partially_approved']) + .order('reviewed_at', { ascending: false }) + .limit(limit); + + if (!submissionsError && submissions) { + for (const submission of submissions) { + const contentData = submission.content as any; + activities.push({ + id: submission.id, + type: 'submission_review', + timestamp: submission.reviewed_at!, + actor_id: submission.reviewer_id, + action: `${submission.status} ${submission.submission_type} submission`, + details: { + submission_id: submission.id, + submission_type: submission.submission_type, + status: submission.status, + entity_name: contentData?.name, + } as SubmissionReviewDetails, + }); + } + } + + // Fetch report resolutions + const { data: reports, error: reportsError } = await supabase + .from('reports') + .select('id, reported_entity_type, reported_entity_id, status, reviewed_by, reviewed_at') + .not('reviewed_at', 'is', null) + .order('reviewed_at', { ascending: false }) + .limit(limit); + + if (!reportsError && reports) { + for (const report of reports) { + activities.push({ + id: report.id, + type: 'report_resolution', + timestamp: report.reviewed_at!, + actor_id: report.reviewed_by, + action: `${report.status} report`, + details: { + report_id: report.id, + reported_entity_type: report.reported_entity_type, + reported_entity_id: report.reported_entity_id, + status: report.status, + } as ReportResolutionDetails, + }); + } + } + + // Fetch review moderation + const { data: reviews, error: reviewsError } = await supabase + .from('reviews') + .select('id, park_id, ride_id, moderation_status, moderated_by, moderated_at') + .not('moderated_at', 'is', null) + .neq('moderation_status', 'pending') + .order('moderated_at', { ascending: false }) + .limit(limit); + + if (!reviewsError && reviews) { + for (const review of reviews) { + const entityType = review.park_id ? 'park' : 'ride'; + const entityId = review.park_id || review.ride_id; + + activities.push({ + id: review.id, + type: 'review_moderation', + timestamp: review.moderated_at!, + actor_id: review.moderated_by, + action: `${review.moderation_status} review`, + details: { + review_id: review.id, + entity_type: entityType, + entity_id: entityId!, + moderation_status: review.moderation_status, + } as ReviewModerationDetails, + }); + } + } + + // Fetch photo approvals + const { data: photos, error: photosError } = await supabase + .from('photos') + .select('id, entity_type, entity_id, approved_by, approved_at') + .not('approved_at', 'is', null) + .order('approved_at', { ascending: false }) + .limit(limit); + + if (!photosError && photos) { + for (const photo of photos) { + activities.push({ + id: photo.id, + type: 'photo_approval', + timestamp: photo.approved_at!, + actor_id: photo.approved_by, + action: `approved ${photo.entity_type} photo`, + details: { + photo_id: photo.id, + entity_type: photo.entity_type, + entity_id: photo.entity_id, + } as PhotoApprovalDetails, + }); + } + } + + // Sort all activities by timestamp (newest first) + activities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + // Apply filters + let filteredActivities = activities; + if (filters?.type) { + filteredActivities = filteredActivities.filter(a => a.type === filters.type); + } + if (filters?.userId) { + filteredActivities = filteredActivities.filter(a => a.actor_id === filters.userId); + } + + // Limit to requested amount + filteredActivities = filteredActivities.slice(0, limit); + + // Enrich with user profile data + const uniqueUserIds = [...new Set(filteredActivities.map(a => a.actor_id).filter(Boolean))] as string[]; + + if (uniqueUserIds.length > 0) { + const { data: profiles } = await supabase + .from('profiles') + .select('user_id, username, display_name, avatar_url') + .in('user_id', uniqueUserIds); + + if (profiles) { + const profileMap = new Map(profiles.map(p => [p.user_id, p])); + + for (const activity of filteredActivities) { + if (activity.actor_id) { + const profile = profileMap.get(activity.actor_id); + if (profile) { + activity.actor = { + id: profile.user_id, + username: profile.username, + display_name: profile.display_name || undefined, + avatar_url: profile.avatar_url || undefined, + }; + } + } + } + + // Also enrich admin action target users + const targetUserIds = filteredActivities + .filter(a => a.type === 'admin_action') + .map(a => (a.details as AdminActionDetails).target_user_id) + .filter(Boolean) as string[]; + + if (targetUserIds.length > 0) { + const { data: targetProfiles } = await supabase + .from('profiles') + .select('user_id, username') + .in('user_id', targetUserIds); + + if (targetProfiles) { + const targetProfileMap = new Map(targetProfiles.map(p => [p.user_id, p])); + + for (const activity of filteredActivities) { + if (activity.type === 'admin_action') { + const details = activity.details as AdminActionDetails; + if (details.target_user_id) { + const targetProfile = targetProfileMap.get(details.target_user_id); + if (targetProfile) { + details.target_username = targetProfile.username; + } + } + } + } + } + } + } + } + + return filteredActivities; +} diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index 9ba1bc97..aa2a76c2 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -1,6 +1,6 @@ import { useRef, useEffect, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { FileText, Flag, AlertCircle, Activity, ShieldAlert } from 'lucide-react'; +import { FileText, Flag, AlertCircle, Activity, ShieldAlert, ScrollText } from 'lucide-react'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; import { Card, CardContent } from '@/components/ui/card'; @@ -10,6 +10,7 @@ import { AdminLayout } from '@/components/layout/AdminLayout'; import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue'; import { ReportsQueue } from '@/components/moderation/ReportsQueue'; import { RecentActivity } from '@/components/moderation/RecentActivity'; +import { SystemActivityLog, SystemActivityLogRef } from '@/components/admin/SystemActivityLog'; import { useModerationStats } from '@/hooks/useModerationStats'; import { useAdminSettings } from '@/hooks/useAdminSettings'; import { supabase } from '@/integrations/supabase/client'; @@ -26,6 +27,7 @@ export default function AdminDashboard() { const moderationQueueRef = useRef(null); const reportsQueueRef = useRef(null); const recentActivityRef = useRef(null); + const systemLogRef = useRef(null); const { getAdminPanelRefreshMode, @@ -75,6 +77,9 @@ export default function AdminDashboard() { case 'activity': recentActivityRef.current?.refresh(); break; + case 'system-log': + systemLogRef.current?.refresh(); + break; } setTimeout(() => setIsRefreshing(false), 500); @@ -223,7 +228,7 @@ export default function AdminDashboard() { - + Moderation Queue @@ -249,6 +254,11 @@ export default function AdminDashboard() { Recent Activity Activity + + + System Log + Log + @@ -262,6 +272,10 @@ export default function AdminDashboard() { + + + +