feat: Implement Modern React Patterns

This commit is contained in:
gpt-engineer-app[bot]
2025-10-13 22:40:35 +00:00
parent 2bd0b414e1
commit 787e16753e
5 changed files with 242 additions and 451 deletions

View File

@@ -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
},
},
});

View File

@@ -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,

View File

@@ -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,

View 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,
};
}

View File

@@ -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,
]);