mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 11:31:13 -05:00
feat: Implement system activity log (Phases 1-3)
This commit is contained in:
368
src/components/admin/SystemActivityLog.tsx
Normal file
368
src/components/admin/SystemActivityLog.tsx
Normal 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';
|
||||||
333
src/lib/systemActivityService.ts
Normal file
333
src/lib/systemActivityService.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user