mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 18:26:58 -05:00
Compare commits
3 Commits
036df6f5b7
...
061c06be29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
061c06be29 | ||
|
|
ecca11a475 | ||
|
|
d44f806afa |
216
src/components/admin/NotificationDebugPanel.tsx
Normal file
216
src/components/admin/NotificationDebugPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/admin/VersionCleanupSettings.tsx
Normal file
196
src/components/admin/VersionCleanupSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
src/components/moderation/ConflictResolutionModal.tsx
Normal file
157
src/components/moderation/ConflictResolutionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/components/moderation/EditHistoryAccordion.tsx
Normal file
134
src/components/moderation/EditHistoryAccordion.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/moderation/EditHistoryEntry.tsx
Normal file
131
src/components/moderation/EditHistoryEntry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { QueueSortControls } from './QueueSortControls';
|
||||||
|
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
||||||
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
||||||
|
|
||||||
interface QueueFiltersProps {
|
interface QueueFiltersProps {
|
||||||
@@ -39,107 +42,161 @@ export const QueueFilters = ({
|
|||||||
onClearFilters,
|
onClearFilters,
|
||||||
showClearButton
|
showClearButton
|
||||||
}: QueueFiltersProps) => {
|
}: 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 (
|
return (
|
||||||
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
|
<div className={`bg-muted/50 rounded-lg transition-all duration-250 ${isMobile ? 'p-3' : 'p-4'}`}>
|
||||||
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
|
<Collapsible open={!isCollapsed} onOpenChange={() => toggle()}>
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
|
{/* Header with collapse trigger on mobile */}
|
||||||
</div>
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
|
||||||
{/* Entity Type Filter */}
|
{isCollapsed && activeFilterCount > 0 && (
|
||||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
<Badge variant="secondary" className="text-xs">
|
||||||
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
|
{activeFilterCount} active
|
||||||
<Select
|
</Badge>
|
||||||
value={activeEntityFilter}
|
)}
|
||||||
onValueChange={onEntityFilterChange}
|
</div>
|
||||||
>
|
{isMobile && (
|
||||||
<SelectTrigger
|
<CollapsibleTrigger asChild>
|
||||||
id="entity-filter"
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
className={isMobile ? "h-10" : ""}
|
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
||||||
aria-label="Filter by entity type"
|
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
||||||
>
|
</Button>
|
||||||
<SelectValue>
|
</CollapsibleTrigger>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter */}
|
<CollapsibleContent className="space-y-4">
|
||||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
|
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
||||||
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
|
{/* Entity Type Filter */}
|
||||||
<Select
|
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
||||||
value={activeStatusFilter}
|
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
|
||||||
onValueChange={onStatusFilterChange}
|
<Select
|
||||||
>
|
value={activeEntityFilter}
|
||||||
<SelectTrigger
|
onValueChange={onEntityFilterChange}
|
||||||
id="status-filter"
|
>
|
||||||
className={isMobile ? "h-10" : ""}
|
<SelectTrigger
|
||||||
aria-label="Filter by submission status"
|
id="entity-filter"
|
||||||
>
|
className={isMobile ? "h-11 min-h-[44px]" : ""}
|
||||||
<SelectValue>
|
aria-label="Filter by entity type"
|
||||||
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
|
>
|
||||||
</SelectValue>
|
<SelectValue>
|
||||||
</SelectTrigger>
|
<div className="flex items-center gap-2">
|
||||||
<SelectContent>
|
{getEntityFilterIcon(activeEntityFilter)}
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
</div>
|
||||||
<SelectItem value="partially_approved">Partially Approved</SelectItem>
|
</SelectValue>
|
||||||
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
|
</SelectTrigger>
|
||||||
<SelectItem value="flagged">Flagged</SelectItem>
|
<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>
|
<Button
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
variant="default"
|
||||||
</SelectContent>
|
size="default"
|
||||||
</Select>
|
onClick={() => toggle()}
|
||||||
</div>
|
className="flex-1 h-11 min-h-[44px]"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
{/* Sort Controls */}
|
{/* Clear Filters Button (desktop only) */}
|
||||||
<QueueSortControls
|
{!isMobile && showClearButton && (
|
||||||
sortConfig={sortConfig}
|
<div className="flex items-end pt-2">
|
||||||
onSortChange={onSortChange}
|
|
||||||
isMobile={isMobile}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clear Filters Button */}
|
|
||||||
{showClearButton && (
|
|
||||||
<div className={isMobile ? "" : "flex items-end"}>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size={isMobile ? "default" : "sm"}
|
size="sm"
|
||||||
onClick={onClearFilters}
|
onClick={onClearFilters}
|
||||||
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
|
className="flex items-center gap-2"
|
||||||
aria-label="Clear all filters"
|
aria-label="Clear all filters"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
approveSubmissionItems,
|
approveSubmissionItems,
|
||||||
rejectSubmissionItems,
|
rejectSubmissionItems,
|
||||||
escalateSubmission,
|
escalateSubmission,
|
||||||
|
checkSubmissionConflict,
|
||||||
type SubmissionItemWithDeps,
|
type SubmissionItemWithDeps,
|
||||||
type DependencyConflict
|
type DependencyConflict,
|
||||||
|
type ConflictCheckResult
|
||||||
} from '@/lib/submissionItemsService';
|
} from '@/lib/submissionItemsService';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
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 { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
@@ -34,6 +36,8 @@ import { RejectionDialog } from './RejectionDialog';
|
|||||||
import { ItemEditDialog } from './ItemEditDialog';
|
import { ItemEditDialog } from './ItemEditDialog';
|
||||||
import { ValidationBlockerDialog } from './ValidationBlockerDialog';
|
import { ValidationBlockerDialog } from './ValidationBlockerDialog';
|
||||||
import { WarningConfirmDialog } from './WarningConfirmDialog';
|
import { WarningConfirmDialog } from './WarningConfirmDialog';
|
||||||
|
import { ConflictResolutionModal } from './ConflictResolutionModal';
|
||||||
|
import { EditHistoryAccordion } from './EditHistoryAccordion';
|
||||||
import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
|
import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
@@ -70,6 +74,9 @@ export function SubmissionReviewManager({
|
|||||||
const [userConfirmedWarnings, setUserConfirmedWarnings] = useState(false);
|
const [userConfirmedWarnings, setUserConfirmedWarnings] = useState(false);
|
||||||
const [hasBlockingErrors, setHasBlockingErrors] = useState(false);
|
const [hasBlockingErrors, setHasBlockingErrors] = useState(false);
|
||||||
const [globalValidationKey, setGlobalValidationKey] = useState(0);
|
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 { toast } = useToast();
|
||||||
const { isAdmin, isSuperuser } = useUserRole();
|
const { isAdmin, isSuperuser } = useUserRole();
|
||||||
@@ -113,15 +120,16 @@ export function SubmissionReviewManager({
|
|||||||
try {
|
try {
|
||||||
const { supabase } = await import('@/integrations/supabase/client');
|
const { supabase } = await import('@/integrations/supabase/client');
|
||||||
|
|
||||||
// Fetch submission type
|
// Fetch submission type and last_modified_at
|
||||||
const { data: submission } = await supabase
|
const { data: submission } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
.select('submission_type')
|
.select('submission_type, last_modified_at')
|
||||||
.eq('id', submissionId)
|
.eq('id', submissionId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (submission) {
|
if (submission) {
|
||||||
setSubmissionType(submission.submission_type || 'submission');
|
setSubmissionType(submission.submission_type || 'submission');
|
||||||
|
setLastModifiedTimestamp(submission.last_modified_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedItems = await fetchSubmissionItems(submissionId);
|
const fetchedItems = await fetchSubmissionItems(submissionId);
|
||||||
@@ -211,6 +219,18 @@ export function SubmissionReviewManager({
|
|||||||
dispatch({ type: 'START_APPROVAL' });
|
dispatch({ type: 'START_APPROVAL' });
|
||||||
|
|
||||||
try {
|
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
|
// Run validation on all selected items
|
||||||
const validationResultsMap = await validateMultipleItems(
|
const validationResultsMap = await validateMultipleItems(
|
||||||
selectedItems.map(item => ({
|
selectedItems.map(item => ({
|
||||||
@@ -603,6 +623,43 @@ export function SubmissionReviewManager({
|
|||||||
i.item_data?.name || i.item_type.replace('_', ' ')
|
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
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v === 'items' || v === 'dependencies') {
|
if (v === 'items' || v === 'dependencies' || v === 'history') {
|
||||||
setActiveTab(v);
|
setActiveTab(v as 'items' | 'dependencies');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 flex flex-col"
|
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">
|
<TabsTrigger value="items">
|
||||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
Items ({items.length})
|
Items ({items.length})
|
||||||
@@ -739,6 +796,10 @@ export function SubmissionReviewManager({
|
|||||||
<Network className="w-4 h-4 mr-2" />
|
<Network className="w-4 h-4 mr-2" />
|
||||||
Dependencies
|
Dependencies
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">
|
||||||
|
<History className="w-4 h-4 mr-2" />
|
||||||
|
History
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="items" className="flex-1 overflow-hidden">
|
<TabsContent value="items" className="flex-1 overflow-hidden">
|
||||||
@@ -778,6 +839,12 @@ export function SubmissionReviewManager({
|
|||||||
<TabsContent value="dependencies" className="flex-1 overflow-hidden">
|
<TabsContent value="dependencies" className="flex-1 overflow-hidden">
|
||||||
<DependencyVisualizer items={items} selectedIds={selectedItemIds} />
|
<DependencyVisualizer items={items} selectedIds={selectedItemIds} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="history" className="flex-1 overflow-hidden">
|
||||||
|
<ScrollArea className="h-full pr-4">
|
||||||
|
<EditHistoryAccordion submissionId={submissionId} />
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Blocking error alert */}
|
{/* Blocking error alert */}
|
||||||
|
|||||||
47
src/hooks/useFilterPanelState.ts
Normal file
47
src/hooks/useFilterPanelState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: {
|
contact_email_threads: {
|
||||||
Row: {
|
Row: {
|
||||||
body_html: string | null
|
body_html: string | null
|
||||||
@@ -4765,6 +4803,12 @@ export type Database = {
|
|||||||
user_agent: string
|
user_agent: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
get_orphaned_edit_history: {
|
||||||
|
Args: never
|
||||||
|
Returns: {
|
||||||
|
id: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
get_recent_changes: {
|
get_recent_changes: {
|
||||||
Args: { limit_count?: number }
|
Args: { limit_count?: number }
|
||||||
Returns: {
|
Returns: {
|
||||||
|
|||||||
@@ -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
|
* Fetch all items for a submission with their dependencies
|
||||||
*/
|
*/
|
||||||
@@ -1368,3 +1380,108 @@ export async function fetchEditHistory(itemId: string) {
|
|||||||
return [];
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,4 +67,7 @@ verify_jwt = true
|
|||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
[functions.process-expired-bans]
|
[functions.process-expired-bans]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.cleanup-old-versions]
|
||||||
|
verify_jwt = false
|
||||||
|
|||||||
198
supabase/functions/cleanup-old-versions/index.ts
Normal file
198
supabase/functions/cleanup-old-versions/index.ts
Normal 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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
// Prepare enhanced notification payload
|
||||||
const notificationPayload = {
|
const notificationPayload = {
|
||||||
baseUrl: 'https://www.thrillwiki.com',
|
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) {
|
if (error) {
|
||||||
const duration = endRequest(tracking);
|
const duration = endRequest(tracking);
|
||||||
edgeLogger.error('Failed to notify moderators via topic', {
|
edgeLogger.error('Failed to notify moderators via topic', {
|
||||||
|
|||||||
@@ -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:', {
|
console.log('Sending notification to user:', {
|
||||||
userId: user_id,
|
userId: user_id,
|
||||||
workflowId,
|
workflowId,
|
||||||
entityName,
|
entityName,
|
||||||
status,
|
status,
|
||||||
|
idempotencyKey,
|
||||||
requestId: tracking.requestId
|
requestId: tracking.requestId
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,6 +228,19 @@ serve(async (req) => {
|
|||||||
throw new Error(`Failed to trigger notification: ${notificationError.message}`);
|
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);
|
console.log('User notification sent successfully:', notificationResult);
|
||||||
|
|
||||||
endRequest(tracking, 200);
|
endRequest(tracking, 200);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
$$
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user