mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 15:11:12 -05:00
Fix N+1 query and UI flashing
This commit is contained in:
@@ -79,6 +79,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
||||
const [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null);
|
||||
const [interactingWith, setInteractingWith] = useState<Set<string>>(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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<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;
|
||||
|
||||
// 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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((props, ref) => {
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user