mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 13:51: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
|
||||
refetchOnReconnect: true, // Keep refetch on network reconnect
|
||||
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
|
||||
} from './useRealtimeSubscriptions';
|
||||
|
||||
export { useQueueQuery } from './useQueueQuery';
|
||||
export type { UseQueueQueryConfig, UseQueueQueryReturn } from './useQueueQuery';
|
||||
|
||||
export { useModerationQueueManager } from './useModerationQueueManager';
|
||||
export type {
|
||||
ModerationQueueManager,
|
||||
|
||||
@@ -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;
|
||||
|
||||
// Get caller info
|
||||
const callerStack = new Error().stack;
|
||||
const callerLine = callerStack?.split("\n")[2]?.trim();
|
||||
|
||||
console.log("🔄 [FETCH ITEMS] Called", {
|
||||
silent,
|
||||
documentHidden: document.hidden,
|
||||
caller: callerLine,
|
||||
sortField: sortRef.current.field,
|
||||
sortDirection: sortRef.current.direction,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Prevent concurrent calls
|
||||
if (fetchInProgressRef.current) {
|
||||
console.log("⚠️ Fetch already in progress, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
const queueQuery = useQueueQuery({
|
||||
userId: user?.id,
|
||||
isAdmin,
|
||||
isSuperuser,
|
||||
profileCache,
|
||||
entityCache,
|
||||
toast
|
||||
],
|
||||
);
|
||||
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,
|
||||
});
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Update total count for pagination
|
||||
useEffect(() => {
|
||||
paginationRef.current.setTotalCount(queueQuery.totalCount);
|
||||
}, [queueQuery.totalCount]);
|
||||
|
||||
// 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]);
|
||||
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);
|
||||
console.log("✅ New items merged into queue:", pendingNewItems.length);
|
||||
}
|
||||
}, [pendingNewItems]);
|
||||
}, [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]);
|
||||
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.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,
|
||||
|
||||
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 { useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import type { ModerationItem, EntityFilter, StatusFilter } from '@/types/moderation';
|
||||
@@ -85,6 +86,8 @@ export interface UseRealtimeSubscriptionsReturn {
|
||||
export function useRealtimeSubscriptions(
|
||||
config: RealtimeSubscriptionConfig
|
||||
): UseRealtimeSubscriptionsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
enabled,
|
||||
filters,
|
||||
@@ -268,21 +271,22 @@ export function useRealtimeSubscriptions(
|
||||
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 {
|
||||
const submission = await fetchSubmissionDetails(newSubmission.id);
|
||||
if (!submission) return;
|
||||
|
||||
// Fetch user profile
|
||||
const profile = await profileCache.bulkFetch([submission.user_id]);
|
||||
const userProfile = profile[0];
|
||||
|
||||
// Resolve entity names
|
||||
const { entityName, parkName } = await resolveEntityNames(submission);
|
||||
|
||||
// Build full ModerationItem
|
||||
const fullItem = buildModerationItem(
|
||||
submission,
|
||||
userProfile,
|
||||
@@ -290,17 +294,15 @@ export function useRealtimeSubscriptions(
|
||||
parkName
|
||||
);
|
||||
|
||||
// Trigger callback
|
||||
onNewItem(fullItem);
|
||||
|
||||
console.log('🎉 New submission added to queue:', fullItem.id);
|
||||
} catch (error) {
|
||||
console.error('Error processing new submission:', error);
|
||||
console.error('Error building new item notification:', error);
|
||||
}
|
||||
}, [
|
||||
filters,
|
||||
pauseWhenHidden,
|
||||
recentlyRemovedIds,
|
||||
queryClient,
|
||||
fetchSubmissionDetails,
|
||||
profileCache,
|
||||
resolveEntityNames,
|
||||
@@ -335,56 +337,18 @@ export function useRealtimeSubscriptions(
|
||||
|
||||
// Debounce the update
|
||||
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 matchesStatus = matchesStatusFilter(updatedSubmission, filters.statusFilter);
|
||||
|
||||
const wasInQueue = currentItemsRef.current.some(i => i.id === updatedSubmission.id);
|
||||
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) {
|
||||
// Item doesn't belong in queue at all
|
||||
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);
|
||||
onItemRemoved(updatedSubmission.id);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
@@ -393,9 +357,7 @@ export function useRealtimeSubscriptions(
|
||||
recentlyRemovedIds,
|
||||
interactingWithIds,
|
||||
debouncedUpdate,
|
||||
fetchSubmissionDetails,
|
||||
profileCache,
|
||||
onUpdateItem,
|
||||
queryClient,
|
||||
onItemRemoved,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user