mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
Implement realtime queue fix
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user