Files
thrilltrack-explorer/src/lib/submissionChangeDetection.ts
gpt-engineer-app[bot] 1e60d6c6b6 Fix enum value comparison
2025-10-17 15:54:58 +00:00

761 lines
25 KiB
TypeScript

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<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) {
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<string, any>;
const originalData = item.original_data as Record<string, any> | 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<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) {
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<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);
}