diff --git a/src/hooks/moderation/index.ts b/src/hooks/moderation/index.ts index a2e86c61..8c1604a4 100644 --- a/src/hooks/moderation/index.ts +++ b/src/hooks/moderation/index.ts @@ -23,3 +23,9 @@ export type { RealtimeSubscriptionConfig, UseRealtimeSubscriptionsReturn } from './useRealtimeSubscriptions'; + +export { useModerationQueueManager } from './useModerationQueueManager'; +export type { + ModerationQueueManager, + ModerationQueueManagerConfig +} from './useModerationQueueManager'; diff --git a/src/hooks/moderation/useModerationQueueManager.ts b/src/hooks/moderation/useModerationQueueManager.ts new file mode 100644 index 00000000..8147e8f0 --- /dev/null +++ b/src/hooks/moderation/useModerationQueueManager.ts @@ -0,0 +1,879 @@ +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 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]); + + /** + * 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(() => { + 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 (settings.refreshOnTabVisible && initialFetchCompleteRef.current && !isMountingRef.current) { + console.log('🔄 Tab became visible - triggering refresh'); + fetchItems(true); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [settings.refreshOnTabVisible, fetchItems]); + + // 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: () => fetchItems(false), + performAction, + deleteSubmission, + resetToPending, + retryFailedItems, + entityCache, + profileCache, + }; +}