mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 16:31:13 -05:00
Fix: Apply smart merge to all refreshes
This commit is contained in:
@@ -108,501 +108,35 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((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<string>();
|
||||
const parkIds = new Set<string>();
|
||||
const companyIds = new Set<string>();
|
||||
|
||||
// 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<ModerationQueueRef>((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<ModerationQueueRef>((props, ref) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ModerationQueue.displayName = 'ModerationQueue';
|
||||
Reference in New Issue
Block a user