mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:11:17 -05:00
Implement plan
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Json } from '@/integrations/supabase/types';
|
||||
import { ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
|
||||
import { uploadPendingImages } from './imageUploadHelper';
|
||||
import type { ProcessedImage } from './supabaseHelpers';
|
||||
import { extractChangedFields } from './submissionChangeDetection';
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
@@ -301,8 +302,8 @@ export async function submitParkUpdate(
|
||||
item_type: 'park',
|
||||
action_type: 'edit',
|
||||
item_data: {
|
||||
...data,
|
||||
park_id: parkId,
|
||||
...extractChangedFields(data, existingPark),
|
||||
park_id: parkId, // Always include for relational integrity
|
||||
images: processedImages as unknown as Json
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingPark)),
|
||||
@@ -464,8 +465,8 @@ export async function submitRideUpdate(
|
||||
item_type: 'ride',
|
||||
action_type: 'edit',
|
||||
item_data: {
|
||||
...data,
|
||||
ride_id: rideId,
|
||||
...extractChangedFields(data, existingRide),
|
||||
ride_id: rideId, // Always include for relational integrity
|
||||
images: processedImages as unknown as Json
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingRide)),
|
||||
@@ -610,8 +611,8 @@ export async function submitRideModelUpdate(
|
||||
item_type: 'ride_model',
|
||||
action_type: 'edit',
|
||||
item_data: {
|
||||
...data,
|
||||
ride_model_id: rideModelId,
|
||||
...extractChangedFields(data, existingModel),
|
||||
ride_model_id: rideModelId, // Always include for relational integrity
|
||||
images: processedImages as unknown as Json
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingModel)),
|
||||
@@ -722,9 +723,9 @@ export async function submitManufacturerUpdate(
|
||||
item_type: 'manufacturer',
|
||||
action_type: 'edit',
|
||||
item_data: {
|
||||
...data,
|
||||
company_id: companyId,
|
||||
company_type: 'manufacturer',
|
||||
...extractChangedFields(data, existingCompany as any),
|
||||
company_id: companyId, // Always include for relational integrity
|
||||
company_type: 'manufacturer', // Always include for entity type discrimination
|
||||
images: processedImages as unknown as Json
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
||||
@@ -830,9 +831,9 @@ export async function submitDesignerUpdate(
|
||||
item_type: 'designer',
|
||||
action_type: 'edit',
|
||||
item_data: {
|
||||
...data,
|
||||
company_id: companyId,
|
||||
company_type: 'designer',
|
||||
...extractChangedFields(data, existingCompany as any),
|
||||
company_id: companyId, // Always include for relational integrity
|
||||
company_type: 'designer', // Always include for entity type discrimination
|
||||
images: processedImages as unknown as Json
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
||||
@@ -938,9 +939,9 @@ export async function submitOperatorUpdate(
|
||||
item_type: 'operator',
|
||||
action_type: 'edit',
|
||||
item_data: {
|
||||
...data,
|
||||
company_id: companyId,
|
||||
company_type: 'operator',
|
||||
...extractChangedFields(data, existingCompany as any),
|
||||
company_id: companyId, // Always include for relational integrity
|
||||
company_type: 'operator', // Always include for entity type discrimination
|
||||
images: processedImages as unknown as Json
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
||||
@@ -1046,9 +1047,9 @@ export async function submitPropertyOwnerUpdate(
|
||||
item_type: 'property_owner',
|
||||
action_type: 'edit',
|
||||
item_data: {
|
||||
...data,
|
||||
company_id: companyId,
|
||||
company_type: 'property_owner',
|
||||
...extractChangedFields(data, existingCompany as any),
|
||||
company_id: companyId, // Always include for relational integrity
|
||||
company_type: 'property_owner', // Always include for entity type discrimination
|
||||
images: processedImages as unknown as Json
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
||||
@@ -1167,21 +1168,14 @@ export async function submitTimelineEventUpdate(
|
||||
throw new Error('Failed to fetch original timeline event');
|
||||
}
|
||||
|
||||
// Prepare item data
|
||||
// Extract only changed fields from form data
|
||||
const changedFields = extractChangedFields(data, originalEvent as any);
|
||||
|
||||
const itemData: Record<string, any> = {
|
||||
...changedFields,
|
||||
// Always include entity reference (for FK integrity)
|
||||
entity_type: originalEvent.entity_type,
|
||||
entity_id: originalEvent.entity_id,
|
||||
event_type: data.event_type,
|
||||
event_date: data.event_date.toISOString().split('T')[0],
|
||||
event_date_precision: data.event_date_precision,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
from_value: data.from_value,
|
||||
to_value: data.to_value,
|
||||
from_entity_id: data.from_entity_id,
|
||||
to_entity_id: data.to_entity_id,
|
||||
from_location_id: data.from_location_id,
|
||||
to_location_id: data.to_location_id,
|
||||
is_public: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -472,6 +472,140 @@ export async function detectChanges(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
* 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 as any;
|
||||
if (!oldLoc || !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
|
||||
*/
|
||||
|
||||
@@ -85,6 +85,10 @@ export function validateEntityDataStrict(
|
||||
if (!hasLocation && !hadLocation) {
|
||||
result.blockingErrors.push('Location is required for parks');
|
||||
}
|
||||
// Block explicit removal of required location
|
||||
if (hadLocation && data.location_id === null) {
|
||||
result.blockingErrors.push('Cannot remove location from a park - location is required');
|
||||
}
|
||||
if (data.opening_date && data.closing_date) {
|
||||
const opening = new Date(data.opening_date);
|
||||
const closing = new Date(data.closing_date);
|
||||
@@ -107,6 +111,10 @@ export function validateEntityDataStrict(
|
||||
if (!hasPark && !hadPark) {
|
||||
result.blockingErrors.push('Park is required for rides');
|
||||
}
|
||||
// Block explicit removal of required park assignment
|
||||
if (hadPark && data.park_id === null) {
|
||||
result.blockingErrors.push('Cannot remove park from a ride - park is required');
|
||||
}
|
||||
if (data.max_speed_kmh && (data.max_speed_kmh < 0 || data.max_speed_kmh > 300)) {
|
||||
result.blockingErrors.push('Max speed must be between 0 and 300 km/h');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user