import type { SubmissionItemData } from '@/types/submissions'; import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data'; import { supabase } from '@/integrations/supabase/client'; type SubmissionDataTypes = | ParkSubmissionData | RideSubmissionData | CompanySubmissionData | RideModelSubmissionData; export interface FieldChange { field: string; oldValue: any; newValue: any; changeType: 'added' | 'removed' | 'modified'; metadata?: { isCreatingNewLocation?: boolean; precision?: 'day' | 'month' | 'year'; oldPrecision?: 'day' | 'month' | 'year'; newPrecision?: 'day' | 'month' | 'year'; }; } 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; entity_type?: string; entity_name?: string; deletion_reason?: 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 { // First check for photo submission items (photo additions) const { data: photoSubmissions, error: photoError } = await supabase .from('photo_submissions') .select(` *, items:photo_submission_items(*) `) .eq('submission_id', submissionId); if (photoError) { console.error('Error fetching photo submissions:', photoError); } else { const photoSubmission = photoSubmissions?.[0]; if (photoSubmission?.items && photoSubmission.items.length > 0) { changes.push({ type: 'added', photos: photoSubmission.items.map((item: any) => ({ url: item.cloudflare_image_url, title: item.title, caption: item.caption })) }); } } // Check for photo edits and deletions in submission_items const { data: submissionItems, error: itemsError } = await supabase .from('submission_items') .select('*') .eq('submission_id', submissionId) .in('item_type', ['photo_edit', 'photo_delete']); if (itemsError) { console.error('Error fetching submission items for photos:', itemsError); } else if (submissionItems && submissionItems.length > 0) { for (const item of submissionItems) { const itemData = item.item_data as Record; const originalData = item.original_data as Record | null; if (item.item_type === 'photo_delete' && itemData) { changes.push({ type: 'deleted', photo: { url: itemData.cloudflare_image_url || itemData.photo_url || '', title: itemData.title, caption: itemData.caption, entity_type: itemData.entity_type, entity_name: itemData.entity_name, deletion_reason: itemData.deletion_reason || itemData.reason } }); } else if (item.item_type === 'photo_edit' && itemData && originalData) { changes.push({ type: 'edited', photo: { url: itemData.photo_url || itemData.cloudflare_image_url || '', title: itemData.title, caption: itemData.caption, oldTitle: originalData.title, oldCaption: originalData.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; action_type?: string }, submissionId?: string ): Promise { const itemData = item.item_data || {}; const originalData = item.original_data || {}; // Determine action type - prioritize explicit action_type field to preserve submission intent let action: 'create' | 'edit' | 'delete' = 'edit'; if (item.item_type === 'photo_delete' || itemData.action === 'delete' || itemData.deleted) { action = 'delete'; } else if (item.action_type) { // Use explicit action_type if set (preserves original submission intent even after moderator edits) action = item.action_type as 'create' | 'edit' | 'delete'; } else if (!originalData || Object.keys(originalData).length === 0) { // Fall back to inference for backwards compatibility action = 'create'; } const fieldChanges: FieldChange[] = []; const imageChanges: ImageChange[] = []; let hasLocationChange = false; if (action === 'create') { // Check if this creation was edited by a moderator const hasModeratorEdits = originalData && Object.keys(originalData).length > 0; if (hasModeratorEdits) { // Compare item_data with original_data to detect moderator changes 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]; // Skip if both are empty const oldEmpty = oldValue === null || oldValue === undefined || oldValue === ''; const newEmpty = newValue === null || newValue === undefined || newValue === ''; if (oldEmpty && newEmpty) return; // Detect the type of change if (!isEqual(oldValue, newValue)) { fieldChanges.push({ field: key, oldValue, newValue, changeType: oldEmpty && !newEmpty ? 'added' : // Moderator added new field newEmpty && !oldEmpty ? 'removed' : // Moderator removed field 'modified', // Moderator changed value }); } else if (!newEmpty) { // Field unchanged - show as 'added' (part of original submission) fieldChanges.push({ field: key, oldValue: null, newValue, changeType: 'added', }); } }); } else { // No moderator edits - show all fields as 'added' (original behavior) Object.entries(itemData).forEach(([key, value]) => { const systemFields = ['id', 'created_at', 'updated_at', 'slug', 'images', 'image_assignments']; const shouldShow = !systemFields.includes(key) && value !== null && value !== undefined && value !== ''; if (shouldShow) { 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)) { const fieldChange: FieldChange = { field: key, oldValue, newValue, changeType: oldEmpty && !newEmpty ? 'added' : newEmpty && !oldEmpty ? 'removed' : 'modified', }; // Add precision metadata for date fields if (key.endsWith('_date') && !key.endsWith('_precision')) { const precisionKey = `${key}_precision`; const newPrecision = itemData[precisionKey]; const oldPrecision = originalData[precisionKey]; if (newPrecision || oldPrecision) { fieldChange.metadata = { ...fieldChange.metadata, precision: newPrecision || oldPrecision, oldPrecision, newPrecision, }; } } fieldChanges.push(fieldChange); } }); // Detect image changes detectImageChanges(originalData, itemData, imageChanges); } // Get entity name - handle different item types let entityName = 'Unknown'; if (item.item_type === 'photo_delete' || item.item_type === 'photo_edit' || item.item_type === 'photo') { // For photo operations, prioritize entity_name from item_data entityName = itemData.entity_name || itemData.caption || itemData.title || 'Photo'; // If we have entity_type and entity_id but no entity_name, fetch it from DB if (!itemData.entity_name && itemData.entity_type && itemData.entity_id) { try { const entityType = itemData.entity_type; const entityId = itemData.entity_id; if (entityType === 'park') { const { data } = await supabase.from('parks').select('name').eq('id', entityId).maybeSingle(); if (data?.name) entityName = `${data.name} (${formatEntityType(entityType)})`; } else if (entityType === 'ride') { const { data } = await supabase.from('rides').select('name').eq('id', entityId).maybeSingle(); if (data?.name) entityName = `${data.name} (${formatEntityType(entityType)})`; } else if (entityType === 'ride_model') { const { data } = await supabase.from('ride_models').select('name').eq('id', entityId).maybeSingle(); if (data?.name) entityName = `${data.name} (${formatEntityType(entityType)})`; } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) { const { data } = await supabase.from('companies').select('name').eq('id', entityId).maybeSingle(); if (data?.name) entityName = `${data.name} (${formatEntityType(entityType)})`; } } catch (err) { console.error('Error fetching entity name for photo operation:', err); } } // Add debugging warning if critical data is missing if (!itemData.entity_name && item.item_type === 'photo_delete') { console.warn(`[Photo Delete] Missing entity_name for photo_delete item`, { item_type: item.item_type, has_entity_type: !!itemData.entity_type, has_entity_id: !!itemData.entity_id, has_cloudflare_url: !!itemData.cloudflare_image_url }); } } else if (item.item_type === 'milestone') { // Milestone submissions reference entity_id and entity_type // Need to fetch the entity name from the database if (itemData.entity_type && itemData.entity_id) { try { const entityType = itemData.entity_type; const entityId = itemData.entity_id; if (entityType === 'park') { const { data } = await supabase.from('parks').select('name').eq('id', entityId).maybeSingle(); if (data?.name) { entityName = `${data.name} - ${itemData.title || 'Milestone'}`; } } else if (entityType === 'ride') { const { data: rideData } = await supabase .from('rides') .select('name, park:parks(name)') .eq('id', entityId) .maybeSingle(); if (rideData?.name) { const parkName = rideData.park?.name; entityName = parkName ? `${rideData.name} at ${parkName} - ${itemData.title || 'Milestone'}` : `${rideData.name} - ${itemData.title || 'Milestone'}`; } } // If lookup failed, fall back to title with entity type if (entityName === 'Unknown' && itemData.title) { entityName = `${formatEntityType(entityType)} - ${itemData.title}`; } } catch (err) { console.error('Error fetching entity name for milestone:', err); // Fall back to just the title if database lookup fails if (itemData.title) { entityName = itemData.title; } } } else if (itemData.title) { // No entity reference, just use the milestone title entityName = itemData.title; } // Add resolved entity name as an explicit field for milestone submissions if (itemData.entity_type && itemData.entity_id) { let resolvedEntityName = 'Unknown Entity'; try { const entityType = itemData.entity_type; const entityId = itemData.entity_id; if (entityType === 'park') { const { data } = await supabase.from('parks').select('name').eq('id', entityId).maybeSingle(); if (data?.name) { resolvedEntityName = data.name; } } else if (entityType === 'ride') { const { data: rideData } = await supabase .from('rides') .select('name, park:parks(name)') .eq('id', entityId) .maybeSingle(); if (rideData?.name) { const parkName = rideData.park?.name; resolvedEntityName = parkName ? `${rideData.name} at ${parkName}` : rideData.name; } } } catch (err) { console.error('Error resolving entity name for field display:', err); } // Add entity name as an explicit field change at the beginning fieldChanges.unshift({ field: 'entity_name', oldValue: null, newValue: resolvedEntityName, changeType: 'added', }); } } else { // For regular entities, use name field 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', // Analytics fields (auto-updated by system) 'view_count_7d', 'view_count_30d', 'view_count_all', ]; return !excludedFields.includes(key); } /** * Normalizes values for consistent comparison * Handles enum-like strings (snake_case and Title Case) by ensuring lowercase */ function normalizeForComparison(value: any): any { // Null/undefined pass through if (value == null) return value; // Normalize enum-like strings to lowercase for comparison // Matches patterns like: "operating", "Operating", "amusement_park", "Amusement_Park", "Amusement Park" if (typeof value === 'string' && /^[a-zA-Z_\s]+$/.test(value)) { return value .toLowerCase() .replace(/_/g, ' ') // Replace underscores with spaces .replace(/\s+/g, ' ') // Collapse multiple spaces .trim(); } // Recursively normalize arrays if (Array.isArray(value)) { return value.map(normalizeForComparison); } // Recursively normalize objects (but not Date objects) if (typeof value === 'object' && !(value instanceof Date)) { const normalized: Record = {}; for (const [key, val] of Object.entries(value)) { normalized[key] = normalizeForComparison(val); } return normalized; } return value; } /** * Deep equality check for values with normalization */ function isEqual(a: any, b: any): boolean { // Normalize both values before comparison const normalizedA = normalizeForComparison(a); const normalizedB = normalizeForComparison(b); if (normalizedA === normalizedB) return true; if (normalizedA == null || normalizedB == null) return normalizedA === normalizedB; if (typeof normalizedA !== typeof normalizedB) return false; if (typeof normalizedA === 'object') { if (Array.isArray(normalizedA) && Array.isArray(normalizedB)) { if (normalizedA.length !== normalizedB.length) return false; return normalizedA.every((item, i) => isEqual(item, normalizedB[i])); } const keysA = Object.keys(normalizedA); const keysB = Object.keys(normalizedB); if (keysA.length !== keysB.length) return false; return keysA.every(key => isEqual(normalizedA[key], normalizedB[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(); } /** * Get table name for entity type */ function getTableNameForEntityType(entityType: string): string | null { const mapping: Record = { 'park': 'parks', 'ride': 'rides', 'manufacturer': 'companies', 'operator': 'companies', 'designer': 'companies', 'property_owner': 'companies', 'ride_model': 'ride_models' }; return mapping[entityType] || null; } /** * Format entity type for display */ function formatEntityType(entityType: string): string { return entityType .split('_') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); } /** * Format field value for display */ export function formatFieldValue(value: any, precision?: 'day' | 'month' | 'year'): string { if (value === null || value === undefined) return 'None'; if (typeof value === 'boolean') return value ? 'Yes' : 'No'; // Handle dates with precision support if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) { try { const date = new Date(value); // Apply precision if provided if (precision === 'year') { return date.getFullYear().toString(); } else if (precision === 'month') { return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); } // Default: full date 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 year-like numbers (prevent comma formatting for founded_year) if (typeof value === 'number') { const currentYear = new Date().getFullYear(); if (value >= 1800 && value <= currentYear + 10) { return value.toString(); // Don't add commas for year values } return value.toLocaleString(); // Add commas for other numbers } // 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; } } // Handle enum-like strings (snake_case or kebab-case) - capitalize and replace separators if (typeof value === 'string' && (value.includes('_') || value.includes('-'))) { return value .split(/[_-]/) .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); } if (typeof value === 'number') return value.toLocaleString(); return String(value); }