Files
thrilltrack-explorer/src/components/moderation/SubmissionReviewManager.tsx
2025-10-20 13:30:02 +00:00

695 lines
22 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import { handleError } from '@/lib/errorHandler';
import {
fetchSubmissionItems,
buildDependencyTree,
detectDependencyConflicts,
approveSubmissionItems,
rejectSubmissionItems,
escalateSubmission,
type SubmissionItemWithDeps,
type DependencyConflict
} from '@/lib/submissionItemsService';
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 } 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 { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
interface SubmissionReviewManagerProps {
submissionId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: () => void;
}
export function SubmissionReviewManager({
submissionId,
open,
onOpenChange,
onComplete
}: SubmissionReviewManagerProps) {
const [items, setItems] = useState<SubmissionItemWithDeps[]>([]);
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
const [conflicts, setConflicts] = useState<DependencyConflict[]>([]);
const [loading, setLoading] = useState(false);
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 { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
const { user } = useAuth();
const isMobile = useIsMobile();
const Container = isMobile ? Sheet : Dialog;
useEffect(() => {
if (open && submissionId) {
loadSubmissionItems();
}
}, [open, submissionId]);
const loadSubmissionItems = async () => {
setLoading(true);
try {
const { supabase } = await import('@/integrations/supabase/client');
// Fetch submission type
const { data: submission } = await supabase
.from('content_submissions')
.select('submission_type')
.eq('id', submissionId)
.single();
if (submission) {
setSubmissionType(submission.submission_type || 'submission');
}
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));
} catch (error: unknown) {
handleError(error, {
action: 'Load Submission Items',
userId: user?.id,
metadata: { submissionId, submissionType }
});
} finally {
setLoading(false);
}
};
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 () => {
setLoading(true);
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 }
});
} finally {
setLoading(false);
}
};
const handleApprove = async () => {
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));
setLoading(true);
try {
// Run validation on all selected items
const 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) {
setHasBlockingErrors(true);
setShowValidationBlockerDialog(true);
setLoading(false);
return; // Block approval
}
// 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);
setLoading(false);
return; // Ask for confirmation
}
// Proceed with approval
const { supabase } = await import('@/integrations/supabase/client');
// Call the edge function for backend processing
const { data, error } = await supabase.functions.invoke('process-selective-approval', {
body: {
itemIds: Array.from(selectedItemIds),
submissionId
}
});
if (error) {
throw new Error(error.message || 'Failed to process approval');
}
if (!data?.success) {
throw new Error(data?.error || 'Approval processing failed');
}
const successCount = data.results.filter((r: any) => r.success).length;
const failCount = data.results.filter((r: any) => !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) {
setLoading(false);
return;
}
onComplete();
onOpenChange(false);
} catch (error: unknown) {
handleError(error, {
action: 'Approve Submission Items',
userId: user?.id,
metadata: {
submissionId,
itemCount: selectedItemIds.size,
hasWarnings: userConfirmedWarnings,
hasBlockingErrors
}
});
} finally {
setLoading(false);
}
};
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) => {
if (!user?.id) return;
setLoading(true);
try {
const selectedItems = items.filter(item => selectedItemIds.has(item.id));
await rejectSubmissionItems(selectedItems, reason, user.id, cascade);
toast({
title: 'Items Rejected',
description: `Successfully rejected ${selectedItems.length} item${selectedItems.length !== 1 ? 's' : ''}`,
});
onComplete();
onOpenChange(false);
} catch (error: unknown) {
handleError(error, {
action: 'Reject Submission Items',
userId: user?.id,
metadata: {
submissionId,
itemCount: selectedItemIds.size,
cascade,
reason: reason.substring(0, 100)
}
});
} finally {
setLoading(false);
}
};
const handleEscalate = async (reason: string) => {
if (!user?.id) {
toast({
title: 'Authentication Required',
description: 'You must be logged in to escalate submissions',
variant: 'destructive',
});
return;
}
setLoading(true);
try {
const { supabase } = await import('@/integrations/supabase/client');
// Call the escalation notification edge function
const { data, error } = await supabase.functions.invoke('send-escalation-notification', {
body: {
submissionId,
escalationReason: reason,
escalatedBy: user.id
}
});
if (error) {
console.error('Edge function error:', error);
// Fallback to direct database update if email fails
await escalateSubmission(submissionId, reason, user.id);
toast({
title: 'Escalated (Email Failed)',
description: 'Submission escalated but notification email failed to send',
variant: 'default',
});
} else {
toast({
title: 'Escalated Successfully',
description: 'Submission escalated and admin notified via email',
});
}
onComplete();
onOpenChange(false);
} catch (error: unknown) {
handleError(error, {
action: 'Escalate Submission',
userId: user?.id,
metadata: {
submissionId,
reason: reason.substring(0, 100)
}
});
} finally {
setLoading(false);
}
};
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;
}
setLoading(true);
try {
if (status === 'approved') {
const { supabase } = await import('@/integrations/supabase/client');
const { data, error } = await supabase.functions.invoke('process-selective-approval', {
body: {
itemIds: [itemId],
submissionId
}
});
if (error || !data?.success) {
throw new Error(error?.message || data?.error || 'Failed to approve item');
}
toast({
title: 'Item Approved',
description: 'Successfully approved the item',
});
} 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
}
});
} finally {
setLoading(false);
}
};
const pendingCount = items.filter(item => item.status === 'pending').length;
const selectedCount = selectedItemIds.size;
return (
<>
<Container open={open} onOpenChange={onOpenChange}>
{isMobile ? (
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto">
<SheetHeader>
<SheetTitle>Review Submission</SheetTitle>
<SheetDescription>
{pendingCount} pending item(s) {selectedCount} selected
</SheetDescription>
</SheetHeader>
<ReviewContent />
</SheetContent>
) : (
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Review Submission</DialogTitle>
<DialogDescription>
{pendingCount} pending item(s) {selectedCount} selected
</DialogDescription>
</DialogHeader>
<ReviewContent />
</DialogContent>
)}
</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}
/>
<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 =>
i.item_data?.name || i.item_type.replace('_', ' ')
)}
/>
<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 =>
i.item_data?.name || i.item_type.replace('_', ' ')
)}
/>
</>
);
function ReviewContent() {
// Protection 2: UI detection of empty submissions
if (items.length === 0 && !loading) {
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') {
setActiveTab(v);
}
}}
className="flex-1 flex flex-col"
>
<TabsList className="grid w-full grid-cols-2">
<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>
</TabsList>
<TabsContent value="items" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
{items.length === 0 && !loading && (
<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>
</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 || loading || hasBlockingErrors}
className="flex-1"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Approve Selected ({selectedCount})
</Button>
<Button
onClick={handleRejectSelected}
disabled={selectedCount === 0 || loading}
variant="destructive"
className="flex-1"
>
<XCircle className="w-4 h-4 mr-2" />
Reject Selected
</Button>
<Button
onClick={() => setShowEscalationDialog(true)}
variant="outline"
disabled={loading}
>
<ArrowUp className="w-4 h-4 mr-2" />
Escalate
</Button>
</div>
</div>
);
}
}