import type { SubmissionItemData } from '@/types/submissions'; import { supabase } from '@/integrations/supabase/client'; export interface FieldChange { field: string; oldValue: any; newValue: any; changeType: 'added' | 'removed' | 'modified'; metadata?: { isCreatingNewLocation?: boolean; }; } export interface ImageChange { type: 'banner' | 'card'; oldUrl?: string; newUrl?: string; oldId?: string; newId?: string; } export interface PhotoChange { type: 'added' | 'edited' | 'deleted'; photos?: Array<{ url: string; title?: string; caption?: string }>; photo?: { url: string; title?: string; caption?: string; oldCaption?: string; newCaption?: string; oldTitle?: string; newTitle?: string; }; } export interface ChangesSummary { action: 'create' | 'edit' | 'delete'; entityType: string; entityName?: string; fieldChanges: FieldChange[]; imageChanges: ImageChange[]; photoChanges: PhotoChange[]; hasLocationChange: boolean; totalChanges: number; } /** * Detects photo changes for a submission */ async function detectPhotoChanges(submissionId: string): Promise { const changes: PhotoChange[] = []; try { // Fetch photo submission with items - use array query to avoid 406 errors const { data: photoSubmissions, error } = await supabase .from('photo_submissions') .select(` *, items:photo_submission_items(*) `) .eq('submission_id', submissionId); if (error) { console.error('Error fetching photo submissions:', error); return changes; } const photoSubmission = photoSubmissions?.[0]; if (photoSubmission?.items && photoSubmission.items.length > 0) { // For now, treat all photos as additions // TODO: Implement edit/delete detection by comparing with existing entity photos changes.push({ type: 'added', photos: photoSubmission.items.map((item: any) => ({ url: item.cloudflare_image_url, title: item.title, caption: item.caption })) }); } } catch (err) { console.error('Error detecting photo changes:', err); } return changes; } /** * Detects what changed between original_data and item_data */ export async function detectChanges( item: { item_data?: any; original_data?: any; item_type: string }, submissionId?: string ): Promise { const itemData = item.item_data || {}; const originalData = item.original_data || {}; // Determine action type const action: 'create' | 'edit' | 'delete' = !originalData || Object.keys(originalData).length === 0 ? 'create' : itemData.deleted ? 'delete' : 'edit'; const fieldChanges: FieldChange[] = []; const imageChanges: ImageChange[] = []; let hasLocationChange = false; if (action === 'create') { // For creates, all fields are "added" Object.entries(itemData).forEach(([key, value]) => { if (shouldTrackField(key) && value !== null && value !== undefined && value !== '') { fieldChanges.push({ field: key, oldValue: null, newValue: value, changeType: 'added', }); } }); } else if (action === 'edit') { // Compare each field const allKeys = new Set([ ...Object.keys(itemData), ...Object.keys(originalData) ]); allKeys.forEach(key => { if (!shouldTrackField(key)) return; const oldValue = originalData[key]; const newValue = itemData[key]; // Handle location changes specially - compare objects not IDs if (key === 'location' || key === 'location_id') { // Skip location_id if we already have a location object if (key === 'location_id' && itemData.location) { return; } const oldLoc = originalData.location; const newLoc = itemData.location; // Check if new location entity is being created (old has location_id, new has location object) const isCreatingNewLocation = originalData.location_id && newLoc && typeof newLoc === 'object' && !oldLoc; // Only compare if we have location objects with actual data if (newLoc && typeof newLoc === 'object' && oldLoc && typeof oldLoc === 'object') { // Compare all location data including coordinates const locChanged = oldLoc.city !== newLoc.city || oldLoc.state_province !== newLoc.state_province || oldLoc.country !== newLoc.country || oldLoc.postal_code !== newLoc.postal_code || Number(oldLoc.latitude) !== Number(newLoc.latitude) || Number(oldLoc.longitude) !== Number(newLoc.longitude); if (locChanged) { hasLocationChange = true; fieldChanges.push({ field: 'location', oldValue: oldLoc, newValue: newLoc, changeType: 'modified', }); } } else if (isCreatingNewLocation) { // New location entity is being created - mark as location change hasLocationChange = true; fieldChanges.push({ field: 'location', oldValue: { location_id: originalData.location_id }, newValue: newLoc, changeType: 'modified', metadata: { isCreatingNewLocation: true }, }); } return; } // Skip if both are "empty" (null, undefined, or empty string) const oldEmpty = oldValue === null || oldValue === undefined || oldValue === ''; const newEmpty = newValue === null || newValue === undefined || newValue === ''; if (oldEmpty && newEmpty) { return; // Both empty, no change } // Check for changes if (!isEqual(oldValue, newValue)) { if (oldEmpty && !newEmpty) { fieldChanges.push({ field: key, oldValue, newValue, changeType: 'added', }); } else if (newEmpty && !oldEmpty) { fieldChanges.push({ field: key, oldValue, newValue, changeType: 'removed', }); } else { fieldChanges.push({ field: key, oldValue, newValue, changeType: 'modified', }); } } }); // Detect image changes detectImageChanges(originalData, itemData, imageChanges); } // Get entity name const entityName = itemData.name || originalData?.name || 'Unknown'; // Detect photo changes if submissionId provided const photoChanges = submissionId ? await detectPhotoChanges(submissionId) : []; return { action, entityType: item.item_type, entityName, fieldChanges, imageChanges, photoChanges, hasLocationChange, totalChanges: fieldChanges.length + imageChanges.length + photoChanges.length + (hasLocationChange ? 1 : 0) }; } /** * Determines if a field should be tracked for changes */ function shouldTrackField(key: string): boolean { const excludedFields = [ // System fields 'id', 'created_at', 'updated_at', 'slug', // Image-related (handled separately) 'images', 'image_assignments', 'banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id', // Reference IDs (not editable, just for linking) 'park_id', 'ride_id', 'company_id', 'manufacturer_id', 'operator_id', 'designer_id', 'property_owner_id', 'location_id', // Location object is tracked instead // Computed/aggregated fields (not editable) 'ride_count', 'review_count', 'coaster_count', 'average_rating', ]; return !excludedFields.includes(key); } /** * Deep equality check for values */ function isEqual(a: any, b: any): boolean { if (a === b) return true; if (a == null || b == null) return a === b; if (typeof a !== typeof b) return false; if (typeof a === 'object') { if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; return a.every((item, i) => isEqual(item, b[i])); } const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; return keysA.every(key => isEqual(a[key], b[key])); } return false; } /** * Normalizes image data structures to extract IDs consistently */ function getImageIds(data: any): { banner?: string; card?: string } { const result: { banner?: string; card?: string } = {}; // Handle flat structure (original_data from DB) if (data.banner_image_id) result.banner = data.banner_image_id; if (data.card_image_id) result.card = data.card_image_id; // Handle nested structure (item_data from form) if (data.images?.uploaded && Array.isArray(data.images.uploaded)) { const uploaded = data.images.uploaded; // Handle banner/card assignment mapping (default to indices 0 and 1) const bannerIdx = data.images.banner_assignment ?? 0; const cardIdx = data.images.card_assignment ?? 1; // Try both 'cloudflare_id' and 'id' for compatibility if (uploaded[bannerIdx]) { result.banner = uploaded[bannerIdx].cloudflare_id || uploaded[bannerIdx].id; } if (uploaded[cardIdx]) { result.card = uploaded[cardIdx].cloudflare_id || uploaded[cardIdx].id; } } return result; } /** * Detects changes in banner/card images */ function detectImageChanges( originalData: any, itemData: any, imageChanges: ImageChange[] ): void { // Normalize both data structures before comparing const oldIds = getImageIds(originalData); const newIds = getImageIds(itemData); // Check banner image if (oldIds.banner !== newIds.banner) { imageChanges.push({ type: 'banner', oldUrl: originalData.banner_image_url, newUrl: itemData.banner_image_url || itemData.images?.uploaded?.[0]?.url, oldId: oldIds.banner, newId: newIds.banner, }); } // Check card image if (oldIds.card !== newIds.card) { imageChanges.push({ type: 'card', oldUrl: originalData.card_image_url, newUrl: itemData.card_image_url || itemData.images?.uploaded?.[1]?.url, oldId: oldIds.card, newId: newIds.card, }); } } /** * Format field name for display */ export function formatFieldName(field: string): string { return field .replace(/_/g, ' ') .replace(/([A-Z])/g, ' $1') .replace(/^./, str => str.toUpperCase()) .trim(); } /** * Format field value for display */ export function formatFieldValue(value: any): string { if (value === null || value === undefined) return 'None'; if (typeof value === 'boolean') return value ? 'Yes' : 'No'; // Handle dates if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) { try { const date = new Date(value); return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } catch { return String(value); } } // Handle arrays - show actual items if (Array.isArray(value)) { if (value.length === 0) return 'None'; if (value.length <= 3) return value.map(v => String(v)).join(', '); return `${value.slice(0, 3).map(v => String(v)).join(', ')}... +${value.length - 3} more`; } // Handle objects - create readable summary if (typeof value === 'object') { // Location object if (value.city || value.state_province || value.country) { const parts = [value.city, value.state_province, value.country].filter(Boolean); return parts.join(', '); } // Generic object - show key-value pairs const entries = Object.entries(value).slice(0, 3); if (entries.length === 0) return 'Empty'; return entries.map(([k, v]) => `${k}: ${v}`).join(', '); } // Handle URLs if (typeof value === 'string' && value.startsWith('http')) { try { const url = new URL(value); return url.hostname + (url.pathname !== '/' ? url.pathname.slice(0, 30) : ''); } catch { return value; } } if (typeof value === 'number') return value.toLocaleString(); return String(value); }