diff --git a/src/hooks/moderation/useModerationQueueManager.ts b/src/hooks/moderation/useModerationQueueManager.ts index ee1915a9..3417011d 100644 --- a/src/hooks/moderation/useModerationQueueManager.ts +++ b/src/hooks/moderation/useModerationQueueManager.ts @@ -1,24 +1,18 @@ -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, +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'; + 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 @@ -27,11 +21,11 @@ export interface ModerationQueueManagerConfig { user: User | null; isAdmin: boolean; isSuperuser: boolean; - toast: ReturnType['toast']; + toast: ReturnType["toast"]; settings: { - refreshMode: 'auto' | 'manual'; + refreshMode: "auto" | "manual"; pollInterval: number; - refreshStrategy: 'notify' | 'merge' | 'replace'; + refreshStrategy: "notify" | "merge" | "replace"; preserveInteraction: boolean; useRealtimeQueue: boolean; refreshOnTabVisible: boolean; @@ -46,33 +40,29 @@ export interface ModerationQueueManager { 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; + 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; @@ -82,53 +72,51 @@ export interface ModerationQueueManager { * Orchestrator hook for moderation queue management * Consolidates all queue-related logic into a single hook */ -export function useModerationQueueManager( - config: ModerationQueueManagerConfig -): ModerationQueueManager { +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', + initialEntityFilter: "all", + initialStatusFilter: "pending", + initialTab: "mainQueue", debounceDelay: 300, persist: true, - storageKey: 'moderationQueue_filters' + storageKey: "moderationQueue_filters", }); - + const pagination = usePagination({ initialPage: 1, initialPageSize: 25, persist: false, onPageChange: (page) => { if (page > 1) { - setLoadingState('loading'); + setLoadingState("loading"); } }, onPageSizeChange: () => { - setLoadingState('loading'); - } + setLoadingState("loading"); + }, }); - + const sort = useModerationSort({ - initialConfig: { field: 'created_at', direction: 'asc' }, + initialConfig: { field: "created_at", direction: "asc" }, persist: true, - storageKey: 'moderationQueue_sortConfig' + storageKey: "moderationQueue_sortConfig", }); - + const queue = useModerationQueue(); const entityCache = useEntityCache(); const profileCache = useProfileCache(); - + // Core state const [items, setItems] = useState([]); - const [loadingState, setLoadingState] = useState('initial'); + 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); @@ -138,80 +126,82 @@ export function useModerationQueueManager( 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; + const fetchItems = useCallback( + async (silent = false) => { + if (!user) return; - // Get caller info - const callerStack = new Error().stack; - const callerLine = callerStack?.split('\n')[2]?.trim(); - - console.log('πŸ”„ [FETCH ITEMS] Called', { - silent, - pauseFetchingRef: pauseFetchingRef.current, - documentHidden: document.hidden, - caller: callerLine, - timestamp: new Date().toISOString() - }); + // Get caller info + const callerStack = new Error().stack; + const callerLine = callerStack?.split("\n")[2]?.trim(); - // Check if fetching is paused (controlled by visibility handler if enabled) - if (pauseFetchingRef.current) { - console.log('⏸️ Fetch paused by pauseFetchingRef'); - 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'); + console.log("πŸ”„ [FETCH ITEMS] Called", { + silent, + pauseFetchingRef: pauseFetchingRef.current, + documentHidden: document.hidden, + caller: callerLine, + timestamp: new Date().toISOString(), + }); + + // Check if fetching is paused (controlled by visibility handler if enabled) + if (pauseFetchingRef.current) { + console.log("⏸️ Fetch paused by pauseFetchingRef"); + return; } - - // Build base query - let submissionsQuery = supabase - .from('content_submissions') - .select(` + + // 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, @@ -230,239 +220,263 @@ export function useModerationQueueManager( 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; - } + `, + ) + .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); } - } 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 entity type filter + if (entityFilter === "photos") { + submissionsQuery = submissionsQuery.eq("submission_type", "photo"); + } else if (entityFilter === "submissions") { + submissionsQuery = submissionsQuery.neq("submission_type", "photo"); } - - // 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'); + + // 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; } - 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, + + 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]; }); - - if (mergeResult.hasChanges) { - setItems(mergeResult.items); - console.log('πŸ”„ Queue updated (replace mode)'); - } - - if (!currentPreserveInteraction) { - setPendingNewItems([]); - setNewItemsCount(0); - } - break; + } + + // 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); } - } 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, + } catch (error: any) { + console.error("Error fetching moderation items:", error); + toast({ + title: "Error", + description: error.message || "Failed to fetch moderation queue", + variant: "destructive", }); - - if (mergeResult.hasChanges) { - setItems(mergeResult.items); - console.log('πŸ”„ Queue updated (manual refresh)'); - } - - setPendingNewItems([]); - setNewItemsCount(0); + } finally { + fetchInProgressRef.current = false; + setLoadingState("ready"); } - - } 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]); - + }, + [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]); + setItems((prev) => [...pendingNewItems, ...prev]); setPendingNewItems([]); setNewItemsCount(0); - console.log('βœ… New items merged into queue:', pendingNewItems.length); + 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 => { + setInteractingWith((prev) => { const next = new Set(prev); if (interacting) { next.add(id); @@ -472,296 +486,290 @@ export function useModerationQueueManager( 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); + 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'); + + // 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]; + + // 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; } - }); - - toast({ - title: "Error", - description: error.message || `Failed to ${action} content`, - variant: "destructive", - }); - } finally { - setActionLoading(null); - } - }, [actionLoading, filters.statusFilter, queue, user, toast]); - + + 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]); - + 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]); - + 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", + 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); }); - return; } - - await supabase.functions.invoke('process-selective-approval', { - body: { - itemIds: failedItems.map(i => i.id), - submissionId: item.id + + 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; } - }); - - 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]); - + + 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; @@ -770,102 +778,102 @@ export function useModerationQueueManager( }); }); }, [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; - + fetchItemsRef.current?.(true); }, [pagination.currentPage, pagination.pageSize]); - + // Polling effect (when realtime disabled) useEffect(() => { - if (!user || settings.refreshMode !== 'auto' || loadingState === 'initial' || settings.useRealtimeQueue) { + if (!user || settings.refreshMode !== "auto" || loadingState === "initial" || settings.useRealtimeQueue) { return; } - - console.log('⚠️ Polling ENABLED - interval:', settings.pollInterval); + + console.log("⚠️ Polling ENABLED - interval:", settings.pollInterval); const interval = setInterval(() => { - console.log('πŸ”„ Polling refresh triggered'); + console.log("πŸ”„ Polling refresh triggered"); fetchItemsRef.current?.(true); }, settings.pollInterval); - + return () => { clearInterval(interval); - console.log('πŸ›‘ Polling stopped'); + console.log("πŸ›‘ Polling stopped"); }; }, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue]); - + // Visibility change handler useEffect(() => { // HARD CHECK: Explicit boolean comparison to prevent any truthy coercion const isEnabled = settings.refreshOnTabVisible === true; - - console.log('πŸ” [VISIBILITY EFFECT] Hard check', { + + console.log("πŸ” [VISIBILITY EFFECT] Hard check", { refreshOnTabVisible: settings.refreshOnTabVisible, typeOf: typeof settings.refreshOnTabVisible, isEnabled, willAttachListener: isEnabled, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); - + // Early return if feature is disabled if (!isEnabled) { - console.log(' βœ… Feature DISABLED - skipping all visibility logic'); - console.log(' βœ… Tab focus will NOT trigger refreshes'); - + console.log(" βœ… Feature DISABLED - skipping all visibility logic"); + console.log(" βœ… Tab focus will NOT trigger refreshes"); + // Cleanup: ensure no lingering handlers return () => { - console.log(' 🧹 Cleanup: Ensuring no visibility listeners exist'); + console.log(" 🧹 Cleanup: Ensuring no visibility listeners exist"); }; } - - console.error(' ❌ Setting is TRUE - listener WILL be attached'); - console.error(' ❌ THIS MEANS TAB FOCUS **WILL** TRIGGER REFRESHES'); - console.error(' ⚠️ If you disabled this setting, it is NOT working properly'); - + + console.error(" ❌ Setting is TRUE - listener WILL be attached"); + console.error(" ❌ THIS MEANS TAB FOCUS **WILL** TRIGGER REFRESHES"); + console.error(" ⚠️ If you disabled this setting, it is NOT working properly"); + const handleVisibilityChange = () => { // Double-check setting before doing anything (defensive check) if (!settings.refreshOnTabVisible) { - console.log('⚠️ Visibility handler called but setting is disabled - ignoring'); + console.log("⚠️ Visibility handler called but setting is disabled - ignoring"); return; } - + if (document.hidden) { - console.log('πŸ‘οΈ [VISIBILITY HANDLER] Tab hidden - pausing fetches'); + console.log("πŸ‘οΈ [VISIBILITY HANDLER] Tab hidden - pausing fetches"); pauseFetchingRef.current = true; } else { - console.error('πŸ‘οΈ [VISIBILITY HANDLER] Tab visible - THIS IS WHERE THE REFRESH HAPPENS'); - console.error(' πŸ”΄ TAB FOCUS REFRESH TRIGGERED HERE'); - console.error(' πŸ“ Stack trace below:'); + console.error("πŸ‘οΈ [VISIBILITY HANDLER] Tab visible - THIS IS WHERE THE REFRESH HAPPENS"); + console.error(" πŸ”΄ TAB FOCUS REFRESH TRIGGERED HERE"); + console.error(" πŸ“ Stack trace below:"); console.trace(); - + pauseFetchingRef.current = false; - + if (initialFetchCompleteRef.current && !isMountingRef.current && fetchItemsRef.current) { - console.error(' ➑️ Calling fetchItems(true) NOW'); + console.error(" ➑️ Calling fetchItems(true) NOW"); fetchItemsRef.current(true); } else { - console.log(' ⏭️ Skipping refresh (initial fetch not complete or mounting)'); + console.log(" ⏭️ Skipping refresh (initial fetch not complete or mounting)"); } } }; - - document.addEventListener('visibilitychange', handleVisibilityChange); + + document.addEventListener("visibilitychange", handleVisibilityChange); return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - console.log('🧹 Visibility listener removed'); + document.removeEventListener("visibilitychange", handleVisibilityChange); + console.log("🧹 Visibility listener removed"); }; }, [settings.refreshOnTabVisible]); - + // Initialize realtime subscriptions useRealtimeSubscriptions({ enabled: settings.useRealtimeQueue && !!user, @@ -875,29 +883,29 @@ export function useModerationQueueManager( }, onNewItem: (item: ModerationItem) => { if (recentlyRemovedRef.current.has(item.id)) return; - - setPendingNewItems(prev => { - if (prev.some(p => p.id === item.id)) return prev; + + setPendingNewItems((prev) => { + if (prev.some((p) => p.id === item.id)) return prev; return [...prev, item]; }); - setNewItemsCount(prev => prev + 1); - + setNewItemsCount((prev) => prev + 1); + toast({ - title: 'πŸ†• New Submission', + 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)); + setItems((prev) => prev.filter((i) => i.id !== item.id)); } else { - setItems(prev => { - const exists = prev.some(i => i.id === item.id); + setItems((prev) => { + const exists = prev.some((i) => i.id === item.id); if (exists) { - return prev.map(i => i.id === item.id ? item : i); + return prev.map((i) => (i.id === item.id ? item : i)); } else { return [item, ...prev]; } @@ -905,7 +913,7 @@ export function useModerationQueueManager( } }, onItemRemoved: (itemId: string) => { - setItems(prev => prev.filter(i => i.id !== itemId)); + setItems((prev) => prev.filter((i) => i.id !== itemId)); }, entityCache, profileCache, @@ -913,7 +921,7 @@ export function useModerationQueueManager( interactingWithIds: interactingWith, currentItems: items, }); - + return { items, loadingState, @@ -927,7 +935,9 @@ export function useModerationQueueManager( showNewItems, interactingWith, markInteracting, - refresh: async () => { await fetchItems(false); }, + refresh: async () => { + await fetchItems(false); + }, performAction, deleteSubmission, resetToPending,