mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
880 lines
29 KiB
TypeScript
880 lines
29 KiB
TypeScript
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<PhotoChange[]> {
|
|
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<ChangesSummary> {
|
|
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<T extends Record<string, any>>(
|
|
formData: T,
|
|
originalData: Partial<T>
|
|
): Partial<T> {
|
|
const changes: Partial<T> = {};
|
|
|
|
// 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<string, any> = {};
|
|
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<string, string> = {
|
|
'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);
|
|
}
|