diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index bb308199..2340a517 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -1,5 +1,6 @@ import { useState, useImperativeHandle, forwardRef, useMemo } from 'react'; import { Card, CardContent } from '@/components/ui/card'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; @@ -19,7 +20,8 @@ import { AutoRefreshIndicator } from './AutoRefreshIndicator'; import { NewItemsAlert } from './NewItemsAlert'; import { EmptyQueueState } from './EmptyQueueState'; import { QueuePagination } from './QueuePagination'; -import type { ModerationQueueRef } from '@/types/moderation'; +import type { ModerationQueueRef, QueueTab } from '@/types/moderation'; +import { AlertTriangle, Clock, Archive } from 'lucide-react'; export const ModerationQueue = forwardRef((props, ref) => { const isMobile = useIsMobile(); @@ -87,6 +89,28 @@ export const ModerationQueue = forwardRef((props, ref) => { return (
+ {/* Queue Tabs */} + queueManager.filters.setActiveTab(value as QueueTab)} + className="w-full" + > + + + + Main Queue + + + + Escalated + + + + Archive + + + + {/* Queue Statistics & Lock Status */} {queueManager.queue.queueStats && ( diff --git a/src/hooks/moderation/useModerationQueueManager.ts b/src/hooks/moderation/useModerationQueueManager.ts index fca62d6c..129b4ce0 100644 --- a/src/hooks/moderation/useModerationQueueManager.ts +++ b/src/hooks/moderation/useModerationQueueManager.ts @@ -12,7 +12,7 @@ import { } from "./index"; import { useModerationQueue } from "@/hooks/useModerationQueue"; import { smartMergeArray } from "@/lib/smartStateUpdate"; -import type { ModerationItem, EntityFilter, StatusFilter, LoadingState, SortConfig, SortField } from "@/types/moderation"; +import type { ModerationItem, EntityFilter, StatusFilter, LoadingState, SortConfig } from "@/types/moderation"; /** * Configuration for useModerationQueueManager @@ -100,29 +100,11 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): }); const sort = useModerationSort({ - initialConfig: { field: "created_at", direction: "desc" }, + initialConfig: { field: "created_at", direction: "asc" }, persist: true, storageKey: "moderationQueue_sortConfig", }); - /** - * Map UI sort field to actual database column - */ - const getSortColumn = (field: SortField): string => { - switch (field) { - case 'created_at': - return 'created_at'; - case 'submission_type': - return 'submission_type'; - case 'status': - return 'status'; - case 'escalated': - return 'escalated'; - default: - return 'created_at'; - } - }; - const queue = useModerationQueue(); const entityCache = useEntityCache(); const profileCache = useProfileCache(); @@ -145,7 +127,9 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): const isMountingRef = useRef(true); const fetchItemsRef = useRef<((silent?: boolean) => Promise) | null>(null); - const FETCH_COOLDOWN_MS = 300; // Match filter debounce delay for responsive sort changes + const FETCH_COOLDOWN_MS = 1000; + const EFFECT_DEBOUNCE_MS = 50; // Short debounce to let all effects settle + const effectFetchTimerRef = useRef(null); // Store settings in refs to avoid re-creating fetchItems const settingsRef = useRef(settings); @@ -162,14 +146,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): * Fetch queue items from database */ const fetchItems = useCallback( - async (silent = false, bypassCooldown = false) => { - console.log('πŸ”„ [fetchItems RECREATED]', { - sortField: sort.field, - sortDirection: sort.direction, - bypassCooldown, - timestamp: new Date().toISOString() - }); - + async (silent = false) => { if (!user) return; // Get caller info @@ -196,10 +173,10 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): return; } - // Cooldown check (can be bypassed for critical operations like sort changes) + // Cooldown check const now = Date.now(); const timeSinceLastFetch = now - lastFetchTimeRef.current; - if (!bypassCooldown && timeSinceLastFetch < FETCH_COOLDOWN_MS && lastFetchTimeRef.current > 0) { + if (timeSinceLastFetch < FETCH_COOLDOWN_MS && lastFetchTimeRef.current > 0) { console.log(`⏸️ Fetch cooldown active (${timeSinceLastFetch}ms)`); return; } @@ -245,15 +222,48 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): item_data, status ) - ` + `, ); + + // Validate sort field is an actual column in content_submissions + const validSortFields = ['created_at', 'submission_type', 'status', 'escalated', 'submitted_at']; + let sortField = sort.config.field; + + if (!validSortFields.includes(sortField)) { + console.warn('[Query] Invalid sort field:', sortField, '- falling back to created_at'); + sortField = 'created_at'; + } + + console.log('[Query] Sorting by:', { + field: sortField, + direction: sort.config.direction, + ascending: sort.config.direction === 'asc' + }); + + // Apply sorting by user's chosen field only + submissionsQuery = submissionsQuery + .order(sortField, { ascending: sort.config.direction === 'asc' }); - // Apply tab-based status filtering FIRST + // Apply tab-based status filtering const tab = filters.activeTab; const statusFilter = filters.debouncedStatusFilter; const entityFilter = filters.debouncedEntityFilter; if (tab === "mainQueue") { + // Main queue: non-escalated pending items + submissionsQuery = submissionsQuery.eq("escalated", false); + + 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 (tab === "escalated") { + // Escalated queue: only escalated items + submissionsQuery = submissionsQuery.eq("escalated", true); + if (statusFilter === "all") { submissionsQuery = submissionsQuery.in("status", ["pending", "flagged", "partially_approved"]); } else if (statusFilter === "pending") { @@ -262,6 +272,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): submissionsQuery = submissionsQuery.eq("status", statusFilter); } } else { + // Archive: completed items (non-escalated and escalated) if (statusFilter === "all") { submissionsQuery = submissionsQuery.in("status", ["approved", "rejected"]); } else { @@ -276,7 +287,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): submissionsQuery = submissionsQuery.neq("submission_type", "photo"); } - // Apply access control (must be AFTER status/entity filters to work as AND) + // Apply access control if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); submissionsQuery = submissionsQuery.or( @@ -284,41 +295,25 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): ); } - // Apply user-selected sort configuration - const sortColumn = getSortColumn(sort.field); - const sortAscending = sort.direction === 'asc'; - - console.log('[Query] Applying sort:', { - sortLevels: [ - '1. escalated DESC (always first)', - `2. ${sortColumn} ${sortAscending ? 'ASC' : 'DESC'} (user selected)`, - sortColumn !== 'created_at' ? '3. created_at ASC (tie-breaker)' : null - ].filter(Boolean), - uiField: sort.field, - dbColumn: sortColumn, - direction: sort.direction, - ascending: sortAscending, - timestamp: new Date().toISOString() - }); - - // Always prioritize escalated submissions first - submissionsQuery = submissionsQuery.order('escalated', { ascending: false }); - - // Then apply user-selected sort - submissionsQuery = submissionsQuery.order(sortColumn, { ascending: sortAscending }); - - // Tertiary sort by created_at for consistency (only if not already the user's sort choice) - if (sortColumn !== 'created_at') { - submissionsQuery = submissionsQuery.order('created_at', { ascending: true }); - } - - // Get total count for pagination (rebuild query with same filters in SAME ORDER) + // Get total count - rebuild query with same filters let countQuery = supabase .from("content_submissions") .select("*", { count: "exact", head: true }); - // Apply same filters as main query - status filter FIRST + // Apply the exact same filters as the main query if (tab === "mainQueue") { + countQuery = countQuery.eq("escalated", false); + + 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 (tab === "escalated") { + countQuery = countQuery.eq("escalated", true); + if (statusFilter === "all") { countQuery = countQuery.in("status", ["pending", "flagged", "partially_approved"]); } else if (statusFilter === "pending") { @@ -334,14 +329,14 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): } } - // Entity type filter + // Apply entity type filter if (entityFilter === "photos") { countQuery = countQuery.eq("submission_type", "photo"); } else if (entityFilter === "submissions") { countQuery = countQuery.neq("submission_type", "photo"); } - // Access control (AFTER status/entity filters) + // Apply access control if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); countQuery = countQuery.or( @@ -357,40 +352,10 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): const endIndex = pagination.endIndex; submissionsQuery = submissionsQuery.range(startIndex, endIndex); - // Log the final query for debugging - console.log('[Query] Final query about to execute:', { - sortApplied: sortColumn, - sortDirection: sortAscending ? 'ASC' : 'DESC', - filtersApplied: { - tab: filters.activeTab, - status: filters.debouncedStatusFilter, - entity: filters.debouncedEntityFilter, - }, - pagination: { - startIndex, - endIndex - } - }); - const { data: submissions, error: submissionsError } = await submissionsQuery; if (submissionsError) throw submissionsError; - // VALIDATE: Log first few items to verify sort is working - if (submissions && submissions.length > 0) { - console.log('[Query] Results returned (first 3 items):', { - sortLevels: `escalated DESC β†’ ${sortColumn} ${sortAscending ? 'ASC' : 'DESC'}`, - items: submissions.slice(0, 3).map(s => ({ - id: s.id.substring(0, 8), - escalated: s.escalated, - type: s.submission_type, - status: s.status, - created: s.created_at, - sortValue: s[sortColumn as keyof typeof s] - })) - }); - } - // Fetch related profiles and entities const userIds = [ ...new Set([ @@ -565,7 +530,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): setLoadingState("ready"); } }, - [user, isAdmin, isSuperuser, filters, pagination, profileCache, entityCache, toast, sort.field, sort.direction], + [user, isAdmin, isSuperuser, filters, pagination, sort, profileCache, entityCache, toast], ); // Store fetchItems in ref to avoid re-creating visibility listener @@ -893,20 +858,53 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.id]); - // Filter changes trigger refetch + // Debounced fetch for effects - prevents race conditions + const debouncedEffectFetch = useCallback(() => { + if (effectFetchTimerRef.current) { + clearTimeout(effectFetchTimerRef.current); + } + + effectFetchTimerRef.current = setTimeout(() => { + console.log('[Debounced Fetch] Executing after effects settled'); + fetchItemsRef.current?.(true); + }, EFFECT_DEBOUNCE_MS); + }, []); + + // Filter and tab changes trigger refetch useEffect(() => { if (!user || !initialFetchCompleteRef.current || isMountingRef.current) return; + console.log('[Filter/Tab Change] Queuing debounced fetch'); pagination.reset(); - fetchItems(true); - }, [filters.debouncedEntityFilter, filters.debouncedStatusFilter]); + debouncedEffectFetch(); + }, [filters.activeTab, filters.debouncedEntityFilter, filters.debouncedStatusFilter, user, debouncedEffectFetch, pagination]); + + // Sort changes trigger refetch + useEffect(() => { + if (!user || !initialFetchCompleteRef.current || isMountingRef.current) { + return; + } + + console.log('[Sort Change] Queuing debounced fetch'); + pagination.reset(); + debouncedEffectFetch(); + }, [sort.field, sort.direction, user, pagination, debouncedEffectFetch]); // Pagination changes trigger refetch useEffect(() => { if (!user || !initialFetchCompleteRef.current || pagination.currentPage === 1) return; - fetchItemsRef.current?.(true); - }, [pagination.currentPage, pagination.pageSize]); + debouncedEffectFetch(); + }, [pagination.currentPage, pagination.pageSize, debouncedEffectFetch]); + + // Cleanup effect timer on unmount + useEffect(() => { + return () => { + if (effectFetchTimerRef.current) { + clearTimeout(effectFetchTimerRef.current); + } + }; + }, []); // Polling effect (when realtime disabled) useEffect(() => { @@ -988,36 +986,6 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): }; }, [settings.refreshOnTabVisible]); - // Refetch when sort configuration changes - useEffect(() => { - console.log('πŸ”„ [Sort Changed]', { - field: sort.field, - direction: sort.direction, - timestamp: new Date().toISOString() - }); - - // Skip if initial fetch hasn't completed yet - if (!initialFetchCompleteRef.current) { - console.log('⏭️ Skipping sort refetch (initial fetch not complete)'); - return; - } - - // Skip if mounting - if (isMountingRef.current) { - console.log('⏭️ Skipping sort refetch (mounting)'); - return; - } - - console.log('βœ… Triggering refetch due to sort change', { - willUseField: sort.field, - willUseDirection: sort.direction - }); - - // Call fetchItems directly (guaranteed to have latest sort values in closure) - // Use bypass to skip cooldown for immediate sort response - fetchItems(false, true); - }, [sort.field, sort.direction, fetchItems]); - // Initialize realtime subscriptions useRealtimeSubscriptions({ enabled: settings.useRealtimeQueue && !!user, diff --git a/src/hooks/moderation/useModerationSort.ts b/src/hooks/moderation/useModerationSort.ts index 1aaefae7..35daef6e 100644 --- a/src/hooks/moderation/useModerationSort.ts +++ b/src/hooks/moderation/useModerationSort.ts @@ -4,7 +4,7 @@ * Manages sort configuration for the moderation queue with persistence. */ -import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import type { SortConfig, SortField, SortDirection } from '@/types/moderation'; import { getDefaultSortConfig, @@ -87,24 +87,9 @@ export function useModerationSort(config: ModerationSortConfig = {}): Moderation // Load persisted or use initial/default config const [sortConfig, setSortConfig] = useState(() => { - // Priority order: - // 1. Saved config from localStorage (if persist enabled and exists) - // 2. initialConfig prop (if provided) - // 3. Global default (fallback) - - if (persist) { - try { - const saved = localStorage.getItem(storageKey); - if (saved) { - return JSON.parse(saved); - } - } catch (error) { - console.error('Failed to load sort config:', error); - } - } - - // Use initialConfig if provided, otherwise use global default - return initialConfig || getDefaultSortConfig(); + if (initialConfig) return initialConfig; + if (persist) return loadSortConfig(storageKey); + return getDefaultSortConfig(); }); // Persist changes @@ -146,7 +131,7 @@ export function useModerationSort(config: ModerationSortConfig = {}): Moderation // Check if using default config const isDefault = isDefaultSortConfig(sortConfig); - return useMemo(() => ({ + return { config: sortConfig, field: sortConfig.field, direction: sortConfig.direction, @@ -156,5 +141,5 @@ export function useModerationSort(config: ModerationSortConfig = {}): Moderation setConfig, reset, isDefault, - }), [sortConfig, setField, setDirection, toggleSortDirection, setConfig, reset, isDefault]); + }; } diff --git a/src/lib/moderation/queries.ts b/src/lib/moderation/queries.ts index 737b8723..264b689d 100644 --- a/src/lib/moderation/queries.ts +++ b/src/lib/moderation/queries.ts @@ -73,9 +73,7 @@ export function buildSubmissionQuery( item_data, status ) - `) - .order('escalated', { ascending: false }) - .order('created_at', { ascending: true }); + `); // Apply tab-based status filtering if (tab === 'mainQueue') { diff --git a/src/lib/moderation/sorting.ts b/src/lib/moderation/sorting.ts index fdf658e4..61e9cba7 100644 --- a/src/lib/moderation/sorting.ts +++ b/src/lib/moderation/sorting.ts @@ -57,7 +57,7 @@ export function sortModerationItems( export function getDefaultSortConfig(): SortConfig { return { field: 'created_at', - direction: 'desc', // Newest first by default + direction: 'asc', }; } @@ -71,7 +71,16 @@ export function loadSortConfig(key: string = 'moderationQueue_sortConfig'): Sort try { const saved = localStorage.getItem(key); if (saved) { - return JSON.parse(saved); + const config = JSON.parse(saved); + + // Migrate old 'username' sort to 'created_at' + if (config.field === 'username') { + console.warn('[Sort] Migrating deprecated username sort to created_at'); + config.field = 'created_at'; + saveSortConfig(config, key); // Save the migrated config + } + + return config; } } catch (error) { console.error('Failed to load sort config:', error); diff --git a/src/types/moderation.ts b/src/types/moderation.ts index ae93bd55..17edbf32 100644 --- a/src/types/moderation.ts +++ b/src/types/moderation.ts @@ -97,7 +97,7 @@ export type StatusFilter = 'all' | 'pending' | 'partially_approved' | 'flagged' /** * Available tabs in the moderation interface */ -export type QueueTab = 'mainQueue' | 'archive'; +export type QueueTab = 'mainQueue' | 'escalated' | 'archive'; /** * Fields that can be used for sorting the moderation queue