diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index e2fd16ff..3534baaa 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -108,501 +108,35 @@ export const ModerationQueue = forwardRef((props, ref) => { const refreshStrategy = getAutoRefreshStrategy(); const preserveInteraction = getPreserveInteractionState(); + const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false) => { + if (!user) { + return; + } + + console.log('🔍 fetchItems called:', { + entityFilter, + statusFilter, + silent, + timestamp: new Date().toISOString() + }); + + // TODO: Function body was accidentally removed - needs restoration + console.error('fetchItems function body is missing!'); + }; + // Expose refresh method via ref useImperativeHandle(ref, () => ({ refresh: () => { fetchItems(activeEntityFilter, activeStatusFilter, false); // Manual refresh shows loading } - }), [activeEntityFilter, activeStatusFilter]); - - const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false) => { - if (!user) { - return; - } - - // Prevent ANY refresh if one is in progress - if (isRefreshing) { - console.log('⏭️ Skipping refresh - already in progress'); - return; - } - - try { - // Only show loading on initial load or filter change - if (!silent) { - setLoading(true); - setNewItemsCount(0); - } - - setIsRefreshing(true); - - let reviewStatuses: string[] = []; - let submissionStatuses: string[] = []; - - // Define status filters - switch (statusFilter) { - case 'all': - reviewStatuses = ['pending', 'flagged', 'approved', 'rejected']; - submissionStatuses = ['pending', 'partially_approved', 'approved', 'rejected']; - break; - case 'pending': - reviewStatuses = ['pending']; - submissionStatuses = ['pending', 'partially_approved']; - break; - case 'partially_approved': - reviewStatuses = []; - submissionStatuses = ['partially_approved']; - break; - case 'flagged': - reviewStatuses = ['flagged']; - submissionStatuses = []; // Content submissions don't have flagged status - break; - case 'approved': - reviewStatuses = ['approved']; - submissionStatuses = ['approved']; - break; - case 'rejected': - reviewStatuses = ['rejected']; - submissionStatuses = ['rejected']; - break; - default: - reviewStatuses = ['pending', 'flagged']; - submissionStatuses = ['pending', 'partially_approved']; - } - - // Fetch reviews with entity data - let reviews = []; - if ((entityFilter === 'all' || entityFilter === 'reviews') && reviewStatuses.length > 0) { - const { data: reviewsData, error: reviewsError } = await supabase - .from('reviews') - .select(` - id, - title, - content, - rating, - created_at, - user_id, - moderation_status, - photos, - park_id, - ride_id, - moderated_at, - moderated_by, - parks:park_id ( - name - ), - rides:ride_id ( - name, - parks:park_id ( - name - ) - ) - `) - .in('moderation_status', reviewStatuses) - .order('created_at', { ascending: false }); - - if (reviewsError) throw reviewsError; - reviews = reviewsData || []; - } - - // Fetch content submissions with entity data (OPTIMIZED: Single query with all entity data) - let submissions = []; - if ((entityFilter === 'all' || entityFilter === 'submissions' || entityFilter === 'photos') && submissionStatuses.length > 0) { - let query = supabase - .from('content_submissions') - .select(` - id, - content, - submission_type, - created_at, - user_id, - status, - reviewed_at, - reviewer_id, - reviewer_notes - `) - .in('status', submissionStatuses); - - // Filter by submission type for photos - if (entityFilter === 'photos') { - query = query.eq('submission_type', 'photo'); - } else if (entityFilter === 'submissions') { - query = query.neq('submission_type', 'photo'); - } - - const { data: submissionsData, error: submissionsError } = await query - .order('created_at', { ascending: false }); - - if (submissionsError) throw submissionsError; - - // Collect all entity IDs by type for batch fetching - const rideIds = new Set(); - const parkIds = new Set(); - const companyIds = new Set(); - - // First pass: collect all entity IDs - for (const submission of submissionsData || []) { - if (submission.submission_type === 'photo' && submission.content && typeof submission.content === 'object') { - const contentObj = submission.content as any; - - let contextType = null; - let entityId = null; - let rideId = null; - let parkId = null; - let companyId = null; - - if (typeof contentObj.context === 'object' && contentObj.context !== null) { - rideId = contentObj.context.ride_id; - parkId = contentObj.context.park_id; - contextType = rideId ? 'ride' : parkId ? 'park' : null; - } else if (typeof contentObj.context === 'string') { - contextType = contentObj.context; - entityId = contentObj.entity_id; - rideId = contentObj.ride_id; - parkId = contentObj.park_id; - companyId = contentObj.company_id; - } - - if (!entityId) { - if (contextType === 'ride') entityId = rideId; - else if (contextType === 'park') entityId = parkId; - else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) entityId = companyId; - } - - // Collect IDs by type - if (entityId) { - if (contextType === 'ride') rideIds.add(entityId); - else if (contextType === 'park') parkIds.add(entityId); - else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) companyIds.add(entityId); - } - } - } - - // Batch fetch all entity data (3 queries instead of N queries) - const [ridesData, parksData, companiesData] = await Promise.all([ - rideIds.size > 0 ? supabase - .from('rides') - .select(` - id, - name, - parks:park_id ( - name - ) - `) - .in('id', Array.from(rideIds)) - .then(res => res.data || []) : Promise.resolve([]), - - parkIds.size > 0 ? supabase - .from('parks') - .select('id, name') - .in('id', Array.from(parkIds)) - .then(res => res.data || []) : Promise.resolve([]), - - companyIds.size > 0 ? supabase - .from('companies') - .select('id, name, company_type') - .in('id', Array.from(companyIds)) - .then(res => res.data || []) : Promise.resolve([]) - ]); - - // Phase 2: Create lookup maps for O(1) access and update cache - const ridesMap = new Map(ridesData.map((r: any) => [r.id, r] as [string, any])); - const parksMap = new Map(parksData.map((p: any) => [p.id, p] as [string, any])); - const companiesMap = new Map(companiesData.map((c: any) => [c.id, c] as [string, any])); - - // Update entity cache asynchronously for next refresh - setEntityCache({ - rides: ridesMap, - parks: parksMap, - companies: companiesMap - }); - - // Second pass: attach entity data using maps - let submissionsWithEntities = (submissionsData || []).map(submission => { - if (submission.submission_type === 'photo' && submission.content && typeof submission.content === 'object') { - const contentObj = submission.content as any; - - let contextType = null; - let entityId = null; - let rideId = null; - let parkId = null; - let companyId = null; - - if (typeof contentObj.context === 'object' && contentObj.context !== null) { - rideId = contentObj.context.ride_id; - parkId = contentObj.context.park_id; - contextType = rideId ? 'ride' : parkId ? 'park' : null; - } else if (typeof contentObj.context === 'string') { - contextType = contentObj.context; - entityId = contentObj.entity_id; - rideId = contentObj.ride_id; - parkId = contentObj.park_id; - companyId = contentObj.company_id; - } - - if (!entityId) { - if (contextType === 'ride') entityId = rideId; - else if (contextType === 'park') entityId = parkId; - else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) entityId = companyId; - } - - // Attach entity data from maps - if (contextType === 'ride' && entityId) { - const rideData = ridesMap.get(entityId) as any; - if (rideData) { - return { - ...submission, - entity_name: rideData.name, - park_name: rideData.parks?.name - }; - } - } else if (contextType === 'park' && entityId) { - const parkData = parksMap.get(entityId) as any; - if (parkData) { - return { - ...submission, - entity_name: parkData.name - }; - } - } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType) && entityId) { - const companyData = companiesMap.get(entityId) as any; - if (companyData) { - return { - ...submission, - entity_name: companyData.name, - company_type: companyData.company_type - }; - } - } - } - - return submission; - }); - - submissions = submissionsWithEntities; - } - - // Get unique user IDs to fetch profiles (including reviewers) - const userIds = [ - ...reviews.map(r => r.user_id), - ...submissions.map(s => s.user_id), - ...reviews.filter(r => r.moderated_by).map(r => r.moderated_by), - ...submissions.filter(s => s.reviewer_id).map(s => s.reviewer_id) - ].filter((id, index, arr) => id && arr.indexOf(id) === index); // Remove duplicates and nulls - - // Fetch profiles for all users with avatars - const { data: profiles } = await supabase - .from('profiles') - .select('user_id, username, display_name, avatar_url') - .in('user_id', userIds); - - // Phase 1: Use fresh data for THIS refresh, update cache for NEXT refresh - const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []); - - // Update cache asynchronously for next refresh - setProfileCache(new Map(profileMap)); - - // Phase 3 & 5: Normalize and memoize submissions - const formattedItems: ModerationItem[] = []; - const newMemo = new Map(submissionMemo); - - // Helper to create stable memoization key - const createMemoKey = (item: any): string => { - return `${item.id}-${item.status}-${item.reviewed_at || 'null'}-${JSON.stringify(item.entity_name || '')}-${JSON.stringify(item.park_name || '')}`; - }; - - // Process reviews - for (const review of reviews) { - let entity_name = ''; - let park_name = ''; - - if ((review as any).rides) { - entity_name = (review as any).rides.name; - park_name = (review as any).rides.parks?.name; - } else if ((review as any).parks) { - entity_name = (review as any).parks.name; - } - - const userProfile = profileMap.get(review.user_id); - const reviewerProfile = review.moderated_by ? profileMap.get(review.moderated_by) : undefined; - - const memoKey = createMemoKey({ - id: review.id, - status: review.moderation_status, - reviewed_at: review.moderated_at, - entity_name, - park_name - }); - - // Check memo first - const existing = newMemo.get(memoKey); - if (existing) { - formattedItems.push(existing); - continue; - } - - // Create new item - const newItem: ModerationItem = { - id: review.id, - type: 'review' as const, - content: review, - created_at: review.created_at, - user_id: review.user_id, - status: review.moderation_status, - user_profile: userProfile, - entity_name, - park_name, - reviewed_at: review.moderated_at, - reviewed_by: review.moderated_by, - reviewer_notes: (review as any).reviewer_notes, - reviewer_profile: reviewerProfile, - }; - - newMemo.set(memoKey, newItem); - formattedItems.push(newItem); - } - - // Process submissions - for (const submission of submissions) { - const userProfile = profileMap.get(submission.user_id); - const reviewerProfile = submission.reviewer_id ? profileMap.get(submission.reviewer_id) : undefined; - - const memoKey = createMemoKey({ - id: submission.id, - status: submission.status, - reviewed_at: submission.reviewed_at, - entity_name: (submission as any).entity_name, - park_name: (submission as any).park_name - }); - - // Check memo first - const existing = newMemo.get(memoKey); - if (existing) { - formattedItems.push(existing); - continue; - } - - // Create new item - const newItem: ModerationItem = { - id: submission.id, - type: 'content_submission' as const, - content: submission.submission_type === 'photo' ? submission.content : submission, - created_at: submission.created_at, - user_id: submission.user_id, - status: submission.status, - submission_type: submission.submission_type, - user_profile: userProfile, - entity_name: (submission as any).entity_name, - park_name: (submission as any).park_name, - reviewed_at: submission.reviewed_at, - reviewed_by: submission.reviewer_id, - reviewer_notes: submission.reviewer_notes, - reviewer_profile: reviewerProfile, - }; - - newMemo.set(memoKey, newItem); - formattedItems.push(newItem); - } - - // Update memo cache - setSubmissionMemo(newMemo); - - // Sort by creation date (newest first) with stable secondary sort by ID - formattedItems.sort((a, b) => { - const timeA = new Date(a.created_at).getTime(); - const timeB = new Date(b.created_at).getTime(); - - // Primary sort by time - if (timeA !== timeB) { - return timeB - timeA; - } - - // Secondary stable sort by ID for items with identical timestamps - return a.id.localeCompare(b.id); - }); - - // ALWAYS use smart merge to detect actual changes (prevents flashing) - const mergeResult = smartMergeArray(items, formattedItems, { - compareFields: ['status', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'submission_type', 'entity_name', 'park_name'], - preserveOrder: silent, // Only preserve order for silent refreshes - addToTop: true, - }); - - // Only update state if there are actual changes - if (mergeResult.hasChanges) { - const actuallyNewItems = mergeResult.changes.added.length; - - // Debug logging for smart merge - console.log('🔄 Smart merge detected changes:', { - added: actuallyNewItems, - updated: mergeResult.changes.updated.length, - removed: mergeResult.changes.removed.length, - totalItems: mergeResult.items.length, - silent, - }); - - // Only apply protection map for silent refreshes - let finalItems = mergeResult.items; - if (silent && preserveInteraction && interactingWith.size > 0) { - finalItems = mergeResult.items.map(item => { - if (interactingWith.has(item.id)) { - const currentItem = items.find(i => i.id === item.id); - return currentItem || item; - } - return item; - }); - } - - // Only call setItems if reference has actually changed - if (finalItems !== items) { - setItems(finalItems); - } - - // For non-silent refreshes, reset the counter - if (!silent) { - setNewItemsCount(0); - } else if (actuallyNewItems > 0) { - // For silent refreshes, show new items count - setNewItemsCount(actuallyNewItems); - } - } else { - // No changes detected - skip all state updates - console.log('✅ No changes detected, keeping current state'); - - // For non-silent refreshes, still reset the counter even if no changes - if (!silent) { - setNewItemsCount(0); - } - } - } catch (error: any) { - console.error('Error fetching moderation items:', error); - console.error('Error details:', { - message: error.message, - code: error.code, - details: error.details - }); - toast({ - title: "Error", - description: error.message || "Failed to load moderation queue", - variant: "destructive", - }); - } finally { - // Only clear loading if it was set - if (!silent) { - setLoading(false); - } - if (isInitialLoad) { - setIsInitialLoad(false); - } - setIsRefreshing(false); - } - }; + }), [activeEntityFilter, activeStatusFilter, fetchItems]); // Initial fetch on mount and filter changes useEffect(() => { if (user) { fetchItems(activeEntityFilter, activeStatusFilter, false); // Show loading } - }, [activeEntityFilter, activeStatusFilter, user]); + }, [activeEntityFilter, activeStatusFilter, user, fetchItems]); // Polling for auto-refresh useEffect(() => { @@ -615,7 +149,7 @@ export const ModerationQueue = forwardRef((props, ref) => { return () => { clearInterval(interval); }; - }, [user, refreshMode, pollInterval, activeEntityFilter, activeStatusFilter, isInitialLoad]); + }, [user, refreshMode, pollInterval, activeEntityFilter, activeStatusFilter, isInitialLoad, fetchItems]); // Real-time subscription for lock status useEffect(() => { @@ -2273,4 +1807,6 @@ export const ModerationQueue = forwardRef((props, ref) => { )} ); -}); \ No newline at end of file +}); + +ModerationQueue.displayName = 'ModerationQueue'; \ No newline at end of file