diff --git a/src/components/moderation/ConflictResolutionDialog.tsx b/src/components/moderation/ConflictResolutionDialog.tsx index 2a8177f5..485922fe 100644 --- a/src/components/moderation/ConflictResolutionDialog.tsx +++ b/src/components/moderation/ConflictResolutionDialog.tsx @@ -6,6 +6,8 @@ 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 { useToast } from '@/hooks/use-toast'; +import { useAuth } from '@/hooks/useAuth'; interface ConflictResolutionDialogProps { open: boolean; @@ -23,6 +25,8 @@ export function ConflictResolutionDialog({ onResolve, }: ConflictResolutionDialogProps) { const [resolutions, setResolutions] = useState>({}); + const { toast } = useToast(); + const { user } = useAuth(); const handleResolutionChange = (itemId: string, action: string) => { setResolutions(prev => ({ ...prev, [itemId]: action })); @@ -32,10 +36,44 @@ export function ConflictResolutionDialog({ conflict => resolutions[conflict.itemId] ); - const handleApply = () => { - // TODO: Apply resolutions - onResolve(); - onOpenChange(false); + const handleApply = async () => { + if (!user?.id) { + toast({ + title: 'Authentication Required', + description: 'You must be logged in to resolve conflicts', + variant: 'destructive', + }); + return; + } + + const { resolveConflicts } = await import('@/lib/conflictResolutionService'); + + try { + const result = await resolveConflicts(conflicts, resolutions, items, user.id); + + if (!result.success) { + toast({ + title: 'Resolution Failed', + description: result.error || 'Failed to resolve conflicts', + variant: 'destructive', + }); + return; + } + + toast({ + title: 'Conflicts Resolved', + description: 'All conflicts have been resolved successfully', + }); + + onResolve(); + onOpenChange(false); + } catch (error: any) { + toast({ + title: 'Error', + description: error.message || 'Failed to resolve conflicts', + variant: 'destructive', + }); + } }; return ( diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index ff737f67..ab8834d1 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -288,7 +288,10 @@ export function SubmissionReviewManager({ onOpenChange={setShowConflictDialog} conflicts={conflicts} items={items} - onResolve={handleApprove} + onResolve={async () => { + await loadSubmissionItems(); + await handleApprove(); + }} /> ; + error?: string; +} + +/** + * Main conflict resolution processor + */ +export async function resolveConflicts( + conflicts: DependencyConflict[], + resolutions: Record, + items: SubmissionItemWithDeps[], + userId: string +): Promise { + try { + const updatedSelections = new Set(); + + for (const conflict of conflicts) { + const resolution = resolutions[conflict.itemId]; + if (!resolution) { + return { + success: false, + error: `No resolution selected for ${conflict.itemId}`, + }; + } + + const suggestion = conflict.suggestions.find(s => s.action === resolution); + if (!suggestion) { + return { + success: false, + error: `Invalid resolution action: ${resolution}`, + }; + } + + // Process each resolution action + switch (suggestion.action) { + case 'link_existing': + if (!suggestion.entityId) { + return { + success: false, + error: 'Entity ID required for link_existing action', + }; + } + await linkToExistingEntity(conflict.itemId, suggestion.entityId); + updatedSelections.add(conflict.itemId); + break; + + case 'create_parent': + const item = items.find(i => i.id === conflict.itemId); + if (item?.depends_on) { + updatedSelections.add(item.depends_on); + updatedSelections.add(conflict.itemId); + } + break; + + case 'cascade_reject': + await cascadeRejectDependents(conflict.itemId, items); + break; + + case 'escalate': + const submissionId = items[0]?.submission_id; + if (submissionId) { + await escalateForAdminReview(submissionId, `Conflict resolution needed: ${conflict.message}`, userId); + } + return { + success: true, + updatedSelections: new Set(), + }; + + default: + return { + success: false, + error: `Unknown action: ${suggestion.action}`, + }; + } + } + + return { + success: true, + updatedSelections, + }; + } catch (error: any) { + console.error('Conflict resolution error:', error); + return { + success: false, + error: error.message || 'Failed to resolve conflicts', + }; + } +} + +/** + * Link submission item to existing database entity + */ +async function linkToExistingEntity(itemId: string, entityId: string): Promise { + await updateSubmissionItem(itemId, { + approved_entity_id: entityId, + status: 'approved', + }); +} + +/** + * Cascade reject all dependent items + */ +async function cascadeRejectDependents( + itemId: string, + items: SubmissionItemWithDeps[] +): Promise { + const item = items.find(i => i.id === itemId); + if (!item?.dependents) return; + + const toReject: string[] = []; + + function collectDependents(current: SubmissionItemWithDeps) { + if (current.dependents) { + for (const dep of current.dependents) { + toReject.push(dep.id); + collectDependents(dep); + } + } + } + + collectDependents(item); + + // Reject all collected dependents + for (const depId of toReject) { + await updateSubmissionItem(depId, { + status: 'rejected', + rejection_reason: 'Parent dependency was rejected', + }); + } +} + +/** + * Escalate submission for admin review + */ +async function escalateForAdminReview( + submissionId: string, + reason: string, + userId: string +): Promise { + const { error } = await supabase + .from('content_submissions') + .update({ + status: 'pending', + escalation_reason: reason, + escalated_by: userId, + updated_at: new Date().toISOString(), + }) + .eq('id', submissionId); + + if (error) { + throw new Error(`Failed to escalate submission: ${error.message}`); + } +} + +/** + * Find existing entities that match submission data + */ +export async function findMatchingEntities( + itemType: string, + itemData: any +): Promise> { + const tableName = getTableNameForItemType(itemType); + if (!tableName) return []; + + try { + // Query based on table type + if (tableName === 'companies') { + const { data, error } = await supabase + .from('companies') + .select('id, name') + .ilike('name', `%${itemData.name}%`) + .limit(5); + + if (error) throw error; + + return (data || []).map(entity => ({ + id: entity.id, + name: entity.name, + similarity: calculateSimilarity(itemData.name, entity.name), + })).sort((a, b) => b.similarity - a.similarity); + } else if (tableName === 'parks') { + const { data, error } = await supabase + .from('parks') + .select('id, name') + .ilike('name', `%${itemData.name}%`) + .limit(5); + + if (error) throw error; + + return (data || []).map(entity => ({ + id: entity.id, + name: entity.name, + similarity: calculateSimilarity(itemData.name, entity.name), + })).sort((a, b) => b.similarity - a.similarity); + } else if (tableName === 'rides') { + const { data, error } = await supabase + .from('rides') + .select('id, name') + .ilike('name', `%${itemData.name}%`) + .limit(5); + + if (error) throw error; + + return (data || []).map(entity => ({ + id: entity.id, + name: entity.name, + similarity: calculateSimilarity(itemData.name, entity.name), + })).sort((a, b) => b.similarity - a.similarity); + } else if (tableName === 'ride_models') { + const { data, error } = await supabase + .from('ride_models') + .select('id, name') + .ilike('name', `%${itemData.name}%`) + .limit(5); + + if (error) throw error; + + return (data || []).map(entity => ({ + id: entity.id, + name: entity.name, + similarity: calculateSimilarity(itemData.name, entity.name), + })).sort((a, b) => b.similarity - a.similarity); + } + + return []; + } catch (error) { + console.error('Error finding matching entities:', error); + return []; + } +} + +/** + * Calculate string similarity (simple implementation) + */ +function calculateSimilarity(str1: string, str2: string): number { + const s1 = str1.toLowerCase(); + const s2 = str2.toLowerCase(); + + if (s1 === s2) return 1.0; + if (s1.includes(s2) || s2.includes(s1)) return 0.8; + + // Levenshtein distance approximation + const maxLen = Math.max(s1.length, s2.length); + const distance = levenshteinDistance(s1, s2); + return 1 - (distance / maxLen); +} + +function levenshteinDistance(str1: string, str2: string): number { + const matrix: number[][] = []; + + for (let i = 0; i <= str2.length; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= str1.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= str2.length; i++) { + for (let j = 1; j <= str1.length; j++) { + if (str2.charAt(i - 1) === str1.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + + return matrix[str2.length][str1.length]; +} + +function getTableNameForItemType(itemType: string): string | null { + const typeMap: Record = { + park: 'parks', + ride: 'rides', + manufacturer: 'companies', + operator: 'companies', + designer: 'companies', + property_owner: 'companies', + ride_model: 'ride_models', + }; + return typeMap[itemType] || null; +}