From 061c06be294b3bc32487faf46b9728d8a6d7fd0b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:38:16 +0000 Subject: [PATCH] Implement planned features --- .../moderation/EditHistoryAccordion.tsx | 134 ++++++++++ .../moderation/EditHistoryEntry.tsx | 131 ++++++++++ src/components/moderation/QueueFilters.tsx | 245 +++++++++++------- .../moderation/SubmissionReviewManager.tsx | 81 +++++- src/hooks/useFilterPanelState.ts | 47 ++++ src/integrations/supabase/types.ts | 38 +++ src/lib/submissionItemsService.ts | 37 ++- .../notify-moderators-submission/index.ts | 63 +++++ .../notify-user-submission-status/index.ts | 66 +++++ ...0_d88ec137-2cfb-446d-a28e-08a1a045b12c.sql | 52 ++++ 10 files changed, 771 insertions(+), 123 deletions(-) create mode 100644 src/components/moderation/EditHistoryAccordion.tsx create mode 100644 src/components/moderation/EditHistoryEntry.tsx create mode 100644 src/hooks/useFilterPanelState.ts create mode 100644 supabase/migrations/20251103003450_d88ec137-2cfb-446d-a28e-08a1a045b12c.sql diff --git a/src/components/moderation/EditHistoryAccordion.tsx b/src/components/moderation/EditHistoryAccordion.tsx new file mode 100644 index 00000000..a35598e2 --- /dev/null +++ b/src/components/moderation/EditHistoryAccordion.tsx @@ -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 ( + + + + + + Edit History + {editHistory && editHistory.length > 0 && ( + + ({editHistory.length} edit{editHistory.length !== 1 ? 's' : ''}) + + )} + + + + {isLoading && ( + + + + )} + + {error && ( + + + + Failed to load edit history: {error instanceof Error ? error.message : 'Unknown error'} + + + )} + + {!isLoading && !error && editHistory && editHistory.length === 0 && ( + + + No edit history found for this submission. + + + )} + + {!isLoading && !error && editHistory && editHistory.length > 0 && ( + + + + {editHistory.map((entry: any) => ( + + ))} + + + + {hasMore && ( + + + Load More + + + )} + + )} + + + + ); +} diff --git a/src/components/moderation/EditHistoryEntry.tsx b/src/components/moderation/EditHistoryEntry.tsx new file mode 100644 index 00000000..71785033 --- /dev/null +++ b/src/components/moderation/EditHistoryEntry.tsx @@ -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; + afterData?: Record; +} + +export function EditHistoryEntry({ + editId, + editorName, + editorAvatar, + timestamp, + changedFields, + editReason, + beforeData, + afterData, +}: EditHistoryEntryProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const getFieldValue = (data: Record | 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 ( + + + + {/* Editor Avatar */} + + + + + + + + {/* Edit Info */} + + + + {editorName} + + + {changedFields.length} field{changedFields.length !== 1 ? 's' : ''} + + + + {formatDistanceToNow(new Date(timestamp), { addSuffix: true })} + + + + {/* Changed Fields Summary */} + + {changedFields.slice(0, 3).map((field) => ( + + {field} + + ))} + {changedFields.length > 3 && ( + + +{changedFields.length - 3} more + + )} + + + {/* Edit Reason */} + {editReason && ( + + "{editReason}" + + )} + + {/* Expand/Collapse Button */} + + + + {isExpanded ? 'Hide' : 'Show'} Changes + + + + + + {/* Detailed Changes */} + + {changedFields.map((field) => { + const beforeValue = getFieldValue(beforeData, field); + const afterValue = getFieldValue(afterData, field); + + return ( + + + {field} + + + + Before + + {beforeValue} + + + + After + + {afterValue} + + + + + ); + })} + + + + ); +} diff --git a/src/components/moderation/QueueFilters.tsx b/src/components/moderation/QueueFilters.tsx index 8804a125..1b34771d 100644 --- a/src/components/moderation/QueueFilters.tsx +++ b/src/components/moderation/QueueFilters.tsx @@ -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 ( - - - Moderation Queue - - - - {/* Entity Type Filter */} - - Entity Type - - - - - {getEntityFilterIcon(activeEntityFilter)} - {activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter} - - - - - - - - All Items - - - - - - Reviews - - - - - - Submissions - - - - - - Photos - - - - + + toggle()}> + {/* Header with collapse trigger on mobile */} + + + Moderation Queue + {isCollapsed && activeFilterCount > 0 && ( + + {activeFilterCount} active + + )} + + {isMobile && ( + + + + {isCollapsed ? 'Expand filters' : 'Collapse filters'} + + + )} - {/* Status Filter */} - - Status - - - - {activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter} - - - - All Status - Pending - Partially Approved - {activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && ( - Flagged + + + {/* Entity Type Filter */} + + Entity Type + + + + + {getEntityFilterIcon(activeEntityFilter)} + {activeEntityFilter === 'all' ? 'All Items' : activeEntityFilter} + + + + + + + + All Items + + + + + + Reviews + + + + + + Submissions + + + + + + Photos + + + + + + + {/* Status Filter */} + + Status + + + + {activeStatusFilter === 'all' ? 'All Status' : activeStatusFilter} + + + + All Status + Pending + Partially Approved + {activeEntityFilter !== 'submissions' && activeEntityFilter !== 'photos' && ( + Flagged + )} + Approved + Rejected + + + + + {/* Sort Controls */} + + + + {/* Clear Filters & Apply Buttons (mobile only) */} + {isMobile && ( + + {showClearButton && ( + + + Clear All + )} - Approved - Rejected - - - + toggle()} + className="flex-1 h-11 min-h-[44px]" + > + Apply + + + )} + + - {/* Sort Controls */} - - - - {/* Clear Filters Button */} - {showClearButton && ( - + {/* Clear Filters Button (desktop only) */} + {!isMobile && showClearButton && ( + diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index d0890016..a74e9bb8 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -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(null); + const [showConflictResolutionModal, setShowConflictResolutionModal] = useState(false); + const [lastModifiedTimestamp, setLastModifiedTimestamp] = useState(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('_', ' ') )} /> + + { + 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({ { - 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" > - + Items ({items.length}) @@ -739,6 +796,10 @@ export function SubmissionReviewManager({ Dependencies + + + History + @@ -778,6 +839,12 @@ export function SubmissionReviewManager({ + + + + + + {/* Blocking error alert */} diff --git a/src/hooks/useFilterPanelState.ts b/src/hooks/useFilterPanelState.ts new file mode 100644 index 00000000..a17287ea --- /dev/null +++ b/src/hooks/useFilterPanelState.ts @@ -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(() => { + // 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, + }; +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 24e3fa90..48149c85 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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 diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts index c7c201fd..e62f8448 100644 --- a/src/lib/submissionItemsService.ts +++ b/src/lib/submissionItemsService.ts @@ -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', { diff --git a/supabase/functions/notify-moderators-submission/index.ts b/supabase/functions/notify-moderators-submission/index.ts index ac340f85..f3138db3 100644 --- a/supabase/functions/notify-moderators-submission/index.ts +++ b/supabase/functions/notify-moderators-submission/index.ts @@ -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', { diff --git a/supabase/functions/notify-user-submission-status/index.ts b/supabase/functions/notify-user-submission-status/index.ts index b72a5b79..b65955fd 100644 --- a/supabase/functions/notify-user-submission-status/index.ts +++ b/supabase/functions/notify-user-submission-status/index.ts @@ -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); diff --git a/supabase/migrations/20251103003450_d88ec137-2cfb-446d-a28e-08a1a045b12c.sql b/supabase/migrations/20251103003450_d88ec137-2cfb-446d-a28e-08a1a045b12c.sql new file mode 100644 index 00000000..95dc7064 --- /dev/null +++ b/supabase/migrations/20251103003450_d88ec137-2cfb-446d-a28e-08a1a045b12c.sql @@ -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'; \ No newline at end of file
+ "{editReason}" +