import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { supabase } from "@/integrations/supabase/client"; import { useToast } from "@/hooks/use-toast"; import { logger } from "@/lib/logger"; import { getErrorMessage } from "@/lib/errorHandler"; import { MODERATION_CONSTANTS } from "@/lib/moderation/constants"; import type { User } from "@supabase/supabase-js"; import { useEntityCache, useProfileCache, useModerationFilters, usePagination, useRealtimeSubscriptions, useQueueQuery, } from "./index"; import { useModerationQueue } from "@/hooks/useModerationQueue"; import type { ModerationItem, EntityFilter, StatusFilter, LoadingState } 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; }; } /** * 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; 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 { logger.log('🚀 [QUEUE MANAGER] Hook mounting/rendering', { hasUser: !!config.user, isAdmin: config.isAdmin, timestamp: new Date().toISOString() }); 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", }); // Memoize filters object for realtime subscriptions to prevent reconnections const realtimeFilters = useMemo(() => ({ entityFilter: filters.debouncedEntityFilter, statusFilter: filters.debouncedStatusFilter, }), [filters.debouncedEntityFilter, filters.debouncedStatusFilter]); const pagination = usePagination({ initialPage: 1, initialPageSize: 25, persist: false, onPageChange: (page) => { if (page > 1) { setLoadingState("loading"); } }, onPageSizeChange: () => { setLoadingState("loading"); }, }); const queue = useModerationQueue({ onLockStateChange: () => { logger.log('🔄 Lock state changed, invalidating queue cache'); queueQuery.invalidate(); // Force immediate re-render by triggering a loading cycle setLoadingState(prev => prev === "loading" ? "ready" : prev); } }); 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 initialFetchCompleteRef = useRef(false); const isMountingRef = useRef(true); // Store settings, filters, and pagination in refs to stabilize fetchItems const settingsRef = useRef(settings); const filtersRef = useRef(filters); const sortRef = useRef(filters.debouncedSortConfig); const paginationRef = useRef(pagination); // Sync refs with state useEffect(() => { settingsRef.current = settings; }, [settings]); useEffect(() => { filtersRef.current = filters; }, [filters]); useEffect(() => { sortRef.current = filters.debouncedSortConfig; }, [filters.debouncedSortConfig]); useEffect(() => { paginationRef.current = pagination; }, [pagination]); /** * Replace manual fetching with TanStack Query */ const queueQuery = useQueueQuery({ userId: user?.id, isAdmin, isSuperuser, entityFilter: filtersRef.current.debouncedEntityFilter, statusFilter: filtersRef.current.debouncedStatusFilter, tab: filtersRef.current.activeTab, currentPage: paginationRef.current.currentPage, pageSize: paginationRef.current.pageSize, sortConfig: sortRef.current, enabled: !!user, }); // Update items when query data changes useEffect(() => { if (queueQuery.items) { setItems(queueQuery.items); logger.log('✅ Queue items updated from TanStack Query:', queueQuery.items.length); } }, [queueQuery.items]); // Update loading state based on query status useEffect(() => { if (queueQuery.isLoading) { setLoadingState('loading'); } else if (queueQuery.isRefreshing) { setLoadingState('refreshing'); } else { setLoadingState('ready'); } }, [queueQuery.isLoading, queueQuery.isRefreshing]); // Show error toast when query fails useEffect(() => { if (queueQuery.error) { logger.error('❌ Queue query error:', queueQuery.error); toast({ variant: 'destructive', title: 'Failed to Load Queue', description: queueQuery.error.message || 'An error occurred while fetching the moderation queue.', }); } }, [queueQuery.error, toast]); // Update total count for pagination useEffect(() => { paginationRef.current.setTotalCount(queueQuery.totalCount); }, [queueQuery.totalCount]); // Mark initial fetch as complete useEffect(() => { if (!queueQuery.isLoading && !initialFetchCompleteRef.current) { initialFetchCompleteRef.current = true; logger.log('✅ Initial queue fetch complete'); } }, [queueQuery.isLoading]); /** * Manual refresh function */ const refresh = useCallback(async () => { logger.log('🔄 Manual refresh triggered'); await queueQuery.refetch(); }, [queueQuery]); /** * Show pending new items by invalidating query */ const showNewItems = useCallback(async () => { logger.log('✅ Showing new items via query invalidation'); await queueQuery.invalidate(); setPendingNewItems([]); setNewItemsCount(0); }, [queueQuery]); /** * 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, true); // Silent release } 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}. Version history updated.`, }); // Refresh stats to update counts queue.refreshStats(); } catch (error) { const errorMsg = getErrorMessage(error); console.error("Error moderating content:", errorMsg); // 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: errorMsg || `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", }); // Refresh stats to update counts queue.refreshStats(); } catch (error) { const errorMsg = getErrorMessage(error); console.error("Error deleting submission:", errorMsg); 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", }); // Refresh stats to update counts queue.refreshStats(); setItems((prev) => prev.filter((i) => i.id !== item.id)); } catch (error) { const errorMsg = getErrorMessage(error); console.error("Error resetting submission:", errorMsg); toast({ title: "Reset Failed", description: errorMsg, 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)`, }); // Refresh stats to update counts queue.refreshStats(); } catch (error) { const errorMsg = getErrorMessage(error); console.error("Error retrying failed items:", errorMsg); toast({ title: "Retry Failed", description: errorMsg, variant: "destructive", }); } finally { setActionLoading(null); } }, [filters.statusFilter, toast], ); // Extract stable callbacks for dependencies const invalidateQuery = useCallback(() => { queueQuery.invalidate(); }, [queueQuery.invalidate]); const resetPagination = useCallback(() => { pagination.reset(); }, [pagination.reset]); // Mark initial fetch as complete when query loads useEffect(() => { if (!queueQuery.isLoading && !initialFetchCompleteRef.current) { initialFetchCompleteRef.current = true; isMountingRef.current = false; logger.log('✅ Initial queue fetch complete'); } }, [queueQuery.isLoading]); // Invalidate query when filters or sort changes (OPTIMIZED) useEffect(() => { if ( !user || !initialFetchCompleteRef.current || isMountingRef.current ) return; logger.log('🔄 Filters/sort changed, invalidating query'); resetPagination(); invalidateQuery(); }, [ filters.debouncedEntityFilter, filters.debouncedStatusFilter, filters.debouncedSortConfig.field, filters.debouncedSortConfig.direction, user, invalidateQuery, resetPagination ]); // Polling effect (when realtime disabled) - MUTUALLY EXCLUSIVE useEffect(() => { const shouldPoll = settings.refreshMode === 'auto' && !settings.useRealtimeQueue && loadingState !== 'initial' && !!user; if (!shouldPoll) { return; } logger.log("⚠️ Polling ENABLED - interval:", settings.pollInterval); const interval = setInterval(() => { logger.log("🔄 Polling refresh triggered"); queueQuery.refetch(); }, settings.pollInterval); return () => { clearInterval(interval); logger.log("🛑 Polling stopped"); }; }, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, queueQuery.refetch]); // Initialize realtime subscriptions useRealtimeSubscriptions({ enabled: settings.useRealtimeQueue && !!user, filters: realtimeFilters, 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; // Only track removals for optimistic update protection if (shouldRemove && !recentlyRemovedRef.current.has(item.id)) { recentlyRemovedRef.current.add(item.id); setTimeout(() => recentlyRemovedRef.current.delete(item.id), MODERATION_CONSTANTS.REALTIME_OPTIMISTIC_REMOVAL_TIMEOUT); } // TanStack Query handles actual state updates via invalidation }, onItemRemoved: (itemId: string) => { // Track for optimistic update protection recentlyRemovedRef.current.add(itemId); setTimeout(() => recentlyRemovedRef.current.delete(itemId), MODERATION_CONSTANTS.REALTIME_OPTIMISTIC_REMOVAL_TIMEOUT); // TanStack Query handles removal via invalidation }, entityCache, profileCache, recentlyRemovedIds: recentlyRemovedRef.current, interactingWithIds: interactingWith, }); return { items, loadingState, actionLoading, filters, pagination, queue, newItemsCount, pendingNewItems, showNewItems, interactingWith, markInteracting, refresh, performAction, deleteSubmission, resetToPending, retryFailedItems, entityCache, profileCache, }; }