Compare commits

..

3 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
061c06be29 Implement planned features 2025-11-03 00:38:16 +00:00
gpt-engineer-app[bot]
ecca11a475 Refactor: Fix notification view security 2025-11-03 00:30:56 +00:00
gpt-engineer-app[bot]
d44f806afa Fix notification logs migration 2025-11-03 00:28:33 +00:00
17 changed files with 1708 additions and 102 deletions

View File

@@ -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<DuplicateStats[]>([]);
const [recentDuplicates, setRecentDuplicates] = useState<RecentDuplicate[]>([]);
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 (
<Badge variant="default" className="bg-green-500">
<CheckCircle className="h-3 w-3 mr-1" />
Healthy
</Badge>
);
case 'warning':
return (
<Badge variant="secondary">
<AlertTriangle className="h-3 w-3 mr-1" />
Warning
</Badge>
);
case 'critical':
return (
<Badge variant="destructive">
<AlertTriangle className="h-3 w-3 mr-1" />
Critical
</Badge>
);
default:
return <Badge>Unknown</Badge>;
}
};
if (isLoading) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Notification Health Dashboard</CardTitle>
<CardDescription>Monitor duplicate prevention and notification system health</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={loadData} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
{stats.length === 0 ? (
<Alert>
<AlertDescription>No notification statistics available yet</AlertDescription>
</Alert>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Total Attempts</TableHead>
<TableHead className="text-right">Duplicates Prevented</TableHead>
<TableHead className="text-right">Prevention Rate</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.map((stat) => (
<TableRow key={stat.date}>
<TableCell>{format(new Date(stat.date), 'MMM d, yyyy')}</TableCell>
<TableCell className="text-right">{stat.total_attempts}</TableCell>
<TableCell className="text-right">{stat.duplicates_prevented}</TableCell>
<TableCell className="text-right">{stat.prevention_rate.toFixed(1)}%</TableCell>
<TableCell>{getHealthBadge(stat.health_status)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Prevented Duplicates</CardTitle>
<CardDescription>Notifications that were blocked due to duplication</CardDescription>
</CardHeader>
<CardContent>
{recentDuplicates.length === 0 ? (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>No recent duplicates detected</AlertDescription>
</Alert>
) : (
<div className="space-y-2">
{recentDuplicates.map((dup) => (
<div key={dup.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<div className="font-medium">
{dup.profiles?.display_name || dup.profiles?.username || 'Unknown User'}
</div>
<div className="text-xs text-muted-foreground">
Channel: {dup.channel} Key: {dup.idempotency_key?.substring(0, 12)}...
</div>
</div>
<div className="text-xs text-muted-foreground">
{format(new Date(dup.created_at), 'PPp')}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<string | null>(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 (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Version History Cleanup</CardTitle>
<CardDescription>
Manage automatic cleanup of old version history records
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="retention">Retention Period (days)</Label>
<div className="flex gap-2">
<Input
id="retention"
type="number"
min={30}
max={365}
value={retentionDays}
onChange={(e) => setRetentionDays(Number(e.target.value))}
className="w-32"
/>
<Button onClick={handleSaveRetention} disabled={isSaving}>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Keep most recent 10 versions per item, delete older ones beyond this period
</p>
</div>
{lastCleanup ? (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
Last cleanup: {format(new Date(lastCleanup), 'PPpp')}
</AlertDescription>
</Alert>
) : (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
No cleanup has been performed yet
</AlertDescription>
</Alert>
)}
<div className="pt-4 border-t">
<Button
onClick={handleManualCleanup}
disabled={isLoading}
variant="outline"
className="w-full"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Run Manual Cleanup Now
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
Automatic cleanup runs every Sunday at 2 AM UTC
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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<void>;
}
export function ConflictResolutionModal({
open,
onOpenChange,
conflictData,
onResolve,
}: ConflictResolutionModalProps) {
const [selectedResolution, setSelectedResolution] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Edit Conflict Detected
</DialogTitle>
<DialogDescription>
Someone else modified this submission while you were editing.
Choose how to resolve the conflict.
</DialogDescription>
</DialogHeader>
<Alert className="border-destructive/50 bg-destructive/10">
<AlertDescription className="flex items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<User className="h-4 w-4" />
<span className="font-medium">
Modified by: {conflictData.serverVersion.modified_by_profile?.display_name ||
conflictData.serverVersion.modified_by_profile?.username || 'Unknown'}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{format(new Date(conflictData.serverVersion.last_modified_at), 'PPpp')}
</div>
</div>
</AlertDescription>
</Alert>
<div className="space-y-3 py-4">
<h4 className="text-sm font-medium">Choose Resolution:</h4>
<Button
variant={selectedResolution === 'keep-mine' ? 'default' : 'outline'}
className="w-full justify-start text-left h-auto py-3"
onClick={() => setSelectedResolution('keep-mine')}
>
<div className="flex-1">
<div className="font-medium">Keep My Changes</div>
<div className="text-xs text-muted-foreground mt-1">
Overwrite their changes with your edits (use with caution)
</div>
</div>
{selectedResolution === 'keep-mine' && (
<Badge variant="default" className="ml-2">Selected</Badge>
)}
</Button>
<Button
variant={selectedResolution === 'keep-theirs' ? 'default' : 'outline'}
className="w-full justify-start text-left h-auto py-3"
onClick={() => setSelectedResolution('keep-theirs')}
>
<div className="flex-1">
<div className="font-medium">Keep Their Changes</div>
<div className="text-xs text-muted-foreground mt-1">
Discard your changes and accept the latest version
</div>
</div>
{selectedResolution === 'keep-theirs' && (
<Badge variant="default" className="ml-2">Selected</Badge>
)}
</Button>
<Button
variant={selectedResolution === 'reload' ? 'default' : 'outline'}
className="w-full justify-start text-left h-auto py-3"
onClick={() => setSelectedResolution('reload')}
>
<div className="flex-1">
<div className="font-medium">Reload and Review</div>
<div className="text-xs text-muted-foreground mt-1">
Load the latest version to review changes before deciding
</div>
</div>
{selectedResolution === 'reload' && (
<Badge variant="default" className="ml-2">Selected</Badge>
)}
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isResolving}>
Cancel
</Button>
<Button
onClick={handleResolve}
disabled={!selectedResolution || isResolving}
>
{isResolving ? 'Resolving...' : 'Apply Resolution'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchEditHistory } from '@/lib/submissionItemsService';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { EditHistoryEntry } from './EditHistoryEntry';
import { History, Loader2, AlertCircle } from 'lucide-react';
interface EditHistoryAccordionProps {
submissionId: string;
}
const INITIAL_LOAD = 20;
const LOAD_MORE_INCREMENT = 10;
export function EditHistoryAccordion({ submissionId }: EditHistoryAccordionProps) {
const [limit, setLimit] = useState(INITIAL_LOAD);
const { data: editHistory, isLoading, error } = useQuery({
queryKey: ['edit-history', submissionId, limit],
queryFn: async () => {
const { supabase } = await import('@/integrations/supabase/client');
// Fetch edit history with user profiles
const { data, error } = await supabase
.from('item_edit_history')
.select(`
id,
item_id,
edited_at,
edited_by,
previous_data,
new_data,
edit_reason,
changed_fields,
profiles:edited_by (
username,
avatar_url
)
`)
.eq('item_id', submissionId)
.order('edited_at', { ascending: false })
.limit(limit);
if (error) throw error;
return data || [];
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
const loadMore = () => {
setLimit(prev => prev + LOAD_MORE_INCREMENT);
};
const hasMore = editHistory && editHistory.length === limit;
return (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="edit-history">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2">
<History className="h-4 w-4" />
<span>Edit History</span>
{editHistory && editHistory.length > 0 && (
<span className="text-xs text-muted-foreground">
({editHistory.length} edit{editHistory.length !== 1 ? 's' : ''})
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load edit history: {error instanceof Error ? error.message : 'Unknown error'}
</AlertDescription>
</Alert>
)}
{!isLoading && !error && editHistory && editHistory.length === 0 && (
<Alert>
<AlertDescription>
No edit history found for this submission.
</AlertDescription>
</Alert>
)}
{!isLoading && !error && editHistory && editHistory.length > 0 && (
<div className="space-y-4">
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-3">
{editHistory.map((entry: any) => (
<EditHistoryEntry
key={entry.id}
editId={entry.id}
editorName={entry.profiles?.username || 'Unknown User'}
editorAvatar={entry.profiles?.avatar_url}
timestamp={entry.edited_at}
changedFields={entry.changed_fields || []}
editReason={entry.edit_reason}
beforeData={entry.previous_data}
afterData={entry.new_data}
/>
))}
</div>
</ScrollArea>
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={loadMore}
>
Load More
</Button>
</div>
)}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -0,0 +1,131 @@
import { formatDistanceToNow } from 'date-fns';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, Edit, User } from 'lucide-react';
import { useState } from 'react';
interface EditHistoryEntryProps {
editId: string;
editorName: string;
editorAvatar?: string;
timestamp: string;
changedFields: string[];
editReason?: string;
beforeData?: Record<string, any>;
afterData?: Record<string, any>;
}
export function EditHistoryEntry({
editId,
editorName,
editorAvatar,
timestamp,
changedFields,
editReason,
beforeData,
afterData,
}: EditHistoryEntryProps) {
const [isExpanded, setIsExpanded] = useState(false);
const getFieldValue = (data: Record<string, any> | undefined, field: string): string => {
if (!data || !(field in data)) return '—';
const value = data[field];
if (value === null || value === undefined) return '—';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
};
return (
<Card className="p-4">
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div className="flex items-start gap-3">
{/* Editor Avatar */}
<Avatar className="h-8 w-8">
<AvatarImage src={editorAvatar} alt={editorName} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
{/* Edit Info */}
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{editorName}</span>
<Badge variant="secondary" className="text-xs">
<Edit className="h-3 w-3 mr-1" />
{changedFields.length} field{changedFields.length !== 1 ? 's' : ''}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(timestamp), { addSuffix: true })}
</span>
</div>
{/* Changed Fields Summary */}
<div className="flex flex-wrap gap-1">
{changedFields.slice(0, 3).map((field) => (
<Badge key={field} variant="outline" className="text-xs">
{field}
</Badge>
))}
{changedFields.length > 3 && (
<Badge variant="outline" className="text-xs">
+{changedFields.length - 3} more
</Badge>
)}
</div>
{/* Edit Reason */}
{editReason && (
<p className="text-sm text-muted-foreground italic">
"{editReason}"
</p>
)}
{/* Expand/Collapse Button */}
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2">
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
<span className="ml-1">{isExpanded ? 'Hide' : 'Show'} Changes</span>
</Button>
</CollapsibleTrigger>
</div>
</div>
{/* Detailed Changes */}
<CollapsibleContent className="mt-3 space-y-3">
{changedFields.map((field) => {
const beforeValue = getFieldValue(beforeData, field);
const afterValue = getFieldValue(afterData, field);
return (
<div key={field} className="border-l-2 border-muted pl-3 space-y-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{field}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Before</div>
<div className="bg-destructive/10 text-destructive rounded p-2 font-mono text-xs break-all">
{beforeValue}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">After</div>
<div className="bg-success/10 text-success rounded p-2 font-mono text-xs break-all">
{afterValue}
</div>
</div>
</div>
</div>
);
})}
</CollapsibleContent>
</Collapsible>
</Card>
);
}

View File

@@ -1,8 +1,11 @@
import { Filter, MessageSquare, FileText, Image, X } from 'lucide-react';
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { QueueSortControls } from './QueueSortControls';
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
interface QueueFiltersProps {
@@ -39,107 +42,161 @@ export const QueueFilters = ({
onClearFilters,
showClearButton
}: QueueFiltersProps) => {
const { isCollapsed, toggle } = useFilterPanelState();
// Count active filters
const activeFilterCount = [
activeEntityFilter !== 'all' ? 1 : 0,
activeStatusFilter !== 'all' ? 1 : 0,
].reduce((sum, val) => sum + val, 0);
return (
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
</div>
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
{/* Entity Type Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
<Select
value={activeEntityFilter}
onValueChange={onEntityFilterChange}
>
<SelectTrigger
id="entity-filter"
className={isMobile ? "h-10" : ""}
aria-label="Filter by entity type"
>
<SelectValue>
<div className="flex items-center gap-2">
{getEntityFilterIcon(activeEntityFilter)}
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
All Items
</div>
</SelectItem>
<SelectItem value="reviews">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Reviews
</div>
</SelectItem>
<SelectItem value="submissions">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
Submissions
</div>
</SelectItem>
<SelectItem value="photos">
<div className="flex items-center gap-2">
<Image className="w-4 h-4" />
Photos
</div>
</SelectItem>
</SelectContent>
</Select>
<div className={`bg-muted/50 rounded-lg transition-all duration-250 ${isMobile ? 'p-3' : 'p-4'}`}>
<Collapsible open={!isCollapsed} onOpenChange={() => toggle()}>
{/* Header with collapse trigger on mobile */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
{isCollapsed && activeFilterCount > 0 && (
<Badge variant="secondary" className="text-xs">
{activeFilterCount} active
</Badge>
)}
</div>
{isMobile && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
</Button>
</CollapsibleTrigger>
)}
</div>
{/* Status Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
<Select
value={activeStatusFilter}
onValueChange={onStatusFilterChange}
>
<SelectTrigger
id="status-filter"
className={isMobile ? "h-10" : ""}
aria-label="Filter by submission status"
>
<SelectValue>
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="partially_approved">Partially Approved</SelectItem>
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
<SelectItem value="flagged">Flagged</SelectItem>
<CollapsibleContent className="space-y-4">
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
{/* Entity Type Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
<Select
value={activeEntityFilter}
onValueChange={onEntityFilterChange}
>
<SelectTrigger
id="entity-filter"
className={isMobile ? "h-11 min-h-[44px]" : ""}
aria-label="Filter by entity type"
>
<SelectValue>
<div className="flex items-center gap-2">
{getEntityFilterIcon(activeEntityFilter)}
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
All Items
</div>
</SelectItem>
<SelectItem value="reviews">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Reviews
</div>
</SelectItem>
<SelectItem value="submissions">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
Submissions
</div>
</SelectItem>
<SelectItem value="photos">
<div className="flex items-center gap-2">
<Image className="w-4 h-4" />
Photos
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
<Select
value={activeStatusFilter}
onValueChange={onStatusFilterChange}
>
<SelectTrigger
id="status-filter"
className={isMobile ? "h-11 min-h-[44px]" : ""}
aria-label="Filter by submission status"
>
<SelectValue>
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="partially_approved">Partially Approved</SelectItem>
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
<SelectItem value="flagged">Flagged</SelectItem>
)}
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
{/* Sort Controls */}
<QueueSortControls
sortConfig={sortConfig}
onSortChange={onSortChange}
isMobile={isMobile}
isLoading={isLoading}
/>
</div>
{/* Clear Filters & Apply Buttons (mobile only) */}
{isMobile && (
<div className="flex gap-2 pt-2 border-t border-border">
{showClearButton && (
<Button
variant="outline"
size="default"
onClick={onClearFilters}
className="flex-1 h-11 min-h-[44px]"
aria-label="Clear all filters"
>
<X className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="default"
size="default"
onClick={() => toggle()}
className="flex-1 h-11 min-h-[44px]"
>
Apply
</Button>
</div>
)}
</CollapsibleContent>
</Collapsible>
{/* Sort Controls */}
<QueueSortControls
sortConfig={sortConfig}
onSortChange={onSortChange}
isMobile={isMobile}
isLoading={isLoading}
/>
</div>
{/* Clear Filters Button */}
{showClearButton && (
<div className={isMobile ? "" : "flex items-end"}>
{/* Clear Filters Button (desktop only) */}
{!isMobile && showClearButton && (
<div className="flex items-end pt-2">
<Button
variant="outline"
size={isMobile ? "default" : "sm"}
size="sm"
onClick={onClearFilters}
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
className="flex items-center gap-2"
aria-label="Clear all filters"
>
<X className="w-4 h-4" />

View File

@@ -13,8 +13,10 @@ import {
approveSubmissionItems,
rejectSubmissionItems,
escalateSubmission,
checkSubmissionConflict,
type SubmissionItemWithDeps,
type DependencyConflict
type DependencyConflict,
type ConflictCheckResult
} from '@/lib/submissionItemsService';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
@@ -22,7 +24,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AlertCircle, CheckCircle2, XCircle, Edit, Network, ArrowUp } from 'lucide-react';
import { AlertCircle, CheckCircle2, XCircle, Edit, Network, ArrowUp, History } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useIsMobile } from '@/hooks/use-mobile';
@@ -34,6 +36,8 @@ import { RejectionDialog } from './RejectionDialog';
import { ItemEditDialog } from './ItemEditDialog';
import { ValidationBlockerDialog } from './ValidationBlockerDialog';
import { WarningConfirmDialog } from './WarningConfirmDialog';
import { ConflictResolutionModal } from './ConflictResolutionModal';
import { EditHistoryAccordion } from './EditHistoryAccordion';
import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
import { logger } from '@/lib/logger';
@@ -70,6 +74,9 @@ export function SubmissionReviewManager({
const [userConfirmedWarnings, setUserConfirmedWarnings] = useState(false);
const [hasBlockingErrors, setHasBlockingErrors] = useState(false);
const [globalValidationKey, setGlobalValidationKey] = useState(0);
const [conflictData, setConflictData] = useState<ConflictCheckResult | null>(null);
const [showConflictResolutionModal, setShowConflictResolutionModal] = useState(false);
const [lastModifiedTimestamp, setLastModifiedTimestamp] = useState<string | null>(null);
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
@@ -113,15 +120,16 @@ export function SubmissionReviewManager({
try {
const { supabase } = await import('@/integrations/supabase/client');
// Fetch submission type
// Fetch submission type and last_modified_at
const { data: submission } = await supabase
.from('content_submissions')
.select('submission_type')
.select('submission_type, last_modified_at')
.eq('id', submissionId)
.single();
if (submission) {
setSubmissionType(submission.submission_type || 'submission');
setLastModifiedTimestamp(submission.last_modified_at);
}
const fetchedItems = await fetchSubmissionItems(submissionId);
@@ -211,6 +219,18 @@ export function SubmissionReviewManager({
dispatch({ type: 'START_APPROVAL' });
try {
// Check for conflicts first (optimistic locking)
if (lastModifiedTimestamp) {
const conflictCheck = await checkSubmissionConflict(submissionId, lastModifiedTimestamp);
if (conflictCheck.hasConflict) {
setConflictData(conflictCheck);
setShowConflictResolutionModal(true);
dispatch({ type: 'RESET' }); // Return to reviewing state
return; // Block approval until conflict resolved
}
}
// Run validation on all selected items
const validationResultsMap = await validateMultipleItems(
selectedItems.map(item => ({
@@ -603,6 +623,43 @@ export function SubmissionReviewManager({
i.item_data?.name || i.item_type.replace('_', ' ')
)}
/>
<ConflictResolutionModal
open={showConflictResolutionModal}
onOpenChange={setShowConflictResolutionModal}
conflictData={conflictData}
onResolve={async (strategy) => {
if (strategy === 'keep-mine') {
// Log conflict resolution
const { supabase } = await import('@/integrations/supabase/client');
await supabase.from('conflict_resolutions').insert([{
submission_id: submissionId,
resolved_by: user?.id || null,
resolution_strategy: strategy,
conflict_details: conflictData as any,
}]);
// Force override and proceed with approval
await handleApprove();
} else if (strategy === 'keep-theirs') {
// Reload data and discard local changes
await loadSubmissionItems();
toast({
title: 'Changes Discarded',
description: 'Loaded the latest version from the server',
});
} else if (strategy === 'reload') {
// Just reload without approving
await loadSubmissionItems();
toast({
title: 'Reloaded',
description: 'Viewing the latest version',
});
}
setShowConflictResolutionModal(false);
setConflictData(null);
}}
/>
</>
);
@@ -724,13 +781,13 @@ export function SubmissionReviewManager({
<Tabs
value={activeTab}
onValueChange={(v) => {
if (v === 'items' || v === 'dependencies') {
setActiveTab(v);
if (v === 'items' || v === 'dependencies' || v === 'history') {
setActiveTab(v as 'items' | 'dependencies');
}
}}
className="flex-1 flex flex-col"
>
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="items">
<CheckCircle2 className="w-4 h-4 mr-2" />
Items ({items.length})
@@ -739,6 +796,10 @@ export function SubmissionReviewManager({
<Network className="w-4 h-4 mr-2" />
Dependencies
</TabsTrigger>
<TabsTrigger value="history">
<History className="w-4 h-4 mr-2" />
History
</TabsTrigger>
</TabsList>
<TabsContent value="items" className="flex-1 overflow-hidden">
@@ -778,6 +839,12 @@ export function SubmissionReviewManager({
<TabsContent value="dependencies" className="flex-1 overflow-hidden">
<DependencyVisualizer items={items} selectedIds={selectedItemIds} />
</TabsContent>
<TabsContent value="history" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
<EditHistoryAccordion submissionId={submissionId} />
</ScrollArea>
</TabsContent>
</Tabs>
{/* Blocking error alert */}

View File

@@ -0,0 +1,47 @@
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'queue-filter-panel-collapsed';
interface UseFilterPanelStateReturn {
isCollapsed: boolean;
toggle: () => void;
setCollapsed: (value: boolean) => void;
}
/**
* Hook to manage filter panel collapsed/expanded state
* Syncs with localStorage for persistence across sessions
*/
export function useFilterPanelState(): UseFilterPanelStateReturn {
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
// Initialize from localStorage on mount
try {
const stored = localStorage.getItem(STORAGE_KEY);
// Default to collapsed on mobile (width < 768px)
const isMobile = window.innerWidth < 768;
return stored ? JSON.parse(stored) : isMobile;
} catch (error) {
console.error('Error reading filter panel state from localStorage:', error);
return window.innerWidth < 768;
}
});
// Sync to localStorage when state changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(isCollapsed));
} catch (error) {
console.error('Error saving filter panel state to localStorage:', error);
}
}, [isCollapsed]);
const toggle = () => setIsCollapsed(prev => !prev);
const setCollapsed = (value: boolean) => setIsCollapsed(value);
return {
isCollapsed,
toggle,
setCollapsed,
};
}

View File

@@ -450,6 +450,44 @@ export type Database = {
},
]
}
conflict_resolutions: {
Row: {
conflict_details: Json | null
created_at: string
detected_at: string
id: string
resolution_strategy: string
resolved_by: string | null
submission_id: string
}
Insert: {
conflict_details?: Json | null
created_at?: string
detected_at?: string
id?: string
resolution_strategy: string
resolved_by?: string | null
submission_id: string
}
Update: {
conflict_details?: Json | null
created_at?: string
detected_at?: string
id?: string
resolution_strategy?: string
resolved_by?: string | null
submission_id?: string
}
Relationships: [
{
foreignKeyName: "conflict_resolutions_submission_id_fkey"
columns: ["submission_id"]
isOneToOne: false
referencedRelation: "content_submissions"
referencedColumns: ["id"]
},
]
}
contact_email_threads: {
Row: {
body_html: string | null
@@ -4765,6 +4803,12 @@ export type Database = {
user_agent: string
}[]
}
get_orphaned_edit_history: {
Args: never
Returns: {
id: string
}[]
}
get_recent_changes: {
Args: { limit_count?: number }
Returns: {

View File

@@ -35,6 +35,18 @@ export interface DependencyConflict {
}>;
}
export interface ConflictCheckResult {
hasConflict: boolean;
clientVersion: {
last_modified_at: string;
};
serverVersion?: {
last_modified_at: string;
last_modified_by: string;
modified_by_profile?: any;
} | null;
}
/**
* Fetch all items for a submission with their dependencies
*/
@@ -1368,3 +1380,108 @@ export async function fetchEditHistory(itemId: string) {
return [];
}
}
/**
* Check if a submission has been modified since the client last loaded it
* Used for optimistic locking to prevent concurrent edit conflicts
*/
export async function checkSubmissionConflict(
submissionId: string,
clientLastModified: string
): Promise<ConflictCheckResult> {
try {
const { data, error } = await supabase
.from('content_submissions')
.select(`
last_modified_at,
last_modified_by,
profiles:last_modified_by (
username,
display_name,
avatar_url
)
`)
.eq('id', submissionId)
.single();
if (error) throw error;
if (!data.last_modified_at) {
return {
hasConflict: false,
clientVersion: { last_modified_at: clientLastModified },
};
}
const serverTimestamp = new Date(data.last_modified_at).getTime();
const clientTimestamp = new Date(clientLastModified).getTime();
return {
hasConflict: serverTimestamp > clientTimestamp,
clientVersion: {
last_modified_at: clientLastModified,
},
serverVersion: {
last_modified_at: data.last_modified_at,
last_modified_by: data.last_modified_by,
modified_by_profile: data.profiles as any,
},
};
} catch (error: unknown) {
logger.error('Error checking submission conflict', {
submissionId,
error: getErrorMessage(error),
});
throw error;
}
}
/**
* Fetch recent versions of submission items for conflict resolution
*/
export async function fetchSubmissionVersions(
submissionId: string,
limit: number = 10
) {
try {
// Get all item IDs for this submission
const { data: items, error: itemsError } = await supabase
.from('submission_items')
.select('id')
.eq('submission_id', submissionId);
if (itemsError) throw itemsError;
if (!items || items.length === 0) return [];
const itemIds = items.map(i => i.id);
// Fetch edit history for all items
const { data, error } = await supabase
.from('item_edit_history')
.select(`
id,
item_id,
changes,
edited_at,
editor:profiles!item_edit_history_editor_id_fkey (
user_id,
username,
display_name,
avatar_url
)
`)
.in('item_id', itemIds)
.order('edited_at', { ascending: false })
.limit(limit);
if (error) throw error;
return data || [];
} catch (error: unknown) {
logger.error('Error fetching submission versions', {
submissionId,
error: getErrorMessage(error),
});
return [];
}
}

View File

@@ -68,3 +68,6 @@ verify_jwt = false
[functions.process-expired-bans]
verify_jwt = false
[functions.cleanup-old-versions]
verify_jwt = false

View File

@@ -0,0 +1,198 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
interface CleanupStats {
item_edit_history_deleted: number;
orphaned_records_deleted: number;
processing_time_ms: number;
errors: string[];
}
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
);
try {
const startTime = Date.now();
const stats: CleanupStats = {
item_edit_history_deleted: 0,
orphaned_records_deleted: 0,
processing_time_ms: 0,
errors: [],
};
console.log('Starting version cleanup job...');
// Get retention settings from admin_settings
const { data: retentionSetting, error: settingsError } = await supabaseClient
.from('admin_settings')
.select('setting_value')
.eq('setting_key', 'version_retention_days')
.single();
if (settingsError) {
throw new Error(`Failed to fetch settings: ${settingsError.message}`);
}
const retentionDays = Number(retentionSetting?.setting_value) || 90;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
console.log(`Cleanup configuration: ${retentionDays} day retention, cutoff: ${cutoffDate.toISOString()}`);
// Step 1: Delete orphaned edit history (where submission_item no longer exists)
const { data: orphanedRecords, error: orphanError } = await supabaseClient
.rpc('get_orphaned_edit_history');
if (orphanError) {
stats.errors.push(`Failed to find orphaned records: ${orphanError.message}`);
console.error('Orphan detection error:', orphanError);
} else if (orphanedRecords && orphanedRecords.length > 0) {
const orphanedIds = orphanedRecords.map((r: any) => r.id);
console.log(`Found ${orphanedIds.length} orphaned edit history records`);
const { error: deleteOrphanError } = await supabaseClient
.from('item_edit_history')
.delete()
.in('id', orphanedIds);
if (deleteOrphanError) {
stats.errors.push(`Failed to delete orphaned records: ${deleteOrphanError.message}`);
console.error('Orphan deletion error:', deleteOrphanError);
} else {
stats.orphaned_records_deleted = orphanedIds.length;
console.log(`Deleted ${orphanedIds.length} orphaned records`);
}
}
// Step 2: For each item, keep most recent 10 versions, delete older ones beyond retention
const { data: items, error: itemsError } = await supabaseClient
.from('submission_items')
.select('id');
if (itemsError) {
throw new Error(`Failed to fetch submission items: ${itemsError.message}`);
}
if (items && items.length > 0) {
console.log(`Processing ${items.length} submission items for version cleanup`);
for (const item of items) {
try {
// Get all versions for this item, ordered by date (newest first)
const { data: versions, error: versionsError } = await supabaseClient
.from('item_edit_history')
.select('id, edited_at')
.eq('item_id', item.id)
.order('edited_at', { ascending: false });
if (versionsError) {
stats.errors.push(`Item ${item.id}: ${versionsError.message}`);
continue;
}
if (versions && versions.length > 10) {
// Keep most recent 10, delete the rest if they're old enough
const versionsToDelete = versions
.slice(10)
.filter(v => new Date(v.edited_at) < cutoffDate)
.map(v => v.id);
if (versionsToDelete.length > 0) {
const { error: deleteError } = await supabaseClient
.from('item_edit_history')
.delete()
.in('id', versionsToDelete);
if (deleteError) {
stats.errors.push(`Item ${item.id} deletion failed: ${deleteError.message}`);
} else {
stats.item_edit_history_deleted += versionsToDelete.length;
}
}
}
} catch (itemError) {
stats.errors.push(`Item ${item.id} processing error: ${itemError}`);
}
}
}
// Step 3: Update last cleanup timestamp
const cleanupTimestamp = new Date().toISOString();
const { error: updateError } = await supabaseClient
.from('admin_settings')
.update({ setting_value: `"${cleanupTimestamp}"` })
.eq('setting_key', 'last_version_cleanup');
if (updateError) {
stats.errors.push(`Failed to update last_version_cleanup: ${updateError.message}`);
}
// Step 4: Log cleanup statistics to audit log
await supabaseClient
.from('admin_audit_log')
.insert({
admin_user_id: null,
target_user_id: null,
action: 'version_cleanup',
details: {
stats: {
...stats,
errors: undefined, // Don't log errors array in details
},
retention_days: retentionDays,
executed_at: cleanupTimestamp,
error_count: stats.errors.length,
},
});
stats.processing_time_ms = Date.now() - startTime;
console.log('Cleanup completed successfully:', {
...stats,
errors: stats.errors.length > 0 ? stats.errors : undefined,
});
return new Response(
JSON.stringify({
success: true,
stats,
message: `Cleaned up ${stats.item_edit_history_deleted + stats.orphaned_records_deleted} version records`,
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
}
);
} catch (error) {
console.error('Cleanup job failed:', error);
return new Response(
JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
}
);
}
});

View File

@@ -112,6 +112,56 @@ serve(async (req) => {
);
}
// Generate idempotency key for duplicate prevention
const { data: keyData, error: keyError } = await supabase
.rpc('generate_notification_idempotency_key', {
p_notification_type: 'moderation_submission',
p_entity_id: submission_id,
p_recipient_id: '00000000-0000-0000-0000-000000000000', // Topic-based, use placeholder
p_event_data: { submission_type, action }
});
const idempotencyKey = keyData || `mod_sub_${submission_id}_${Date.now()}`;
// Check for duplicate within 24h window
const { data: existingLog, error: logCheckError } = await supabase
.from('notification_logs')
.select('id')
.eq('idempotency_key', idempotencyKey)
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
.maybeSingle();
if (existingLog) {
// Duplicate detected - log and skip
await supabase.from('notification_logs').update({
is_duplicate: true
}).eq('id', existingLog.id);
edgeLogger.info('Duplicate notification prevented', {
action: 'notify_moderators',
requestId: tracking.requestId,
idempotencyKey,
submission_id
});
return new Response(
JSON.stringify({
success: true,
message: 'Duplicate notification prevented',
idempotencyKey,
requestId: tracking.requestId,
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 200,
}
);
}
// Prepare enhanced notification payload
const notificationPayload = {
baseUrl: 'https://www.thrillwiki.com',
@@ -146,6 +196,19 @@ serve(async (req) => {
},
});
// Log notification in notification_logs with idempotency key
await supabase.from('notification_logs').insert({
user_id: '00000000-0000-0000-0000-000000000000', // Topic-based
notification_type: 'moderation_submission',
idempotency_key: idempotencyKey,
is_duplicate: false,
metadata: {
submission_id,
submission_type,
transaction_id: data?.transactionId
}
});
if (error) {
const duration = endRequest(tracking);
edgeLogger.error('Failed to notify moderators via topic', {

View File

@@ -151,11 +151,64 @@ serve(async (req) => {
};
}
// Generate idempotency key for duplicate prevention
const { data: keyData, error: keyError } = await supabase
.rpc('generate_notification_idempotency_key', {
p_notification_type: `submission_${status}`,
p_entity_id: submission_id,
p_recipient_id: user_id,
});
const idempotencyKey = keyData || `user_sub_${submission_id}_${user_id}_${status}_${Date.now()}`;
// Check for duplicate within 24h window
const { data: existingLog, error: logCheckError } = await supabase
.from('notification_logs')
.select('id')
.eq('user_id', user_id)
.eq('idempotency_key', idempotencyKey)
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
.maybeSingle();
if (existingLog) {
// Duplicate detected - log and skip
await supabase.from('notification_logs').update({
is_duplicate: true
}).eq('id', existingLog.id);
console.log('Duplicate notification prevented:', {
userId: user_id,
idempotencyKey,
submissionId: submission_id,
requestId: tracking.requestId
});
endRequest(tracking, 200);
return new Response(
JSON.stringify({
success: true,
message: 'Duplicate notification prevented',
idempotencyKey,
requestId: tracking.requestId
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 200,
}
);
}
console.log('Sending notification to user:', {
userId: user_id,
workflowId,
entityName,
status,
idempotencyKey,
requestId: tracking.requestId
});
@@ -175,6 +228,19 @@ serve(async (req) => {
throw new Error(`Failed to trigger notification: ${notificationError.message}`);
}
// Log notification in notification_logs with idempotency key
await supabase.from('notification_logs').insert({
user_id,
notification_type: `submission_${status}`,
idempotency_key: idempotencyKey,
is_duplicate: false,
metadata: {
submission_id,
submission_type,
transaction_id: notificationResult?.transactionId
}
});
console.log('User notification sent successfully:', notificationResult);
endRequest(tracking, 200);

View File

@@ -0,0 +1,44 @@
-- Migration: Setup version cleanup cron job
-- Add version retention settings to admin_settings (using proper JSONB values)
INSERT INTO public.admin_settings (setting_key, setting_value, category, description)
VALUES (
'version_retention_days',
'90'::jsonb,
'maintenance',
'Number of days to retain old version history'
)
ON CONFLICT (setting_key) DO NOTHING;
INSERT INTO public.admin_settings (setting_key, setting_value, category, description)
VALUES (
'last_version_cleanup',
'null'::jsonb,
'maintenance',
'Timestamp of last successful version cleanup'
)
ON CONFLICT (setting_key) DO NOTHING;
-- Enable pg_cron extension if not already enabled
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- Unschedule existing job if it exists (to avoid duplicates)
SELECT cron.unschedule('cleanup-old-versions-weekly') WHERE EXISTS (
SELECT 1 FROM cron.job WHERE jobname = 'cleanup-old-versions-weekly'
);
-- Schedule cleanup job: Every Sunday at 2 AM UTC
SELECT cron.schedule(
'cleanup-old-versions-weekly',
'0 2 * * 0',
$$
SELECT net.http_post(
url := 'https://ydvtmnrszybqnbcqbdcy.supabase.co/functions/v1/cleanup-old-versions',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4'
),
body := jsonb_build_object('scheduled', true)
) as request_id;
$$
);

View File

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

View File

@@ -0,0 +1,52 @@
-- Phase 1: Add conflict resolution tracking
CREATE TABLE IF NOT EXISTS public.conflict_resolutions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES public.content_submissions(id) ON DELETE CASCADE,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
resolution_strategy TEXT NOT NULL CHECK (resolution_strategy IN ('keep-mine', 'keep-theirs', 'reload', 'merge')),
conflict_details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add index for performance
CREATE INDEX IF NOT EXISTS idx_conflict_resolutions_submission
ON public.conflict_resolutions(submission_id);
CREATE INDEX IF NOT EXISTS idx_conflict_resolutions_detected_at
ON public.conflict_resolutions(detected_at DESC);
-- Enable RLS
ALTER TABLE public.conflict_resolutions ENABLE ROW LEVEL SECURITY;
-- Policy: Moderators can view all conflict resolutions
CREATE POLICY "Moderators can view conflict resolutions"
ON public.conflict_resolutions
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = auth.uid()
AND role IN ('moderator', 'admin', 'superuser')
)
);
-- Policy: Moderators can insert conflict resolutions
CREATE POLICY "Moderators can insert conflict resolutions"
ON public.conflict_resolutions
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = auth.uid()
AND role IN ('moderator', 'admin', 'superuser')
)
AND resolved_by = auth.uid()
);
-- Add index for notification deduplication performance (Phase 3)
CREATE INDEX IF NOT EXISTS idx_notification_logs_dedup
ON public.notification_logs(user_id, idempotency_key, created_at);
-- Add comment
COMMENT ON TABLE public.conflict_resolutions IS 'Tracks resolution of concurrent edit conflicts in moderation system';