mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 11:51:14 -05:00
Implement realtime queue fix
This commit is contained in:
@@ -52,6 +52,9 @@ interface ModerationItem {
|
|||||||
display_name?: string;
|
display_name?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
};
|
};
|
||||||
|
escalated?: boolean;
|
||||||
|
assigned_to?: string;
|
||||||
|
locked_until?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
|
type EntityFilter = 'all' | 'reviews' | 'submissions' | 'photos';
|
||||||
@@ -109,12 +112,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
getAdminPanelRefreshMode,
|
getAdminPanelRefreshMode,
|
||||||
getAdminPanelPollInterval,
|
getAdminPanelPollInterval,
|
||||||
getAutoRefreshStrategy,
|
getAutoRefreshStrategy,
|
||||||
getPreserveInteractionState
|
getPreserveInteractionState,
|
||||||
|
getUseRealtimeQueue
|
||||||
} = useAdminSettings();
|
} = useAdminSettings();
|
||||||
const refreshMode = getAdminPanelRefreshMode();
|
const refreshMode = getAdminPanelRefreshMode();
|
||||||
const pollInterval = getAdminPanelPollInterval();
|
const pollInterval = getAdminPanelPollInterval();
|
||||||
const refreshStrategy = getAutoRefreshStrategy();
|
const refreshStrategy = getAutoRefreshStrategy();
|
||||||
const preserveInteraction = getPreserveInteractionState();
|
const preserveInteraction = getPreserveInteractionState();
|
||||||
|
const useRealtimeQueue = getUseRealtimeQueue();
|
||||||
|
|
||||||
// Store admin settings in refs to avoid triggering fetchItems recreation
|
// Store admin settings in refs to avoid triggering fetchItems recreation
|
||||||
const refreshStrategyRef = useRef(refreshStrategy);
|
const refreshStrategyRef = useRef(refreshStrategy);
|
||||||
@@ -552,9 +557,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [debouncedEntityFilter, debouncedStatusFilter, user]);
|
}, [debouncedEntityFilter, debouncedStatusFilter, user]);
|
||||||
|
|
||||||
// Polling for auto-refresh
|
// Polling for auto-refresh (only if realtime is disabled)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || refreshMode !== 'auto' || isInitialLoad) return;
|
if (!user || refreshMode !== 'auto' || isInitialLoad || useRealtimeQueue) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
fetchItems(filtersRef.current.entityFilter, filtersRef.current.statusFilter, true); // Silent refresh
|
fetchItems(filtersRef.current.entityFilter, filtersRef.current.statusFilter, true); // Silent refresh
|
||||||
@@ -564,7 +569,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, refreshMode, pollInterval, isInitialLoad]);
|
}, [user, refreshMode, pollInterval, isInitialLoad, useRealtimeQueue]);
|
||||||
|
|
||||||
// Real-time subscription for lock status
|
// Real-time subscription for lock status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -611,6 +616,146 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
};
|
};
|
||||||
}, [user]);
|
}, [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) => {
|
const handleResetToPending = async (item: ModerationItem) => {
|
||||||
setActionLoading(item.id);
|
setActionLoading(item.id);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ export function useAdminSettings() {
|
|||||||
return getSettingValue('notifications.recipients', []);
|
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 {
|
return {
|
||||||
settings,
|
settings,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -172,5 +178,6 @@ export function useAdminSettings() {
|
|||||||
getAdminPanelPollInterval,
|
getAdminPanelPollInterval,
|
||||||
getAutoRefreshStrategy,
|
getAutoRefreshStrategy,
|
||||||
getPreserveInteractionState,
|
getPreserveInteractionState,
|
||||||
|
getUseRealtimeQueue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -80,11 +80,9 @@ export const useModerationQueue = () => {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Fetch stats on mount and periodically
|
// Fetch stats on mount only (realtime updates handled by useModerationStats)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStats();
|
fetchStats();
|
||||||
const interval = setInterval(fetchStats, 30000); // Every 30 seconds
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [fetchStats]);
|
}, [fetchStats]);
|
||||||
|
|
||||||
// Start countdown timer for lock expiry
|
// Start countdown timer for lock expiry
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UseModerationStatsOptions {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
pollingEnabled?: boolean;
|
pollingEnabled?: boolean;
|
||||||
pollingInterval?: number;
|
pollingInterval?: number;
|
||||||
|
realtimeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
|
export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
|
||||||
@@ -19,7 +20,8 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
|
|||||||
onStatsChange,
|
onStatsChange,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
pollingEnabled = true,
|
pollingEnabled = true,
|
||||||
pollingInterval = 30000 // Default 30 seconds
|
pollingInterval = 60000, // Reduced to 60 seconds
|
||||||
|
realtimeEnabled = true
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const [stats, setStats] = useState<ModerationStats>({
|
const [stats, setStats] = useState<ModerationStats>({
|
||||||
@@ -91,9 +93,31 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
|
|||||||
}
|
}
|
||||||
}, [enabled, fetchStats]);
|
}, [enabled, fetchStats]);
|
||||||
|
|
||||||
// Polling
|
// Realtime subscription for instant stat updates
|
||||||
useEffect(() => {
|
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(() => {
|
const interval = setInterval(() => {
|
||||||
fetchStats(true); // Silent refresh
|
fetchStats(true); // Silent refresh
|
||||||
@@ -102,7 +126,7 @@ export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
|
|||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [enabled, pollingEnabled, pollingInterval, fetchStats, isInitialLoad]);
|
}, [enabled, pollingEnabled, realtimeEnabled, pollingInterval, fetchStats, isInitialLoad]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stats,
|
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