diff --git a/src/components/admin/NotificationDebugPanel.tsx b/src/components/admin/NotificationDebugPanel.tsx new file mode 100644 index 00000000..b03879a4 --- /dev/null +++ b/src/components/admin/NotificationDebugPanel.tsx @@ -0,0 +1,216 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { AlertTriangle, CheckCircle, RefreshCw, Loader2 } from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { format } from 'date-fns'; + +interface DuplicateStats { + date: string; + total_attempts: number; + duplicates_prevented: number; + prevention_rate: number; + health_status: 'healthy' | 'warning' | 'critical'; +} + +interface RecentDuplicate { + id: string; + user_id: string; + channel: string; + idempotency_key: string; + created_at: string; + profiles?: { + username: string; + display_name: string; + }; +} + +export function NotificationDebugPanel() { + const [stats, setStats] = useState([]); + const [recentDuplicates, setRecentDuplicates] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setIsLoading(true); + try { + // Load health dashboard + const { data: healthData, error: healthError } = await supabase + .from('notification_health_dashboard') + .select('*') + .limit(7); + + if (healthError) throw healthError; + if (healthData) { + setStats(healthData.map(stat => ({ + ...stat, + health_status: stat.health_status as 'healthy' | 'warning' | 'critical' + }))); + } + + // Load recent prevented duplicates + const { data: duplicates, error: duplicatesError } = await supabase + .from('notification_logs') + .select(` + id, + user_id, + channel, + idempotency_key, + created_at + `) + .eq('is_duplicate', true) + .order('created_at', { ascending: false }) + .limit(10); + + if (duplicatesError) throw duplicatesError; + + if (duplicates) { + // Fetch profiles separately + const userIds = [...new Set(duplicates.map(d => d.user_id))]; + const { data: profiles } = await supabase + .from('profiles') + .select('user_id, username, display_name') + .in('user_id', userIds); + + const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []); + + setRecentDuplicates(duplicates.map(dup => ({ + ...dup, + profiles: profileMap.get(dup.user_id) + }))); + } + } catch (error) { + console.error('Failed to load notification debug data:', error); + } finally { + setIsLoading(false); + } + }; + + const getHealthBadge = (status: string) => { + switch (status) { + case 'healthy': + return ( + + + Healthy + + ); + case 'warning': + return ( + + + Warning + + ); + case 'critical': + return ( + + + Critical + + ); + default: + return Unknown; + } + }; + + if (isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + return ( +
+ + +
+
+ Notification Health Dashboard + Monitor duplicate prevention and notification system health +
+ +
+
+ + {stats.length === 0 ? ( + + No notification statistics available yet + + ) : ( + + + + Date + Total Attempts + Duplicates Prevented + Prevention Rate + Status + + + + {stats.map((stat) => ( + + {format(new Date(stat.date), 'MMM d, yyyy')} + {stat.total_attempts} + {stat.duplicates_prevented} + {stat.prevention_rate.toFixed(1)}% + {getHealthBadge(stat.health_status)} + + ))} + +
+ )} +
+
+ + + + Recent Prevented Duplicates + Notifications that were blocked due to duplication + + + {recentDuplicates.length === 0 ? ( + + + No recent duplicates detected + + ) : ( +
+ {recentDuplicates.map((dup) => ( +
+
+
+ {dup.profiles?.display_name || dup.profiles?.username || 'Unknown User'} +
+
+ Channel: {dup.channel} • Key: {dup.idempotency_key?.substring(0, 12)}... +
+
+
+ {format(new Date(dup.created_at), 'PPp')} +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/admin/VersionCleanupSettings.tsx b/src/components/admin/VersionCleanupSettings.tsx new file mode 100644 index 00000000..fcaade37 --- /dev/null +++ b/src/components/admin/VersionCleanupSettings.tsx @@ -0,0 +1,196 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, Trash2, CheckCircle, AlertCircle } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { supabase } from '@/integrations/supabase/client'; +import { format } from 'date-fns'; + +export function VersionCleanupSettings() { + const [retentionDays, setRetentionDays] = useState(90); + const [lastCleanup, setLastCleanup] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isInitialLoad, setIsInitialLoad] = useState(true); + const { toast } = useToast(); + + useEffect(() => { + loadSettings(); + }, []); + + const loadSettings = async () => { + try { + const { data: retention, error: retentionError } = await supabase + .from('admin_settings') + .select('setting_value') + .eq('setting_key', 'version_retention_days') + .single(); + + if (retentionError) throw retentionError; + + const { data: cleanup, error: cleanupError } = await supabase + .from('admin_settings') + .select('setting_value') + .eq('setting_key', 'last_version_cleanup') + .single(); + + if (cleanupError) throw cleanupError; + + if (retention?.setting_value) { + const retentionValue = typeof retention.setting_value === 'string' + ? retention.setting_value + : String(retention.setting_value); + setRetentionDays(Number(retentionValue)); + } + if (cleanup?.setting_value && cleanup.setting_value !== 'null') { + const cleanupValue = typeof cleanup.setting_value === 'string' + ? cleanup.setting_value.replace(/"/g, '') + : String(cleanup.setting_value); + setLastCleanup(cleanupValue); + } + } catch (error) { + console.error('Failed to load settings:', error); + toast({ + title: 'Error', + description: 'Failed to load cleanup settings', + variant: 'destructive', + }); + } finally { + setIsInitialLoad(false); + } + }; + + const handleSaveRetention = async () => { + setIsSaving(true); + try { + const { error } = await supabase + .from('admin_settings') + .update({ setting_value: retentionDays.toString() }) + .eq('setting_key', 'version_retention_days'); + + if (error) throw error; + + toast({ + title: 'Settings Saved', + description: 'Retention period updated successfully' + }); + } catch (error) { + toast({ + title: 'Save Failed', + description: error instanceof Error ? error.message : 'Failed to save settings', + variant: 'destructive' + }); + } finally { + setIsSaving(false); + } + }; + + const handleManualCleanup = async () => { + setIsLoading(true); + try { + const { data, error } = await supabase.functions.invoke('cleanup-old-versions', { + body: { manual: true } + }); + + if (error) throw error; + + toast({ + title: 'Cleanup Complete', + description: data.message || `Deleted ${data.stats?.item_edit_history_deleted || 0} old versions`, + }); + + await loadSettings(); + } catch (error) { + toast({ + title: 'Cleanup Failed', + description: error instanceof Error ? error.message : 'Failed to run cleanup', + variant: 'destructive' + }); + } finally { + setIsLoading(false); + } + }; + + if (isInitialLoad) { + return ( + + +
+ +
+
+
+ ); + } + + return ( + + + Version History Cleanup + + Manage automatic cleanup of old version history records + + + +
+ +
+ setRetentionDays(Number(e.target.value))} + className="w-32" + /> + +
+

+ Keep most recent 10 versions per item, delete older ones beyond this period +

+
+ + {lastCleanup ? ( + + + + Last cleanup: {format(new Date(lastCleanup), 'PPpp')} + + + ) : ( + + + + No cleanup has been performed yet + + + )} + +
+ +

+ Automatic cleanup runs every Sunday at 2 AM UTC +

+
+
+
+ ); +} diff --git a/src/components/moderation/ConflictResolutionModal.tsx b/src/components/moderation/ConflictResolutionModal.tsx new file mode 100644 index 00000000..fc0b2c46 --- /dev/null +++ b/src/components/moderation/ConflictResolutionModal.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { AlertTriangle, User, Clock } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { format } from 'date-fns'; +import type { ConflictCheckResult } from '@/lib/submissionItemsService'; + +interface ConflictResolutionModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + conflictData: ConflictCheckResult; + onResolve: (resolution: 'keep-mine' | 'keep-theirs' | 'reload') => Promise; +} + +export function ConflictResolutionModal({ + open, + onOpenChange, + conflictData, + onResolve, +}: ConflictResolutionModalProps) { + const [selectedResolution, setSelectedResolution] = useState(null); + const [isResolving, setIsResolving] = useState(false); + const { toast } = useToast(); + + const handleResolve = async () => { + if (!selectedResolution) return; + + setIsResolving(true); + try { + await onResolve(selectedResolution as 'keep-mine' | 'keep-theirs' | 'reload'); + toast({ + title: 'Conflict Resolved', + description: 'Changes have been applied successfully', + }); + onOpenChange(false); + } catch (error) { + toast({ + title: 'Resolution Failed', + description: error instanceof Error ? error.message : 'Failed to resolve conflict', + variant: 'destructive', + }); + } finally { + setIsResolving(false); + } + }; + + if (!conflictData.serverVersion) return null; + + return ( + + + + + + Edit Conflict Detected + + + Someone else modified this submission while you were editing. + Choose how to resolve the conflict. + + + + + +
+
+ + + Modified by: {conflictData.serverVersion.modified_by_profile?.display_name || + conflictData.serverVersion.modified_by_profile?.username || 'Unknown'} + +
+
+ + {format(new Date(conflictData.serverVersion.last_modified_at), 'PPpp')} +
+
+
+
+ +
+

Choose Resolution:

+ + + + + + +
+ + + + + +
+
+ ); +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index e5938b62..24e3fa90 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -4765,6 +4765,12 @@ export type Database = { user_agent: string }[] } + get_orphaned_edit_history: { + Args: never + Returns: { + id: string + }[] + } get_recent_changes: { Args: { limit_count?: number } Returns: { diff --git a/supabase/migrations/20251103002926_c819d1c0-be27-4811-8587-5cbfee979bc5.sql b/supabase/migrations/20251103002926_c819d1c0-be27-4811-8587-5cbfee979bc5.sql new file mode 100644 index 00000000..0bf8804b --- /dev/null +++ b/supabase/migrations/20251103002926_c819d1c0-be27-4811-8587-5cbfee979bc5.sql @@ -0,0 +1,14 @@ +-- Add helper function for cleanup edge function + +CREATE OR REPLACE FUNCTION public.get_orphaned_edit_history() +RETURNS TABLE (id UUID) AS $$ +BEGIN + RETURN QUERY + SELECT ieh.id + FROM item_edit_history ieh + WHERE NOT EXISTS ( + SELECT 1 FROM submission_items si + WHERE si.id = ieh.item_id + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; \ No newline at end of file