mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 14:11:13 -05:00
feat: Implement Modern React Patterns
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
useModerationFilters,
|
||||
usePagination,
|
||||
useRealtimeSubscriptions,
|
||||
useQueueQuery,
|
||||
} from "./index";
|
||||
import { useModerationQueue } from "@/hooks/useModerationQueue";
|
||||
|
||||
@@ -122,13 +123,9 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
|
||||
// Refs for tracking
|
||||
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
||||
const fetchInProgressRef = useRef(false);
|
||||
const lastFetchTimeRef = useRef<number>(0);
|
||||
const initialFetchCompleteRef = useRef(false);
|
||||
const isMountingRef = useRef(true);
|
||||
|
||||
const FETCH_COOLDOWN_MS = 1000;
|
||||
|
||||
// Store settings, filters, and pagination in refs to stabilize fetchItems
|
||||
const settingsRef = useRef(settings);
|
||||
const filtersRef = useRef(filters);
|
||||
@@ -153,366 +150,70 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
}, [pagination]);
|
||||
|
||||
/**
|
||||
* Fetch queue items from database
|
||||
* Replace manual fetching with TanStack Query
|
||||
*/
|
||||
const fetchItems = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!user) return;
|
||||
const queueQuery = useQueueQuery({
|
||||
userId: user?.id,
|
||||
isAdmin,
|
||||
isSuperuser,
|
||||
entityFilter: filtersRef.current.debouncedEntityFilter,
|
||||
statusFilter: filtersRef.current.debouncedStatusFilter,
|
||||
tab: filtersRef.current.activeTab,
|
||||
currentPage: paginationRef.current.currentPage,
|
||||
pageSize: paginationRef.current.pageSize,
|
||||
sortConfig: sortRef.current,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Get caller info
|
||||
const callerStack = new Error().stack;
|
||||
const callerLine = callerStack?.split("\n")[2]?.trim();
|
||||
// Update items when query data changes
|
||||
useEffect(() => {
|
||||
if (queueQuery.items) {
|
||||
setItems(queueQuery.items);
|
||||
console.log('✅ Queue items updated from TanStack Query:', queueQuery.items.length);
|
||||
}
|
||||
}, [queueQuery.items]);
|
||||
|
||||
console.log("🔄 [FETCH ITEMS] Called", {
|
||||
silent,
|
||||
documentHidden: document.hidden,
|
||||
caller: callerLine,
|
||||
sortField: sortRef.current.field,
|
||||
sortDirection: sortRef.current.direction,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// Update loading state based on query status
|
||||
useEffect(() => {
|
||||
if (queueQuery.isLoading) {
|
||||
setLoadingState('loading');
|
||||
} else if (queueQuery.isRefreshing) {
|
||||
setLoadingState('refreshing');
|
||||
} else {
|
||||
setLoadingState('ready');
|
||||
}
|
||||
}, [queueQuery.isLoading, queueQuery.isRefreshing]);
|
||||
|
||||
// Prevent concurrent calls
|
||||
if (fetchInProgressRef.current) {
|
||||
console.log("⚠️ Fetch already in progress, skipping");
|
||||
return;
|
||||
}
|
||||
// Update total count for pagination
|
||||
useEffect(() => {
|
||||
paginationRef.current.setTotalCount(queueQuery.totalCount);
|
||||
}, [queueQuery.totalCount]);
|
||||
|
||||
// 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: filtersRef.current.debouncedEntityFilter,
|
||||
statusFilter: filtersRef.current.debouncedStatusFilter,
|
||||
sortField: sortRef.current.field,
|
||||
sortDirection: sortRef.current.direction,
|
||||
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,
|
||||
content,
|
||||
created_at,
|
||||
user_id,
|
||||
reviewed_at,
|
||||
reviewer_id,
|
||||
reviewer_notes,
|
||||
escalated,
|
||||
assigned_to,
|
||||
locked_until,
|
||||
submission_items (
|
||||
id,
|
||||
item_type,
|
||||
item_data,
|
||||
status
|
||||
)
|
||||
`,
|
||||
);
|
||||
|
||||
// CRITICAL: Multi-level ordering
|
||||
console.log('📊 [SORT QUERY] Applying multi-level sort:', {
|
||||
level1: 'escalated DESC',
|
||||
level2: `${sortRef.current.field} ${sortRef.current.direction.toUpperCase()}`,
|
||||
level3: sortRef.current.field !== 'created_at' ? 'created_at ASC' : 'none'
|
||||
});
|
||||
|
||||
// Level 1: Always sort by escalated first (descending)
|
||||
submissionsQuery = submissionsQuery.order('escalated', { ascending: false });
|
||||
|
||||
// Level 2: Apply user-selected sort (use ref)
|
||||
submissionsQuery = submissionsQuery.order(
|
||||
sortRef.current.field,
|
||||
{ ascending: sortRef.current.direction === 'asc' }
|
||||
);
|
||||
|
||||
// Level 3: Tertiary sort by created_at (if not already primary)
|
||||
if (sortRef.current.field !== 'created_at') {
|
||||
submissionsQuery = submissionsQuery.order('created_at', { ascending: true });
|
||||
}
|
||||
|
||||
// Apply tab-based status filtering (use refs)
|
||||
const tab = filtersRef.current.activeTab;
|
||||
const statusFilter = filtersRef.current.debouncedStatusFilter;
|
||||
const entityFilter = filtersRef.current.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 - build separate query with same filters
|
||||
let countQuery = supabase
|
||||
.from("content_submissions")
|
||||
.select("*", { count: "exact", head: true });
|
||||
|
||||
// Apply same filters as main query
|
||||
if (tab === "mainQueue") {
|
||||
if (statusFilter === "all") {
|
||||
countQuery = countQuery.in("status", ["pending", "flagged", "partially_approved"]);
|
||||
} else if (statusFilter === "pending") {
|
||||
countQuery = countQuery.in("status", ["pending", "partially_approved"]);
|
||||
} else {
|
||||
countQuery = countQuery.eq("status", statusFilter);
|
||||
}
|
||||
} else {
|
||||
if (statusFilter === "all") {
|
||||
countQuery = countQuery.in("status", ["approved", "rejected"]);
|
||||
} else {
|
||||
countQuery = countQuery.eq("status", statusFilter);
|
||||
}
|
||||
}
|
||||
|
||||
if (entityFilter === "photos") {
|
||||
countQuery = countQuery.eq("submission_type", "photo");
|
||||
} else if (entityFilter === "submissions") {
|
||||
countQuery = countQuery.neq("submission_type", "photo");
|
||||
}
|
||||
|
||||
if (!isAdmin && !isSuperuser) {
|
||||
const now = new Date().toISOString();
|
||||
countQuery = countQuery.or(
|
||||
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { count } = await countQuery;
|
||||
|
||||
paginationRef.current.setTotalCount(count || 0);
|
||||
|
||||
// Apply pagination (use refs)
|
||||
const startIndex = paginationRef.current.startIndex;
|
||||
const endIndex = paginationRef.current.endIndex;
|
||||
submissionsQuery = submissionsQuery.range(startIndex, endIndex);
|
||||
|
||||
const { data: submissions, error: submissionsError } = await submissionsQuery;
|
||||
|
||||
if (submissionsError) throw submissionsError;
|
||||
|
||||
// Log the actual data returned to verify sort order
|
||||
if (submissions && submissions.length > 0) {
|
||||
const sortField = sortRef.current.field;
|
||||
const preview = submissions.slice(0, 3).map(s => ({
|
||||
id: s.id.substring(0, 8),
|
||||
[sortField]: s[sortField],
|
||||
escalated: s.escalated
|
||||
}));
|
||||
console.log(`📋 [SORT RESULT] First 3 items by ${sortField}:`, preview);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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(items.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 refresh strategy
|
||||
switch (currentRefreshStrategy) {
|
||||
case "notify":
|
||||
console.log("✅ Queue frozen (notify mode)");
|
||||
break;
|
||||
|
||||
case "merge":
|
||||
if (newSubmissions.length > 0) {
|
||||
const currentIds = new Set(items.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":
|
||||
// Full replace - simple and predictable
|
||||
setItems(moderationItems);
|
||||
console.log("🔄 Queue updated (replace mode)");
|
||||
|
||||
if (!currentPreserveInteraction) {
|
||||
setPendingNewItems([]);
|
||||
setNewItemsCount(0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Manual refresh - ALWAYS update items for user-initiated changes (sort/filter)
|
||||
// Don't use smartMerge here because it won't detect order changes
|
||||
setItems(moderationItems);
|
||||
console.log("🔄 Queue updated (manual refresh) - items:", moderationItems.length);
|
||||
|
||||
setPendingNewItems([]);
|
||||
setNewItemsCount(0);
|
||||
}
|
||||
} 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,
|
||||
profileCache,
|
||||
entityCache,
|
||||
toast
|
||||
],
|
||||
);
|
||||
// Mark initial fetch as complete
|
||||
useEffect(() => {
|
||||
if (!queueQuery.isLoading && !initialFetchCompleteRef.current) {
|
||||
initialFetchCompleteRef.current = true;
|
||||
console.log('✅ Initial queue fetch complete');
|
||||
}
|
||||
}, [queueQuery.isLoading]);
|
||||
|
||||
/**
|
||||
* Show pending new items by merging them into the queue
|
||||
* Manual refresh function
|
||||
*/
|
||||
const showNewItems = useCallback(() => {
|
||||
if (pendingNewItems.length > 0) {
|
||||
setItems((prev) => [...pendingNewItems, ...prev]);
|
||||
setPendingNewItems([]);
|
||||
setNewItemsCount(0);
|
||||
console.log("✅ New items merged into queue:", pendingNewItems.length);
|
||||
}
|
||||
}, [pendingNewItems]);
|
||||
const refresh = useCallback(async () => {
|
||||
console.log('🔄 Manual refresh triggered');
|
||||
await queueQuery.refetch();
|
||||
}, [queueQuery]);
|
||||
|
||||
/**
|
||||
* Show pending new items by invalidating query
|
||||
*/
|
||||
const showNewItems = useCallback(async () => {
|
||||
console.log('✅ Showing new items via query invalidation');
|
||||
await queueQuery.invalidate();
|
||||
setPendingNewItems([]);
|
||||
setNewItemsCount(0);
|
||||
}, [queueQuery]);
|
||||
|
||||
/**
|
||||
* Mark an item as being interacted with (prevents realtime updates)
|
||||
@@ -808,51 +509,36 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
[filters.statusFilter, toast],
|
||||
);
|
||||
|
||||
// Initial fetch on mount
|
||||
// Mark initial fetch as complete when query loads
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
isMountingRef.current = true;
|
||||
fetchItems(false).then(() => {
|
||||
if (!queueQuery.isLoading && !initialFetchCompleteRef.current) {
|
||||
initialFetchCompleteRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
isMountingRef.current = false;
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id]);
|
||||
isMountingRef.current = false;
|
||||
console.log('✅ Initial queue fetch complete');
|
||||
}
|
||||
}, [queueQuery.isLoading]);
|
||||
|
||||
// Filter and sort changes trigger refetch
|
||||
// Invalidate query when filters or sort changes
|
||||
useEffect(() => {
|
||||
if (!user || !initialFetchCompleteRef.current || isMountingRef.current) return;
|
||||
|
||||
console.log('🔄 [SORT/FILTER CHANGE] Detected change:', {
|
||||
entityFilter: filters.debouncedEntityFilter,
|
||||
statusFilter: filters.debouncedStatusFilter,
|
||||
sortField: filters.debouncedSortConfig.field,
|
||||
sortDirection: filters.debouncedSortConfig.direction,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
if (
|
||||
!user ||
|
||||
!initialFetchCompleteRef.current ||
|
||||
isMountingRef.current
|
||||
) return;
|
||||
|
||||
console.log('🔄 Filters/sort changed, invalidating query');
|
||||
pagination.reset();
|
||||
fetchItems(false);
|
||||
queueQuery.invalidate();
|
||||
}, [
|
||||
filters.debouncedEntityFilter,
|
||||
filters.debouncedStatusFilter,
|
||||
filters.debouncedEntityFilter,
|
||||
filters.debouncedStatusFilter,
|
||||
filters.debouncedSortConfig.field,
|
||||
filters.debouncedSortConfig.direction,
|
||||
user,
|
||||
fetchItems,
|
||||
queueQuery,
|
||||
pagination
|
||||
]);
|
||||
|
||||
// Pagination changes trigger refetch
|
||||
useEffect(() => {
|
||||
if (!user || !initialFetchCompleteRef.current || pagination.currentPage === 1) return;
|
||||
|
||||
fetchItems(true);
|
||||
}, [pagination.currentPage, pagination.pageSize, user, fetchItems]);
|
||||
|
||||
// Polling effect (when realtime disabled)
|
||||
useEffect(() => {
|
||||
if (!user || settings.refreshMode !== "auto" || loadingState === "initial" || settings.useRealtimeQueue) {
|
||||
@@ -862,14 +548,14 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
console.log("⚠️ Polling ENABLED - interval:", settings.pollInterval);
|
||||
const interval = setInterval(() => {
|
||||
console.log("🔄 Polling refresh triggered");
|
||||
fetchItems(true);
|
||||
queueQuery.refetch();
|
||||
}, settings.pollInterval);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
console.log("🛑 Polling stopped");
|
||||
};
|
||||
}, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, fetchItems]);
|
||||
}, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, queueQuery]);
|
||||
|
||||
// Initialize realtime subscriptions
|
||||
useRealtimeSubscriptions({
|
||||
@@ -927,9 +613,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
||||
showNewItems,
|
||||
interactingWith,
|
||||
markInteracting,
|
||||
refresh: async () => {
|
||||
await fetchItems(false);
|
||||
},
|
||||
refresh,
|
||||
performAction,
|
||||
deleteSubmission,
|
||||
resetToPending,
|
||||
|
||||
Reference in New Issue
Block a user