import { useState, useEffect, useReducer } from 'react'; import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; import { handleError, getErrorMessage } from '@/lib/errorHandler'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { moderationReducer, canApprove, canReject, hasActiveLock } from '@/lib/moderationStateMachine'; import { useLockMonitor } from '@/lib/moderation/lockMonitor'; import { useTransactionResilience } from '@/hooks/useTransactionResilience'; import * as localStorage from '@/lib/localStorage'; import { fetchSubmissionItems, buildDependencyTree, detectDependencyConflicts, approveSubmissionItems, rejectSubmissionItems, checkSubmissionConflict, type SubmissionItemWithDeps, type DependencyConflict, type ConflictCheckResult } from '@/lib/submissionItemsService'; import { useModerationActions } from '@/hooks/moderation/useModerationActions'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; 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, History } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useIsMobile } from '@/hooks/use-mobile'; import { ItemReviewCard } from './ItemReviewCard'; import { DependencyVisualizer } from './DependencyVisualizer'; import { ConflictResolutionDialog } from './ConflictResolutionDialog'; import { EscalationDialog } from './EscalationDialog'; 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 { TransactionStatusIndicator } from './TransactionStatusIndicator'; import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas'; import { logger } from '@/lib/logger'; import { ModerationErrorBoundary } from '@/components/error'; interface SubmissionReviewManagerProps { submissionId: string; open: boolean; onOpenChange: (open: boolean) => void; onComplete: () => void; } export function SubmissionReviewManager({ submissionId, open, onOpenChange, onComplete }: SubmissionReviewManagerProps) { // State machine for moderation workflow const [state, dispatch] = useReducer(moderationReducer, { status: 'idle' }); // UI-specific state (kept separate from state machine) const [items, setItems] = useState([]); const [selectedItemIds, setSelectedItemIds] = useState>(new Set()); const [conflicts, setConflicts] = useState([]); const [showConflictDialog, setShowConflictDialog] = useState(false); const [showEscalationDialog, setShowEscalationDialog] = useState(false); const [showRejectionDialog, setShowRejectionDialog] = useState(false); const [showEditDialog, setShowEditDialog] = useState(false); const [editingItem, setEditingItem] = useState(null); const [activeTab, setActiveTab] = useState<'items' | 'dependencies'>('items'); const [submissionType, setSubmissionType] = useState('submission'); const [showValidationBlockerDialog, setShowValidationBlockerDialog] = useState(false); const [showWarningConfirmDialog, setShowWarningConfirmDialog] = useState(false); const [validationResults, setValidationResults] = useState>(new Map()); 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 [escalationError, setEscalationError] = useState<{ message: string; errorId?: string; } | null>(null); const [transactionStatus, setTransactionStatus] = useState<'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'>(() => { // Restore from localStorage on mount const stored = localStorage.getJSON<{ status: string; message?: string }>(`moderation-transaction-status-${submissionId}`, { status: 'idle' }); const validStatuses = ['idle', 'processing', 'timeout', 'cached', 'completed', 'failed']; return validStatuses.includes(stored.status) ? stored.status as 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed' : 'idle'; }); const [transactionMessage, setTransactionMessage] = useState(() => { // Restore from localStorage on mount const stored = localStorage.getJSON<{ status: string; message?: string }>(`moderation-transaction-status-${submissionId}`, { status: 'idle' }); return stored.message; }); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); const { user } = useAuth(); const isMobile = useIsMobile(); const Container = isMobile ? Sheet : Dialog; // Lock monitoring integration const { extendLock } = useLockMonitor(state, dispatch, submissionId); // Transaction resilience (timeout detection & auto-release) const { executeTransaction } = useTransactionResilience({ submissionId, timeoutMs: 30000, // 30s timeout autoReleaseOnUnload: true, autoReleaseOnInactivity: true, inactivityMinutes: 10, }); // Moderation actions const { escalateSubmission } = useModerationActions({ user, onActionStart: (itemId: string) => { logger.log(`Starting escalation for ${itemId}`); }, onActionComplete: () => { logger.log('Escalation complete'); } }); // Persist transaction status to localStorage useEffect(() => { localStorage.setJSON(`moderation-transaction-status-${submissionId}`, { status: transactionStatus, message: transactionMessage, }); }, [transactionStatus, transactionMessage, submissionId]); // Auto-claim on mount useEffect(() => { if (open && submissionId && state.status === 'idle') { handleClaimSubmission(); } }, [open, submissionId, state.status]); const handleClaimSubmission = async () => { dispatch({ type: 'CLAIM_ITEM', payload: { itemId: submissionId } }); try { // Assume lock is acquired by parent component or moderation queue const lockExpires = new Date(Date.now() + 15 * 60 * 1000).toISOString(); dispatch({ type: 'LOCK_ACQUIRED', payload: { lockExpires } }); // Load data dispatch({ type: 'LOAD_DATA' }); const loadedItems = await loadSubmissionItems(); // Transition to reviewing state with actual loaded data dispatch({ type: 'DATA_LOADED', payload: { reviewData: loadedItems || [] } }); } catch (error: unknown) { dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } }); handleError(error, { action: 'Claim Submission', userId: user?.id }); } }; const loadSubmissionItems = async () => { // State machine already transitioned via handleClaimSubmission // This just handles the data fetching try { const { supabase } = await import('@/integrations/supabase/client'); // Fetch submission type and last_modified_at const { data: submission } = await supabase .from('content_submissions') .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); // Protection 2: Detect empty submissions if (!fetchedItems || fetchedItems.length === 0) { setItems([]); return; } const itemsWithDeps = buildDependencyTree(fetchedItems); setItems(itemsWithDeps); // Auto-select pending items const pendingIds = fetchedItems .filter(item => item.status === 'pending') .map(item => item.id); setSelectedItemIds(new Set(pendingIds)); return itemsWithDeps; } catch (error: unknown) { throw error; // Let handleClaimSubmission handle the error } }; const toggleItemSelection = (itemId: string) => { setSelectedItemIds(prev => { const next = new Set(prev); if (next.has(itemId)) { next.delete(itemId); } else { next.add(itemId); } // Clear blocking errors and warning confirmation when selection changes setHasBlockingErrors(false); setValidationResults(new Map()); setUserConfirmedWarnings(false); return next; }); }; const handleCheckConflicts = async () => { try { const detectedConflicts = await detectDependencyConflicts(items, Array.from(selectedItemIds)); setConflicts(detectedConflicts); if (detectedConflicts.length > 0) { setShowConflictDialog(true); } else { // No conflicts, proceed with approval handleApprove(); } } catch (error: unknown) { handleError(error, { action: 'Check Dependency Conflicts', userId: user?.id, metadata: { submissionId, selectedCount: selectedItemIds.size } }); } }; const handleApprove = async () => { // State machine validation if (!canApprove(state)) { toast({ title: 'Cannot Approve', description: state.status === 'lock_expired' ? 'Your lock has expired. Please re-claim this submission.' : `Invalid state for approval: ${state.status}`, variant: 'destructive', }); return; } if (!user?.id) { toast({ title: 'Authentication Required', description: 'You must be logged in to approve items', variant: 'destructive', }); return; } const selectedItems = items.filter(item => selectedItemIds.has(item.id)); const selectedIds = Array.from(selectedItemIds); // Transition: reviewing → approving 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 let validationResultsMap: Map; try { validationResultsMap = await validateMultipleItems( selectedItems.map(item => ({ item_type: item.item_type, item_data: item.item_data, id: item.id })) ); setValidationResults(validationResultsMap); // Check for blocking errors const itemsWithBlockingErrors = selectedItems.filter(item => { const result = validationResultsMap.get(item.id); return result && result.blockingErrors.length > 0; }); // CRITICAL: Blocking errors can NEVER be bypassed, regardless of warnings if (itemsWithBlockingErrors.length > 0) { // Log which items have blocking errors itemsWithBlockingErrors.forEach(item => { const result = validationResultsMap.get(item.id); logger.error('Blocking validation errors prevent approval', { submissionId, itemId: item.id, itemType: item.item_type, errors: result?.blockingErrors }); }); setHasBlockingErrors(true); setShowValidationBlockerDialog(true); dispatch({ type: 'ERROR', payload: { error: 'Validation failed' } }); return; // Block approval } } catch (error) { // Validation itself failed (network error, bug, etc.) const errorId = handleError(error, { action: 'Validation System Error', userId: user?.id, metadata: { submissionId, selectedItemCount: selectedItems.length, itemTypes: selectedItems.map(i => i.item_type) } }); toast({ title: 'Validation System Error', description: (

Unable to validate submission. Please try again.

Ref: {errorId.slice(0, 8)}

), variant: 'destructive' }); dispatch({ type: 'ERROR', payload: { error: 'Validation system error' } }); return; } // Check for warnings const itemsWithWarnings = selectedItems.filter(item => { const result = validationResultsMap.get(item.id); return result && result.warnings.length > 0; }); if (itemsWithWarnings.length > 0 && !userConfirmedWarnings) { setShowWarningConfirmDialog(true); dispatch({ type: 'RESET' }); // Reset to reviewing state return; // Ask for confirmation } // Proceed with approval - wrapped with transaction resilience setTransactionStatus('processing'); await executeTransaction( 'approval', selectedIds, async (idempotencyKey) => { const { supabase } = await import('@/integrations/supabase/client'); // Call the edge function for backend processing const { data, error, requestId } = await invokeWithTracking( 'process-selective-approval', { itemIds: selectedIds, submissionId, idempotencyKey, // Pass idempotency key to edge function }, user?.id ); if (error) { throw new Error(error.message || 'Failed to process approval'); } if (!data?.success) { throw new Error(data?.error || 'Approval processing failed'); } // Transition: approving → complete dispatch({ type: 'COMPLETE', payload: { result: 'approved' } }); toast({ title: 'Items Approved', description: `Successfully approved ${selectedIds.length} item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`, }); interface ApprovalResult { success: boolean; item_id: string; error?: string } const successCount = data.results.filter((r: ApprovalResult) => r.success).length; const failCount = data.results.filter((r: ApprovalResult) => !r.success).length; const allFailed = failCount > 0 && successCount === 0; const someFailed = failCount > 0 && successCount > 0; toast({ title: allFailed ? 'Approval Failed' : someFailed ? 'Partial Approval' : 'Approval Complete', description: failCount > 0 ? `Approved ${successCount} item(s), ${failCount} failed` : `Successfully approved ${successCount} item(s)`, variant: allFailed ? 'destructive' : someFailed ? 'default' : 'default', }); // Reset warning confirmation state after approval setUserConfirmedWarnings(false); // If ALL items failed, don't close dialog - show errors if (allFailed) { dispatch({ type: 'ERROR', payload: { error: 'All items failed' } }); return data; } // Reset warning confirmation state after approval setUserConfirmedWarnings(false); onComplete(); onOpenChange(false); setTransactionStatus('completed'); setTimeout(() => setTransactionStatus('idle'), 3000); return data; } ); } catch (error: unknown) { // Check for timeout if (error && typeof error === 'object' && 'type' in error && error.type === 'timeout') { setTransactionStatus('timeout'); setTransactionMessage(getErrorMessage(error)); } // Check for cached/409 else if (error && typeof error === 'object' && ('status' in error && error.status === 409)) { setTransactionStatus('cached'); setTransactionMessage('Using cached result from duplicate request'); } // Generic failure else { setTransactionStatus('failed'); setTransactionMessage(getErrorMessage(error)); } setTimeout(() => { setTransactionStatus('idle'); setTransactionMessage(undefined); }, 5000); dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } }); handleError(error, { action: 'Approve Submission Items', userId: user?.id, metadata: { submissionId, itemCount: selectedItemIds.size, hasWarnings: userConfirmedWarnings, hasBlockingErrors } }); } }; const handleRejectSelected = async () => { if (selectedItemIds.size === 0) { toast({ title: 'No Items Selected', description: 'Please select items to reject', variant: 'destructive', }); return; } if (!user?.id) { toast({ title: 'Authentication Required', description: 'You must be logged in to reject items', variant: 'destructive', }); return; } // Check if any selected items have dependents const selectedItems = items.filter(item => selectedItemIds.has(item.id)); const hasDependents = selectedItems.some(item => item.dependents && item.dependents.length > 0 ); setShowRejectionDialog(true); }; const handleReject = async (reason: string, cascade: boolean) => { // State machine validation if (!canReject(state)) { toast({ title: 'Cannot Reject', description: 'Invalid state for rejection', variant: 'destructive', }); return; } if (!user?.id) return; const selectedItems = items.filter(item => selectedItemIds.has(item.id)); const selectedIds = selectedItems.map(item => item.id); // Transition: reviewing → rejecting dispatch({ type: 'START_REJECTION' }); try { // Wrap rejection with transaction resilience setTransactionStatus('processing'); await executeTransaction( 'rejection', selectedIds, async (idempotencyKey) => { await rejectSubmissionItems(selectedItems, reason, user.id, cascade); // Transition: rejecting → complete dispatch({ type: 'COMPLETE', payload: { result: 'rejected' } }); toast({ title: 'Items Rejected', description: `Successfully rejected ${selectedItems.length} item${selectedItems.length !== 1 ? 's' : ''}`, }); onComplete(); onOpenChange(false); setTransactionStatus('completed'); setTimeout(() => setTransactionStatus('idle'), 3000); return { success: true }; } ); } catch (error: unknown) { // Check for timeout if (error && typeof error === 'object' && 'type' in error && error.type === 'timeout') { setTransactionStatus('timeout'); setTransactionMessage(getErrorMessage(error)); } // Check for cached/409 else if (error && typeof error === 'object' && ('status' in error && error.status === 409)) { setTransactionStatus('cached'); setTransactionMessage('Using cached result from duplicate request'); } // Generic failure else { setTransactionStatus('failed'); setTransactionMessage(getErrorMessage(error)); } setTimeout(() => { setTransactionStatus('idle'); setTransactionMessage(undefined); }, 5000); dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } }); handleError(error, { action: 'Reject Submission Items', userId: user?.id, metadata: { submissionId, itemCount: selectedItemIds.size, cascade, reason: reason.substring(0, 100) } }); } }; const handleEscalate = async (reason: string) => { if (!user?.id) { toast({ title: 'Authentication Required', description: 'You must be logged in to escalate submissions', variant: 'destructive', }); return; } try { setEscalationError(null); // Use consolidated action from useModerationActions // This handles: edge function call, fallback, error logging, cache invalidation await escalateSubmission( { id: submissionId, submission_type: submissionType, type: 'submission' } as any, reason ); // Success - close dialog onComplete(); onOpenChange(false); } catch (error: any) { // Track error for retry UI setEscalationError({ message: getErrorMessage(error), errorId: error.errorId }); logger.error('Escalation failed in SubmissionReviewManager', { submissionId, error: getErrorMessage(error) }); // Don't close dialog on error - let user retry } }; const handleEdit = (item: SubmissionItemWithDeps) => { setEditingItem(item); setShowEditDialog(true); }; const handleEditComplete = async () => { setShowEditDialog(false); setEditingItem(null); await loadSubmissionItems(); setGlobalValidationKey(prev => prev + 1); }; const handleItemStatusChange = async (itemId: string, status: 'approved' | 'rejected') => { if (!user?.id) { toast({ title: 'Authentication Required', description: 'You must be logged in to change item status', variant: 'destructive', }); return; } try { if (status === 'approved') { const { supabase } = await import('@/integrations/supabase/client'); const { data, error, requestId } = await invokeWithTracking( 'process-selective-approval', { itemIds: [itemId], submissionId }, user?.id ); if (error || !data?.success) { throw new Error(error?.message || data?.error || 'Failed to approve item'); } toast({ title: 'Item Approved', description: `Successfully approved the item${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`, }); } else { const item = items.find(i => i.id === itemId); if (!item) { throw new Error('Item not found'); } await rejectSubmissionItems([item], 'Quick rejection from review', user.id, false); toast({ title: 'Item Rejected', description: 'Successfully rejected the item', }); } await loadSubmissionItems(); } catch (error: unknown) { handleError(error, { action: `${status === 'approved' ? 'Approve' : 'Reject'} Item`, userId: user?.id, metadata: { submissionId, itemId, status } }); } }; const pendingCount = items.filter(item => item.status === 'pending').length; const selectedCount = selectedItemIds.size; return ( <> {isMobile ? (
Review Submission
{pendingCount} pending item(s) • {selectedCount} selected
) : (
Review Submission
{pendingCount} pending item(s) • {selectedCount} selected
)}
{ await loadSubmissionItems(); await handleApprove(); }} /> selectedItemIds.has(item.id)).some(item => item.dependents && item.dependents.length > 0 )} onReject={handleReject} /> setShowValidationBlockerDialog(false)} blockingErrors={Array.from(validationResults.values()).flatMap(r => r.blockingErrors)} itemNames={items.filter(i => selectedItemIds.has(i.id)).map(i => { const name = typeof i.item_data === 'object' && i.item_data !== null && !Array.isArray(i.item_data) && 'name' in i.item_data ? String((i.item_data as Record).name) : i.item_type.replace('_', ' '); return name; })} /> setShowWarningConfirmDialog(false)} onProceed={() => { setUserConfirmedWarnings(true); setShowWarningConfirmDialog(false); handleApprove(); }} warnings={Array.from(validationResults.values()).flatMap(r => r.warnings)} itemNames={items.filter(i => selectedItemIds.has(i.id)).map(i => { const name = typeof i.item_data === 'object' && i.item_data !== null && !Array.isArray(i.item_data) && 'name' in i.item_data ? String((i.item_data as Record).name) : i.item_type.replace('_', ' '); return name; })} /> { if (strategy === 'keep-mine') { // Log conflict resolution using relational tables const { supabase } = await import('@/integrations/supabase/client'); const { writeConflictDetailFields } = await import('@/lib/auditHelpers'); const { data: resolution, error } = await supabase .from('conflict_resolutions') .insert([{ submission_id: submissionId, resolved_by: user?.id || null, resolution_strategy: strategy, }]) .select('id') .single(); if (!error && resolution && conflictData) { await writeConflictDetailFields(resolution.id, 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); }} /> ); function ReviewContent() { // Show loading states based on state machine if (state.status === 'claiming' || state.status === 'loading_data') { return (

{state.status === 'claiming' ? 'Claiming submission...' : 'Loading items...'}

); } // Show error state if (state.status === 'error') { return ( {state.error || 'An error occurred while processing this submission'}
); } // Show lock expired warning if (state.status === 'lock_expired') { return ( Your lock on this submission has expired. You need to re-claim it to continue.
); } // Protection 2: UI detection of empty submissions if (items.length === 0 && state.status === 'reviewing') { return ( This submission has no items and appears to be corrupted or incomplete. This usually happens when the submission creation process was interrupted.
); } return (
{ if (v === 'items' || v === 'dependencies' || v === 'history') { setActiveTab(v as 'items' | 'dependencies'); } }} className="flex-1 flex flex-col" > Items ({items.length}) Dependencies History
{items.length === 0 && state.status === 'reviewing' && ( No items found in this submission )} {items.map((item) => (
toggleItemSelection(item.id)} disabled={item.status !== 'pending'} /> handleEdit(item)} onStatusChange={async () => { // Status changes handled via approve/reject actions await loadSubmissionItems(); }} submissionId={submissionId} />
))}
{/* Blocking error alert */} {hasBlockingErrors && ( Cannot approve: Selected items have validation errors that must be fixed first. )} {/* Action buttons */}
); } }