Implement plan

This commit is contained in:
gpt-engineer-app[bot]
2025-10-19 19:28:04 +00:00
parent 5a138688bc
commit 5fa073cca2
3 changed files with 166 additions and 30 deletions

View File

@@ -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,
};

View File

@@ -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
*/

View File

@@ -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');
}