mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 10:51:12 -05:00
Implement conflict resolution logic
This commit is contained in:
@@ -6,6 +6,8 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { type DependencyConflict, type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
import { type DependencyConflict, type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
interface ConflictResolutionDialogProps {
|
interface ConflictResolutionDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -23,6 +25,8 @@ export function ConflictResolutionDialog({
|
|||||||
onResolve,
|
onResolve,
|
||||||
}: ConflictResolutionDialogProps) {
|
}: ConflictResolutionDialogProps) {
|
||||||
const [resolutions, setResolutions] = useState<Record<string, string>>({});
|
const [resolutions, setResolutions] = useState<Record<string, string>>({});
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const handleResolutionChange = (itemId: string, action: string) => {
|
const handleResolutionChange = (itemId: string, action: string) => {
|
||||||
setResolutions(prev => ({ ...prev, [itemId]: action }));
|
setResolutions(prev => ({ ...prev, [itemId]: action }));
|
||||||
@@ -32,10 +36,44 @@ export function ConflictResolutionDialog({
|
|||||||
conflict => resolutions[conflict.itemId]
|
conflict => resolutions[conflict.itemId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = async () => {
|
||||||
// TODO: Apply resolutions
|
if (!user?.id) {
|
||||||
onResolve();
|
toast({
|
||||||
onOpenChange(false);
|
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 (
|
return (
|
||||||
|
|||||||
@@ -288,7 +288,10 @@ export function SubmissionReviewManager({
|
|||||||
onOpenChange={setShowConflictDialog}
|
onOpenChange={setShowConflictDialog}
|
||||||
conflicts={conflicts}
|
conflicts={conflicts}
|
||||||
items={items}
|
items={items}
|
||||||
onResolve={handleApprove}
|
onResolve={async () => {
|
||||||
|
await loadSubmissionItems();
|
||||||
|
await handleApprove();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EscalationDialog
|
<EscalationDialog
|
||||||
|
|||||||
292
src/lib/conflictResolutionService.ts
Normal file
292
src/lib/conflictResolutionService.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { updateSubmissionItem, type SubmissionItemWithDeps, type DependencyConflict } from './submissionItemsService';
|
||||||
|
|
||||||
|
export interface ResolutionResult {
|
||||||
|
success: boolean;
|
||||||
|
updatedSelections?: Set<string>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main conflict resolution processor
|
||||||
|
*/
|
||||||
|
export async function resolveConflicts(
|
||||||
|
conflicts: DependencyConflict[],
|
||||||
|
resolutions: Record<string, string>,
|
||||||
|
items: SubmissionItemWithDeps[],
|
||||||
|
userId: string
|
||||||
|
): Promise<ResolutionResult> {
|
||||||
|
try {
|
||||||
|
const updatedSelections = new Set<string>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
await updateSubmissionItem(itemId, {
|
||||||
|
approved_entity_id: entityId,
|
||||||
|
status: 'approved',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascade reject all dependent items
|
||||||
|
*/
|
||||||
|
async function cascadeRejectDependents(
|
||||||
|
itemId: string,
|
||||||
|
items: SubmissionItemWithDeps[]
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Array<{ id: string; name: string; similarity: number }>> {
|
||||||
|
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<string, string> = {
|
||||||
|
park: 'parks',
|
||||||
|
ride: 'rides',
|
||||||
|
manufacturer: 'companies',
|
||||||
|
operator: 'companies',
|
||||||
|
designer: 'companies',
|
||||||
|
property_owner: 'companies',
|
||||||
|
ride_model: 'ride_models',
|
||||||
|
};
|
||||||
|
return typeMap[itemType] || null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user