From 1d452947032fc86afd1702923dbcfab7301f09b9 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:54:39 +0000 Subject: [PATCH] Implement realtime queue fix --- src/components/moderation/ModerationQueue.tsx | 153 +++++++++++++++++- src/hooks/useAdminSettings.ts | 7 + src/hooks/useModerationQueue.ts | 4 +- src/hooks/useModerationStats.ts | 32 +++- ...4_0e2de440-26a1-48e2-98c5-6cf792790f65.sql | 15 ++ 5 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 supabase/migrations/20251009135214_0e2de440-26a1-48e2-98c5-6cf792790f65.sql diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index e372ae2e..1c45969d 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -52,6 +52,9 @@ interface ModerationItem { display_name?: string; avatar_url?: string; }; + escalated?: boolean; + assigned_to?: string; + locked_until?: string; } type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos'; @@ -109,12 +112,14 @@ export const ModerationQueue = forwardRef((props, ref) => { getAdminPanelRefreshMode, getAdminPanelPollInterval, getAutoRefreshStrategy, - getPreserveInteractionState + getPreserveInteractionState, + getUseRealtimeQueue } = useAdminSettings(); const refreshMode = getAdminPanelRefreshMode(); const pollInterval = getAdminPanelPollInterval(); const refreshStrategy = getAutoRefreshStrategy(); const preserveInteraction = getPreserveInteractionState(); + const useRealtimeQueue = getUseRealtimeQueue(); // Store admin settings in refs to avoid triggering fetchItems recreation const refreshStrategyRef = useRef(refreshStrategy); @@ -552,9 +557,9 @@ export const ModerationQueue = forwardRef((props, ref) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedEntityFilter, debouncedStatusFilter, user]); - // Polling for auto-refresh + // Polling for auto-refresh (only if realtime is disabled) useEffect(() => { - if (!user || refreshMode !== 'auto' || isInitialLoad) return; + if (!user || refreshMode !== 'auto' || isInitialLoad || useRealtimeQueue) return; const interval = setInterval(() => { fetchItems(filtersRef.current.entityFilter, filtersRef.current.statusFilter, true); // Silent refresh @@ -564,7 +569,7 @@ export const ModerationQueue = forwardRef((props, ref) => { clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, refreshMode, pollInterval, isInitialLoad]); + }, [user, refreshMode, pollInterval, isInitialLoad, useRealtimeQueue]); // Real-time subscription for lock status useEffect(() => { @@ -611,6 +616,146 @@ export const ModerationQueue = forwardRef((props, ref) => { }; }, [user]); + // Real-time subscription for NEW submissions (replaces polling) + useEffect(() => { + if (!user || !useRealtimeQueue) return; + + const channel = supabase + .channel('moderation-new-submissions') + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'content_submissions', + }, + async (payload) => { + const newSubmission = payload.new as any; + + // Only process pending/partially_approved submissions + if (!['pending', 'partially_approved'].includes(newSubmission.status)) { + return; + } + + // Apply entity filter + const matchesEntityFilter = + filtersRef.current.entityFilter === 'all' || + (filtersRef.current.entityFilter === 'photos' && newSubmission.submission_type === 'photo') || + (filtersRef.current.entityFilter === 'submissions' && newSubmission.submission_type !== 'photo'); + + // Apply status filter + const matchesStatusFilter = + filtersRef.current.statusFilter === 'all' || + (filtersRef.current.statusFilter === 'pending' && ['pending', 'partially_approved'].includes(newSubmission.status)) || + filtersRef.current.statusFilter === newSubmission.status; + + if (matchesEntityFilter && matchesStatusFilter) { + console.log('🆕 NEW submission detected:', newSubmission.id); + + // Fetch full submission details + try { + const { data: submission, error } = await 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 + `) + .eq('id', newSubmission.id) + .single(); + + if (error || !submission) { + console.error('Error fetching submission details:', error); + return; + } + + // Fetch user profile + const { data: profile } = await supabase + .from('profiles') + .select('user_id, username, display_name, avatar_url') + .eq('user_id', submission.user_id) + .maybeSingle(); + + // Resolve entity name + const content = submission.content as any; + let entityName = content?.name || 'Unknown'; + let parkName: string | undefined; + + if (submission.submission_type === 'ride' && content?.entity_id) { + const { data: ride } = await supabase + .from('rides') + .select('name, park_id') + .eq('id', content.entity_id) + .maybeSingle(); + if (ride) { + entityName = ride.name; + if (ride.park_id) { + const { data: park } = await supabase + .from('parks') + .select('name') + .eq('id', ride.park_id) + .maybeSingle(); + if (park) parkName = park.name; + } + } + } else if (submission.submission_type === 'park' && content?.entity_id) { + const { data: park } = await supabase + .from('parks') + .select('name') + .eq('id', content.entity_id) + .maybeSingle(); + if (park) entityName = park.name; + } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type) && content?.entity_id) { + const { data: company } = await supabase + .from('companies') + .select('name') + .eq('id', content.entity_id) + .maybeSingle(); + if (company) entityName = company.name; + } + + const fullItem: ModerationItem = { + 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: profile || undefined, + entity_name: entityName, + park_name: parkName, + reviewed_at: submission.reviewed_at || undefined, + reviewer_notes: submission.reviewer_notes || undefined, + escalated: submission.escalated, + assigned_to: submission.assigned_to || undefined, + locked_until: submission.locked_until || undefined, + }; + + // Add to pending items + setPendingNewItems(prev => { + if (prev.some(p => p.id === fullItem.id)) return prev; + return [...prev, fullItem]; + }); + setNewItemsCount(prev => prev + 1); + + // Toast notification + toast({ + title: '🆕 New Submission', + description: `${fullItem.submission_type} - ${fullItem.entity_name}`, + }); + } catch (error) { + console.error('Error processing new submission:', error); + } + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [user, useRealtimeQueue, toast]); + const handleResetToPending = async (item: ModerationItem) => { setActionLoading(item.id); try { diff --git a/src/hooks/useAdminSettings.ts b/src/hooks/useAdminSettings.ts index d5f3ac2c..63064bdc 100644 --- a/src/hooks/useAdminSettings.ts +++ b/src/hooks/useAdminSettings.ts @@ -150,6 +150,12 @@ export function useAdminSettings() { return getSettingValue('notifications.recipients', []); }; + const getUseRealtimeQueue = (): boolean => { + const value = getSettingValue('system.use_realtime_queue', 'true'); + const cleanValue = typeof value === 'string' ? value.replace(/"/g, '') : value; + return cleanValue === 'true' || cleanValue === true; + }; + return { settings, isLoading, @@ -172,5 +178,6 @@ export function useAdminSettings() { getAdminPanelPollInterval, getAutoRefreshStrategy, getPreserveInteractionState, + getUseRealtimeQueue, }; } \ No newline at end of file diff --git a/src/hooks/useModerationQueue.ts b/src/hooks/useModerationQueue.ts index 8abc81c7..26a25b5d 100644 --- a/src/hooks/useModerationQueue.ts +++ b/src/hooks/useModerationQueue.ts @@ -80,11 +80,9 @@ export const useModerationQueue = () => { } }, [user]); - // Fetch stats on mount and periodically + // Fetch stats on mount only (realtime updates handled by useModerationStats) useEffect(() => { fetchStats(); - const interval = setInterval(fetchStats, 30000); // Every 30 seconds - return () => clearInterval(interval); }, [fetchStats]); // Start countdown timer for lock expiry diff --git a/src/hooks/useModerationStats.ts b/src/hooks/useModerationStats.ts index 714f608c..71dd2dd8 100644 --- a/src/hooks/useModerationStats.ts +++ b/src/hooks/useModerationStats.ts @@ -12,6 +12,7 @@ interface UseModerationStatsOptions { enabled?: boolean; pollingEnabled?: boolean; pollingInterval?: number; + realtimeEnabled?: boolean; } export const useModerationStats = (options: UseModerationStatsOptions = {}) => { @@ -19,7 +20,8 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => { onStatsChange, enabled = true, pollingEnabled = true, - pollingInterval = 30000 // Default 30 seconds + pollingInterval = 60000, // Reduced to 60 seconds + realtimeEnabled = true } = options; const [stats, setStats] = useState({ @@ -91,9 +93,31 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => { } }, [enabled, fetchStats]); - // Polling + // Realtime subscription for instant stat updates useEffect(() => { - if (!enabled || !pollingEnabled || isInitialLoad) return; + if (!enabled || !realtimeEnabled) return; + + const channel = supabase + .channel('moderation-stats-realtime') + .on('postgres_changes', { event: '*', schema: 'public', table: 'content_submissions' }, () => { + fetchStats(true); // Silent refresh + }) + .on('postgres_changes', { event: '*', schema: 'public', table: 'reports' }, () => { + fetchStats(true); + }) + .on('postgres_changes', { event: '*', schema: 'public', table: 'reviews' }, () => { + fetchStats(true); + }) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [enabled, realtimeEnabled, fetchStats]); + + // Polling (fallback when realtime is disabled) + useEffect(() => { + if (!enabled || !pollingEnabled || realtimeEnabled || isInitialLoad) return; const interval = setInterval(() => { fetchStats(true); // Silent refresh @@ -102,7 +126,7 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => { return () => { clearInterval(interval); }; - }, [enabled, pollingEnabled, pollingInterval, fetchStats, isInitialLoad]); + }, [enabled, pollingEnabled, realtimeEnabled, pollingInterval, fetchStats, isInitialLoad]); return { stats, diff --git a/supabase/migrations/20251009135214_0e2de440-26a1-48e2-98c5-6cf792790f65.sql b/supabase/migrations/20251009135214_0e2de440-26a1-48e2-98c5-6cf792790f65.sql new file mode 100644 index 00000000..0cbd584b --- /dev/null +++ b/supabase/migrations/20251009135214_0e2de440-26a1-48e2-98c5-6cf792790f65.sql @@ -0,0 +1,15 @@ +-- Enable full row data for realtime updates on content_submissions +ALTER TABLE public.content_submissions REPLICA IDENTITY FULL; + +-- Add table to realtime publication +ALTER PUBLICATION supabase_realtime ADD TABLE public.content_submissions; + +-- Add admin setting for realtime queue toggle +INSERT INTO public.admin_settings (setting_key, setting_value, category, description) +VALUES ( + 'system.use_realtime_queue', + '"true"'::jsonb, + 'system', + 'Use realtime subscriptions for moderation queue updates instead of polling' +) +ON CONFLICT (setting_key) DO NOTHING; \ No newline at end of file