import type { SubmissionItemData } from '@/types/submissions'; import type { ParkSubmissionData, RideSubmissionData, CompanySubmissionData, RideModelSubmissionData } from '@/types/submission-data'; import { supabase } from '@/lib/supabaseClient'; import { handleNonCriticalError, getErrorMessage } from './errorHandler'; 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) { handleNonCriticalError(photoError, { action: 'Detect Photo Changes (Fetch Photo Submission)', metadata: { submissionId } }); } 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) { handleNonCriticalError(itemsError, { action: 'Detect Photo Changes (Fetch Submission Items)', metadata: { submissionId } }); } else if (submissionItems && submissionItems.length > 0) { for (const item of submissionItems) { // For photo items, data is stored differently // Skip for now as photo submissions use separate table continue; } } } catch (err: unknown) { handleNonCriticalError(err, { action: 'Detect Photo Changes', metadata: { submissionId } }); } 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) { handleNonCriticalError(err, { action: 'Fetch Entity Name for Photo Operation', metadata: { entityType: itemData.entity_type, entityId: itemData.entity_id } }); } } } 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) { handleNonCriticalError(err, { action: 'Fetch Entity Name for Milestone', metadata: { entityType: itemData.entity_type, entityId: itemData.entity_id } }); // 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) { handleNonCriticalError(err, { action: 'Resolve Entity Name for Field Display', metadata: { entityType: itemData.entity_type, entityId: itemData.entity_id } }); } // 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) }; } /** * ═══════════════════════════════════════════════════════════════════ * TYPE-SAFE CHANGE EXTRACTION FOR EDIT SUBMISSIONS * ═══════════════════════════════════════════════════════════════════ * * Extracts ONLY changed fields from form data compared to original entity data. * Critical for edit operations to avoid passing unchanged fields through moderation. * * Benefits: * ✅ Clearer audit trail (only see actual changes) * ✅ Smaller database writes (no unnecessary updates) * ✅ Correct validation (unchanged required fields stay in original_data) * ✅ Type-safe with generics (compiler catches errors) * ✅ Follows project knowledge: "only funnel through real changes" * * @param formData - New form data from user submission * @param originalData - Original entity data from database * @returns Object containing ONLY changed fields * * @example * // Edit that only changes description * extractChangedFields( * { name: "Cedar Point", description: "New desc" }, * { name: "Cedar Point", description: "Old desc", location_id: "uuid-123" } * ) * // Returns: { description: "New desc" } * // ✅ location_id NOT included (unchanged, exists in original_data) */ export function extractChangedFields>( formData: T, originalData: Partial ): Partial { const changes: Partial = {}; // Critical IDs that MUST always be included for relational integrity // Even if "unchanged", these maintain foreign key relationships const alwaysIncludeIds = [ 'park_id', // Rides belong to parks 'ride_id', // For ride updates 'company_id', // For company updates 'manufacturer_id', // Rides reference manufacturers 'ride_model_id', // Rides reference models 'operator_id', // Parks reference operators 'property_owner_id', // Parks reference property owners 'designer_id', // Rides reference designers ]; Object.keys(formData).forEach((key) => { const newValue = formData[key]; const oldValue = originalData[key]; // Always include critical relational IDs (even if unchanged) if (alwaysIncludeIds.includes(key)) { if (newValue !== undefined && newValue !== null) { changes[key as keyof T] = newValue; } return; } // Skip system fields and fields that shouldn't be tracked if (!shouldTrackField(key)) { return; } // ═══ SPECIAL HANDLING: LOCATION OBJECTS ═══ // Location can be an object (from form) vs location_id (from DB) if (key === 'location' && newValue && typeof newValue === 'object') { const oldLoc = originalData.location; if (!oldLoc || typeof oldLoc !== 'object' || !isEqual(oldLoc, newValue)) { changes[key as keyof T] = newValue; } return; } // ═══ SPECIAL HANDLING: DATE FIELDS WITH PRECISION ═══ // opening_date, closing_date, founded_date, etc. if (key.endsWith('_date') && !key.endsWith('_precision')) { const precisionKey = `${key}_precision` as keyof T; const newDate = newValue; const oldDate = oldValue; const newPrecision = formData[precisionKey]; const oldPrecision = originalData[precisionKey]; // Include if EITHER date OR precision changed if (!isEqual(newDate, oldDate) || !isEqual(newPrecision, oldPrecision)) { changes[key as keyof T] = newValue; // Also include precision if it exists if (newPrecision !== undefined) { changes[precisionKey] = newPrecision; } } return; } // Skip precision fields (they're handled with their date above) if (key.endsWith('_precision')) { return; } // ═══ SPECIAL HANDLING: IMAGE FIELDS ═══ // Images have their own assignment system and should always be included if present if (key === 'images' || key.includes('image_')) { if (!isEqual(newValue, oldValue)) { changes[key as keyof T] = newValue; } return; } // ═══ GENERAL FIELD COMPARISON ═══ // Include field if: // 1. Value changed from something to something else // 2. Value added (old was empty, new has value) // // Do NOT include if: // 1. Both values are empty (null, undefined, '') // 2. Values are equal after normalization const oldEmpty = oldValue === null || oldValue === undefined || oldValue === ''; const newEmpty = newValue === null || newValue === undefined || newValue === ''; // If both empty, don't track (no change) if (oldEmpty && newEmpty) { return; } // If values differ, include the change if (!isEqual(oldValue, newValue)) { changes[key as keyof T] = newValue; } }); return changes; } /** * 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); }