diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 01805c54..abd1ec6e 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -79,6 +79,7 @@ export const ModerationQueue = forwardRef((props, ref) => { const [selectedItemForAction, setSelectedItemForAction] = useState(null); const [interactingWith, setInteractingWith] = useState>(new Set()); const [newItemsCount, setNewItemsCount] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); const { user } = useAuth(); @@ -108,12 +109,21 @@ export const ModerationQueue = forwardRef((props, ref) => { return; } + // Prevent duplicate requests + if (isRefreshing && silent) { + 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[] = []; @@ -183,7 +193,7 @@ export const ModerationQueue = forwardRef((props, ref) => { reviews = reviewsData || []; } - // Fetch content submissions with entity data + // 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 @@ -213,13 +223,16 @@ export const ModerationQueue = forwardRef((props, ref) => { if (submissionsError) throw submissionsError; - // Get entity data for photo submissions - let submissionsWithEntities = submissionsData || []; - for (const submission of submissionsWithEntities) { + // 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; - // Handle both old format (context as object) and new format (context as string) let contextType = null; let entityId = null; let rideId = null; @@ -227,12 +240,10 @@ export const ModerationQueue = forwardRef((props, ref) => { let companyId = null; if (typeof contentObj.context === 'object' && contentObj.context !== null) { - // OLD FORMAT: context is an object like {ride_id: "...", park_id: "..."} rideId = contentObj.context.ride_id; parkId = contentObj.context.park_id; contextType = rideId ? 'ride' : parkId ? 'park' : null; } else if (typeof contentObj.context === 'string') { - // NEW FORMAT: context is a string, IDs are at top level contextType = contentObj.context; entityId = contentObj.entity_id; rideId = contentObj.ride_id; @@ -240,54 +251,115 @@ export const ModerationQueue = forwardRef((props, ref) => { companyId = contentObj.company_id; } - // Determine entity ID based on context type if (!entityId) { if (contextType === 'ride') entityId = rideId; else if (contextType === 'park') entityId = parkId; else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) entityId = companyId; } - if (contextType === 'ride' && entityId) { - const { data: rideData } = await supabase - .from('rides') - .select(` - name, - parks:park_id ( - name - ) - `) - .eq('id', entityId) - .single(); - - if (rideData) { - (submission as any).entity_name = rideData.name; - (submission as any).park_name = rideData.parks?.name; - } - } else if (contextType === 'park' && entityId) { - const { data: parkData } = await supabase - .from('parks') - .select('name') - .eq('id', entityId) - .single(); - - if (parkData) { - (submission as any).entity_name = parkData.name; - } - } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType) && entityId) { - const { data: companyData } = await supabase - .from('companies') - .select('name, company_type') - .eq('id', entityId) - .single(); - - if (companyData) { - (submission as any).entity_name = companyData.name; - (submission as any).company_type = companyData.company_type; - } + // 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([]) + ]); + + // Create lookup maps for O(1) access + 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])); + + // 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; } @@ -371,7 +443,7 @@ export const ModerationQueue = forwardRef((props, ref) => { // Use smart merging for silent refreshes if strategy is 'merge' if (silent && refreshStrategy === 'merge') { const mergeResult = smartMergeArray(items, formattedItems, { - compareFields: ['status', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'content', 'submission_type'], + compareFields: ['status', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'submission_type', 'entity_name', 'park_name'], preserveOrder: true, addToTop: true, }); @@ -437,6 +509,7 @@ export const ModerationQueue = forwardRef((props, ref) => { if (isInitialLoad) { setIsInitialLoad(false); } + setIsRefreshing(false); } }; diff --git a/src/lib/smartStateUpdate.ts b/src/lib/smartStateUpdate.ts index 32c36696..a0e6846a 100644 --- a/src/lib/smartStateUpdate.ts +++ b/src/lib/smartStateUpdate.ts @@ -1,10 +1,27 @@ /** - * Smart State Update Utility - * - * Provides intelligent array diffing and merging to prevent - * unnecessary re-renders and preserve user interaction state. + * Utility functions for performing "smart" updates on arrays */ +/** + * Creates a stable content hash for comparison + */ +function hashContent(obj: any): string { + if (obj === null || obj === undefined) return 'null'; + if (typeof obj !== 'object') return String(obj); + + // Sort keys for stable hashing + const sortedKeys = Object.keys(obj).sort(); + const parts = sortedKeys.map(key => `${key}:${hashContent(obj[key])}`); + return parts.join('|'); +} + +/** + * Checks if content has meaningfully changed (not just object reference) + */ +function hasContentChanged(current: any, next: any): boolean { + return hashContent(current) !== hashContent(next); +} + export interface SmartMergeOptions { compareFields?: (keyof T)[]; preserveOrder?: boolean; @@ -130,9 +147,8 @@ function hasItemChanged( compareFields?: (keyof T)[] ): boolean { if (!compareFields || compareFields.length === 0) { - // If no fields specified, assume no change (too sensitive to compare everything) - // This prevents false positives from object reference changes - return false; + // If no fields specified, use content hash comparison + return hasContentChanged(currentItem, newItem); } // Compare only specified fields @@ -140,9 +156,9 @@ function hasItemChanged( const currentValue = currentItem[field]; const newValue = newItem[field]; - // Handle nested objects/arrays + // Handle nested objects/arrays with content hash if (typeof currentValue === 'object' && typeof newValue === 'object') { - if (JSON.stringify(currentValue) !== JSON.stringify(newValue)) { + if (hasContentChanged(currentValue, newValue)) { return true; } } else if (currentValue !== newValue) {