diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index 753dad8a..f181451a 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -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 = { + ...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, }; diff --git a/src/lib/submissionChangeDetection.ts b/src/lib/submissionChangeDetection.ts index 3efdbcb7..ce3dffab 100644 --- a/src/lib/submissionChangeDetection.ts +++ b/src/lib/submissionChangeDetection.ts @@ -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>( + formData: T, + originalData: Partial +): Partial { + const changes: Partial = {}; + + // 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 */ diff --git a/supabase/functions/process-selective-approval/validation.ts b/supabase/functions/process-selective-approval/validation.ts index 63176a32..55bf33c7 100644 --- a/supabase/functions/process-selective-approval/validation.ts +++ b/supabase/functions/process-selective-approval/validation.ts @@ -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'); }