mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:31:13 -05:00
Implement planned features
This commit is contained in:
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { QueueSortControls } from './QueueSortControls';
|
||||
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
|
||||
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
|
||||
|
||||
interface QueueFiltersProps {
|
||||
@@ -39,107 +42,161 @@ export const QueueFilters = ({
|
||||
onClearFilters,
|
||||
showClearButton
|
||||
}: QueueFiltersProps) => {
|
||||
const { isCollapsed, toggle } = useFilterPanelState();
|
||||
|
||||
// Count active filters
|
||||
const activeFilterCount = [
|
||||
activeEntityFilter !== 'all' ? 1 : 0,
|
||||
activeStatusFilter !== 'all' ? 1 : 0,
|
||||
].reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
|
||||
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
|
||||
</div>
|
||||
|
||||
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
||||
{/* Entity Type Filter */}
|
||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
||||
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
|
||||
<Select
|
||||
value={activeEntityFilter}
|
||||
onValueChange={onEntityFilterChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="entity-filter"
|
||||
className={isMobile ? "h-10" : ""}
|
||||
aria-label="Filter by entity type"
|
||||
>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
{getEntityFilterIcon(activeEntityFilter)}
|
||||
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
All Items
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="reviews">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Reviews
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="submissions">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Submissions
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="photos">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Photos
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className={`bg-muted/50 rounded-lg transition-all duration-250 ${isMobile ? 'p-3' : 'p-4'}`}>
|
||||
<Collapsible open={!isCollapsed} onOpenChange={() => toggle()}>
|
||||
{/* Header with collapse trigger on mobile */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
|
||||
{isCollapsed && activeFilterCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{activeFilterCount} active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isMobile && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
|
||||
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
|
||||
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
|
||||
<Select
|
||||
value={activeStatusFilter}
|
||||
onValueChange={onStatusFilterChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="status-filter"
|
||||
className={isMobile ? "h-10" : ""}
|
||||
aria-label="Filter by submission status"
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="partially_approved">Partially Approved</SelectItem>
|
||||
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
|
||||
<SelectItem value="flagged">Flagged</SelectItem>
|
||||
<CollapsibleContent className="space-y-4">
|
||||
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
|
||||
{/* Entity Type Filter */}
|
||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
|
||||
<Label htmlFor="entity-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>
|
||||
<Select
|
||||
value={activeEntityFilter}
|
||||
onValueChange={onEntityFilterChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="entity-filter"
|
||||
className={isMobile ? "h-11 min-h-[44px]" : ""}
|
||||
aria-label="Filter by entity type"
|
||||
>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
{getEntityFilterIcon(activeEntityFilter)}
|
||||
<span className="capitalize">{activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter}</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
All Items
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="reviews">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Reviews
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="submissions">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Submissions
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="photos">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Photos
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[120px]'}`}>
|
||||
<Label htmlFor="status-filter" className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Status</Label>
|
||||
<Select
|
||||
value={activeStatusFilter}
|
||||
onValueChange={onStatusFilterChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="status-filter"
|
||||
className={isMobile ? "h-11 min-h-[44px]" : ""}
|
||||
aria-label="Filter by submission status"
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="capitalize">{activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="partially_approved">Partially Approved</SelectItem>
|
||||
{activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && (
|
||||
<SelectItem value="flagged">Flagged</SelectItem>
|
||||
)}
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sort Controls */}
|
||||
<QueueSortControls
|
||||
sortConfig={sortConfig}
|
||||
onSortChange={onSortChange}
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters & Apply Buttons (mobile only) */}
|
||||
{isMobile && (
|
||||
<div className="flex gap-2 pt-2 border-t border-border">
|
||||
{showClearButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={onClearFilters}
|
||||
className="flex-1 h-11 min-h-[44px]"
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
onClick={() => toggle()}
|
||||
className="flex-1 h-11 min-h-[44px]"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Sort Controls */}
|
||||
<QueueSortControls
|
||||
sortConfig={sortConfig}
|
||||
onSortChange={onSortChange}
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters Button */}
|
||||
{showClearButton && (
|
||||
<div className={isMobile ? "" : "flex items-end"}>
|
||||
{/* Clear Filters Button (desktop only) */}
|
||||
{!isMobile && showClearButton && (
|
||||
<div className="flex items-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size={isMobile ? "default" : "sm"}
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
approveSubmissionItems,
|
||||
rejectSubmissionItems,
|
||||
escalateSubmission,
|
||||
checkSubmissionConflict,
|
||||
type SubmissionItemWithDeps,
|
||||
type DependencyConflict
|
||||
type DependencyConflict,
|
||||
type ConflictCheckResult
|
||||
} from '@/lib/submissionItemsService';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
@@ -22,7 +24,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { AlertCircle, CheckCircle2, XCircle, Edit, Network, ArrowUp } from 'lucide-react';
|
||||
import { AlertCircle, CheckCircle2, XCircle, Edit, Network, ArrowUp, History } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
@@ -34,6 +36,8 @@ import { RejectionDialog } from './RejectionDialog';
|
||||
import { ItemEditDialog } from './ItemEditDialog';
|
||||
import { ValidationBlockerDialog } from './ValidationBlockerDialog';
|
||||
import { WarningConfirmDialog } from './WarningConfirmDialog';
|
||||
import { ConflictResolutionModal } from './ConflictResolutionModal';
|
||||
import { EditHistoryAccordion } from './EditHistoryAccordion';
|
||||
import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
@@ -70,6 +74,9 @@ export function SubmissionReviewManager({
|
||||
const [userConfirmedWarnings, setUserConfirmedWarnings] = useState(false);
|
||||
const [hasBlockingErrors, setHasBlockingErrors] = useState(false);
|
||||
const [globalValidationKey, setGlobalValidationKey] = useState(0);
|
||||
const [conflictData, setConflictData] = useState<ConflictCheckResult | null>(null);
|
||||
const [showConflictResolutionModal, setShowConflictResolutionModal] = useState(false);
|
||||
const [lastModifiedTimestamp, setLastModifiedTimestamp] = useState<string | null>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
const { isAdmin, isSuperuser } = useUserRole();
|
||||
@@ -113,15 +120,16 @@ export function SubmissionReviewManager({
|
||||
try {
|
||||
const { supabase } = await import('@/integrations/supabase/client');
|
||||
|
||||
// Fetch submission type
|
||||
// Fetch submission type and last_modified_at
|
||||
const { data: submission } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('submission_type')
|
||||
.select('submission_type, last_modified_at')
|
||||
.eq('id', submissionId)
|
||||
.single();
|
||||
|
||||
if (submission) {
|
||||
setSubmissionType(submission.submission_type || 'submission');
|
||||
setLastModifiedTimestamp(submission.last_modified_at);
|
||||
}
|
||||
|
||||
const fetchedItems = await fetchSubmissionItems(submissionId);
|
||||
@@ -211,6 +219,18 @@ export function SubmissionReviewManager({
|
||||
dispatch({ type: 'START_APPROVAL' });
|
||||
|
||||
try {
|
||||
// Check for conflicts first (optimistic locking)
|
||||
if (lastModifiedTimestamp) {
|
||||
const conflictCheck = await checkSubmissionConflict(submissionId, lastModifiedTimestamp);
|
||||
|
||||
if (conflictCheck.hasConflict) {
|
||||
setConflictData(conflictCheck);
|
||||
setShowConflictResolutionModal(true);
|
||||
dispatch({ type: 'RESET' }); // Return to reviewing state
|
||||
return; // Block approval until conflict resolved
|
||||
}
|
||||
}
|
||||
|
||||
// Run validation on all selected items
|
||||
const validationResultsMap = await validateMultipleItems(
|
||||
selectedItems.map(item => ({
|
||||
@@ -603,6 +623,43 @@ export function SubmissionReviewManager({
|
||||
i.item_data?.name || i.item_type.replace('_', ' ')
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConflictResolutionModal
|
||||
open={showConflictResolutionModal}
|
||||
onOpenChange={setShowConflictResolutionModal}
|
||||
conflictData={conflictData}
|
||||
onResolve={async (strategy) => {
|
||||
if (strategy === 'keep-mine') {
|
||||
// Log conflict resolution
|
||||
const { supabase } = await import('@/integrations/supabase/client');
|
||||
await supabase.from('conflict_resolutions').insert([{
|
||||
submission_id: submissionId,
|
||||
resolved_by: user?.id || null,
|
||||
resolution_strategy: strategy,
|
||||
conflict_details: conflictData as any,
|
||||
}]);
|
||||
|
||||
// Force override and proceed with approval
|
||||
await handleApprove();
|
||||
} else if (strategy === 'keep-theirs') {
|
||||
// Reload data and discard local changes
|
||||
await loadSubmissionItems();
|
||||
toast({
|
||||
title: 'Changes Discarded',
|
||||
description: 'Loaded the latest version from the server',
|
||||
});
|
||||
} else if (strategy === 'reload') {
|
||||
// Just reload without approving
|
||||
await loadSubmissionItems();
|
||||
toast({
|
||||
title: 'Reloaded',
|
||||
description: 'Viewing the latest version',
|
||||
});
|
||||
}
|
||||
setShowConflictResolutionModal(false);
|
||||
setConflictData(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -724,13 +781,13 @@ export function SubmissionReviewManager({
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'items' || v === 'dependencies') {
|
||||
setActiveTab(v);
|
||||
if (v === 'items' || v === 'dependencies' || v === 'history') {
|
||||
setActiveTab(v as 'items' | 'dependencies');
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="items">
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Items ({items.length})
|
||||
@@ -739,6 +796,10 @@ export function SubmissionReviewManager({
|
||||
<Network className="w-4 h-4 mr-2" />
|
||||
Dependencies
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history">
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
History
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="items" className="flex-1 overflow-hidden">
|
||||
@@ -778,6 +839,12 @@ export function SubmissionReviewManager({
|
||||
<TabsContent value="dependencies" className="flex-1 overflow-hidden">
|
||||
<DependencyVisualizer items={items} selectedIds={selectedItemIds} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<EditHistoryAccordion submissionId={submissionId} />
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Blocking error alert */}
|
||||
|
||||
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: {
|
||||
Row: {
|
||||
body_html: string | null
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -1369,25 +1381,6 @@ export async function fetchEditHistory(itemId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict detection interfaces and functions
|
||||
*/
|
||||
export interface ConflictCheckResult {
|
||||
hasConflict: boolean;
|
||||
serverVersion?: {
|
||||
last_modified_at: string;
|
||||
last_modified_by: string;
|
||||
modified_by_profile?: {
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
};
|
||||
clientVersion?: {
|
||||
last_modified_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a submission has been modified since the client last loaded it
|
||||
* Used for optimistic locking to prevent concurrent edit conflicts
|
||||
@@ -1425,14 +1418,14 @@ export async function checkSubmissionConflict(
|
||||
|
||||
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,
|
||||
},
|
||||
clientVersion: {
|
||||
last_modified_at: clientLastModified,
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error checking submission conflict', {
|
||||
|
||||
@@ -112,6 +112,56 @@ serve(async (req) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Generate idempotency key for duplicate prevention
|
||||
const { data: keyData, error: keyError } = await supabase
|
||||
.rpc('generate_notification_idempotency_key', {
|
||||
p_notification_type: 'moderation_submission',
|
||||
p_entity_id: submission_id,
|
||||
p_recipient_id: '00000000-0000-0000-0000-000000000000', // Topic-based, use placeholder
|
||||
p_event_data: { submission_type, action }
|
||||
});
|
||||
|
||||
const idempotencyKey = keyData || `mod_sub_${submission_id}_${Date.now()}`;
|
||||
|
||||
// Check for duplicate within 24h window
|
||||
const { data: existingLog, error: logCheckError } = await supabase
|
||||
.from('notification_logs')
|
||||
.select('id')
|
||||
.eq('idempotency_key', idempotencyKey)
|
||||
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
|
||||
.maybeSingle();
|
||||
|
||||
if (existingLog) {
|
||||
// Duplicate detected - log and skip
|
||||
await supabase.from('notification_logs').update({
|
||||
is_duplicate: true
|
||||
}).eq('id', existingLog.id);
|
||||
|
||||
edgeLogger.info('Duplicate notification prevented', {
|
||||
action: 'notify_moderators',
|
||||
requestId: tracking.requestId,
|
||||
idempotencyKey,
|
||||
submission_id
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Duplicate notification prevented',
|
||||
idempotencyKey,
|
||||
requestId: tracking.requestId,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-ID': tracking.requestId
|
||||
},
|
||||
status: 200,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare enhanced notification payload
|
||||
const notificationPayload = {
|
||||
baseUrl: 'https://www.thrillwiki.com',
|
||||
@@ -146,6 +196,19 @@ serve(async (req) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Log notification in notification_logs with idempotency key
|
||||
await supabase.from('notification_logs').insert({
|
||||
user_id: '00000000-0000-0000-0000-000000000000', // Topic-based
|
||||
notification_type: 'moderation_submission',
|
||||
idempotency_key: idempotencyKey,
|
||||
is_duplicate: false,
|
||||
metadata: {
|
||||
submission_id,
|
||||
submission_type,
|
||||
transaction_id: data?.transactionId
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const duration = endRequest(tracking);
|
||||
edgeLogger.error('Failed to notify moderators via topic', {
|
||||
|
||||
@@ -151,11 +151,64 @@ serve(async (req) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Generate idempotency key for duplicate prevention
|
||||
const { data: keyData, error: keyError } = await supabase
|
||||
.rpc('generate_notification_idempotency_key', {
|
||||
p_notification_type: `submission_${status}`,
|
||||
p_entity_id: submission_id,
|
||||
p_recipient_id: user_id,
|
||||
});
|
||||
|
||||
const idempotencyKey = keyData || `user_sub_${submission_id}_${user_id}_${status}_${Date.now()}`;
|
||||
|
||||
// Check for duplicate within 24h window
|
||||
const { data: existingLog, error: logCheckError } = await supabase
|
||||
.from('notification_logs')
|
||||
.select('id')
|
||||
.eq('user_id', user_id)
|
||||
.eq('idempotency_key', idempotencyKey)
|
||||
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
|
||||
.maybeSingle();
|
||||
|
||||
if (existingLog) {
|
||||
// Duplicate detected - log and skip
|
||||
await supabase.from('notification_logs').update({
|
||||
is_duplicate: true
|
||||
}).eq('id', existingLog.id);
|
||||
|
||||
console.log('Duplicate notification prevented:', {
|
||||
userId: user_id,
|
||||
idempotencyKey,
|
||||
submissionId: submission_id,
|
||||
requestId: tracking.requestId
|
||||
});
|
||||
|
||||
endRequest(tracking, 200);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Duplicate notification prevented',
|
||||
idempotencyKey,
|
||||
requestId: tracking.requestId
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-ID': tracking.requestId
|
||||
},
|
||||
status: 200,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Sending notification to user:', {
|
||||
userId: user_id,
|
||||
workflowId,
|
||||
entityName,
|
||||
status,
|
||||
idempotencyKey,
|
||||
requestId: tracking.requestId
|
||||
});
|
||||
|
||||
@@ -175,6 +228,19 @@ serve(async (req) => {
|
||||
throw new Error(`Failed to trigger notification: ${notificationError.message}`);
|
||||
}
|
||||
|
||||
// Log notification in notification_logs with idempotency key
|
||||
await supabase.from('notification_logs').insert({
|
||||
user_id,
|
||||
notification_type: `submission_${status}`,
|
||||
idempotency_key: idempotencyKey,
|
||||
is_duplicate: false,
|
||||
metadata: {
|
||||
submission_id,
|
||||
submission_type,
|
||||
transaction_id: notificationResult?.transactionId
|
||||
}
|
||||
});
|
||||
|
||||
console.log('User notification sent successfully:', notificationResult);
|
||||
|
||||
endRequest(tracking, 200);
|
||||
|
||||
@@ -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