Implement real-time features

This commit is contained in:
gpt-engineer-app[bot]
2025-09-30 16:46:12 +00:00
parent 7bbf67156b
commit 7920bdb911
7 changed files with 313 additions and 85 deletions

View File

@@ -14,6 +14,7 @@ import { useAuth } from '@/hooks/useAuth';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { PhotoModal } from './PhotoModal'; import { PhotoModal } from './PhotoModal';
import { SubmissionReviewManager } from './SubmissionReviewManager'; import { SubmissionReviewManager } from './SubmissionReviewManager';
import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions';
interface ModerationItem { interface ModerationItem {
id: string; id: string;
@@ -339,6 +340,36 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((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(() => { useEffect(() => {
if (user) { if (user) {
fetchItems(activeEntityFilter, activeStatusFilter); fetchItems(activeEntityFilter, activeStatusFilter);

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useRealtimeSubmissionItems } from '@/hooks/useRealtimeSubmissionItems';
import { import {
fetchSubmissionItems, fetchSubmissionItems,
buildDependencyTree, buildDependencyTree,
@@ -59,6 +60,20 @@ export function SubmissionReviewManager({
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const Container = isMobile ? Sheet : Dialog; 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(() => { useEffect(() => {
if (open && submissionId) { if (open && submissionId) {
loadSubmissionItems(); loadSubmissionItems();

View File

@@ -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<ModerationStats>({
pendingSubmissions: 0,
openReports: 0,
flaggedContent: 0,
});
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
const [updateTimer, setUpdateTimer] = useState<NodeJS.Timeout | null>(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 };
};

View File

@@ -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<RealtimeChannel | null>(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 };
};

View File

@@ -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<RealtimeChannel | null>(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 };
};

View File

@@ -11,6 +11,7 @@ import { ReportsQueue } from '@/components/moderation/ReportsQueue';
import { UserManagement } from '@/components/admin/UserManagement'; import { UserManagement } from '@/components/admin/UserManagement';
import { AdminHeader } from '@/components/layout/AdminHeader'; import { AdminHeader } from '@/components/layout/AdminHeader';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useRealtimeModerationStats } from '@/hooks/useRealtimeModerationStats';
export default function Admin() { export default function Admin() {
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
@@ -18,76 +19,19 @@ export default function Admin() {
const navigate = useNavigate(); const navigate = useNavigate();
const moderationQueueRef = useRef<ModerationQueueRef>(null); const moderationQueueRef = useRef<ModerationQueueRef>(null);
// State for dashboard stats // Use realtime stats hook for live updates
const [stats, setStats] = useState({ const { stats: realtimeStats, refresh: refreshStats } = useRealtimeModerationStats({
pendingSubmissions: 0, onStatsChange: (newStats) => {
openReports: 0, console.log('Stats updated in real-time:', newStats);
flaggedContent: 0, },
loading: true, enabled: !!user && !authLoading && !roleLoading && isModerator(),
}); });
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const fetchStats = useCallback(async () => { const fetchStats = useCallback(async () => {
if (!user || isFetching) { refreshStats();
console.log('Skipping stats fetch - user not authenticated or already fetching'); }, [refreshStats]);
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);
}
}, []);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
moderationQueueRef.current?.refresh(); moderationQueueRef.current?.refresh();
@@ -105,11 +49,8 @@ export default function Admin() {
navigate('/'); navigate('/');
return; return;
} }
// Fetch stats when user is authenticated and authorized
fetchStats();
} }
}, [user, authLoading, roleLoading, navigate]); }, [user, authLoading, roleLoading, navigate, isModerator]);
if (authLoading || roleLoading) { if (authLoading || roleLoading) {
return ( return (
@@ -146,11 +87,7 @@ export default function Admin() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{stats.loading ? ( {realtimeStats.pendingSubmissions}
<span className="animate-pulse">--</span>
) : (
stats.pendingSubmissions
)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Content submissions awaiting moderation Content submissions awaiting moderation
@@ -165,11 +102,7 @@ export default function Admin() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{stats.loading ? ( {realtimeStats.openReports}
<span className="animate-pulse">--</span>
) : (
stats.openReports
)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
User reports to review User reports to review
@@ -184,11 +117,7 @@ export default function Admin() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{stats.loading ? ( {realtimeStats.flaggedContent}
<span className="animate-pulse">--</span>
) : (
stats.flaggedContent
)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Auto-flagged items Auto-flagged items

View File

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