mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 15:51:13 -05:00
351 lines
9.4 KiB
TypeScript
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);
|
|
}
|