feat: Implement system activity log (Phases 1-3)

This commit is contained in:
gpt-engineer-app[bot]
2025-10-06 17:52:08 +00:00
parent 3210147654
commit d6bddd6459
3 changed files with 717 additions and 2 deletions

View File

@@ -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<void>;
}
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<SystemActivityLogRef, SystemActivityLogProps>(
({ limit = 50, showFilters = true }, ref) => {
const [activities, setActivities] = useState<SystemActivity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filterType, setFilterType] = useState<ActivityType | 'all'>('all');
const [expandedIds, setExpandedIds] = useState<Set<string>>(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 (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="capitalize">
{details.change_type}
</Badge>
<span className="text-muted-foreground">{details.entity_type}</span>
{details.entity_name && (
<span className="font-medium">{details.entity_name}</span>
)}
</div>
{isExpanded && details.change_reason && (
<p className="text-sm text-muted-foreground pl-4 border-l-2">
Reason: {details.change_reason}
</p>
)}
{isExpanded && details.version_number && (
<p className="text-xs text-muted-foreground">
Version #{details.version_number}
</p>
)}
</div>
);
}
case 'admin_action': {
const details = activity.details as AdminActionDetails;
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge variant="destructive" className="capitalize">
{details.action.replace(/_/g, ' ')}
</Badge>
{details.target_username && (
<span className="text-muted-foreground">
@{details.target_username}
</span>
)}
</div>
{isExpanded && details.details && (
<pre className="text-xs bg-muted p-2 rounded overflow-auto">
{JSON.stringify(details.details, null, 2)}
</pre>
)}
</div>
);
}
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 (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge className={statusColor}>
{details.status}
</Badge>
<span className="text-muted-foreground">{details.submission_type}</span>
{details.entity_name && (
<span className="font-medium">{details.entity_name}</span>
)}
</div>
</div>
);
}
case 'report_resolution': {
const details = activity.details as ReportResolutionDetails;
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline">{details.status}</Badge>
<span className="text-muted-foreground">{details.reported_entity_type}</span>
</div>
{isExpanded && details.resolution_notes && (
<p className="text-sm text-muted-foreground pl-4 border-l-2">
{details.resolution_notes}
</p>
)}
</div>
);
}
case 'review_moderation': {
const details = activity.details as ReviewModerationDetails;
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline">{details.moderation_status}</Badge>
<span className="text-muted-foreground">{details.entity_type} review</span>
</div>
</div>
);
}
case 'photo_approval': {
const details = activity.details as PhotoApprovalDetails;
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge className="bg-teal-500/10 text-teal-500">Approved</Badge>
<span className="text-muted-foreground">{details.entity_type} photo</span>
</div>
</div>
);
}
default:
return null;
}
};
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>System Activity Log</CardTitle>
<CardDescription>Loading recent system activities...</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>System Activity Log</CardTitle>
<CardDescription>
Complete audit trail of all system changes and actions
</CardDescription>
</div>
{showFilters && (
<Select value={filterType} onValueChange={(value) => setFilterType(value as ActivityType | 'all')}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Activities</SelectItem>
{Object.entries(activityTypeConfig).map(([key, config]) => (
<SelectItem key={key} value={key}>
{config.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</CardHeader>
<CardContent>
{activities.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No activities found
</div>
) : (
<div className="space-y-4">
{activities.map((activity) => {
const config = activityTypeConfig[activity.type];
const Icon = config.icon;
const isExpanded = expandedIds.has(activity.id);
return (
<div
key={activity.id}
className="flex gap-4 p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div className={`p-2 rounded-full ${config.bgColor} h-fit`}>
<Icon className={`h-5 w-5 ${config.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2 flex-wrap">
{activity.actor ? (
<>
<Avatar className="h-6 w-6">
<AvatarImage src={activity.actor.avatar_url} />
<AvatarFallback>
{activity.actor.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="font-medium">
{activity.actor.display_name || activity.actor.username}
</span>
</>
) : (
<span className="font-medium text-muted-foreground">System</span>
)}
<span className="text-muted-foreground">
{activity.action}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true })}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => toggleExpanded(activity.id)}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
</div>
{renderActivityDetails(activity)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
);
SystemActivityLog.displayName = 'SystemActivityLog';

View File

@@ -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<string, any>;
}
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<SystemActivity[]> {
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;
}

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect, useCallback, useState } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Card, CardContent } from '@/components/ui/card'; 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 { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue';
import { ReportsQueue } from '@/components/moderation/ReportsQueue'; import { ReportsQueue } from '@/components/moderation/ReportsQueue';
import { RecentActivity } from '@/components/moderation/RecentActivity'; import { RecentActivity } from '@/components/moderation/RecentActivity';
import { SystemActivityLog, SystemActivityLogRef } from '@/components/admin/SystemActivityLog';
import { useModerationStats } from '@/hooks/useModerationStats'; import { useModerationStats } from '@/hooks/useModerationStats';
import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useAdminSettings } from '@/hooks/useAdminSettings';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
@@ -26,6 +27,7 @@ export default function AdminDashboard() {
const moderationQueueRef = useRef<ModerationQueueRef>(null); const moderationQueueRef = useRef<ModerationQueueRef>(null);
const reportsQueueRef = useRef<any>(null); const reportsQueueRef = useRef<any>(null);
const recentActivityRef = useRef<any>(null); const recentActivityRef = useRef<any>(null);
const systemLogRef = useRef<SystemActivityLogRef>(null);
const { const {
getAdminPanelRefreshMode, getAdminPanelRefreshMode,
@@ -75,6 +77,9 @@ export default function AdminDashboard() {
case 'activity': case 'activity':
recentActivityRef.current?.refresh(); recentActivityRef.current?.refresh();
break; break;
case 'system-log':
systemLogRef.current?.refresh();
break;
} }
setTimeout(() => setIsRefreshing(false), 500); setTimeout(() => setIsRefreshing(false), 500);
@@ -223,7 +228,7 @@ export default function AdminDashboard() {
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 h-auto p-1"> <TabsList className="grid w-full grid-cols-4 h-auto p-1">
<TabsTrigger value="moderation" className="flex items-center gap-2 py-3"> <TabsTrigger value="moderation" className="flex items-center gap-2 py-3">
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
<span className="hidden sm:inline">Moderation Queue</span> <span className="hidden sm:inline">Moderation Queue</span>
@@ -249,6 +254,11 @@ export default function AdminDashboard() {
<span className="hidden sm:inline">Recent Activity</span> <span className="hidden sm:inline">Recent Activity</span>
<span className="sm:hidden">Activity</span> <span className="sm:hidden">Activity</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="system-log" className="flex items-center gap-2 py-3">
<ScrollText className="w-4 h-4" />
<span className="hidden sm:inline">System Log</span>
<span className="sm:hidden">Log</span>
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="moderation" className="mt-6"> <TabsContent value="moderation" className="mt-6">
@@ -262,6 +272,10 @@ export default function AdminDashboard() {
<TabsContent value="activity" className="mt-6"> <TabsContent value="activity" className="mt-6">
<RecentActivity ref={recentActivityRef} /> <RecentActivity ref={recentActivityRef} />
</TabsContent> </TabsContent>
<TabsContent value="system-log" className="mt-6">
<SystemActivityLog ref={systemLogRef} limit={50} />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
</AdminLayout> </AdminLayout>