import { useState, useEffect, useImperativeHandle, forwardRef, useCallback, useRef } from 'react'; import { CheckCircle, XCircle, Eye, Calendar, User, Filter, MessageSquare, FileText, Image, X, Trash2, ListTree, RefreshCw, AlertCircle, Clock, Lock, Unlock, AlertTriangle, UserCog, Zap } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; import { format, formatDistance } from 'date-fns'; import { PhotoModal } from './PhotoModal'; import { SubmissionReviewManager } from './SubmissionReviewManager'; import { useIsMobile } from '@/hooks/use-mobile'; import { SubmissionChangesDisplay } from './SubmissionChangesDisplay'; import { SubmissionItemsList } from './SubmissionItemsList'; import { MeasurementDisplay } from '@/components/ui/measurement-display'; import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useModerationQueue } from '@/hooks/useModerationQueue'; import { Progress } from '@/components/ui/progress'; import { QueueStatsDashboard } from './QueueStatsDashboard'; import { EscalationDialog } from './EscalationDialog'; import { ReassignDialog } from './ReassignDialog'; import { smartMergeArray } from '@/lib/smartStateUpdate'; import { useDebounce } from '@/hooks/useDebounce'; 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; avatar_url?: string; }; entity_name?: string; park_name?: string; reviewed_at?: string; reviewed_by?: string; reviewer_notes?: string; reviewer_profile?: { username: string; display_name?: string; avatar_url?: string; }; } type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; type StatusFilter = 'all' | 'pending' | 'partially_approved' | 'flagged' | 'approved' | 'rejected'; type QueueTab = 'mainQueue' | 'archive'; export interface ModerationQueueRef { refresh: () => void; } export const ModerationQueue = forwardRef((props, ref) => { const isMobile = useIsMobile(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [isInitialLoad, setIsInitialLoad] = useState(true); const [actionLoading, setActionLoading] = useState(null); const [notes, setNotes] = useState>({}); const [activeTab, setActiveTab] = useState('mainQueue'); const [activeEntityFilter, setActiveEntityFilter] = useState('all'); const [activeStatusFilter, setActiveStatusFilter] = useState('pending'); const [photoModalOpen, setPhotoModalOpen] = useState(false); const [selectedPhotos, setSelectedPhotos] = useState([]); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0); const [reviewManagerOpen, setReviewManagerOpen] = useState(false); const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); const [lockedSubmissions, setLockedSubmissions] = useState>(new Set()); const [escalationDialogOpen, setEscalationDialogOpen] = useState(false); const [reassignDialogOpen, setReassignDialogOpen] = useState(false); const [selectedItemForAction, setSelectedItemForAction] = useState(null); const [interactingWith, setInteractingWith] = useState>(new Set()); const [newItemsCount, setNewItemsCount] = useState(0); const [isRefreshing, setIsRefreshing] = useState(false); const [profileCache, setProfileCache] = useState>(new Map()); const [entityCache, setEntityCache] = useState<{ rides: Map, parks: Map, companies: Map }>({ rides: new Map(), parks: new Map(), companies: new Map() }); const [submissionMemo, setSubmissionMemo] = useState>(new Map()); const [pendingNewItems, setPendingNewItems] = useState([]); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); const { user } = useAuth(); const queue = useModerationQueue(); const fetchInProgressRef = useRef(false); const itemsRef = useRef([]); const loadedIdsRef = useRef>(new Set()); // Get admin settings for polling configuration const { getAdminPanelRefreshMode, getAdminPanelPollInterval, getAutoRefreshStrategy, getPreserveInteractionState } = useAdminSettings(); const refreshMode = getAdminPanelRefreshMode(); const pollInterval = getAdminPanelPollInterval(); const refreshStrategy = getAutoRefreshStrategy(); const preserveInteraction = getPreserveInteractionState(); // Sync itemsRef and loadedIdsRef with items state useEffect(() => { itemsRef.current = items; loadedIdsRef.current = new Set(items.map(item => item.id)); }, [items]); const fetchItems = useCallback(async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false, tab: QueueTab = 'mainQueue') => { if (!user) { return; } // Prevent concurrent calls - race condition guard if (fetchInProgressRef.current) { console.log('⚠️ Fetch already in progress, skipping duplicate call'); return; } fetchInProgressRef.current = true; console.log('🔍 fetchItems called:', { entityFilter, statusFilter, tab, silent, timestamp: new Date().toISOString() }); try { // Set loading states if (!silent) { setLoading(true); } else { setIsRefreshing(true); } // Build base query for content submissions let submissionsQuery = supabase .from('content_submissions') .select(` id, submission_type, status, content, created_at, user_id, reviewed_at, reviewer_id, reviewer_notes, escalated, priority, assigned_to, locked_until `) .order('priority', { ascending: false }) .order('created_at', { ascending: true }); // Apply tab-based status filtering if (tab === 'mainQueue') { // Main queue: pending, flagged, partially_approved submissions if (statusFilter === 'all') { submissionsQuery = submissionsQuery.in('status', ['pending', 'flagged', 'partially_approved']); } else if (statusFilter === 'pending') { submissionsQuery = submissionsQuery.in('status', ['pending', 'partially_approved']); } else { submissionsQuery = submissionsQuery.eq('status', statusFilter); } } else { // Archive: approved or rejected submissions if (statusFilter === 'all') { submissionsQuery = submissionsQuery.in('status', ['approved', 'rejected']); } else { submissionsQuery = submissionsQuery.eq('status', statusFilter); } } // Apply entity type filter if (entityFilter === 'photos') { submissionsQuery = submissionsQuery.eq('submission_type', 'photo'); } else if (entityFilter === 'submissions') { submissionsQuery = submissionsQuery.neq('submission_type', 'photo'); } // CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions // Admins see all submissions if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); // Show submissions that are: // 1. Unclaimed (assigned_to is null) // 2. Have expired locks (locked_until < now) // 3. Are assigned to current user submissionsQuery = submissionsQuery.or( `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}` ); } const { data: submissions, error: submissionsError } = await submissionsQuery; if (submissionsError) throw submissionsError; // Get user IDs and fetch user profiles const userIds = submissions?.map(s => s.user_id).filter(Boolean) || []; const reviewerIds = submissions?.map(s => s.reviewer_id).filter((id): id is string => !!id) || []; const allUserIds = [...new Set([...userIds, ...reviewerIds])]; let userProfiles: any[] = []; if (allUserIds.length > 0) { const { data: profiles } = await supabase .from('profiles') .select('user_id, username, display_name, avatar_url') .in('user_id', allUserIds); userProfiles = profiles || []; } const userProfileMap = new Map(userProfiles.map(p => [p.user_id, p])); // Collect entity IDs for bulk fetching const rideIds = new Set(); const parkIds = new Set(); const companyIds = new Set(); const rideModelIds = new Set(); submissions?.forEach(submission => { const content = submission.content as any; if (content && typeof content === 'object') { if (content.ride_id) rideIds.add(content.ride_id); if (content.park_id) parkIds.add(content.park_id); if (content.company_id) companyIds.add(content.company_id); if (content.entity_id) { if (submission.submission_type === 'ride') rideIds.add(content.entity_id); if (submission.submission_type === 'park') parkIds.add(content.entity_id); if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type)) { companyIds.add(content.entity_id); } } if (content.manufacturer_id) companyIds.add(content.manufacturer_id); if (content.designer_id) companyIds.add(content.designer_id); if (content.operator_id) companyIds.add(content.operator_id); if (content.property_owner_id) companyIds.add(content.property_owner_id); if (content.ride_model_id) rideModelIds.add(content.ride_model_id); } }); // Fetch entities only if we don't have them cached or if they're new const fetchPromises: Promise<{ type: string; data: any[] }>[] = []; if (rideIds.size > 0) { const uncachedRideIds = Array.from(rideIds).filter(id => !entityCache.rides.has(id)); if (uncachedRideIds.length > 0) { fetchPromises.push( Promise.resolve( supabase .from('rides') .select('id, name, park_id') .in('id', uncachedRideIds) ).then(({ data }) => ({ type: 'rides', data: data || [] })) ); } } if (parkIds.size > 0) { const uncachedParkIds = Array.from(parkIds).filter(id => !entityCache.parks.has(id)); if (uncachedParkIds.length > 0) { fetchPromises.push( Promise.resolve( supabase .from('parks') .select('id, name') .in('id', uncachedParkIds) ).then(({ data }) => ({ type: 'parks', data: data || [] })) ); } } if (companyIds.size > 0) { const uncachedCompanyIds = Array.from(companyIds).filter(id => !entityCache.companies.has(id)); if (uncachedCompanyIds.length > 0) { fetchPromises.push( Promise.resolve( supabase .from('companies') .select('id, name') .in('id', uncachedCompanyIds) ).then(({ data }) => ({ type: 'companies', data: data || [] })) ); } } // Fetch all uncached entities const entityResults = await Promise.all(fetchPromises); // Update entity cache entityResults.forEach(result => { if (result.type === 'rides') { result.data.forEach((ride: any) => { entityCache.rides.set(ride.id, ride); if (ride.park_id) parkIds.add(ride.park_id); }); } else if (result.type === 'parks') { result.data.forEach((park: any) => { entityCache.parks.set(park.id, park); }); } else if (result.type === 'companies') { result.data.forEach((company: any) => { entityCache.companies.set(company.id, company); }); } }); // Helper function to create memo key const createMemoKey = (submission: any): string => { return JSON.stringify({ id: submission.id, status: submission.status, content: submission.content, reviewed_at: submission.reviewed_at, reviewer_notes: submission.reviewer_notes, }); }; // Map submissions to moderation items with memoization const moderationItems: ModerationItem[] = submissions?.map(submission => { const memoKey = createMemoKey(submission); const existingMemo = submissionMemo.get(submission.id); // Check if we can reuse the memoized item if (existingMemo && createMemoKey(existingMemo) === memoKey) { return existingMemo as ModerationItem; } // Resolve entity name const content = submission.content as any; let entityName = content?.name || 'Unknown'; let parkName: string | undefined; if (submission.submission_type === 'ride' && content?.entity_id) { const ride = entityCache.rides.get(content.entity_id); if (ride) { entityName = ride.name; if (ride.park_id) { const park = entityCache.parks.get(ride.park_id); if (park) parkName = park.name; } } } else if (submission.submission_type === 'park' && content?.entity_id) { const park = entityCache.parks.get(content.entity_id); if (park) entityName = park.name; } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type) && content?.entity_id) { const company = entityCache.companies.get(content.entity_id); if (company) entityName = company.name; } else if (content?.ride_id) { const ride = entityCache.rides.get(content.ride_id); if (ride) { entityName = ride.name; if (ride.park_id) { const park = entityCache.parks.get(ride.park_id); if (park) parkName = park.name; } } } else if (content?.park_id) { const park = entityCache.parks.get(content.park_id); if (park) parkName = park.name; } const userProfile = userProfileMap.get(submission.user_id); const reviewerProfile = submission.reviewer_id ? userProfileMap.get(submission.reviewer_id) : undefined; const item: ModerationItem = { id: submission.id, type: 'content_submission', content: submission.content, created_at: submission.created_at, user_id: submission.user_id, status: submission.status, submission_type: submission.submission_type, user_profile: userProfile ? { username: userProfile.username, display_name: userProfile.display_name, avatar_url: userProfile.avatar_url, } : undefined, entity_name: entityName, park_name: parkName, reviewed_at: submission.reviewed_at, reviewed_by: submission.reviewer_id, reviewer_notes: submission.reviewer_notes, reviewer_profile: reviewerProfile, }; return item; }) || []; // Update memoization cache const newMemoMap = new Map(); moderationItems.forEach(item => { newMemoMap.set(item.id, item); }); setSubmissionMemo(newMemoMap); // CRM-style frozen queue logic if (silent) { // Background polling: ONLY detect NEW submissions, never update existing ones const currentLoadedIds = loadedIdsRef.current; const newSubmissions = moderationItems.filter(item => !currentLoadedIds.has(item.id)); if (newSubmissions.length > 0) { console.log('🆕 Detected new submissions:', newSubmissions.length); // Check against existing pendingNewItems to avoid double-counting setPendingNewItems(prev => { const existingIds = new Set(prev.map(p => p.id)); const uniqueNew = newSubmissions.filter(item => !existingIds.has(item.id)); // Track these IDs as loaded to prevent re-counting on next poll if (uniqueNew.length > 0) { const newIds = uniqueNew.map(item => item.id); loadedIdsRef.current = new Set([...currentLoadedIds, ...newIds]); setNewItemsCount(prev => prev + uniqueNew.length); } return [...prev, ...uniqueNew]; }); } // DON'T update items array during background polling - queue stays frozen console.log('✅ Queue frozen - existing submissions unchanged'); } else { // Normal fetch: Load all items and reset pending setItems(moderationItems); setPendingNewItems([]); setNewItemsCount(0); console.log('📋 Queue loaded with', moderationItems.length, 'submissions'); } } catch (error: any) { console.error('Error fetching moderation items:', error); toast({ title: 'Error', description: error.message || 'Failed to fetch moderation queue', variant: 'destructive', }); } finally { fetchInProgressRef.current = false; setLoading(false); setIsRefreshing(false); setIsInitialLoad(false); } }, [ user, refreshStrategy, preserveInteraction, toast ]); // Debounced filters to prevent rapid-fire calls const debouncedEntityFilter = useDebounce(activeEntityFilter, 500); const debouncedStatusFilter = useDebounce(activeStatusFilter, 500); // Store latest filter values in ref to avoid dependency issues const filtersRef = useRef({ entityFilter: debouncedEntityFilter, statusFilter: debouncedStatusFilter }); useEffect(() => { filtersRef.current = { entityFilter: debouncedEntityFilter, statusFilter: debouncedStatusFilter }; }, [debouncedEntityFilter, debouncedStatusFilter]); // Expose refresh method via ref useImperativeHandle(ref, () => ({ refresh: () => { fetchItems(filtersRef.current.entityFilter, filtersRef.current.statusFilter, false); // Manual refresh shows loading } }), [fetchItems]); // Initial fetch on mount and filter changes useEffect(() => { if (user) { fetchItems(debouncedEntityFilter, debouncedStatusFilter, false); // Show loading } // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedEntityFilter, debouncedStatusFilter, user]); // Polling for auto-refresh useEffect(() => { if (!user || refreshMode !== 'auto' || isInitialLoad) return; const interval = setInterval(() => { fetchItems(filtersRef.current.entityFilter, filtersRef.current.statusFilter, true); // Silent refresh }, pollInterval); return () => { clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, refreshMode, pollInterval, isInitialLoad]); // Real-time subscription for lock status useEffect(() => { if (!user) return; const channel = supabase .channel('moderation-locks') .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'content_submissions', }, (payload) => { const newData = payload.new as any; // Track submissions locked by others if (newData.assigned_to && newData.assigned_to !== user.id && newData.locked_until) { const lockExpiry = new Date(newData.locked_until); if (lockExpiry > new Date()) { setLockedSubmissions((prev) => new Set(prev).add(newData.id)); } else { setLockedSubmissions((prev) => { const next = new Set(prev); next.delete(newData.id); return next; }); } } else { // Lock released setLockedSubmissions((prev) => { const next = new Set(prev); next.delete(newData.id); return next; }); } } ) .subscribe(); return () => { supabase.removeChannel(channel); }; }, [user]); const handleResetToPending = async (item: ModerationItem) => { setActionLoading(item.id); try { const { resetRejectedItemsToPending } = await import('@/lib/submissionItemsService'); await resetRejectedItemsToPending(item.id); toast({ title: "Reset Complete", description: "Submission and all items have been reset to pending status", }); fetchItems(activeEntityFilter, activeStatusFilter); } catch (error: any) { console.error('Error resetting submission:', error); toast({ title: "Reset Failed", description: error.message, variant: "destructive", }); } finally { setActionLoading(null); } }; const handleRetryFailedItems = async (item: ModerationItem) => { setActionLoading(item.id); try { // Fetch failed/rejected submission items const { data: failedItems, error: fetchError } = await supabase .from('submission_items') .select('id') .eq('submission_id', item.id) .eq('status', 'rejected'); if (fetchError) throw fetchError; if (!failedItems || failedItems.length === 0) { toast({ title: "No Failed Items", description: "All items have been processed successfully", }); return; } // Call edge function to retry failed items const { data, error } = await supabase.functions.invoke( 'process-selective-approval', { body: { itemIds: failedItems.map(i => i.id), submissionId: item.id } } ); if (error) throw error; toast({ title: "Retry Complete", description: `Processed ${failedItems.length} failed item(s)`, }); fetchItems(activeEntityFilter, activeStatusFilter); } catch (error: any) { console.error('Error retrying failed items:', error); toast({ title: "Retry Failed", description: error.message, variant: "destructive", }); } finally { setActionLoading(null); } }; const handleModerationAction = async ( item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string ) => { // Prevent multiple clicks on the same item if (actionLoading === item.id) { return; } setActionLoading(item.id); // Release lock if this submission is claimed by current user if (queue.currentLock?.submissionId === item.id) { await queue.releaseLock(item.id); } try { // Handle composite ride submissions with sequential entity creation if (action === 'approved' && item.type === 'content_submission' && (item.submission_type === 'ride_with_manufacturer' || item.submission_type === 'ride_with_model' || item.submission_type === 'ride_with_manufacturer_and_model')) { let manufacturerId = item.content.ride?.manufacturer_id; let rideModelId = item.content.ride?.ride_model_id; // STEP 1: Create manufacturer if needed if (item.content.new_manufacturer) { const { data: newManufacturer, error: mfrError } = await supabase .from('companies') .insert({ name: item.content.new_manufacturer.name, slug: item.content.new_manufacturer.slug, description: item.content.new_manufacturer.description, company_type: 'manufacturer', person_type: item.content.new_manufacturer.person_type || 'company', website_url: item.content.new_manufacturer.website_url, founded_year: item.content.new_manufacturer.founded_year, headquarters_location: item.content.new_manufacturer.headquarters_location }) .select() .single(); if (mfrError) { throw new Error(`Failed to create manufacturer: ${mfrError.message}`); } manufacturerId = newManufacturer.id; toast({ title: "Manufacturer Created", description: `Created ${newManufacturer.name}`, }); } // STEP 2: Create ride model if needed if (item.content.new_ride_model) { const modelManufacturerId = manufacturerId || item.content.new_ride_model.manufacturer_id; if (!modelManufacturerId) { throw new Error('Cannot create ride model: No manufacturer ID available'); } const { data: newModel, error: modelError } = await supabase .from('ride_models') .insert({ name: item.content.new_ride_model.name, slug: item.content.new_ride_model.slug, manufacturer_id: modelManufacturerId, category: item.content.new_ride_model.category, ride_type: item.content.new_ride_model.ride_type, description: item.content.new_ride_model.description }) .select() .single(); if (modelError) { throw new Error(`Failed to create ride model: ${modelError.message}`); } rideModelId = newModel.id; toast({ title: "Ride Model Created", description: `Created ${newModel.name}`, }); } // STEP 3: Create the ride const { error: rideError } = await supabase .from('rides') .insert({ ...item.content.ride, manufacturer_id: manufacturerId, ride_model_id: rideModelId, park_id: item.content.park_id }); if (rideError) { throw new Error(`Failed to create ride: ${rideError.message}`); } // STEP 4: Update submission status const { error: updateError } = await supabase .from('content_submissions') .update({ status: 'approved', reviewer_id: user?.id, reviewed_at: new Date().toISOString(), reviewer_notes: moderatorNotes }) .eq('id', item.id); if (updateError) throw updateError; toast({ title: "Submission Approved", description: "All entities created successfully", }); // Refresh the queue fetchItems(activeEntityFilter, activeStatusFilter); return; } // Handle photo submissions - create photos records when approved if (action === 'approved' && item.type === 'content_submission' && item.submission_type === 'photo') { try { // Fetch photo submission from new relational tables const { data: photoSubmission, error: fetchError } = await supabase .from('photo_submissions') .select(` *, items:photo_submission_items(*), submission:content_submissions!inner(user_id, status) `) .eq('submission_id', item.id) .single(); if (fetchError || !photoSubmission) { console.error('Failed to fetch photo submission:', fetchError); throw new Error('Failed to fetch photo submission data'); } if (!photoSubmission.items || photoSubmission.items.length === 0) { console.error('No photo items found in submission'); throw new Error('No photos found in submission'); } // Check if photos already exist for this submission (in case of re-approval) const { data: existingPhotos } = await supabase .from('photos') .select('id') .eq('submission_id', item.id); if (existingPhotos && existingPhotos.length > 0) { // Just update submission status const { error: updateError } = await supabase .from('content_submissions') .update({ status: 'approved', reviewer_id: user?.id, reviewed_at: new Date().toISOString(), reviewer_notes: moderatorNotes }) .eq('id', item.id); } else { // Create new photo records from photo_submission_items const photoRecords = photoSubmission.items.map((item) => ({ entity_id: photoSubmission.entity_id, entity_type: photoSubmission.entity_type, cloudflare_image_id: item.cloudflare_image_id, cloudflare_image_url: item.cloudflare_image_url, title: item.title || null, caption: item.caption || null, date_taken: item.date_taken || null, order_index: item.order_index, submission_id: photoSubmission.submission_id, submitted_by: photoSubmission.submission?.user_id, approved_by: user?.id, approved_at: new Date().toISOString(), })); const { data: createdPhotos, error: insertError } = await supabase .from('photos') .insert(photoRecords) .select(); if (insertError) { console.error('Failed to insert photos:', insertError); throw insertError; } } // Update submission status const { error: updateError } = await supabase .from('content_submissions') .update({ status: 'approved', reviewer_id: user?.id, reviewed_at: new Date().toISOString(), reviewer_notes: moderatorNotes }) .eq('id', item.id); if (updateError) { console.error('Error updating submission:', updateError); throw updateError; } toast({ title: "Photos Approved", description: `Successfully approved and published ${photoSubmission.items.length} photo(s)`, }); // Refresh the queue fetchItems(activeEntityFilter, activeStatusFilter); return; } catch (error: any) { console.error('Photo approval error:', error); throw error; } } // Check if this submission has submission_items that need processing if (item.type === 'content_submission') { const { data: submissionItems, error: itemsError } = await supabase .from('submission_items') .select('id, status') .eq('submission_id', item.id) .in('status', ['pending', 'rejected']); if (!itemsError && submissionItems && submissionItems.length > 0) { if (action === 'approved') { // Call the edge function to process all items const { data: approvalData, error: approvalError } = await supabase.functions.invoke( 'process-selective-approval', { body: { itemIds: submissionItems.map(i => i.id), submissionId: item.id } } ); if (approvalError) { throw new Error(`Failed to process submission items: ${approvalError.message}`); } toast({ title: "Submission Approved", description: `Successfully processed ${submissionItems.length} item(s)`, }); // Refresh and return early since edge function already updated parent fetchItems(activeEntityFilter, activeStatusFilter); return; } else if (action === 'rejected') { // Cascade rejection to all pending items const { error: rejectError } = await supabase .from('submission_items') .update({ status: 'rejected', rejection_reason: moderatorNotes || 'Parent submission rejected', updated_at: new Date().toISOString() }) .eq('submission_id', item.id) .eq('status', 'pending'); if (rejectError) { console.error('Failed to cascade rejection:', rejectError); // Don't fail the whole operation, just log it } } } } // Standard moderation flow for other items 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 if (user) { updateData[reviewerField] = user.id; } if (moderatorNotes) { updateData.reviewer_notes = moderatorNotes; } const { error, data } = await supabase .from(table) .update(updateData) .eq('id', item.id) .select(); if (error) { console.error('Database update error:', error); throw error; } // 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.'); } 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; }); // Refresh if needed based on filter if ((activeStatusFilter === 'pending' && (action === 'approved' || action === 'rejected')) || (activeStatusFilter === 'flagged' && (action === 'approved' || action === 'rejected'))) { // Item no longer matches filter } } 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; // Prevent duplicate calls if (actionLoading === item.id) { return; } setActionLoading(item.id); // Remove item from UI immediately to prevent flickering setItems(prev => prev.filter(i => i.id !== item.id)); try { // Step 1: Extract photo IDs from the submission content const photoIds: string[] = []; const validImageIds: string[] = []; const skippedPhotos: string[] = []; // Try both nested paths for photos array (handle different content structures) const photosArray = item.content?.content?.photos || item.content?.photos; if (photosArray && Array.isArray(photosArray)) { for (const photo of photosArray) { let imageId = ''; // First try to use the stored imageId directly if (photo.imageId) { imageId = photo.imageId; } else 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); continue; } // Fallback: Try to extract from URL for backward compatibility const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; if (uuidRegex.test(photo.url)) { imageId = photo.url; } else { // Extract from Cloudflare image delivery URL format const cloudflareMatch = photo.url.match(/imagedelivery\.net\/[^\/]+\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i); if (cloudflareMatch) { imageId = cloudflareMatch[1]; } } } if (imageId) { photoIds.push(imageId); validImageIds.push(imageId); } else { console.warn('Could not get valid image ID from photo:', photo); skippedPhotos.push(photo.url || 'unknown'); } } } // Step 2: Delete photos from Cloudflare Images (if any valid IDs) if (validImageIds.length > 0) { const deletePromises = validImageIds.map(async (imageId) => { try { // Use Supabase SDK - automatically includes session token const { data, error } = await supabase.functions.invoke('upload-image', { method: 'DELETE', body: { imageId } }); if (error) { throw new Error(`Failed to delete image: ${error.message}`); } } 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 const { error } = await supabase .from('content_submissions') .delete() .eq('id', item.id); if (error) { console.error('Database deletion error:', error); throw error; } // Verify the deletion actually worked const { data: checkData, error: checkError } = await supabase .from('content_submissions') .select('id') .eq('id', item.id) .single(); if (checkData && !checkError) { console.error('DELETION FAILED: Item still exists in database after delete operation'); throw new Error('Deletion failed - item still exists in 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 // Item was already removed at the start for immediate UI feedback } catch (error) { console.error('Error deleting submission:', error); // Restore item to list on error since we removed it optimistically setItems(prev => { // Avoid duplicates if (prev.some(i => i.id === item.id)) return prev; return [...prev, item]; }); toast({ title: "Error", description: "Failed to delete submission", variant: "destructive", }); } finally { setActionLoading(null); } }; const getStatusBadgeVariant = (status: string) => { switch (status) { case 'pending': return 'secondary'; case 'partially_approved': return 'secondary'; case 'flagged': return 'destructive'; case 'approved': return 'default'; case 'rejected': return 'destructive'; 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 'partially_approved': return `No partially approved ${entityLabel} found.`; 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 (
); } if (items.length === 0) { return (

No items found

{getEmptyStateMessage(activeEntityFilter, activeStatusFilter)}

); } return (
{items.map((item) => (
{item.type === 'review' ? ( <> Review ) : item.submission_type === 'photo' ? ( <> Photo ) : ( <> Submission )} {item.status === 'partially_approved' ? 'Partially Approved' : item.status.charAt(0).toUpperCase() + item.status.slice(1)} {item.status === 'partially_approved' && ( Needs Retry )} {lockedSubmissions.has(item.id) && item.type === 'content_submission' && ( Locked by Another Moderator )} {queue.currentLock?.submissionId === item.id && item.type === 'content_submission' && ( Claimed by You )}
{format(new Date(item.created_at), isMobile ? 'MMM d, yyyy' : 'MMM d, yyyy HH:mm')}
{item.user_profile && (
{(item.user_profile.display_name || item.user_profile.username)?.slice(0, 2).toUpperCase()}
{item.user_profile.display_name || item.user_profile.username} {item.user_profile.display_name && ( @{item.user_profile.username} )}
)}
{item.type === 'review' ? (
{item.content.title && (

{item.content.title}

)} {item.content.content && (

{item.content.content}

)}
Rating: {item.content.rating}/5
{/* Entity Names for Reviews */} {(item.entity_name || item.park_name) && (
{item.entity_name && (
{item.park_name ? 'Ride:' : 'Park:'} {item.entity_name}
)} {item.park_name && (
Park: {item.park_name}
)}
)} {item.content.photos && item.content.photos.length > 0 && (
Attached Photos:
{item.content.photos.map((photo: any, index: number) => (
{ setSelectedPhotos(item.content.photos.map((p: any, i: number) => ({ id: `${item.id}-${i}`, url: p.url, filename: `Review photo ${i + 1}`, caption: p.caption }))); setSelectedPhotoIndex(index); setPhotoModalOpen(true); }}> {`Review { console.error('Failed to load review photo:', photo.url); (e.target as HTMLImageElement).style.display = 'none'; }} />
))}
)}
) : item.submission_type === 'photo' ? (
Photo Submission
{/* Submission Title */} {item.content.title && (
Title:

{item.content.title}

)} {/* Submission Caption */} {item.content.content?.caption && (
Caption:

{item.content.content.caption}

)} {/* Photos */} {item.content.content?.photos && item.content.content.photos.length > 0 ? (
Photos ({item.content.content.photos.length}):
{item.content.content.photos.map((photo: any, index: number) => (
{ setSelectedPhotos(item.content.content.photos.map((p: any, i: number) => ({ id: `${item.id}-${i}`, url: p.url, filename: p.filename, caption: p.caption }))); setSelectedPhotoIndex(index); setPhotoModalOpen(true); }}> {`Photo { console.error('Failed to load photo submission:', photo); const target = e.target as HTMLImageElement; target.style.display = 'none'; const parent = target.parentElement; if (parent) { // Create elements safely using DOM API to prevent XSS const errorContainer = document.createElement('div'); errorContainer.className = 'absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs'; const errorIcon = document.createElement('div'); errorIcon.textContent = '⚠️ Image failed to load'; const urlDisplay = document.createElement('div'); urlDisplay.className = 'mt-1 font-mono text-xs break-all px-2'; // Use textContent to prevent XSS - it escapes HTML automatically urlDisplay.textContent = photo.url; errorContainer.appendChild(errorIcon); errorContainer.appendChild(urlDisplay); parent.appendChild(errorContainer); } }} />
URL: {photo.url}
Filename: {photo.filename || 'Unknown'}
Size: {photo.size ? `${Math.round(photo.size / 1024)} KB` : 'Unknown'}
Type: {photo.type || 'Unknown'}
{photo.caption && (
Caption:
{photo.caption}
)}
))}
) : (
No photos found in submission
)} {/* Context Information */} {item.content.content?.context && (
Context: {typeof item.content.content.context === 'object' ? (item.content.content.context.ride_id ? 'ride' : item.content.content.context.park_id ? 'park' : 'unknown') : item.content.content.context}
{item.entity_name && (
{(typeof item.content.content.context === 'object' ? (item.content.content.context.ride_id ? 'ride' : 'park') : item.content.content.context) === 'ride' ? 'Ride:' : 'Park:'} {item.entity_name}
)} {item.park_name && (typeof item.content.content.context === 'object' ? !!item.content.content.context.ride_id : item.content.content.context === 'ride') && (
Park: {item.park_name}
)}
)}
) : (
{/* Composite Submissions (Ride with Manufacturer/Model) */} {(item.submission_type === 'ride_with_manufacturer' || item.submission_type === 'ride_with_model' || item.submission_type === 'ride_with_manufacturer_and_model') ? (
{/* New Manufacturer Card */} {item.content.new_manufacturer && (
New Manufacturer {item.content.new_manufacturer.name}
{item.content.new_manufacturer.description && (
Description: {item.content.new_manufacturer.description}
)}
{item.content.new_manufacturer.person_type && (
Type: {item.content.new_manufacturer.person_type}
)} {item.content.new_manufacturer.founded_year && (
Founded: {item.content.new_manufacturer.founded_year}
)} {item.content.new_manufacturer.headquarters_location && (
HQ: {item.content.new_manufacturer.headquarters_location}
)} {item.content.new_manufacturer.website_url && ( )}
)} {/* New Ride Model Card */} {item.content.new_ride_model && (
New Ride Model {item.content.new_ride_model.name}
Manufacturer: {item.content.new_manufacturer ? item.content.new_manufacturer.name : 'Existing manufacturer'}
Category: {item.content.new_ride_model.category?.replace('_', ' ')}
Type: {item.content.new_ride_model.ride_type}
{item.content.new_ride_model.description && (
Description: {item.content.new_ride_model.description}
)}
)} {/* Ride Details Card */}
Ride {item.content.ride?.name}
{item.content.ride?.description && (

{item.content.ride.description}

)}
{item.content.ride?.category && (
Category: {item.content.ride.category.replace('_', ' ')}
)} {item.content.ride?.status && (
Status: {item.content.ride.status}
)} {item.content.ride?.max_speed_kmh && (
Max Speed:
)} {item.content.ride?.max_height_meters && (
Max Height:
)}
) : (item.submission_type === 'manufacturer' || item.submission_type === 'designer' || item.submission_type === 'operator' || item.submission_type === 'property_owner' || item.submission_type === 'park' || item.submission_type === 'ride' || item.submission_type === 'ride_model' || item.submission_type === 'photo_delete' || item.submission_type === 'photo_edit') ? ( ) : (
Unknown Submission Type
Type: {item.submission_type}
{item.content?.action && (
Action: {item.content.action}
)}
View raw data (for developers)
                            {JSON.stringify(item.content, null, 2)}
                          
)}
)}
{/* Action buttons based on status */} {(item.status === 'pending' || item.status === 'flagged') && ( <> {/* Claim button for unclaimed submissions */} {!lockedSubmissions.has(item.id) && queue.currentLock?.submissionId !== item.id && (
Unclaimed Submission
Claim this submission to lock it for 15 minutes while you review
)}