Files
thrilltrack-explorer/src/components/moderation/ModerationQueue.tsx
2025-09-29 00:51:50 +00:00

838 lines
32 KiB
TypeScript

import { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole';
import { format } from 'date-fns';
interface ModerationItem {
id: string;
type: 'review' | 'content_submission';
content: any;
created_at: string;
user_id: string;
status: string;
submission_type?: string;
user_profile?: {
username: string;
display_name?: string;
};
}
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
type StatusFilter = 'all' | 'pending' | 'flagged' | 'approved' | 'rejected';
export interface ModerationQueueRef {
refresh: () => void;
}
export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const [items, setItems] = useState<ModerationItem[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
const [activeEntityFilter, setActiveEntityFilter] = useState<EntityFilter>('all');
const [activeStatusFilter, setActiveStatusFilter] = useState<StatusFilter>('pending');
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
// Expose refresh method via ref
useImperativeHandle(ref, () => ({
refresh: () => {
fetchItems(activeEntityFilter, activeStatusFilter);
}
}), [activeEntityFilter, activeStatusFilter]);
const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending') => {
try {
setLoading(true);
let reviewStatuses: string[] = [];
let submissionStatuses: string[] = [];
// Define status filters
switch (statusFilter) {
case 'all':
reviewStatuses = ['pending', 'flagged', 'approved', 'rejected'];
submissionStatuses = ['pending', 'approved', 'rejected'];
break;
case 'pending':
reviewStatuses = ['pending'];
submissionStatuses = ['pending'];
break;
case 'flagged':
reviewStatuses = ['flagged'];
submissionStatuses = []; // Content submissions don't have flagged status
break;
case 'approved':
reviewStatuses = ['approved'];
submissionStatuses = ['approved'];
break;
case 'rejected':
reviewStatuses = ['rejected'];
submissionStatuses = ['rejected'];
break;
default:
reviewStatuses = ['pending', 'flagged'];
submissionStatuses = ['pending'];
}
// Fetch reviews
let reviews = [];
if ((entityFilter === 'all' || entityFilter === 'reviews') && reviewStatuses.length > 0) {
const { data: reviewsData, error: reviewsError } = await supabase
.from('reviews')
.select(`
id,
title,
content,
rating,
created_at,
user_id,
moderation_status,
photos
`)
.in('moderation_status', reviewStatuses)
.order('created_at', { ascending: false });
if (reviewsError) throw reviewsError;
reviews = reviewsData || [];
}
// Fetch content submissions
let submissions = [];
if ((entityFilter === 'all' || entityFilter === 'submissions' || entityFilter === 'photos') && submissionStatuses.length > 0) {
let query = supabase
.from('content_submissions')
.select(`
id,
content,
submission_type,
created_at,
user_id,
status
`)
.in('status', submissionStatuses);
// Filter by submission type for photos
if (entityFilter === 'photos') {
query = query.eq('submission_type', 'photo');
} else if (entityFilter === 'submissions') {
query = query.neq('submission_type', 'photo');
}
const { data: submissionsData, error: submissionsError } = await query
.order('created_at', { ascending: false });
if (submissionsError) throw submissionsError;
submissions = submissionsData || [];
}
// Get unique user IDs to fetch profiles
const userIds = [
...reviews.map(r => r.user_id),
...submissions.map(s => s.user_id)
];
// Fetch profiles for all users
const { data: profiles } = await supabase
.from('profiles')
.select('user_id, username, display_name')
.in('user_id', userIds);
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
// Combine and format items
const formattedItems: ModerationItem[] = [
...reviews.map(review => ({
id: review.id,
type: 'review' as const,
content: review,
created_at: review.created_at,
user_id: review.user_id,
status: review.moderation_status,
user_profile: profileMap.get(review.user_id),
})),
...submissions.map(submission => ({
id: submission.id,
type: 'content_submission' as const,
content: submission,
created_at: submission.created_at,
user_id: submission.user_id,
status: submission.status,
submission_type: submission.submission_type,
user_profile: profileMap.get(submission.user_id),
})),
];
// Sort by creation date (newest first for better UX)
formattedItems.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setItems(formattedItems);
} catch (error) {
console.error('Error fetching moderation items:', error);
toast({
title: "Error",
description: "Failed to load moderation queue",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchItems(activeEntityFilter, activeStatusFilter);
}, [activeEntityFilter, activeStatusFilter]);
const handleModerationAction = async (
item: ModerationItem,
action: 'approved' | 'rejected',
moderatorNotes?: string
) => {
// Prevent multiple clicks on the same item
if (actionLoading === item.id) {
console.log('Action already in progress for item:', item.id);
return;
}
setActionLoading(item.id);
try {
const table = item.type === 'review' ? 'reviews' : 'content_submissions';
const statusField = item.type === 'review' ? 'moderation_status' : 'status';
// Use correct timestamp column name based on table
const timestampField = item.type === 'review' ? 'moderated_at' : 'reviewed_at';
const reviewerField = item.type === 'review' ? 'moderated_by' : 'reviewer_id';
const updateData: any = {
[statusField]: action,
[timestampField]: new Date().toISOString(),
};
// Get current user ID for reviewer tracking
const { data: { user } } = await supabase.auth.getUser();
if (user) {
updateData[reviewerField] = user.id;
}
if (moderatorNotes) {
updateData.reviewer_notes = moderatorNotes;
}
console.log('Updating item:', item.id, 'with data:', updateData, 'table:', table);
const { error, data } = await supabase
.from(table)
.update(updateData)
.eq('id', item.id)
.select();
if (error) {
console.error('Database update error:', error);
throw error;
}
console.log('Update response:', { data, rowsAffected: data?.length });
// Check if the update actually affected any rows
if (!data || data.length === 0) {
console.error('No rows were updated. This might be due to RLS policies or the item not existing.');
throw new Error('Failed to update item - no rows affected. You might not have permission to moderate this content.');
}
console.log('Update successful, rows affected:', data.length);
toast({
title: `Content ${action}`,
description: `The ${item.type} has been ${action}`,
});
// Only update local state if the database update was successful
setItems(prev => prev.map(i =>
i.id === item.id
? { ...i, status: action }
: i
));
// Clear notes only after successful update
setNotes(prev => {
const newNotes = { ...prev };
delete newNotes[item.id];
return newNotes;
});
// Only refresh if we're viewing a filter that should no longer show this item
if ((activeStatusFilter === 'pending' && (action === 'approved' || action === 'rejected')) ||
(activeStatusFilter === 'flagged' && (action === 'approved' || action === 'rejected'))) {
console.log('Item no longer matches filter, removing from view');
}
} catch (error: any) {
console.error('Error moderating content:', error);
// Revert any optimistic updates
setItems(prev => prev.map(i =>
i.id === item.id
? { ...i, status: item.status } // Revert to original status
: i
));
toast({
title: "Error",
description: error.message || `Failed to ${action} content`,
variant: "destructive",
});
} finally {
setActionLoading(null);
}
};
const handleDeleteSubmission = async (item: ModerationItem) => {
if (item.type !== 'content_submission') return;
setActionLoading(item.id);
try {
console.log('Starting deletion process for submission:', item.id);
// Step 1: Extract photo IDs from the submission content
const photoIds: string[] = [];
const validImageIds: string[] = [];
const skippedPhotos: string[] = [];
if (item.content?.photos && Array.isArray(item.content.photos)) {
for (const photo of item.content.photos) {
if (photo.url) {
// Check if this looks like a Cloudflare image ID (not a blob URL)
if (photo.url.startsWith('blob:')) {
// This is a blob URL - we can't extract a valid Cloudflare image ID
console.warn('Skipping blob URL (cannot extract Cloudflare image ID):', photo.url);
skippedPhotos.push(photo.url);
} else {
// Try to extract image ID from various URL formats
let imageId = '';
// If it's already just an ID
if (photo.url.match(/^[a-f0-9-]{36}$/)) {
imageId = photo.url;
} else {
// Extract from URL path
const urlParts = photo.url.split('/');
const lastPart = urlParts[urlParts.length - 1];
if (lastPart && lastPart.match(/^[a-f0-9-]{36}$/)) {
imageId = lastPart;
}
}
if (imageId) {
photoIds.push(imageId);
validImageIds.push(imageId);
} else {
console.warn('Could not extract valid image ID from URL:', photo.url);
skippedPhotos.push(photo.url);
}
}
}
}
}
console.log(`Found ${validImageIds.length} valid image IDs to delete, ${skippedPhotos.length} photos will be orphaned`);
// Step 2: Delete photos from Cloudflare Images (if any valid IDs)
if (validImageIds.length > 0) {
const deletePromises = validImageIds.map(async (imageId) => {
try {
console.log('Attempting to delete image from Cloudflare:', imageId);
// Direct fetch call to the edge function with proper DELETE method
const response = await fetch('https://ydvtmnrszybqnbcqbdcy.supabase.co/functions/v1/upload-image', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4`
},
body: JSON.stringify({ imageId })
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`HTTP ${response.status}: ${errorData}`);
}
const result = await response.json();
console.log('Successfully deleted image:', imageId, result);
} catch (deleteError) {
console.error(`Failed to delete photo ${imageId} from Cloudflare:`, deleteError);
// Continue with other deletions - don't fail the entire operation
}
});
// Execute all photo deletions in parallel
await Promise.allSettled(deletePromises);
}
// Step 3: Delete the submission from the database
console.log('Deleting submission from database:', item.id);
const { error } = await supabase
.from('content_submissions')
.delete()
.eq('id', item.id);
if (error) {
console.error('Database deletion error:', error);
throw error;
}
console.log('Submission successfully deleted from database');
const deletedCount = validImageIds.length;
const orphanedCount = skippedPhotos.length;
let description = 'The submission has been permanently deleted';
if (deletedCount > 0 && orphanedCount > 0) {
description = `The submission and ${deletedCount} photo(s) have been deleted. ${orphanedCount} photo(s) could not be deleted from storage (orphaned blob URLs)`;
} else if (deletedCount > 0) {
description = `The submission and ${deletedCount} associated photo(s) have been permanently deleted`;
} else if (orphanedCount > 0) {
description = `The submission has been deleted. ${orphanedCount} photo(s) could not be deleted from storage (orphaned blob URLs)`;
}
toast({
title: "Submission deleted",
description,
});
// Remove item from the current view
setItems(prev => prev.filter(i => i.id !== item.id));
} catch (error) {
console.error('Error deleting submission:', error);
toast({
title: "Error",
description: "Failed to delete submission",
variant: "destructive",
});
} finally {
setActionLoading(null);
}
};
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'pending':
return 'secondary';
case 'flagged':
return 'destructive';
case 'approved':
return 'default';
case 'rejected':
return 'outline';
default:
return 'secondary';
}
};
const getEmptyStateMessage = (entityFilter: EntityFilter, statusFilter: StatusFilter) => {
const entityLabel = entityFilter === 'all' ? 'items' :
entityFilter === 'reviews' ? 'reviews' :
entityFilter === 'photos' ? 'photos' : 'submissions';
switch (statusFilter) {
case 'pending':
return `No pending ${entityLabel} require moderation at this time.`;
case 'flagged':
return `No flagged ${entityLabel} found.`;
case 'approved':
return `No approved ${entityLabel} found.`;
case 'rejected':
return `No rejected ${entityLabel} found.`;
case 'all':
return `No ${entityLabel} found.`;
default:
return `No ${entityLabel} found for the selected filter.`;
}
};
const QueueContent = () => {
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
);
}
if (items.length === 0) {
return (
<div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No items found</h3>
<p className="text-muted-foreground">
{getEmptyStateMessage(activeEntityFilter, activeStatusFilter)}
</p>
</div>
);
}
return (
<div className="space-y-6">
{items.map((item) => (
<Card key={item.id} className={`border-l-4 ${
item.status === 'flagged' ? 'border-l-red-500' :
item.status === 'approved' ? 'border-l-green-500' :
item.status === 'rejected' ? 'border-l-red-400' :
'border-l-amber-500'
}`}>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant={getStatusBadgeVariant(item.status)}>
{item.type === 'review' ? (
<>
<MessageSquare className="w-3 h-3 mr-1" />
Review
</>
) : item.submission_type === 'photo' ? (
<>
<Image className="w-3 h-3 mr-1" />
Photo
</>
) : (
<>
<FileText className="w-3 h-3 mr-1" />
Submission
</>
)}
</Badge>
<Badge variant={getStatusBadgeVariant(item.status)}>
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="w-4 h-4" />
{format(new Date(item.created_at), 'MMM d, yyyy HH:mm')}
</div>
</div>
{item.user_profile && (
<div className="flex items-center gap-2 text-sm">
<User className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">
{item.user_profile.display_name || item.user_profile.username}
</span>
{item.user_profile.display_name && (
<span className="text-muted-foreground">
@{item.user_profile.username}
</span>
)}
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted/50 p-4 rounded-lg">
{item.type === 'review' ? (
<div>
{item.content.title && (
<h4 className="font-semibold mb-2">{item.content.title}</h4>
)}
{item.content.content && (
<p className="text-sm mb-2">{item.content.content}</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rating: {item.content.rating}/5</span>
</div>
{item.content.photos && item.content.photos.length > 0 && (
<div className="mt-3">
<div className="text-sm font-medium mb-2">Attached Photos:</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{item.content.photos.map((photo: any, index: number) => (
<div key={index} className="relative">
<img
src={photo.url}
alt={`Review photo ${index + 1}`}
className="w-full h-20 object-cover rounded border"
/>
</div>
))}
</div>
</div>
)}
</div>
) : item.submission_type === 'photo' ? (
<div>
<div className="text-sm text-muted-foreground mb-3">
Photo Submission
</div>
{/* Submission Title */}
{item.content.title && (
<div className="mb-3">
<div className="text-sm font-medium mb-1">Title:</div>
<p className="text-sm">{item.content.title}</p>
</div>
)}
{/* Submission Caption */}
{item.content.caption && (
<div className="mb-3">
<div className="text-sm font-medium mb-1">Caption:</div>
<p className="text-sm">{item.content.caption}</p>
</div>
)}
{/* Photos */}
{item.content.photos && item.content.photos.length > 0 ? (
<div className="space-y-4">
<div className="text-sm font-medium">Photos ({item.content.photos.length}):</div>
{item.content.photos.map((photo: any, index: number) => (
<div key={index} className="border rounded-lg p-3 space-y-2">
<img
src={photo.url}
alt={`Photo ${index + 1}: ${photo.filename}`}
className="w-full max-h-64 object-contain rounded border bg-muted/30"
/>
<div className="space-y-1 text-xs text-muted-foreground">
<div className="flex justify-between">
<span className="font-medium">Filename:</span>
<span>{photo.filename || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Size:</span>
<span>{photo.size ? `${Math.round(photo.size / 1024)} KB` : 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Type:</span>
<span>{photo.type || 'Unknown'}</span>
</div>
{photo.caption && (
<div className="pt-1">
<div className="font-medium">Caption:</div>
<div className="text-sm text-foreground mt-1">{photo.caption}</div>
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">
No photos found in submission
</div>
)}
{/* Context Information */}
{item.content.context && (
<div className="mt-3 pt-3 border-t text-xs text-muted-foreground">
<div className="flex justify-between">
<span className="font-medium">Context:</span>
<span className="capitalize">{item.content.context}</span>
</div>
{item.content.ride_id && (
<div className="flex justify-between">
<span className="font-medium">Ride ID:</span>
<span className="font-mono">{item.content.ride_id}</span>
</div>
)}
{item.content.park_id && (
<div className="flex justify-between">
<span className="font-medium">Park ID:</span>
<span className="font-mono">{item.content.park_id}</span>
</div>
)}
</div>
)}
</div>
) : (
<div>
<div className="text-sm text-muted-foreground mb-2">
Type: {item.content.submission_type}
</div>
<pre className="text-sm whitespace-pre-wrap">
{JSON.stringify(item.content.content, null, 2)}
</pre>
</div>
)}
</div>
{/* Action buttons based on status */}
{(item.status === 'pending' || item.status === 'flagged') && (
<>
<div className="space-y-2">
<Label htmlFor={`notes-${item.id}`}>Moderation Notes (optional)</Label>
<Textarea
id={`notes-${item.id}`}
placeholder="Add notes about your moderation decision..."
value={notes[item.id] || ''}
onChange={(e) => setNotes(prev => ({ ...prev, [item.id]: e.target.value }))}
rows={2}
/>
</div>
<div className="flex gap-2 pt-2">
<Button
onClick={() => handleModerationAction(item, 'approved', notes[item.id])}
disabled={actionLoading === item.id}
className="flex-1"
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve
</Button>
<Button
variant="destructive"
onClick={() => handleModerationAction(item, 'rejected', notes[item.id])}
disabled={actionLoading === item.id}
className="flex-1"
>
<XCircle className="w-4 h-4 mr-2" />
Reject
</Button>
</div>
</>
)}
{/* Delete button for rejected submissions (admin/superadmin only) */}
{item.status === 'rejected' && item.type === 'content_submission' && (isAdmin() || isSuperuser()) && (
<div className="pt-2">
<Button
variant="destructive"
onClick={() => handleDeleteSubmission(item)}
disabled={actionLoading === item.id}
className="w-full"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Submission
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
);
};
const clearFilters = () => {
setActiveEntityFilter('all');
setActiveStatusFilter('pending');
};
const getEntityFilterIcon = (filter: EntityFilter) => {
switch (filter) {
case 'reviews': return <MessageSquare className="w-4 h-4" />;
case 'submissions': return <FileText className="w-4 h-4" />;
case 'photos': return <Image className="w-4 h-4" />;
default: return <Filter className="w-4 h-4" />;
}
};
return (
<div className="space-y-4">
{/* Filter Bar */}
<div className="flex flex-col sm:flex-row gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex flex-col sm:flex-row gap-4 flex-1">
<div className="space-y-2 min-w-[140px]">
<Label className="text-sm font-medium">Entity Type</Label>
<Select value={activeEntityFilter} onValueChange={(value) => setActiveEntityFilter(value as EntityFilter)}>
<SelectTrigger>
<SelectValue>
<div className="flex items-center gap-2">
{getEntityFilterIcon(activeEntityFilter)}
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
All Items
</div>
</SelectItem>
<SelectItem value="reviews">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Reviews
</div>
</SelectItem>
<SelectItem value="submissions">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
Submissions
</div>
</SelectItem>
<SelectItem value="photos">
<div className="flex items-center gap-2">
<Image className="w-4 h-4" />
Photos
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 min-w-[120px]">
<Label className="text-sm font-medium">Status</Label>
<Select value={activeStatusFilter} onValueChange={(value) => setActiveStatusFilter(value as StatusFilter)}>
<SelectTrigger>
<SelectValue>
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
<SelectItem value="flagged">Flagged</SelectItem>
)}
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending') && (
<div className="flex items-end">
<Button
variant="outline"
size="sm"
onClick={clearFilters}
className="flex items-center gap-2"
>
<X className="w-4 h-4" />
Clear Filters
</Button>
</div>
)}
</div>
{/* Active Filters Display */}
{(activeEntityFilter !== 'all' || activeStatusFilter !== 'pending') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Active filters:</span>
{activeEntityFilter !== 'all' && (
<Badge variant="secondary" className="flex items-center gap-1">
{getEntityFilterIcon(activeEntityFilter)}
{activeEntityFilter}
</Badge>
)}
{activeStatusFilter !== 'pending' && (
<Badge variant="secondary" className="capitalize">
{activeStatusFilter}
</Badge>
)}
</div>
)}
{/* Queue Content */}
<QueueContent />
</div>
);
});