mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 14:31:12 -05:00
1077 lines
38 KiB
TypeScript
1077 lines
38 KiB
TypeScript
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<SubmissionItemWithDeps[]>([]);
|
|
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
|
const [conflicts, setConflicts] = useState<DependencyConflict[]>([]);
|
|
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
|
const [showEscalationDialog, setShowEscalationDialog] = useState(false);
|
|
const [showRejectionDialog, setShowRejectionDialog] = useState(false);
|
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
|
const [editingItem, setEditingItem] = useState<SubmissionItemWithDeps | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'items' | 'dependencies'>('items');
|
|
const [submissionType, setSubmissionType] = useState<string>('submission');
|
|
const [showValidationBlockerDialog, setShowValidationBlockerDialog] = useState(false);
|
|
const [showWarningConfirmDialog, setShowWarningConfirmDialog] = useState(false);
|
|
const [validationResults, setValidationResults] = useState<Map<string, ValidationResult>>(new Map());
|
|
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 [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<string | undefined>(() => {
|
|
// 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<string, any>;
|
|
|
|
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: (
|
|
<div className="space-y-2">
|
|
<p>Unable to validate submission. Please try again.</p>
|
|
<p className="text-xs font-mono">Ref: {errorId.slice(0, 8)}</p>
|
|
</div>
|
|
),
|
|
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 (
|
|
<>
|
|
<Container open={open} onOpenChange={onOpenChange}>
|
|
<ModerationErrorBoundary submissionId={submissionId}>
|
|
{isMobile ? (
|
|
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto">
|
|
<SheetHeader>
|
|
<div className="flex items-center justify-between">
|
|
<SheetTitle>Review Submission</SheetTitle>
|
|
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
|
|
</div>
|
|
<SheetDescription>
|
|
{pendingCount} pending item(s) • {selectedCount} selected
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<ReviewContent />
|
|
</SheetContent>
|
|
) : (
|
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<div className="flex items-center justify-between">
|
|
<DialogTitle>Review Submission</DialogTitle>
|
|
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
|
|
</div>
|
|
<DialogDescription>
|
|
{pendingCount} pending item(s) • {selectedCount} selected
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<ReviewContent />
|
|
</DialogContent>
|
|
)}
|
|
</ModerationErrorBoundary>
|
|
</Container>
|
|
|
|
<ConflictResolutionDialog
|
|
open={showConflictDialog}
|
|
onOpenChange={setShowConflictDialog}
|
|
conflicts={conflicts}
|
|
items={items}
|
|
onResolve={async () => {
|
|
await loadSubmissionItems();
|
|
await handleApprove();
|
|
}}
|
|
/>
|
|
|
|
<EscalationDialog
|
|
open={showEscalationDialog}
|
|
onOpenChange={setShowEscalationDialog}
|
|
onEscalate={handleEscalate}
|
|
submissionType={submissionType}
|
|
error={escalationError}
|
|
/>
|
|
|
|
<RejectionDialog
|
|
open={showRejectionDialog}
|
|
onOpenChange={setShowRejectionDialog}
|
|
itemCount={selectedItemIds.size}
|
|
hasDependents={items.filter(item => selectedItemIds.has(item.id)).some(item =>
|
|
item.dependents && item.dependents.length > 0
|
|
)}
|
|
onReject={handleReject}
|
|
/>
|
|
|
|
<ItemEditDialog
|
|
item={editingItem}
|
|
open={showEditDialog}
|
|
onOpenChange={setShowEditDialog}
|
|
onComplete={handleEditComplete}
|
|
/>
|
|
|
|
<ValidationBlockerDialog
|
|
open={showValidationBlockerDialog}
|
|
onClose={() => 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<string, unknown>).name)
|
|
: i.item_type.replace('_', ' ');
|
|
return name;
|
|
})}
|
|
/>
|
|
|
|
<WarningConfirmDialog
|
|
open={showWarningConfirmDialog}
|
|
onClose={() => 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<string, unknown>).name)
|
|
: i.item_type.replace('_', ' ');
|
|
return name;
|
|
})}
|
|
/>
|
|
|
|
<ConflictResolutionModal
|
|
open={showConflictResolutionModal}
|
|
onOpenChange={setShowConflictResolutionModal}
|
|
conflictData={conflictData || {
|
|
hasConflict: false,
|
|
clientVersion: { last_modified_at: new Date().toISOString() }
|
|
}}
|
|
onResolve={async (strategy) => {
|
|
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 (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-center space-y-4">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
|
<p className="text-muted-foreground">
|
|
{state.status === 'claiming' ? 'Claiming submission...' : 'Loading items...'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show error state
|
|
if (state.status === 'error') {
|
|
return (
|
|
<Alert variant="destructive" className="my-4">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
{state.error || 'An error occurred while processing this submission'}
|
|
<div className="mt-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
dispatch({ type: 'RESET' });
|
|
handleClaimSubmission();
|
|
}}
|
|
>
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
// Show lock expired warning
|
|
if (state.status === 'lock_expired') {
|
|
return (
|
|
<Alert variant="destructive" className="my-4">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
Your lock on this submission has expired. You need to re-claim it to continue.
|
|
<div className="mt-2 flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
dispatch({ type: 'RESET' });
|
|
handleClaimSubmission();
|
|
}}
|
|
>
|
|
Re-claim Submission
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
// Protection 2: UI detection of empty submissions
|
|
if (items.length === 0 && state.status === 'reviewing') {
|
|
return (
|
|
<Alert variant="destructive" className="my-4">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
This submission has no items and appears to be corrupted or incomplete.
|
|
This usually happens when the submission creation process was interrupted.
|
|
<div className="mt-2 flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={async () => {
|
|
try {
|
|
const { supabase } = await import('@/integrations/supabase/client');
|
|
await supabase
|
|
.from('content_submissions')
|
|
.delete()
|
|
.eq('id', submissionId);
|
|
|
|
toast({
|
|
title: 'Submission Archived',
|
|
description: 'The corrupted submission has been removed',
|
|
});
|
|
onComplete();
|
|
onOpenChange(false);
|
|
} catch (error: unknown) {
|
|
handleError(error, {
|
|
action: 'Archive Corrupted Submission',
|
|
userId: user?.id,
|
|
metadata: { submissionId }
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
Archive Submission
|
|
</Button>
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 h-full">
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(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-3">
|
|
<TabsTrigger value="items">
|
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
Items ({items.length})
|
|
</TabsTrigger>
|
|
<TabsTrigger value="dependencies">
|
|
<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">
|
|
<ScrollArea className="h-full pr-4">
|
|
<div className="space-y-4">
|
|
{items.length === 0 && state.status === 'reviewing' && (
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
No items found in this submission
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{items.map((item) => (
|
|
<div key={item.id} className="flex gap-3 items-start">
|
|
<Checkbox
|
|
checked={selectedItemIds.has(item.id)}
|
|
onCheckedChange={() => toggleItemSelection(item.id)}
|
|
disabled={item.status !== 'pending'}
|
|
/>
|
|
<ItemReviewCard
|
|
item={item}
|
|
onEdit={() => handleEdit(item)}
|
|
onStatusChange={async () => {
|
|
// Status changes handled via approve/reject actions
|
|
await loadSubmissionItems();
|
|
}}
|
|
submissionId={submissionId}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
|
|
<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 */}
|
|
{hasBlockingErrors && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="w-4 h-4" />
|
|
<AlertDescription>
|
|
Cannot approve: Selected items have validation errors that must be fixed first.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t">
|
|
<Button
|
|
onClick={handleCheckConflicts}
|
|
disabled={
|
|
selectedCount === 0 ||
|
|
!canApprove(state) ||
|
|
hasBlockingErrors
|
|
}
|
|
className="flex-1"
|
|
>
|
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
{state.status === 'approving' ? 'Approving...' : `Approve Selected (${selectedCount})`}
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleRejectSelected}
|
|
disabled={
|
|
selectedCount === 0 ||
|
|
!canReject(state)
|
|
}
|
|
variant="destructive"
|
|
className="flex-1"
|
|
>
|
|
<XCircle className="w-4 h-4 mr-2" />
|
|
{state.status === 'rejecting' ? 'Rejecting...' : 'Reject Selected'}
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => setShowEscalationDialog(true)}
|
|
variant="outline"
|
|
disabled={state.status !== 'reviewing'}
|
|
>
|
|
<ArrowUp className="w-4 h-4 mr-2" />
|
|
Escalate
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|