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'; } 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 if (key === 'location' || key === 'location_id') { if (!isEqual(oldValue, newValue)) { hasLocationChange = true; fieldChanges.push({ field: key, oldValue, newValue, changeType: 'modified', }); } return; } // Check for changes if (!isEqual(oldValue, newValue)) { if ((oldValue === null || oldValue === undefined || oldValue === '') && newValue) { fieldChanges.push({ field: key, oldValue, newValue, changeType: 'added', }); } else if ((newValue === null || newValue === undefined || newValue === '') && oldValue) { 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 = [ 'id', 'created_at', 'updated_at', 'slug', 'image_assignments', 'banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id', ]; 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) { const uploaded = data.images.uploaded; if (uploaded[0]?.id) result.banner = uploaded[0].id; if (uploaded[1]?.id) result.card = uploaded[1].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); }