diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 1e83ff81..ff28d40c 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -14,6 +14,7 @@ import { useAuth } from '@/hooks/useAuth'; import { format } from 'date-fns'; import { PhotoModal } from './PhotoModal'; import { SubmissionReviewManager } from './SubmissionReviewManager'; +import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions'; interface ModerationItem { id: string; @@ -339,6 +340,36 @@ export const ModerationQueue = forwardRef((props, ref) => { } }; + // Set up realtime subscriptions + useRealtimeSubmissions({ + onInsert: (payload) => { + console.log('New submission received'); + toast({ + title: 'New Submission', + description: 'A new content submission has been added', + }); + fetchItems(activeEntityFilter, activeStatusFilter); + }, + onUpdate: (payload) => { + console.log('Submission updated'); + // Update items state directly for better UX + setItems(prevItems => + prevItems.map(item => + item.id === payload.new.id && item.type === 'content_submission' + ? { ...item, status: payload.new.status, content: { ...item.content, ...payload.new } } + : item + ) + ); + }, + onDelete: (payload) => { + console.log('Submission deleted'); + setItems(prevItems => + prevItems.filter(item => !(item.id === payload.old.id && item.type === 'content_submission')) + ); + }, + enabled: !!user, + }); + useEffect(() => { if (user) { fetchItems(activeEntityFilter, activeStatusFilter); diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index ab8834d1..7893ac27 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; +import { useRealtimeSubmissionItems } from '@/hooks/useRealtimeSubmissionItems'; import { fetchSubmissionItems, buildDependencyTree, @@ -59,6 +60,20 @@ export function SubmissionReviewManager({ const isMobile = useIsMobile(); const Container = isMobile ? Sheet : Dialog; + // Set up realtime subscription for submission items + useRealtimeSubmissionItems({ + submissionId, + onUpdate: (payload) => { + console.log('Submission item updated in real-time:', payload); + toast({ + title: 'Item Updated', + description: 'A submission item was updated by another moderator', + }); + loadSubmissionItems(); + }, + enabled: open && !!submissionId, + }); + useEffect(() => { if (open && submissionId) { loadSubmissionItems(); diff --git a/src/hooks/useRealtimeModerationStats.ts b/src/hooks/useRealtimeModerationStats.ts new file mode 100644 index 00000000..d62e6f2f --- /dev/null +++ b/src/hooks/useRealtimeModerationStats.ts @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { RealtimeChannel } from '@supabase/supabase-js'; + +interface ModerationStats { + pendingSubmissions: number; + openReports: number; + flaggedContent: number; +} + +interface UseRealtimeModerationStatsOptions { + onStatsChange?: (stats: ModerationStats) => void; + enabled?: boolean; + debounceMs?: number; +} + +export const useRealtimeModerationStats = (options: UseRealtimeModerationStatsOptions = {}) => { + const { onStatsChange, enabled = true, debounceMs = 1000 } = options; + const [stats, setStats] = useState({ + pendingSubmissions: 0, + openReports: 0, + flaggedContent: 0, + }); + const [channel, setChannel] = useState(null); + const [updateTimer, setUpdateTimer] = useState(null); + + const fetchStats = async () => { + try { + const [submissionsResult, reportsResult, reviewsResult] = await Promise.all([ + supabase + .from('content_submissions') + .select('id', { count: 'exact', head: true }) + .eq('status', 'pending'), + supabase + .from('reports') + .select('id', { count: 'exact', head: true }) + .eq('status', 'pending'), + supabase + .from('reviews') + .select('id', { count: 'exact', head: true }) + .eq('moderation_status', 'flagged'), + ]); + + const newStats = { + pendingSubmissions: submissionsResult.count || 0, + openReports: reportsResult.count || 0, + flaggedContent: reviewsResult.count || 0, + }; + + setStats(newStats); + onStatsChange?.(newStats); + } catch (error) { + console.error('Error fetching moderation stats:', error); + } + }; + + const debouncedFetchStats = () => { + if (updateTimer) { + clearTimeout(updateTimer); + } + const timer = setTimeout(fetchStats, debounceMs); + setUpdateTimer(timer); + }; + + useEffect(() => { + if (!enabled) return; + + // Initial fetch + fetchStats(); + + // Set up realtime subscriptions + const realtimeChannel = supabase + .channel('moderation-stats-changes') + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'content_submissions', + }, + () => { + console.log('Content submissions changed'); + debouncedFetchStats(); + } + ) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'reports', + }, + () => { + console.log('Reports changed'); + debouncedFetchStats(); + } + ) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'reviews', + }, + () => { + console.log('Reviews changed'); + debouncedFetchStats(); + } + ) + .subscribe((status) => { + console.log('Moderation stats realtime status:', status); + }); + + setChannel(realtimeChannel); + + return () => { + console.log('Cleaning up moderation stats realtime subscription'); + if (updateTimer) { + clearTimeout(updateTimer); + } + supabase.removeChannel(realtimeChannel); + }; + }, [enabled]); + + return { stats, refresh: fetchStats }; +}; diff --git a/src/hooks/useRealtimeSubmissionItems.ts b/src/hooks/useRealtimeSubmissionItems.ts new file mode 100644 index 00000000..06c6af92 --- /dev/null +++ b/src/hooks/useRealtimeSubmissionItems.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { RealtimeChannel } from '@supabase/supabase-js'; + +interface UseRealtimeSubmissionItemsOptions { + submissionId?: string; + onUpdate?: (payload: any) => void; + enabled?: boolean; +} + +export const useRealtimeSubmissionItems = (options: UseRealtimeSubmissionItemsOptions = {}) => { + const { submissionId, onUpdate, enabled = true } = options; + const [channel, setChannel] = useState(null); + + useEffect(() => { + if (!enabled || !submissionId) return; + + const realtimeChannel = supabase + .channel(`submission-items-${submissionId}`) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'submission_items', + filter: `submission_id=eq.${submissionId}`, + }, + (payload) => { + console.log('Submission item updated:', payload); + onUpdate?.(payload); + } + ) + .subscribe((status) => { + console.log('Submission items realtime status:', status); + }); + + setChannel(realtimeChannel); + + return () => { + console.log('Cleaning up submission items realtime subscription'); + supabase.removeChannel(realtimeChannel); + }; + }, [submissionId, enabled, onUpdate]); + + return { channel }; +}; diff --git a/src/hooks/useRealtimeSubmissions.ts b/src/hooks/useRealtimeSubmissions.ts new file mode 100644 index 00000000..0ec04b40 --- /dev/null +++ b/src/hooks/useRealtimeSubmissions.ts @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { RealtimeChannel } from '@supabase/supabase-js'; + +interface UseRealtimeSubmissionsOptions { + onInsert?: (payload: any) => void; + onUpdate?: (payload: any) => void; + onDelete?: (payload: any) => void; + enabled?: boolean; +} + +export const useRealtimeSubmissions = (options: UseRealtimeSubmissionsOptions = {}) => { + const { onInsert, onUpdate, onDelete, enabled = true } = options; + const [channel, setChannel] = useState(null); + + useEffect(() => { + if (!enabled) return; + + const realtimeChannel = supabase + .channel('content-submissions-changes') + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'content_submissions', + }, + (payload) => { + console.log('Submission inserted:', payload); + onInsert?.(payload); + } + ) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'content_submissions', + }, + (payload) => { + console.log('Submission updated:', payload); + onUpdate?.(payload); + } + ) + .on( + 'postgres_changes', + { + event: 'DELETE', + schema: 'public', + table: 'content_submissions', + }, + (payload) => { + console.log('Submission deleted:', payload); + onDelete?.(payload); + } + ) + .subscribe((status) => { + console.log('Submissions realtime status:', status); + }); + + setChannel(realtimeChannel); + + return () => { + console.log('Cleaning up submissions realtime subscription'); + supabase.removeChannel(realtimeChannel); + }; + }, [enabled, onInsert, onUpdate, onDelete]); + + return { channel }; +}; diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 1347d0c4..2adea8c4 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -11,6 +11,7 @@ import { ReportsQueue } from '@/components/moderation/ReportsQueue'; import { UserManagement } from '@/components/admin/UserManagement'; import { AdminHeader } from '@/components/layout/AdminHeader'; import { supabase } from '@/integrations/supabase/client'; +import { useRealtimeModerationStats } from '@/hooks/useRealtimeModerationStats'; export default function Admin() { const { user, loading: authLoading } = useAuth(); @@ -18,76 +19,19 @@ export default function Admin() { const navigate = useNavigate(); const moderationQueueRef = useRef(null); - // State for dashboard stats - const [stats, setStats] = useState({ - pendingSubmissions: 0, - openReports: 0, - flaggedContent: 0, - loading: true, + // Use realtime stats hook for live updates + const { stats: realtimeStats, refresh: refreshStats } = useRealtimeModerationStats({ + onStatsChange: (newStats) => { + console.log('Stats updated in real-time:', newStats); + }, + enabled: !!user && !authLoading && !roleLoading && isModerator(), }); + const [isFetching, setIsFetching] = useState(false); const fetchStats = useCallback(async () => { - if (!user || isFetching) { - console.log('Skipping stats fetch - user not authenticated or already fetching'); - return; - } - - setIsFetching(true); - try { - setStats(prev => ({ ...prev, loading: true })); - - // Fetch pending submissions count - const { count: pendingCount, error: submissionsError } = await supabase - .from('content_submissions') - .select('*', { count: 'exact', head: true }) - .eq('status', 'pending'); - - if (submissionsError) { - console.error('Error fetching pending submissions:', submissionsError); - throw submissionsError; - } - - // Fetch open reports count - const { count: reportsCount, error: reportsError } = await supabase - .from('reports') - .select('*', { count: 'exact', head: true }) - .eq('status', 'pending'); - - if (reportsError) { - console.error('Error fetching reports:', reportsError); - throw reportsError; - } - - // Fetch flagged content count (reviews) - const { count: flaggedCount, error: flaggedError } = await supabase - .from('reviews') - .select('*', { count: 'exact', head: true }) - .eq('moderation_status', 'flagged'); - - if (flaggedError) { - console.error('Error fetching flagged content:', flaggedError); - throw flaggedError; - } - - setStats({ - pendingSubmissions: pendingCount || 0, - openReports: reportsCount || 0, - flaggedContent: flaggedCount || 0, - loading: false, - }); - } catch (error: any) { - console.error('Error fetching admin stats:', error); - console.error('Error details:', { - message: error.message, - code: error.code, - details: error.details - }); - setStats(prev => ({ ...prev, loading: false })); - } finally { - setIsFetching(false); - } - }, []); + refreshStats(); + }, [refreshStats]); const handleRefresh = useCallback(() => { moderationQueueRef.current?.refresh(); @@ -105,11 +49,8 @@ export default function Admin() { navigate('/'); return; } - - // Fetch stats when user is authenticated and authorized - fetchStats(); } - }, [user, authLoading, roleLoading, navigate]); + }, [user, authLoading, roleLoading, navigate, isModerator]); if (authLoading || roleLoading) { return ( @@ -146,11 +87,7 @@ export default function Admin() {
- {stats.loading ? ( - -- - ) : ( - stats.pendingSubmissions - )} + {realtimeStats.pendingSubmissions}

Content submissions awaiting moderation @@ -165,11 +102,7 @@ export default function Admin() {

- {stats.loading ? ( - -- - ) : ( - stats.openReports - )} + {realtimeStats.openReports}

User reports to review @@ -184,11 +117,7 @@ export default function Admin() {

- {stats.loading ? ( - -- - ) : ( - stats.flaggedContent - )} + {realtimeStats.flaggedContent}

Auto-flagged items diff --git a/supabase/migrations/20250930164432_beb68992-11c7-4453-afa2-dc989ed08a31.sql b/supabase/migrations/20250930164432_beb68992-11c7-4453-afa2-dc989ed08a31.sql new file mode 100644 index 00000000..fe863cc0 --- /dev/null +++ b/supabase/migrations/20250930164432_beb68992-11c7-4453-afa2-dc989ed08a31.sql @@ -0,0 +1,11 @@ +-- Enable replica identity for complete row data during realtime updates +ALTER TABLE public.content_submissions REPLICA IDENTITY FULL; +ALTER TABLE public.submission_items REPLICA IDENTITY FULL; +ALTER TABLE public.reviews REPLICA IDENTITY FULL; +ALTER TABLE public.reports REPLICA IDENTITY FULL; + +-- Add tables to realtime publication for live updates +ALTER PUBLICATION supabase_realtime ADD TABLE public.content_submissions; +ALTER PUBLICATION supabase_realtime ADD TABLE public.submission_items; +ALTER PUBLICATION supabase_realtime ADD TABLE public.reviews; +ALTER PUBLICATION supabase_realtime ADD TABLE public.reports; \ No newline at end of file