Implement realtime queue fix

This commit is contained in:
gpt-engineer-app[bot]
2025-10-09 13:54:39 +00:00
parent 57368eb309
commit 1d45294703
5 changed files with 200 additions and 11 deletions

View File

@@ -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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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 {

View File

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

View File

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

View File

@@ -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<ModerationStats>({
@@ -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,

View File

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