import { useState, useCallback, useRef, useEffect } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; import type { User } from '@supabase/supabase-js'; import { useEntityCache, useProfileCache, useModerationFilters, useModerationSort, usePagination, useRealtimeSubscriptions } from './index'; import { useModerationQueue } from '@/hooks/useModerationQueue'; import { smartMergeArray } from '@/lib/smartStateUpdate'; import type { ModerationItem, EntityFilter, StatusFilter, LoadingState, SortConfig } from '@/types/moderation'; /** * Configuration for useModerationQueueManager */ export interface ModerationQueueManagerConfig { user: User | null; isAdmin: boolean; isSuperuser: boolean; toast: ReturnType['toast']; settings: { refreshMode: 'auto' | 'manual'; pollInterval: number; refreshStrategy: 'notify' | 'merge' | 'replace'; preserveInteraction: boolean; useRealtimeQueue: boolean; refreshOnTabVisible: boolean; }; } /** * Return type for useModerationQueueManager */ export interface ModerationQueueManager { // State items: ModerationItem[]; loadingState: LoadingState; actionLoading: string | null; // Sub-hooks (exposed for granular control) filters: ReturnType; pagination: ReturnType; sort: ReturnType; queue: ReturnType; // Realtime newItemsCount: number; pendingNewItems: ModerationItem[]; showNewItems: () => void; // Interaction tracking interactingWith: Set; markInteracting: (id: string, interacting: boolean) => void; // Actions refresh: () => void; performAction: ( item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string ) => Promise; deleteSubmission: (item: ModerationItem) => Promise; resetToPending: (item: ModerationItem) => Promise; retryFailedItems: (item: ModerationItem) => Promise; // Caches (for QueueItem enrichment) entityCache: ReturnType; profileCache: ReturnType; } /** * Orchestrator hook for moderation queue management * Consolidates all queue-related logic into a single hook */ export function useModerationQueueManager( config: ModerationQueueManagerConfig ): ModerationQueueManager { const { user, isAdmin, isSuperuser, toast, settings } = config; // Initialize sub-hooks const filters = useModerationFilters({ initialEntityFilter: 'all', initialStatusFilter: 'pending', initialTab: 'mainQueue', debounceDelay: 300, persist: true, storageKey: 'moderationQueue_filters' }); const pagination = usePagination({ initialPage: 1, initialPageSize: 25, persist: false, onPageChange: (page) => { if (page > 1) { setLoadingState('loading'); } }, onPageSizeChange: () => { setLoadingState('loading'); } }); const sort = useModerationSort({ initialConfig: { field: 'created_at', direction: 'asc' }, persist: true, storageKey: 'moderationQueue_sortConfig' }); const queue = useModerationQueue(); const entityCache = useEntityCache(); const profileCache = useProfileCache(); // Core state const [items, setItems] = useState([]); const [loadingState, setLoadingState] = useState('initial'); const [actionLoading, setActionLoading] = useState(null); const [interactingWith, setInteractingWith] = useState>(new Set()); const [pendingNewItems, setPendingNewItems] = useState([]); const [newItemsCount, setNewItemsCount] = useState(0); // Refs for tracking const recentlyRemovedRef = useRef>(new Set()); const fetchInProgressRef = useRef(false); const itemsRef = useRef([]); const lastFetchTimeRef = useRef(0); const pauseFetchingRef = useRef(false); const initialFetchCompleteRef = useRef(false); const isMountingRef = useRef(true); const fetchItemsRef = useRef<((silent?: boolean) => Promise) | null>(null); const FETCH_COOLDOWN_MS = 1000; // Store settings in refs to avoid re-creating fetchItems const settingsRef = useRef(settings); useEffect(() => { settingsRef.current = settings; }, [settings]); // Sync items with ref useEffect(() => { itemsRef.current = items; }, [items]); /** * Fetch queue items from database */ const fetchItems = useCallback(async (silent = false) => { if (!user) return; // Check if tab is hidden if (pauseFetchingRef.current || document.hidden) { console.log('⏸️ Fetch paused (tab hidden)'); return; } // Prevent concurrent calls if (fetchInProgressRef.current) { console.log('⚠️ Fetch already in progress, skipping'); return; } // Cooldown check const now = Date.now(); const timeSinceLastFetch = now - lastFetchTimeRef.current; if (timeSinceLastFetch < FETCH_COOLDOWN_MS && lastFetchTimeRef.current > 0) { console.log(`⏸️ Fetch cooldown active (${timeSinceLastFetch}ms)`); return; } fetchInProgressRef.current = true; lastFetchTimeRef.current = now; console.log('🔍 fetchItems called:', { entityFilter: filters.debouncedEntityFilter, statusFilter: filters.debouncedStatusFilter, silent, timestamp: new Date().toISOString() }); try { // Set loading states if (!silent) { setLoadingState('loading'); } else { setLoadingState('refreshing'); } // Build base query let submissionsQuery = supabase .from('content_submissions') .select(` id, submission_type, status, content, created_at, user_id, reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until, submission_items ( id, item_type, item_data, status ) `) .order('escalated', { ascending: false }) .order('created_at', { ascending: true }); // Apply tab-based status filtering const tab = filters.activeTab; const statusFilter = filters.debouncedStatusFilter; const entityFilter = filters.debouncedEntityFilter; if (tab === 'mainQueue') { 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 { 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'); } // Apply access control if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); submissionsQuery = submissionsQuery.or( `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}` ); } // Get total count const { count } = await supabase .from('content_submissions') .select('*', { count: 'exact', head: true }) .match(submissionsQuery as any); pagination.setTotalCount(count || 0); // Apply pagination const startIndex = pagination.startIndex; const endIndex = pagination.endIndex; submissionsQuery = submissionsQuery.range(startIndex, endIndex); const { data: submissions, error: submissionsError } = await submissionsQuery; if (submissionsError) throw submissionsError; // Fetch related profiles and entities const userIds = [...new Set([ ...(submissions?.map(s => s.user_id) || []), ...(submissions?.map(s => s.reviewer_id).filter(Boolean) || []) ])]; if (userIds.length > 0) { await profileCache.bulkFetch(userIds); } // Collect entity IDs if (submissions) { await entityCache.fetchRelatedEntities(submissions); } // Map to ModerationItems const moderationItems: ModerationItem[] = submissions?.map(submission => { const content = submission.content as any; let entityName = content?.name || 'Unknown'; let parkName: string | undefined; // Resolve entity names from cache if (submission.submission_type === 'ride' && content?.entity_id) { const ride = entityCache.getCached('rides', content.entity_id); if (ride) { entityName = ride.name; if (ride.park_id) { const park = entityCache.getCached('parks', ride.park_id); if (park) parkName = park.name; } } } else if (submission.submission_type === 'park' && content?.entity_id) { const park = entityCache.getCached('parks', 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.getCached('companies', content.entity_id); if (company) entityName = company.name; } const userProfile = profileCache.getCached(submission.user_id); const reviewerProfile = submission.reviewer_id ? profileCache.getCached(submission.reviewer_id) : undefined; return { 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, entity_name: entityName, park_name: parkName, reviewed_at: submission.reviewed_at || undefined, reviewed_by: submission.reviewer_id || undefined, reviewer_notes: submission.reviewer_notes || undefined, reviewer_profile: reviewerProfile, escalated: submission.escalated, assigned_to: submission.assigned_to || undefined, locked_until: submission.locked_until || undefined, submission_items: submission.submission_items || undefined, }; }) || []; // Apply refresh strategy const currentRefreshStrategy = settingsRef.current.refreshStrategy; const currentPreserveInteraction = settingsRef.current.preserveInteraction; if (silent) { // Background polling: detect new submissions const currentDisplayedIds = new Set(itemsRef.current.map(item => item.id)); const newSubmissions = moderationItems.filter(item => !currentDisplayedIds.has(item.id)); if (newSubmissions.length > 0) { console.log('🆕 Detected new submissions:', newSubmissions.length); setPendingNewItems(prev => { const existingIds = new Set(prev.map(p => p.id)); const uniqueNew = newSubmissions.filter(item => !existingIds.has(item.id)); if (uniqueNew.length > 0) { setNewItemsCount(prev => prev + uniqueNew.length); } return [...prev, ...uniqueNew]; }); } // Apply refresh strategy switch (currentRefreshStrategy) { case 'notify': console.log('✅ Queue frozen (notify mode)'); break; case 'merge': if (newSubmissions.length > 0) { const currentIds = new Set(itemsRef.current.map(item => item.id)); const trulyNewSubmissions = newSubmissions.filter(item => !currentIds.has(item.id)); if (trulyNewSubmissions.length > 0) { setItems(prev => [...prev, ...trulyNewSubmissions]); console.log('🔀 Queue merged - added', trulyNewSubmissions.length, 'items'); } } break; case 'replace': const mergeResult = smartMergeArray(itemsRef.current, moderationItems, { compareFields: ['status', 'content', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'assigned_to', 'locked_until'], preserveOrder: false, addToTop: false, }); if (mergeResult.hasChanges) { setItems(mergeResult.items); console.log('🔄 Queue updated (replace mode)'); } if (!currentPreserveInteraction) { setPendingNewItems([]); setNewItemsCount(0); } break; } } else { // Manual refresh const mergeResult = smartMergeArray(itemsRef.current, moderationItems, { compareFields: ['status', 'content', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'assigned_to', 'locked_until'], preserveOrder: false, addToTop: false, }); if (mergeResult.hasChanges) { setItems(mergeResult.items); console.log('🔄 Queue updated (manual refresh)'); } setPendingNewItems([]); setNewItemsCount(0); } } 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; setLoadingState('ready'); } }, [user, isAdmin, isSuperuser, filters, pagination, profileCache, entityCache, toast]); // Store fetchItems in ref to avoid re-creating visibility listener useEffect(() => { fetchItemsRef.current = fetchItems; }, [fetchItems]); /** * Show pending new items by merging them into the queue */ const showNewItems = useCallback(() => { if (pendingNewItems.length > 0) { setItems(prev => [...pendingNewItems, ...prev]); setPendingNewItems([]); setNewItemsCount(0); console.log('✅ New items merged into queue:', pendingNewItems.length); } }, [pendingNewItems]); /** * Mark an item as being interacted with (prevents realtime updates) */ const markInteracting = useCallback((id: string, interacting: boolean) => { setInteractingWith(prev => { const next = new Set(prev); if (interacting) { next.add(id); } else { next.delete(id); } return next; }); }, []); /** * Perform moderation action (approve/reject) */ const performAction = useCallback(async ( item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string ) => { if (actionLoading === item.id) return; setActionLoading(item.id); // Optimistic update const shouldRemove = (filters.statusFilter === 'pending' || filters.statusFilter === 'flagged') && (action === 'approved' || action === 'rejected'); if (shouldRemove) { setItems(prev => prev.map(i => i.id === item.id ? { ...i, _removing: true } : i )); setTimeout(() => { setItems(prev => prev.filter(i => i.id !== item.id)); recentlyRemovedRef.current.add(item.id); setTimeout(() => recentlyRemovedRef.current.delete(item.id), 10000); }, 300); } // Release lock if claimed if (queue.currentLock?.submissionId === item.id) { await queue.releaseLock(item.id); } try { // Handle photo submissions if (action === 'approved' && item.submission_type === 'photo') { const { data: photoSubmission } = await supabase .from('photo_submissions') .select(`*, items:photo_submission_items(*), submission:content_submissions!inner(user_id)`) .eq('submission_id', item.id) .single(); if (photoSubmission && photoSubmission.items) { const { data: existingPhotos } = await supabase .from('photos') .select('id') .eq('submission_id', item.id); if (!existingPhotos || existingPhotos.length === 0) { const photoRecords = photoSubmission.items.map((photoItem: any) => ({ entity_id: photoSubmission.entity_id, entity_type: photoSubmission.entity_type, cloudflare_image_id: photoItem.cloudflare_image_id, cloudflare_image_url: photoItem.cloudflare_image_url, title: photoItem.title || null, caption: photoItem.caption || null, date_taken: photoItem.date_taken || null, order_index: photoItem.order_index, submission_id: photoSubmission.submission_id, submitted_by: photoSubmission.submission?.user_id, approved_by: user?.id, approved_at: new Date().toISOString(), })); await supabase.from('photos').insert(photoRecords); } } } // Check for submission items const { data: submissionItems } = await supabase .from('submission_items') .select('id, status') .eq('submission_id', item.id) .in('status', ['pending', 'rejected']); if (submissionItems && submissionItems.length > 0) { if (action === 'approved') { await supabase.functions.invoke('process-selective-approval', { body: { itemIds: submissionItems.map(i => i.id), submissionId: item.id } }); toast({ title: "Submission Approved", description: `Successfully processed ${submissionItems.length} item(s)`, }); return; } else if (action === 'rejected') { 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'); } } // Standard update const table = item.type === 'review' ? 'reviews' : 'content_submissions'; const statusField = item.type === 'review' ? 'moderation_status' : 'status'; 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(), }; if (user) { updateData[reviewerField] = user.id; } if (moderatorNotes) { updateData.reviewer_notes = moderatorNotes; } const { error } = await supabase .from(table) .update(updateData) .eq('id', item.id); if (error) throw error; toast({ title: `Content ${action}`, description: `The ${item.type} has been ${action}`, }); } catch (error: any) { console.error('Error moderating content:', error); // Revert optimistic update setItems(prev => { const exists = prev.find(i => i.id === item.id); if (exists) { return prev.map(i => i.id === item.id ? item : i); } else { return [...prev, item]; } }); toast({ title: "Error", description: error.message || `Failed to ${action} content`, variant: "destructive", }); } finally { setActionLoading(null); } }, [actionLoading, filters.statusFilter, queue, user, toast]); /** * Delete a submission permanently */ const deleteSubmission = useCallback(async (item: ModerationItem) => { if (item.type !== 'content_submission') return; if (actionLoading === item.id) return; setActionLoading(item.id); setItems(prev => prev.filter(i => i.id !== item.id)); try { const { error } = await supabase .from('content_submissions') .delete() .eq('id', item.id); if (error) throw error; toast({ title: "Submission deleted", description: "The submission has been permanently deleted", }); } catch (error: any) { console.error('Error deleting submission:', error); setItems(prev => { 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); } }, [actionLoading, toast]); /** * Reset submission to pending status */ const resetToPending = useCallback(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", }); setItems(prev => prev.filter(i => i.id !== item.id)); } catch (error: any) { console.error('Error resetting submission:', error); toast({ title: "Reset Failed", description: error.message, variant: "destructive", }); } finally { setActionLoading(null); } }, [toast]); /** * Retry failed items in a submission */ const retryFailedItems = useCallback(async (item: ModerationItem) => { setActionLoading(item.id); const shouldRemove = ( filters.statusFilter === 'pending' || filters.statusFilter === 'flagged' || filters.statusFilter === 'partially_approved' ); if (shouldRemove) { requestAnimationFrame(() => { setItems(prev => prev.filter(i => i.id !== item.id)); recentlyRemovedRef.current.add(item.id); setTimeout(() => recentlyRemovedRef.current.delete(item.id), 10000); }); } try { const { data: failedItems } = await supabase .from('submission_items') .select('id') .eq('submission_id', item.id) .eq('status', 'rejected'); if (!failedItems || failedItems.length === 0) { toast({ title: "No Failed Items", description: "All items have been processed successfully", }); return; } await supabase.functions.invoke('process-selective-approval', { body: { itemIds: failedItems.map(i => i.id), submissionId: item.id } }); toast({ title: "Retry Complete", description: `Processed ${failedItems.length} failed item(s)`, }); } catch (error: any) { console.error('Error retrying failed items:', error); toast({ title: "Retry Failed", description: error.message, variant: "destructive", }); } finally { setActionLoading(null); } }, [filters.statusFilter, toast]); // Initial fetch on mount useEffect(() => { if (!user) return; isMountingRef.current = true; fetchItems(false).then(() => { initialFetchCompleteRef.current = true; requestAnimationFrame(() => { isMountingRef.current = false; }); }); }, [user]); // Filter changes trigger refetch useEffect(() => { if (!user || !initialFetchCompleteRef.current || isMountingRef.current) return; pagination.reset(); fetchItems(true); }, [filters.debouncedEntityFilter, filters.debouncedStatusFilter]); // Pagination changes trigger refetch useEffect(() => { if (!user || !initialFetchCompleteRef.current || pagination.currentPage === 1) return; fetchItems(true); }, [pagination.currentPage, pagination.pageSize]); // Polling effect (when realtime disabled) useEffect(() => { if (!user || settings.refreshMode !== 'auto' || loadingState === 'initial' || settings.useRealtimeQueue) { return; } console.log('⚠️ Polling ENABLED - interval:', settings.pollInterval); const interval = setInterval(() => { console.log('🔄 Polling refresh triggered'); fetchItems(true); }, settings.pollInterval); return () => { clearInterval(interval); console.log('🛑 Polling stopped'); }; }, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, fetchItems]); // Visibility change handler useEffect(() => { // Early return if feature is disabled if (!settings.refreshOnTabVisible) { console.log('⚙️ refreshOnTabVisible is DISABLED - no listener attached'); return; } const handleVisibilityChange = () => { if (document.hidden) { console.log('📴 Tab hidden - pausing queue updates'); pauseFetchingRef.current = true; } else { console.log('📱 Tab visible - resuming queue updates'); pauseFetchingRef.current = false; if (initialFetchCompleteRef.current && !isMountingRef.current && fetchItemsRef.current) { console.log('🔄 Tab became visible - triggering refresh (setting enabled)'); fetchItemsRef.current(true); } else { console.log('⏭️ Tab became visible - skipping refresh (initial fetch not complete or mounting)'); } } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); console.log('🧹 Visibility listener removed'); }; }, [settings.refreshOnTabVisible]); // Initialize realtime subscriptions useRealtimeSubscriptions({ enabled: settings.useRealtimeQueue && !!user, filters: { entityFilter: filters.debouncedEntityFilter, statusFilter: filters.debouncedStatusFilter, }, onNewItem: (item: ModerationItem) => { if (recentlyRemovedRef.current.has(item.id)) return; setPendingNewItems(prev => { if (prev.some(p => p.id === item.id)) return prev; return [...prev, item]; }); setNewItemsCount(prev => prev + 1); toast({ title: '🆕 New Submission', description: `${item.submission_type} - ${item.entity_name}`, }); }, onUpdateItem: (item: ModerationItem, shouldRemove: boolean) => { if (recentlyRemovedRef.current.has(item.id)) return; if (interactingWith.has(item.id)) return; if (shouldRemove) { setItems(prev => prev.filter(i => i.id !== item.id)); } else { setItems(prev => { const exists = prev.some(i => i.id === item.id); if (exists) { return prev.map(i => i.id === item.id ? item : i); } else { return [item, ...prev]; } }); } }, onItemRemoved: (itemId: string) => { setItems(prev => prev.filter(i => i.id !== itemId)); }, entityCache, profileCache, recentlyRemovedIds: recentlyRemovedRef.current, interactingWithIds: interactingWith, currentItems: items, }); return { items, loadingState, actionLoading, filters, pagination, sort, queue, newItemsCount, pendingNewItems, showNewItems, interactingWith, markInteracting, refresh: async () => { await fetchItems(false); }, performAction, deleteSubmission, resetToPending, retryFailedItems, entityCache, profileCache, }; }