mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 17:31:12 -05:00
feat: Implement Modern React Patterns
This commit is contained in:
@@ -53,7 +53,8 @@ const queryClient = new QueryClient({
|
|||||||
refetchOnMount: true, // Keep refetch on component mount
|
refetchOnMount: true, // Keep refetch on component mount
|
||||||
refetchOnReconnect: true, // Keep refetch on network reconnect
|
refetchOnReconnect: true, // Keep refetch on network reconnect
|
||||||
retry: 1, // Keep retry attempts
|
retry: 1, // Keep retry attempts
|
||||||
staleTime: 0, // Keep data fresh
|
staleTime: 30000, // 30 seconds - queries stay fresh for 30s
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes - keep in cache for 5 mins
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export type {
|
|||||||
UseRealtimeSubscriptionsReturn
|
UseRealtimeSubscriptionsReturn
|
||||||
} from './useRealtimeSubscriptions';
|
} from './useRealtimeSubscriptions';
|
||||||
|
|
||||||
|
export { useQueueQuery } from './useQueueQuery';
|
||||||
|
export type { UseQueueQueryConfig, UseQueueQueryReturn } from './useQueueQuery';
|
||||||
|
|
||||||
export { useModerationQueueManager } from './useModerationQueueManager';
|
export { useModerationQueueManager } from './useModerationQueueManager';
|
||||||
export type {
|
export type {
|
||||||
ModerationQueueManager,
|
ModerationQueueManager,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useModerationFilters,
|
useModerationFilters,
|
||||||
usePagination,
|
usePagination,
|
||||||
useRealtimeSubscriptions,
|
useRealtimeSubscriptions,
|
||||||
|
useQueueQuery,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
import { useModerationQueue } from "@/hooks/useModerationQueue";
|
import { useModerationQueue } from "@/hooks/useModerationQueue";
|
||||||
|
|
||||||
@@ -122,13 +123,9 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
|
|
||||||
// Refs for tracking
|
// Refs for tracking
|
||||||
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
||||||
const fetchInProgressRef = useRef(false);
|
|
||||||
const lastFetchTimeRef = useRef<number>(0);
|
|
||||||
const initialFetchCompleteRef = useRef(false);
|
const initialFetchCompleteRef = useRef(false);
|
||||||
const isMountingRef = useRef(true);
|
const isMountingRef = useRef(true);
|
||||||
|
|
||||||
const FETCH_COOLDOWN_MS = 1000;
|
|
||||||
|
|
||||||
// Store settings, filters, and pagination in refs to stabilize fetchItems
|
// Store settings, filters, and pagination in refs to stabilize fetchItems
|
||||||
const settingsRef = useRef(settings);
|
const settingsRef = useRef(settings);
|
||||||
const filtersRef = useRef(filters);
|
const filtersRef = useRef(filters);
|
||||||
@@ -153,366 +150,70 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
}, [pagination]);
|
}, [pagination]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch queue items from database
|
* Replace manual fetching with TanStack Query
|
||||||
*/
|
*/
|
||||||
const fetchItems = useCallback(
|
const queueQuery = useQueueQuery({
|
||||||
async (silent = false) => {
|
userId: user?.id,
|
||||||
if (!user) return;
|
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
|
// Update items when query data changes
|
||||||
const callerStack = new Error().stack;
|
useEffect(() => {
|
||||||
const callerLine = callerStack?.split("\n")[2]?.trim();
|
if (queueQuery.items) {
|
||||||
|
setItems(queueQuery.items);
|
||||||
|
console.log('✅ Queue items updated from TanStack Query:', queueQuery.items.length);
|
||||||
|
}
|
||||||
|
}, [queueQuery.items]);
|
||||||
|
|
||||||
console.log("🔄 [FETCH ITEMS] Called", {
|
// Update loading state based on query status
|
||||||
silent,
|
useEffect(() => {
|
||||||
documentHidden: document.hidden,
|
if (queueQuery.isLoading) {
|
||||||
caller: callerLine,
|
setLoadingState('loading');
|
||||||
sortField: sortRef.current.field,
|
} else if (queueQuery.isRefreshing) {
|
||||||
sortDirection: sortRef.current.direction,
|
setLoadingState('refreshing');
|
||||||
timestamp: new Date().toISOString(),
|
} else {
|
||||||
});
|
setLoadingState('ready');
|
||||||
|
}
|
||||||
|
}, [queueQuery.isLoading, queueQuery.isRefreshing]);
|
||||||
|
|
||||||
// Prevent concurrent calls
|
// Update total count for pagination
|
||||||
if (fetchInProgressRef.current) {
|
useEffect(() => {
|
||||||
console.log("⚠️ Fetch already in progress, skipping");
|
paginationRef.current.setTotalCount(queueQuery.totalCount);
|
||||||
return;
|
}, [queueQuery.totalCount]);
|
||||||
}
|
|
||||||
|
|
||||||
// Cooldown check
|
// Mark initial fetch as complete
|
||||||
const now = Date.now();
|
useEffect(() => {
|
||||||
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
if (!queueQuery.isLoading && !initialFetchCompleteRef.current) {
|
||||||
if (timeSinceLastFetch < FETCH_COOLDOWN_MS && lastFetchTimeRef.current > 0) {
|
initialFetchCompleteRef.current = true;
|
||||||
console.log(`⏸️ Fetch cooldown active (${timeSinceLastFetch}ms)`);
|
console.log('✅ Initial queue fetch complete');
|
||||||
return;
|
}
|
||||||
}
|
}, [queueQuery.isLoading]);
|
||||||
|
|
||||||
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
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show pending new items by merging them into the queue
|
* Manual refresh function
|
||||||
*/
|
*/
|
||||||
const showNewItems = useCallback(() => {
|
const refresh = useCallback(async () => {
|
||||||
if (pendingNewItems.length > 0) {
|
console.log('🔄 Manual refresh triggered');
|
||||||
setItems((prev) => [...pendingNewItems, ...prev]);
|
await queueQuery.refetch();
|
||||||
setPendingNewItems([]);
|
}, [queueQuery]);
|
||||||
setNewItemsCount(0);
|
|
||||||
console.log("✅ New items merged into queue:", pendingNewItems.length);
|
/**
|
||||||
}
|
* Show pending new items by invalidating query
|
||||||
}, [pendingNewItems]);
|
*/
|
||||||
|
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)
|
* Mark an item as being interacted with (prevents realtime updates)
|
||||||
@@ -808,51 +509,36 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
[filters.statusFilter, toast],
|
[filters.statusFilter, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial fetch on mount
|
// Mark initial fetch as complete when query loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!queueQuery.isLoading && !initialFetchCompleteRef.current) {
|
||||||
|
|
||||||
isMountingRef.current = true;
|
|
||||||
fetchItems(false).then(() => {
|
|
||||||
initialFetchCompleteRef.current = true;
|
initialFetchCompleteRef.current = true;
|
||||||
requestAnimationFrame(() => {
|
isMountingRef.current = false;
|
||||||
isMountingRef.current = false;
|
console.log('✅ Initial queue fetch complete');
|
||||||
});
|
}
|
||||||
});
|
}, [queueQuery.isLoading]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [user?.id]);
|
|
||||||
|
|
||||||
// Filter and sort changes trigger refetch
|
// Invalidate query when filters or sort changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !initialFetchCompleteRef.current || isMountingRef.current) return;
|
if (
|
||||||
|
!user ||
|
||||||
console.log('🔄 [SORT/FILTER CHANGE] Detected change:', {
|
!initialFetchCompleteRef.current ||
|
||||||
entityFilter: filters.debouncedEntityFilter,
|
isMountingRef.current
|
||||||
statusFilter: filters.debouncedStatusFilter,
|
) return;
|
||||||
sortField: filters.debouncedSortConfig.field,
|
|
||||||
sortDirection: filters.debouncedSortConfig.direction,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
|
console.log('🔄 Filters/sort changed, invalidating query');
|
||||||
pagination.reset();
|
pagination.reset();
|
||||||
fetchItems(false);
|
queueQuery.invalidate();
|
||||||
}, [
|
}, [
|
||||||
filters.debouncedEntityFilter,
|
filters.debouncedEntityFilter,
|
||||||
filters.debouncedStatusFilter,
|
filters.debouncedStatusFilter,
|
||||||
filters.debouncedSortConfig.field,
|
filters.debouncedSortConfig.field,
|
||||||
filters.debouncedSortConfig.direction,
|
filters.debouncedSortConfig.direction,
|
||||||
user,
|
user,
|
||||||
fetchItems,
|
queueQuery,
|
||||||
pagination
|
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)
|
// Polling effect (when realtime disabled)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || settings.refreshMode !== "auto" || loadingState === "initial" || settings.useRealtimeQueue) {
|
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);
|
console.log("⚠️ Polling ENABLED - interval:", settings.pollInterval);
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
console.log("🔄 Polling refresh triggered");
|
console.log("🔄 Polling refresh triggered");
|
||||||
fetchItems(true);
|
queueQuery.refetch();
|
||||||
}, settings.pollInterval);
|
}, settings.pollInterval);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
console.log("🛑 Polling stopped");
|
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
|
// Initialize realtime subscriptions
|
||||||
useRealtimeSubscriptions({
|
useRealtimeSubscriptions({
|
||||||
@@ -927,9 +613,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
|
|||||||
showNewItems,
|
showNewItems,
|
||||||
interactingWith,
|
interactingWith,
|
||||||
markInteracting,
|
markInteracting,
|
||||||
refresh: async () => {
|
refresh,
|
||||||
await fetchItems(false);
|
|
||||||
},
|
|
||||||
performAction,
|
performAction,
|
||||||
deleteSubmission,
|
deleteSubmission,
|
||||||
resetToPending,
|
resetToPending,
|
||||||
|
|||||||
141
src/hooks/moderation/useQueueQuery.ts
Normal file
141
src/hooks/moderation/useQueueQuery.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* TanStack Query hook for moderation queue data fetching
|
||||||
|
*
|
||||||
|
* Wraps the existing fetchSubmissions query builder with React Query
|
||||||
|
* to provide automatic caching, deduplication, and background refetching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { fetchSubmissions, type QueryConfig } from '@/lib/moderation/queries';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import type { ModerationItem } from '@/types/moderation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for queue query
|
||||||
|
*/
|
||||||
|
export interface UseQueueQueryConfig {
|
||||||
|
/** User making the query */
|
||||||
|
userId: string | undefined;
|
||||||
|
|
||||||
|
/** Whether user is admin */
|
||||||
|
isAdmin: boolean;
|
||||||
|
|
||||||
|
/** Whether user is superuser */
|
||||||
|
isSuperuser: boolean;
|
||||||
|
|
||||||
|
/** Entity filter */
|
||||||
|
entityFilter: string;
|
||||||
|
|
||||||
|
/** Status filter */
|
||||||
|
statusFilter: string;
|
||||||
|
|
||||||
|
/** Active tab */
|
||||||
|
tab: 'mainQueue' | 'archive';
|
||||||
|
|
||||||
|
/** Current page */
|
||||||
|
currentPage: number;
|
||||||
|
|
||||||
|
/** Page size */
|
||||||
|
pageSize: number;
|
||||||
|
|
||||||
|
/** Sort configuration */
|
||||||
|
sortConfig: {
|
||||||
|
field: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whether query is enabled (defaults to true) */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for useQueueQuery
|
||||||
|
*/
|
||||||
|
export interface UseQueueQueryReturn {
|
||||||
|
/** Queue items */
|
||||||
|
items: ModerationItem[];
|
||||||
|
|
||||||
|
/** Total count of items matching filters */
|
||||||
|
totalCount: number;
|
||||||
|
|
||||||
|
/** Initial loading state (no data yet) */
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
/** Background refresh in progress (has data already) */
|
||||||
|
isRefreshing: boolean;
|
||||||
|
|
||||||
|
/** Any error that occurred */
|
||||||
|
error: Error | null;
|
||||||
|
|
||||||
|
/** Manually trigger a refetch */
|
||||||
|
refetch: () => Promise<any>;
|
||||||
|
|
||||||
|
/** Invalidate this query (triggers background refetch) */
|
||||||
|
invalidate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch moderation queue data using TanStack Query
|
||||||
|
*/
|
||||||
|
export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Build query config for fetchSubmissions
|
||||||
|
const queryConfig: QueryConfig = {
|
||||||
|
userId: config.userId || '',
|
||||||
|
isAdmin: config.isAdmin,
|
||||||
|
isSuperuser: config.isSuperuser,
|
||||||
|
entityFilter: config.entityFilter as any,
|
||||||
|
statusFilter: config.statusFilter as any,
|
||||||
|
tab: config.tab,
|
||||||
|
currentPage: config.currentPage,
|
||||||
|
pageSize: config.pageSize,
|
||||||
|
sortConfig: config.sortConfig as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create stable query key (TanStack Query uses this for caching/deduplication)
|
||||||
|
const queryKey = [
|
||||||
|
'moderation-queue',
|
||||||
|
config.entityFilter,
|
||||||
|
config.statusFilter,
|
||||||
|
config.tab,
|
||||||
|
config.currentPage,
|
||||||
|
config.pageSize,
|
||||||
|
config.sortConfig.field,
|
||||||
|
config.sortConfig.direction,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
console.log('🔍 [TanStack Query] Fetching queue data:', queryKey);
|
||||||
|
const result = await fetchSubmissions(supabase, queryConfig);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ [TanStack Query] Fetched', result.submissions.length, 'items');
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
enabled: config.enabled !== false && !!config.userId,
|
||||||
|
staleTime: 30000, // 30 seconds
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate helper
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: query.data?.submissions || [],
|
||||||
|
totalCount: query.data?.totalCount || 0,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isRefreshing: query.isFetching && !query.isLoading,
|
||||||
|
error: query.error as Error | null,
|
||||||
|
refetch: query.refetch,
|
||||||
|
invalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
import type { ModerationItem, EntityFilter, StatusFilter } from '@/types/moderation';
|
import type { ModerationItem, EntityFilter, StatusFilter } from '@/types/moderation';
|
||||||
@@ -85,6 +86,8 @@ export interface UseRealtimeSubscriptionsReturn {
|
|||||||
export function useRealtimeSubscriptions(
|
export function useRealtimeSubscriptions(
|
||||||
config: RealtimeSubscriptionConfig
|
config: RealtimeSubscriptionConfig
|
||||||
): UseRealtimeSubscriptionsReturn {
|
): UseRealtimeSubscriptionsReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
enabled,
|
enabled,
|
||||||
filters,
|
filters,
|
||||||
@@ -268,21 +271,22 @@ export function useRealtimeSubscriptions(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ NEW submission matches filters:', newSubmission.id);
|
console.log('✅ NEW submission matches filters, invalidating query:', newSubmission.id);
|
||||||
|
|
||||||
// Fetch full submission details
|
// Invalidate the query to trigger background refetch
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
|
||||||
|
|
||||||
|
// Call legacy callback for new item notification
|
||||||
|
// (This maintains compatibility with NewItemsAlert component)
|
||||||
try {
|
try {
|
||||||
const submission = await fetchSubmissionDetails(newSubmission.id);
|
const submission = await fetchSubmissionDetails(newSubmission.id);
|
||||||
if (!submission) return;
|
if (!submission) return;
|
||||||
|
|
||||||
// Fetch user profile
|
|
||||||
const profile = await profileCache.bulkFetch([submission.user_id]);
|
const profile = await profileCache.bulkFetch([submission.user_id]);
|
||||||
const userProfile = profile[0];
|
const userProfile = profile[0];
|
||||||
|
|
||||||
// Resolve entity names
|
|
||||||
const { entityName, parkName } = await resolveEntityNames(submission);
|
const { entityName, parkName } = await resolveEntityNames(submission);
|
||||||
|
|
||||||
// Build full ModerationItem
|
|
||||||
const fullItem = buildModerationItem(
|
const fullItem = buildModerationItem(
|
||||||
submission,
|
submission,
|
||||||
userProfile,
|
userProfile,
|
||||||
@@ -290,17 +294,15 @@ export function useRealtimeSubscriptions(
|
|||||||
parkName
|
parkName
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger callback
|
|
||||||
onNewItem(fullItem);
|
onNewItem(fullItem);
|
||||||
|
|
||||||
console.log('🎉 New submission added to queue:', fullItem.id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing new submission:', error);
|
console.error('Error building new item notification:', error);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
filters,
|
filters,
|
||||||
pauseWhenHidden,
|
pauseWhenHidden,
|
||||||
recentlyRemovedIds,
|
recentlyRemovedIds,
|
||||||
|
queryClient,
|
||||||
fetchSubmissionDetails,
|
fetchSubmissionDetails,
|
||||||
profileCache,
|
profileCache,
|
||||||
resolveEntityNames,
|
resolveEntityNames,
|
||||||
@@ -335,56 +337,18 @@ export function useRealtimeSubscriptions(
|
|||||||
|
|
||||||
// Debounce the update
|
// Debounce the update
|
||||||
debouncedUpdate(updatedSubmission.id, async () => {
|
debouncedUpdate(updatedSubmission.id, async () => {
|
||||||
// Check if submission matches current filters
|
console.log('🔄 Invalidating query due to UPDATE:', updatedSubmission.id);
|
||||||
|
|
||||||
|
// Simply invalidate the query - TanStack Query handles the rest
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
|
||||||
|
|
||||||
|
// Legacy callback for compatibility
|
||||||
const matchesEntity = matchesEntityFilter(updatedSubmission, filters.entityFilter);
|
const matchesEntity = matchesEntityFilter(updatedSubmission, filters.entityFilter);
|
||||||
const matchesStatus = matchesStatusFilter(updatedSubmission, filters.statusFilter);
|
const matchesStatus = matchesStatusFilter(updatedSubmission, filters.statusFilter);
|
||||||
|
|
||||||
const wasInQueue = currentItemsRef.current.some(i => i.id === updatedSubmission.id);
|
|
||||||
const shouldBeInQueue = matchesEntity && matchesStatus;
|
const shouldBeInQueue = matchesEntity && matchesStatus;
|
||||||
|
|
||||||
if (wasInQueue && !shouldBeInQueue) {
|
|
||||||
// Submission moved out of current filter (e.g., pending → approved)
|
|
||||||
console.log('❌ Submission moved out of queue:', updatedSubmission.id);
|
|
||||||
onItemRemoved(updatedSubmission.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldBeInQueue) {
|
if (!shouldBeInQueue) {
|
||||||
// Item doesn't belong in queue at all
|
onItemRemoved(updatedSubmission.id);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch full details
|
|
||||||
try {
|
|
||||||
const submission = await fetchSubmissionDetails(updatedSubmission.id);
|
|
||||||
if (!submission) return;
|
|
||||||
|
|
||||||
// Get user profile
|
|
||||||
const profiles = await profileCache.bulkFetch([submission.user_id]);
|
|
||||||
const profile = profiles[0];
|
|
||||||
|
|
||||||
// Resolve entity name (simplified for updates)
|
|
||||||
const content = submission.content as any;
|
|
||||||
const entityName = content?.name || 'Unknown';
|
|
||||||
|
|
||||||
const fullItem = buildModerationItem(
|
|
||||||
submission,
|
|
||||||
profile,
|
|
||||||
entityName,
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if item actually changed
|
|
||||||
const currentItem = currentItemsRef.current.find(i => i.id === fullItem.id);
|
|
||||||
if (currentItem && !hasItemChanged(currentItem, fullItem)) {
|
|
||||||
console.log('✅ Realtime UPDATE: No changes detected for', fullItem.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔄 Realtime UPDATE: Changes detected for', fullItem.id);
|
|
||||||
onUpdateItem(fullItem, false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing updated submission:', error);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
@@ -393,9 +357,7 @@ export function useRealtimeSubscriptions(
|
|||||||
recentlyRemovedIds,
|
recentlyRemovedIds,
|
||||||
interactingWithIds,
|
interactingWithIds,
|
||||||
debouncedUpdate,
|
debouncedUpdate,
|
||||||
fetchSubmissionDetails,
|
queryClient,
|
||||||
profileCache,
|
|
||||||
onUpdateItem,
|
|
||||||
onItemRemoved,
|
onItemRemoved,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user