mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
300 lines
7.9 KiB
TypeScript
300 lines
7.9 KiB
TypeScript
import { supabase } from '@/lib/supabaseClient';
|
|
import { handleError, handleNonCriticalError } from '@/lib/errorHandler';
|
|
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: unknown) {
|
|
handleError(error, {
|
|
action: 'Resolve conflicts',
|
|
metadata: { conflictCount: conflicts.length },
|
|
});
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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' as const,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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' as const,
|
|
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' as const,
|
|
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: unknown) {
|
|
handleNonCriticalError(error, {
|
|
action: 'Find matching entities',
|
|
metadata: { itemType },
|
|
});
|
|
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;
|
|
}
|