Files
thrilltrack-explorer/src/lib/submissionChangeDetection.ts
2025-10-06 13:32:56 +00:00

351 lines
9.4 KiB
TypeScript

import type { SubmissionItemData } from '@/types/submissions';
import { supabase } from '@/integrations/supabase/client';
export interface FieldChange {
field: string;
oldValue: any;
newValue: any;
changeType: 'added' | 'removed' | 'modified';
}
export interface ImageChange {
type: 'banner' | 'card';
oldUrl?: string;
newUrl?: string;
oldId?: string;
newId?: string;
}
export interface PhotoChange {
type: 'added' | 'edited' | 'deleted';
photos?: Array<{ url: string; title?: string; caption?: string }>;
photo?: {
url: string;
title?: string;
caption?: string;
oldCaption?: string;
newCaption?: string;
oldTitle?: string;
newTitle?: string;
};
}
export interface ChangesSummary {
action: 'create' | 'edit' | 'delete';
entityType: string;
entityName?: string;
fieldChanges: FieldChange[];
imageChanges: ImageChange[];
photoChanges: PhotoChange[];
hasLocationChange: boolean;
totalChanges: number;
}
/**
* Detects photo changes for a submission
*/
async function detectPhotoChanges(submissionId: string): Promise<PhotoChange[]> {
const changes: PhotoChange[] = [];
try {
// Fetch photo submission with items - use array query to avoid 406 errors
const { data: photoSubmissions, error } = await supabase
.from('photo_submissions')
.select(`
*,
items:photo_submission_items(*)
`)
.eq('submission_id', submissionId);
if (error) {
console.error('Error fetching photo submissions:', error);
return changes;
}
const photoSubmission = photoSubmissions?.[0];
if (photoSubmission?.items && photoSubmission.items.length > 0) {
// For now, treat all photos as additions
// TODO: Implement edit/delete detection by comparing with existing entity photos
changes.push({
type: 'added',
photos: photoSubmission.items.map((item: any) => ({
url: item.cloudflare_image_url,
title: item.title,
caption: item.caption
}))
});
}
} catch (err) {
console.error('Error detecting photo changes:', err);
}
return changes;
}
/**
* Detects what changed between original_data and item_data
*/
export async function detectChanges(
item: { item_data?: any; original_data?: any; item_type: string },
submissionId?: string
): Promise<ChangesSummary> {
const itemData = item.item_data || {};
const originalData = item.original_data || {};
// Determine action type
const action: 'create' | 'edit' | 'delete' =
!originalData || Object.keys(originalData).length === 0 ? 'create' :
itemData.deleted ? 'delete' : 'edit';
const fieldChanges: FieldChange[] = [];
const imageChanges: ImageChange[] = [];
let hasLocationChange = false;
if (action === 'create') {
// For creates, all fields are "added"
Object.entries(itemData).forEach(([key, value]) => {
if (shouldTrackField(key) && value !== null && value !== undefined && value !== '') {
fieldChanges.push({
field: key,
oldValue: null,
newValue: value,
changeType: 'added',
});
}
});
} else if (action === 'edit') {
// Compare each field
const allKeys = new Set([
...Object.keys(itemData),
...Object.keys(originalData)
]);
allKeys.forEach(key => {
if (!shouldTrackField(key)) return;
const oldValue = originalData[key];
const newValue = itemData[key];
// Handle location changes specially
if (key === 'location' || key === 'location_id') {
if (!isEqual(oldValue, newValue)) {
hasLocationChange = true;
fieldChanges.push({
field: key,
oldValue,
newValue,
changeType: 'modified',
});
}
return;
}
// Check for changes
if (!isEqual(oldValue, newValue)) {
if ((oldValue === null || oldValue === undefined || oldValue === '') && newValue) {
fieldChanges.push({
field: key,
oldValue,
newValue,
changeType: 'added',
});
} else if ((newValue === null || newValue === undefined || newValue === '') && oldValue) {
fieldChanges.push({
field: key,
oldValue,
newValue,
changeType: 'removed',
});
} else {
fieldChanges.push({
field: key,
oldValue,
newValue,
changeType: 'modified',
});
}
}
});
// Detect image changes
detectImageChanges(originalData, itemData, imageChanges);
}
// Get entity name
const entityName = itemData.name || originalData?.name || 'Unknown';
// Detect photo changes if submissionId provided
const photoChanges = submissionId ? await detectPhotoChanges(submissionId) : [];
return {
action,
entityType: item.item_type,
entityName,
fieldChanges,
imageChanges,
photoChanges,
hasLocationChange,
totalChanges: fieldChanges.length + imageChanges.length + photoChanges.length + (hasLocationChange ? 1 : 0)
};
}
/**
* Determines if a field should be tracked for changes
*/
function shouldTrackField(key: string): boolean {
const excludedFields = [
'id',
'created_at',
'updated_at',
'slug',
'image_assignments',
'banner_image_url',
'banner_image_id',
'card_image_url',
'card_image_id',
];
return !excludedFields.includes(key);
}
/**
* Deep equality check for values
*/
function isEqual(a: any, b: any): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (typeof a === 'object') {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, i) => isEqual(item, b[i]));
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => isEqual(a[key], b[key]));
}
return false;
}
/**
* Normalizes image data structures to extract IDs consistently
*/
function getImageIds(data: any): { banner?: string; card?: string } {
const result: { banner?: string; card?: string } = {};
// Handle flat structure (original_data from DB)
if (data.banner_image_id) result.banner = data.banner_image_id;
if (data.card_image_id) result.card = data.card_image_id;
// Handle nested structure (item_data from form)
if (data.images?.uploaded) {
const uploaded = data.images.uploaded;
if (uploaded[0]?.id) result.banner = uploaded[0].id;
if (uploaded[1]?.id) result.card = uploaded[1].id;
}
return result;
}
/**
* Detects changes in banner/card images
*/
function detectImageChanges(
originalData: any,
itemData: any,
imageChanges: ImageChange[]
): void {
// Normalize both data structures before comparing
const oldIds = getImageIds(originalData);
const newIds = getImageIds(itemData);
// Check banner image
if (oldIds.banner !== newIds.banner) {
imageChanges.push({
type: 'banner',
oldUrl: originalData.banner_image_url,
newUrl: itemData.banner_image_url || itemData.images?.uploaded?.[0]?.url,
oldId: oldIds.banner,
newId: newIds.banner,
});
}
// Check card image
if (oldIds.card !== newIds.card) {
imageChanges.push({
type: 'card',
oldUrl: originalData.card_image_url,
newUrl: itemData.card_image_url || itemData.images?.uploaded?.[1]?.url,
oldId: oldIds.card,
newId: newIds.card,
});
}
}
/**
* Format field name for display
*/
export function formatFieldName(field: string): string {
return field
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
}
/**
* Format field value for display
*/
export function formatFieldValue(value: any): string {
if (value === null || value === undefined) return 'None';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
// Handle dates
if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) {
try {
const date = new Date(value);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
} catch {
return String(value);
}
}
// Handle arrays - show actual items
if (Array.isArray(value)) {
if (value.length === 0) return 'None';
if (value.length <= 3) return value.map(v => String(v)).join(', ');
return `${value.slice(0, 3).map(v => String(v)).join(', ')}... +${value.length - 3} more`;
}
// Handle objects - create readable summary
if (typeof value === 'object') {
// Location object
if (value.city || value.state_province || value.country) {
const parts = [value.city, value.state_province, value.country].filter(Boolean);
return parts.join(', ');
}
// Generic object - show key-value pairs
const entries = Object.entries(value).slice(0, 3);
if (entries.length === 0) return 'Empty';
return entries.map(([k, v]) => `${k}: ${v}`).join(', ');
}
// Handle URLs
if (typeof value === 'string' && value.startsWith('http')) {
try {
const url = new URL(value);
return url.hostname + (url.pathname !== '/' ? url.pathname.slice(0, 30) : '');
} catch {
return value;
}
}
if (typeof value === 'number') return value.toLocaleString();
return String(value);
}