mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 11:51:14 -05:00
Refactor: Fix notification view security
This commit is contained in:
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4765,6 +4765,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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user