diff --git a/src/components/moderation/ConflictResolutionDialog.tsx b/src/components/moderation/ConflictResolutionDialog.tsx index 2a8177f5..79c53858 100644 --- a/src/components/moderation/ConflictResolutionDialog.tsx +++ b/src/components/moderation/ConflictResolutionDialog.tsx @@ -4,8 +4,10 @@ import { Button } from '@/components/ui/button'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { AlertCircle } from 'lucide-react'; -import { type DependencyConflict, type SubmissionItemWithDeps } from '@/lib/submissionItemsService'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { type DependencyConflict, type SubmissionItemWithDeps, resolveConflicts } from '@/lib/submissionItemsService'; +import { useAuth } from '@/hooks/useAuth'; interface ConflictResolutionDialogProps { open: boolean; @@ -22,7 +24,9 @@ export function ConflictResolutionDialog({ items, onResolve, }: ConflictResolutionDialogProps) { + const { user } = useAuth(); const [resolutions, setResolutions] = useState>({}); + const [isProcessing, setIsProcessing] = useState(false); const handleResolutionChange = (itemId: string, action: string) => { setResolutions(prev => ({ ...prev, [itemId]: action })); @@ -32,10 +36,36 @@ export function ConflictResolutionDialog({ conflict => resolutions[conflict.itemId] ); - const handleApply = () => { - // TODO: Apply resolutions - onResolve(); - onOpenChange(false); + const handleApply = async () => { + if (!user) { + toast.error('You must be logged in to resolve conflicts'); + return; + } + + setIsProcessing(true); + + try { + const { updatedItems, newConflicts } = await resolveConflicts( + conflicts, + resolutions, + items, + user.id + ); + + if (newConflicts.length > 0) { + toast.error(`Resolution completed with ${newConflicts.length} remaining conflicts`); + } else { + toast.success('All conflicts resolved successfully'); + } + + onResolve(); + onOpenChange(false); + } catch (error) { + console.error('Error resolving conflicts:', error); + toast.error(error instanceof Error ? error.message : 'Failed to resolve conflicts'); + } finally { + setIsProcessing(false); + } }; return ( @@ -83,11 +113,12 @@ export function ConflictResolutionDialog({ - - diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts index d75a7aa6..416e0189 100644 --- a/src/lib/submissionItemsService.ts +++ b/src/lib/submissionItemsService.ts @@ -97,7 +97,7 @@ export async function detectDependencyConflicts( }); } - // Suggest linking to existing entity + // Suggest linking to existing entity based on type if (parent.item_type === 'park') { const { data: parks } = await supabase .from('parks') @@ -112,6 +112,35 @@ export async function detectDependencyConflicts( entityId: park.id, }); }); + } else if (['manufacturer', 'operator', 'property_owner', 'designer'].includes(parent.item_type)) { + const { data: companies } = await supabase + .from('companies') + .select('id, name') + .eq('company_type', parent.item_type) + .ilike('name', `%${parent.item_data.name}%`) + .limit(3); + + companies?.forEach(company => { + suggestions.push({ + action: 'link_existing', + label: `Link to existing ${parent.item_type}: ${company.name}`, + entityId: company.id, + }); + }); + } else if (parent.item_type === 'ride_model') { + const { data: models } = await supabase + .from('ride_models') + .select('id, name') + .ilike('name', `%${parent.item_data.name}%`) + .limit(3); + + models?.forEach(model => { + suggestions.push({ + action: 'link_existing', + label: `Link to existing ride model: ${model.name}`, + entityId: model.id, + }); + }); } suggestions.push({ @@ -121,7 +150,7 @@ export async function detectDependencyConflicts( conflicts.push({ itemId: item.id, - type: 'missing_parent', + type: parent.status === 'rejected' ? 'rejected_parent' : 'missing_parent', message: `Cannot approve ${item.item_type} without its parent ${parent.item_type}`, suggestions, }); @@ -129,9 +158,205 @@ export async function detectDependencyConflicts( } } + // Check for circular dependencies + const circularDeps = detectCircularDependencies(items); + circularDeps.forEach(itemId => { + conflicts.push({ + itemId, + type: 'circular_dependency', + message: 'Circular dependency detected in submission items', + suggestions: [ + { + action: 'escalate', + label: 'Escalate for manual review', + }, + ], + }); + }); + return conflicts; } +/** + * Detect circular dependencies + */ +function detectCircularDependencies(items: SubmissionItemWithDeps[]): string[] { + const circular: string[] = []; + const visited = new Set(); + const recursionStack = new Set(); + + function hasCycle(itemId: string): boolean { + visited.add(itemId); + recursionStack.add(itemId); + + const item = items.find(i => i.id === itemId); + if (item?.depends_on) { + if (!visited.has(item.depends_on)) { + if (hasCycle(item.depends_on)) { + return true; + } + } else if (recursionStack.has(item.depends_on)) { + return true; + } + } + + recursionStack.delete(itemId); + return false; + } + + items.forEach(item => { + if (!visited.has(item.id) && hasCycle(item.id)) { + circular.push(item.id); + } + }); + + return circular; +} + +/** + * Link submission item to existing entity + */ +export async function linkToExistingEntity( + itemId: string, + entityId: string, + entityType: string +): Promise { + const { data: item, error: fetchError } = await supabase + .from('submission_items') + .select('*') + .eq('id', itemId) + .single(); + + if (fetchError || !item) throw fetchError || new Error('Item not found'); + + // Update item_data to mark it as linked to existing entity + const currentItemData = (item.item_data as any) || {}; + const updatedItemData = { + ...currentItemData, + _linked_entity_id: entityId, + _skip_creation: true, + }; + + const { error: updateError } = await supabase + .from('submission_items') + .update({ + item_data: updatedItemData, + approved_entity_id: entityId, + updated_at: new Date().toISOString(), + }) + .eq('id', itemId); + + if (updateError) throw updateError; +} + +/** + * Resolve conflicts based on user selections + */ +export async function resolveConflicts( + conflicts: DependencyConflict[], + resolutions: Record, + items: SubmissionItemWithDeps[], + userId: string +): Promise<{ updatedItems: SubmissionItemWithDeps[]; newConflicts: DependencyConflict[] }> { + if (!userId) { + throw new Error('User authentication required to resolve conflicts'); + } + + const updatedItems = [...items]; + const newConflicts: DependencyConflict[] = []; + + for (const conflict of conflicts) { + const resolution = resolutions[conflict.itemId]; + if (!resolution) continue; + + const suggestion = conflict.suggestions.find(s => s.action === resolution); + if (!suggestion) continue; + + try { + switch (suggestion.action) { + case 'create_parent': { + // Add parent item to be approved along with child + const item = items.find(i => i.id === conflict.itemId); + if (item?.depends_on) { + const parent = items.find(i => i.id === item.depends_on); + if (parent && parent.status === 'pending') { + // Mark parent for approval (will be handled in approval flow) + const idx = updatedItems.findIndex(i => i.id === parent.id); + if (idx >= 0) { + updatedItems[idx] = { + ...updatedItems[idx], + status: 'pending' as const + }; + } + } + } + break; + } + + case 'link_existing': { + // Link to existing entity + if (suggestion.entityId) { + const item = items.find(i => i.id === conflict.itemId); + if (item?.depends_on) { + await linkToExistingEntity(item.depends_on, suggestion.entityId, item.item_type); + + // Update local copy + const parentIdx = updatedItems.findIndex(i => i.id === item.depends_on); + if (parentIdx >= 0) { + updatedItems[parentIdx] = { + ...updatedItems[parentIdx], + approved_entity_id: suggestion.entityId, + item_data: { + ...updatedItems[parentIdx].item_data, + _linked_entity_id: suggestion.entityId, + _skip_creation: true, + }, + }; + } + } + } + break; + } + + case 'escalate': { + // Escalate submission for admin review + const item = items.find(i => i.id === conflict.itemId); + if (item?.submission_id) { + await escalateSubmission( + item.submission_id, + `Dependency conflict: ${conflict.message}`, + userId + ); + } + break; + } + + case 'cascade_reject': { + // This would reject the item and its dependents + const item = items.find(i => i.id === conflict.itemId); + if (item) { + await rejectSubmissionItems( + [item], + 'Rejected due to parent dependency conflict', + userId, + true + ); + } + break; + } + } + } catch (error) { + console.error(`Error resolving conflict for item ${conflict.itemId}:`, error); + newConflicts.push({ + ...conflict, + message: `Failed to resolve: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + } + } + + return { updatedItems, newConflicts }; +} + /** * Update individual submission item status */ @@ -161,29 +386,45 @@ export async function approveSubmissionItems( // Sort by dependency order (parents first) const sortedItems = topologicalSort(items); + // Track entity ID mappings for dependency resolution + const entityIdMap = new Map(); + for (const item of sortedItems) { let entityId: string | null = null; - // Create the entity based on type - switch (item.item_type) { - case 'park': - entityId = await createPark(item.item_data); - break; - case 'ride': - entityId = await createRide(item.item_data); - break; - case 'manufacturer': - case 'operator': - case 'property_owner': - case 'designer': - entityId = await createCompany(item.item_data, item.item_type); - break; - case 'ride_model': - entityId = await createRideModel(item.item_data); - break; - case 'photo': - entityId = await approvePhotos(item.item_data); - break; + // Check if item is linked to existing entity (skip creation) + if (item.item_data._skip_creation && item.item_data._linked_entity_id) { + entityId = item.item_data._linked_entity_id; + entityIdMap.set(item.id, entityId); + } else { + // Resolve dependencies using entityIdMap + const resolvedItemData = resolveDependencies(item.item_data, entityIdMap); + + // Create the entity based on type + switch (item.item_type) { + case 'park': + entityId = await createPark(resolvedItemData); + break; + case 'ride': + entityId = await createRide(resolvedItemData); + break; + case 'manufacturer': + case 'operator': + case 'property_owner': + case 'designer': + entityId = await createCompany(resolvedItemData, item.item_type); + break; + case 'ride_model': + entityId = await createRideModel(resolvedItemData); + break; + case 'photo': + entityId = await approvePhotos(resolvedItemData); + break; + } + + if (entityId) { + entityIdMap.set(item.id, entityId); + } } // Update item status @@ -194,6 +435,31 @@ export async function approveSubmissionItems( } } +/** + * Resolve dependencies in item data using entity ID mappings + */ +function resolveDependencies(itemData: any, entityIdMap: Map): any { + const resolved = { ...itemData }; + + // Map common dependency fields + const dependencyFields = [ + 'park_id', + 'manufacturer_id', + 'operator_id', + 'property_owner_id', + 'designer_id', + 'ride_model_id', + ]; + + dependencyFields.forEach(field => { + if (resolved[field] && entityIdMap.has(resolved[field])) { + resolved[field] = entityIdMap.get(resolved[field]); + } + }); + + return resolved; +} + /** * Topological sort for dependency-ordered processing */