import { supabase } from '@/integrations/supabase/client'; 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'; import type { CompanyDatabaseRecord, TimelineEventDatabaseRecord } from '@/types/company-data'; import { logger } from './logger'; import { getErrorMessage } from './errorHandler'; import type { TimelineEventFormData, EntityType } from '@/types/timeline'; // ============================================ // COMPOSITE SUBMISSION TYPES // ============================================ interface CompositeSubmissionDependency { type: 'park' | 'ride' | 'company' | 'ride_model'; data: any; tempId: string; companyType?: 'manufacturer' | 'designer' | 'operator' | 'property_owner'; parentTempId?: string; // For linking ride_model to manufacturer } interface CompositeSubmissionData { primaryEntity: { type: 'park' | 'ride'; data: any; }; dependencies: CompositeSubmissionDependency[]; } /** * ═══════════════════════════════════════════════════════════════════ * SUBMISSION PATTERN STANDARD - CRITICAL PROJECT RULE * ═══════════════════════════════════════════════════════════════════ * * ⚠️ NEVER STORE JSON IN SQL COLUMNS ⚠️ * * content_submissions.content should ONLY contain: * ✅ action: 'create' | 'edit' | 'delete' * ✅ Minimal reference IDs (entity_id, parent_id, etc.) - MAX 3 fields * ❌ NO actual form data * ❌ NO submission content * ❌ NO large objects * * ALL actual data MUST go in: * ✅ submission_items.item_data (new data) * ✅ submission_items.original_data (for edits) * ✅ Specialized relational tables: * - photo_submissions + photo_submission_items * - park_submissions * - ride_submissions * - company_submissions * - ride_model_submissions * * If your data is relational, model it relationally. * JSON blobs destroy: * - Queryability (can't filter/join) * - Performance (slower, larger) * - Data integrity (no constraints) * - Maintainability (impossible to refactor) * * EXAMPLES: * * ✅ CORRECT: * content: { action: 'create' } * content: { action: 'edit', park_id: uuid } * content: { action: 'delete', photo_id: uuid } * * ❌ WRONG: * content: { name: '...', description: '...', ...formData } * content: { photos: [...], metadata: {...} } * content: data // entire object dump * * ═══════════════════════════════════════════════════════════════════ */ export interface ParkFormData { name: string; slug: string; description?: string; park_type: string; status: string; opening_date?: string; closing_date?: string; website_url?: string; phone?: string; email?: string; operator_id?: string; property_owner_id?: string; // Location can be stored as object for new submissions or ID for editing location?: { name: string; city?: string; state_province?: string; country: string; postal_code?: string; latitude: number; longitude: number; timezone?: string; display_name: string; }; location_id?: string; images?: ImageAssignments; banner_image_url?: string; banner_image_id?: string; card_image_url?: string; card_image_id?: string; } export interface RideFormData { name: string; slug: string; description?: string; category: string; status: string; park_id: string; manufacturer_id?: string; designer_id?: string; ride_model_id?: string; opening_date?: string; closing_date?: string; max_speed_kmh?: number; max_height_meters?: number; length_meters?: number; duration_seconds?: number; capacity_per_hour?: number; height_requirement?: number; age_requirement?: number; inversions?: number; drop_height_meters?: number; max_g_force?: number; intensity_level?: string; coaster_type?: string; seating_type?: string; ride_sub_type?: string; images?: ImageAssignments; banner_image_url?: string; banner_image_id?: string; card_image_url?: string; card_image_id?: string; } export interface CompanyFormData { name: string; slug: string; description?: string; person_type: 'company' | 'individual' | 'firm' | 'organization'; founded_year?: number; founded_date?: string; founded_date_precision?: string; headquarters_location?: string; website_url?: string; images?: ImageAssignments; banner_image_url?: string; banner_image_id?: string; card_image_url?: string; card_image_id?: string; } export interface RideModelFormData { name: string; slug: string; manufacturer_id: string; category: string; ride_type?: string; description?: string; images?: ImageAssignments; banner_image_url?: string; banner_image_id?: string; card_image_url?: string; card_image_id?: string; } /** * ═══════════════════════════════════════════════════════════════════ * COMPOSITE SUBMISSION HANDLER * ═══════════════════════════════════════════════════════════════════ * * Creates a single submission containing multiple related entities * (e.g., Park + Operator, Ride + Manufacturer + Model) * * All entities are submitted atomically through the moderation queue, * with dependency tracking to ensure correct approval order. * * @param primaryEntity - The main entity being created (park or ride) * @param dependencies - Array of dependent entities (companies, models) * @param userId - The ID of the user submitting * @returns Object containing submitted boolean and submissionId */ async function submitCompositeCreation( primaryEntity: { type: 'park' | 'ride'; data: any }, dependencies: CompositeSubmissionDependency[], userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Check if user is banned const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Upload all pending images for all entities const uploadedEntities = await Promise.all([ ...dependencies.map(async (dep) => { if (dep.data.images?.uploaded && dep.data.images.uploaded.length > 0) { const uploadedImages = await uploadPendingImages(dep.data.images.uploaded); return { ...dep, data: { ...dep.data, images: { ...dep.data.images, uploaded: uploadedImages } } }; } return dep; }), (async () => { if (primaryEntity.data.images?.uploaded && primaryEntity.data.images.uploaded.length > 0) { const uploadedImages = await uploadPendingImages(primaryEntity.data.images.uploaded); return { ...primaryEntity, data: { ...primaryEntity.data, images: { ...primaryEntity.data.images, uploaded: uploadedImages } } }; } return primaryEntity; })() ]); const uploadedDependencies = uploadedEntities.slice(0, -1) as CompositeSubmissionDependency[]; const uploadedPrimary = uploadedEntities[uploadedEntities.length - 1] as typeof primaryEntity; // Build submission items array with dependencies first const submissionItems: any[] = []; const tempIdMap = new Map(); // Maps tempId to order_index // Add dependency items (companies, models) first let orderIndex = 0; for (const dep of uploadedDependencies) { const itemType = dep.type === 'company' ? dep.companyType : dep.type; tempIdMap.set(dep.tempId, orderIndex); const itemData: any = { ...extractChangedFields(dep.data, {}), images: dep.data.images as unknown as Json }; // Add company_type for company entities if (dep.type === 'company') { itemData.company_type = dep.companyType; } // Add manufacturer dependency for ride models if (dep.type === 'ride_model' && dep.parentTempId) { const parentOrderIndex = tempIdMap.get(dep.parentTempId); if (parentOrderIndex !== undefined) { itemData._temp_manufacturer_ref = parentOrderIndex; } } submissionItems.push({ item_type: itemType, action_type: 'create' as const, item_data: itemData, status: 'pending' as const, order_index: orderIndex, depends_on: null // Dependencies don't have parents }); orderIndex++; } // Add primary entity last const primaryData: any = { ...extractChangedFields(uploadedPrimary.data, {}), images: uploadedPrimary.data.images as unknown as Json }; // Map temporary IDs to order indices for foreign keys if (uploadedPrimary.type === 'park') { if (uploadedPrimary.data.operator_id?.startsWith('temp-')) { const opIndex = tempIdMap.get('temp-operator'); if (opIndex !== undefined) primaryData._temp_operator_ref = opIndex; delete primaryData.operator_id; } if (uploadedPrimary.data.property_owner_id?.startsWith('temp-')) { const ownerIndex = tempIdMap.get('temp-property-owner'); const operatorIndex = tempIdMap.get('temp-operator'); if (ownerIndex !== undefined) { primaryData._temp_property_owner_ref = ownerIndex; } else if (operatorIndex !== undefined) { // Property owner references operator (operator is owner scenario) primaryData._temp_property_owner_ref = operatorIndex; } delete primaryData.property_owner_id; } } else if (uploadedPrimary.type === 'ride') { if (uploadedPrimary.data.park_id?.startsWith('temp-')) { const parkIndex = tempIdMap.get(uploadedPrimary.data.park_id); if (parkIndex !== undefined) primaryData._temp_park_ref = parkIndex; delete primaryData.park_id; } if (uploadedPrimary.data.manufacturer_id?.startsWith('temp-')) { const mfgIndex = tempIdMap.get(uploadedPrimary.data.manufacturer_id); if (mfgIndex !== undefined) primaryData._temp_manufacturer_ref = mfgIndex; delete primaryData.manufacturer_id; } if (uploadedPrimary.data.designer_id?.startsWith('temp-')) { const designerIndex = tempIdMap.get(uploadedPrimary.data.designer_id); if (designerIndex !== undefined) primaryData._temp_designer_ref = designerIndex; delete primaryData.designer_id; } if (uploadedPrimary.data.ride_model_id?.startsWith('temp-')) { const modelIndex = tempIdMap.get(uploadedPrimary.data.ride_model_id); if (modelIndex !== undefined) primaryData._temp_ride_model_ref = modelIndex; delete primaryData.ride_model_id; } } submissionItems.push({ item_type: uploadedPrimary.type, action_type: 'create' as const, item_data: primaryData, status: 'pending' as const, order_index: orderIndex, depends_on: null // Will be set by RPC based on refs }); // Use RPC to create submission with items atomically const { data: result, error } = await supabase.rpc('create_submission_with_items', { p_user_id: userId, p_submission_type: uploadedPrimary.type, p_content: { action: 'create' } as unknown as Json, p_items: submissionItems as unknown as Json[] }); if (error) { logger.error('Composite submission failed', { action: 'composite_submission', primaryType: uploadedPrimary.type, dependencyCount: dependencies.length, error: getErrorMessage(error) }); throw new Error(`Failed to create composite submission: ${getErrorMessage(error)}`); } return { submitted: true, submissionId: result }; } /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * * Submits a new park for creation through the moderation queue. * This is the ONLY correct way to create parks. * * DO NOT use direct database inserts: * ❌ await supabase.from('parks').insert(data) // BYPASSES MODERATION! * ✅ await submitParkCreation(data, userId) // CORRECT * * Flow: User Submit → Moderation Queue → Approval → Versioning → Live * * Even moderators/admins must use this function to ensure proper versioning and audit trails. * * @see docs/SUBMISSION_FLOW.md for complete documentation * @param data - The park form data to submit * @param userId - The ID of the user submitting the park * @returns Object containing submitted boolean and submissionId */ export async function submitParkCreation( data: ParkFormData & { _compositeSubmission?: any }, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Check for composite submission with dependencies if (data._compositeSubmission) { const dependencies: CompositeSubmissionDependency[] = []; // Check if operator and owner are the same new entity const operatorIsOwner = data._compositeSubmission.new_operator && data._compositeSubmission.new_property_owner && data._compositeSubmission.new_operator === data._compositeSubmission.new_property_owner; if (data._compositeSubmission.new_operator) { dependencies.push({ type: 'company', data: { ...data._compositeSubmission.new_operator, company_type: 'operator' }, tempId: 'temp-operator', companyType: 'operator' }); } // Only add separate property owner if different from operator if (data._compositeSubmission.new_property_owner && !operatorIsOwner) { dependencies.push({ type: 'company', data: { ...data._compositeSubmission.new_property_owner, company_type: 'property_owner' }, tempId: 'temp-property-owner', companyType: 'property_owner' }); } if (dependencies.length > 0) { return submitCompositeCreation( { type: 'park', data: data._compositeSubmission.park }, dependencies, userId ); } } // Standard single-entity creation // Check if user is banned const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Upload any pending local images first let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { try { const uploadedImages = await uploadPendingImages(data.images.uploaded); processedImages = { ...data.images, uploaded: uploadedImages }; } catch (error: unknown) { const errorMessage = getErrorMessage(error); logger.error('Park image upload failed', { action: 'park_creation', error: errorMessage }); throw new Error(`Failed to upload images: ${errorMessage}`); } } // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'park', content: { action: 'create' }, status: 'pending' as const }) .select('id') .single(); if (submissionError) throw submissionError; // Create the submission item with actual park data const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'park', action_type: 'create', item_data: { ...extractChangedFields(data, {}), images: processedImages as unknown as Json }, status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * * Submits an update to an existing park through the moderation queue. * This is the ONLY correct way to update parks. * * DO NOT use direct database updates: * ❌ await supabase.from('parks').update(data) // BYPASSES MODERATION! * ✅ await submitParkUpdate(parkId, data, userId) // CORRECT * * Flow: User Submit → Moderation Queue → Approval → Versioning → Live * * Even moderators/admins must use this function to ensure proper versioning and audit trails. * * @see docs/SUBMISSION_FLOW.md for complete documentation * @param parkId - The ID of the park to update * @param data - The updated park form data * @param userId - The ID of the user submitting the update * @returns Object containing submitted boolean and submissionId */ export async function submitParkUpdate( parkId: string, data: ParkFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Check if user is banned const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Fetch existing park data first const { data: existingPark, error: fetchError } = await supabase .from('parks') .select('id, name, slug, description, park_type, status, opening_date, opening_date_precision, closing_date, closing_date_precision, website_url, phone, email, location_id, operator_id, property_owner_id, banner_image_url, banner_image_id, card_image_url, card_image_id') .eq('id', parkId) .single(); if (fetchError) throw new Error(`Failed to fetch park: ${fetchError.message}`); if (!existingPark) throw new Error('Park not found'); // CRITICAL: Block new photo uploads on edits // Photos can only be submitted during creation or via the photo gallery if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) { throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.'); } // Only allow banner/card reassignments from existing photos let processedImages = data.images; // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'park', content: { action: 'edit', park_id: parkId }, status: 'pending' as const }) .select('id') .single(); if (submissionError) throw submissionError; // Create the submission item with actual park data AND original data const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'park', action_type: 'edit', item_data: JSON.parse(JSON.stringify({ ...extractChangedFields(data, existingPark as any), park_id: parkId, // Always include for relational integrity images: processedImages })) as Json, original_data: JSON.parse(JSON.stringify(existingPark)), status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * * Submits a new ride for creation through the moderation queue. * This is the ONLY correct way to create rides. * * DO NOT use direct database inserts: * ❌ await supabase.from('rides').insert(data) // BYPASSES MODERATION! * ✅ await submitRideCreation(data, userId) // CORRECT * * Flow: User Submit → Moderation Queue → Approval → Versioning → Live * * Even moderators/admins must use this function to ensure proper versioning and audit trails. * * @see docs/SUBMISSION_FLOW.md for complete documentation * @param data - The ride form data to submit * @param userId - The ID of the user submitting the ride * @returns Object containing submitted boolean and submissionId */ export async function submitRideCreation( data: RideFormData & { _tempNewPark?: any; _tempNewManufacturer?: any; _tempNewDesigner?: any; _tempNewRideModel?: any; }, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Check for composite submission with dependencies if (data._tempNewPark || data._tempNewManufacturer || data._tempNewDesigner || data._tempNewRideModel) { const dependencies: CompositeSubmissionDependency[] = []; // Handle new park operator (from nested park) if (data._tempNewPark?._tempNewOperator) { dependencies.push({ type: 'company', data: { ...data._tempNewPark._tempNewOperator, company_type: 'operator' }, tempId: 'temp-park-operator', companyType: 'operator' }); } // Handle new park property owner (from nested park) if (data._tempNewPark?._tempNewPropertyOwner) { dependencies.push({ type: 'company', data: { ...data._tempNewPark._tempNewPropertyOwner, company_type: 'property_owner' }, tempId: 'temp-park-owner', companyType: 'property_owner' }); } // Handle new park (depends on operator/owner) if (data._tempNewPark) { dependencies.push({ type: 'park', data: { ...data._tempNewPark, operator_id: data._tempNewPark._tempNewOperator ? 'temp-park-operator' : data._tempNewPark.operator_id, property_owner_id: data._tempNewPark._tempNewPropertyOwner ? 'temp-park-owner' : data._tempNewPark.property_owner_id, _tempNewOperator: undefined, _tempNewPropertyOwner: undefined }, tempId: 'temp-park' }); } // Handle new manufacturer if (data._tempNewManufacturer) { dependencies.push({ type: 'company', data: { ...data._tempNewManufacturer, company_type: 'manufacturer' }, tempId: 'temp-manufacturer', companyType: 'manufacturer' }); } // Handle new designer if (data._tempNewDesigner) { dependencies.push({ type: 'company', data: { ...data._tempNewDesigner, company_type: 'designer' }, tempId: 'temp-designer', companyType: 'designer' }); } // Handle new ride model (depends on manufacturer) if (data._tempNewRideModel) { dependencies.push({ type: 'ride_model', data: { ...data._tempNewRideModel, manufacturer_id: data._tempNewManufacturer ? 'temp-manufacturer' : data._tempNewRideModel.manufacturer_id }, tempId: 'temp-ride-model', parentTempId: data._tempNewManufacturer ? 'temp-manufacturer' : undefined }); } if (dependencies.length > 0) { // Prepare ride data with temp references const rideData = { ...data, park_id: data._tempNewPark ? 'temp-park' : data.park_id, manufacturer_id: data._tempNewManufacturer ? 'temp-manufacturer' : data.manufacturer_id, designer_id: data._tempNewDesigner ? 'temp-designer' : data.designer_id, ride_model_id: data._tempNewRideModel ? 'temp-ride-model' : data.ride_model_id, _tempNewPark: undefined, _tempNewManufacturer: undefined, _tempNewDesigner: undefined, _tempNewRideModel: undefined }; return submitCompositeCreation( { type: 'ride', data: rideData }, dependencies, userId ); } } // Standard single-entity creation // Check if user is banned const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Upload any pending local images first let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { try { const uploadedImages = await uploadPendingImages(data.images.uploaded); processedImages = { ...data.images, uploaded: uploadedImages }; } catch (error: unknown) { const errorMessage = getErrorMessage(error); logger.error('Ride image upload failed', { action: 'ride_creation', error: errorMessage }); throw new Error(`Failed to upload images: ${errorMessage}`); } } // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'ride', content: { action: 'create' }, status: 'pending' as const }) .select('id') .single(); if (submissionError) throw submissionError; // Create the submission item with actual ride data const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'ride', action_type: 'create', item_data: { ...extractChangedFields(data, {}), images: processedImages as unknown as Json }, status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * * Submits an update to an existing ride through the moderation queue. * This is the ONLY correct way to update rides. * * DO NOT use direct database updates: * ❌ await supabase.from('rides').update(data) // BYPASSES MODERATION! * ✅ await submitRideUpdate(rideId, data, userId) // CORRECT * * Flow: User Submit → Moderation Queue → Approval → Versioning → Live * * Even moderators/admins must use this function to ensure proper versioning and audit trails. * * @see docs/SUBMISSION_FLOW.md for complete documentation * @param rideId - The ID of the ride to update * @param data - The updated ride form data * @param userId - The ID of the user submitting the update * @returns Object containing submitted boolean and submissionId */ export async function submitRideUpdate( rideId: string, data: RideFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Check if user is banned const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Fetch existing ride data first const { data: existingRide, error: fetchError } = await supabase .from('rides') .select('*') .eq('id', rideId) .single(); if (fetchError) throw new Error(`Failed to fetch ride: ${fetchError.message}`); if (!existingRide) throw new Error('Ride not found'); // CRITICAL: Block new photo uploads on edits // Photos can only be submitted during creation or via the photo gallery if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) { throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.'); } // Only allow banner/card reassignments from existing photos let processedImages = data.images; // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'ride', content: { action: 'edit', ride_id: rideId }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; // Create the submission item with actual ride data AND original data const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'ride', action_type: 'edit', item_data: { ...extractChangedFields(data, existingRide as any), ride_id: rideId, // Always include for relational integrity images: processedImages as unknown as Json }, original_data: JSON.parse(JSON.stringify(existingRide)), status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * * Submits a new ride model for creation through the moderation queue. * This is the ONLY correct way to create ride models. * * DO NOT use direct database inserts: * ❌ await supabase.from('ride_models').insert(data) // BYPASSES MODERATION! * ✅ await submitRideModelCreation(data, userId) // CORRECT * * Flow: User Submit → Moderation Queue → Approval → Versioning → Live * * @param data - The ride model form data to submit * @param userId - The ID of the user submitting the ride model * @returns Object containing submitted boolean and submissionId */ export async function submitRideModelCreation( data: RideModelFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Check if user is banned const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Upload any pending local images first let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { try { const uploadedImages = await uploadPendingImages(data.images.uploaded); processedImages = { ...data.images, uploaded: uploadedImages }; } catch (error: unknown) { const errorMessage = getErrorMessage(error); logger.error('Ride model image upload failed', { action: 'ride_model_creation', error: errorMessage }); throw new Error(`Failed to upload images: ${errorMessage}`); } } // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'ride_model', content: { action: 'create' }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; // Create the submission item with actual ride model data const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'ride_model', action_type: 'create', item_data: { ...extractChangedFields(data, {}), images: processedImages as unknown as Json }, status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * * Submits a ride model update through the moderation queue. * This is the ONLY correct way to update ride models. */ export async function submitRideModelUpdate( rideModelId: string, data: RideModelFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Check if user is banned const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Fetch existing ride model const { data: existingModel, error: fetchError } = await supabase .from('ride_models') .select('*') .eq('id', rideModelId) .single(); if (fetchError) throw new Error(`Failed to fetch ride model: ${fetchError.message}`); if (!existingModel) throw new Error('Ride model not found'); // CRITICAL: Block new photo uploads on edits if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) { throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.'); } let processedImages = data.images; // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'ride_model', content: { action: 'edit', ride_model_id: rideModelId }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; // Create the submission item with actual ride model data const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'ride_model', action_type: 'edit', item_data: { ...extractChangedFields(data, existingModel as any), ride_model_id: rideModelId, // Always include for relational integrity images: processedImages as unknown as Json }, original_data: JSON.parse(JSON.stringify(existingModel)), status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * * Submits a new manufacturer for creation through the moderation queue. */ export async function submitManufacturerCreation( data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { try { const uploadedImages = await uploadPendingImages(data.images.uploaded); processedImages = { ...data.images, uploaded: uploadedImages }; } catch (error: unknown) { const errorMessage = getErrorMessage(error); logger.error('Company image upload failed', { action: 'manufacturer_creation', error: errorMessage }); throw new Error(`Failed to upload images: ${errorMessage}`); } } const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'manufacturer', content: { action: 'create' }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'manufacturer', action_type: 'create', item_data: { ...extractChangedFields(data, {}), company_type: 'manufacturer', images: processedImages as unknown as Json }, status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } export async function submitManufacturerUpdate( companyId: string, data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { const { data: existingCompany, error: fetchError } = await supabase .from('companies') .select('*') .eq('id', companyId) .single(); if (fetchError) throw new Error(`Failed to fetch manufacturer: ${fetchError.message}`); if (!existingCompany) throw new Error('Manufacturer not found'); // CRITICAL: Block new photo uploads on edits if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) { throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.'); } let processedImages = data.images; const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'manufacturer', content: { action: 'edit', company_id: companyId }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'manufacturer', action_type: 'edit', item_data: { ...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)), status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } export async function submitDesignerCreation( data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { try { const uploadedImages = await uploadPendingImages(data.images.uploaded); processedImages = { ...data.images, uploaded: uploadedImages }; } catch (error: unknown) { const errorMessage = getErrorMessage(error); logger.error('Company image upload failed', { action: 'designer_creation', error: errorMessage }); throw new Error(`Failed to upload images: ${errorMessage}`); } } const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'designer', content: { action: 'create' }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'designer', action_type: 'create', item_data: { ...extractChangedFields(data, {}), company_type: 'designer', images: processedImages as unknown as Json }, status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } export async function submitDesignerUpdate( companyId: string, data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { const { data: existingCompany, error: fetchError } = await supabase .from('companies') .select('*') .eq('id', companyId) .single(); if (fetchError) throw new Error(`Failed to fetch designer: ${fetchError.message}`); if (!existingCompany) throw new Error('Designer not found'); // CRITICAL: Block new photo uploads on edits if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) { throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.'); } let processedImages = data.images; const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'designer', content: { action: 'edit', company_id: companyId }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'designer', action_type: 'edit', item_data: { ...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)), status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } export async function submitOperatorCreation( data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { try { const uploadedImages = await uploadPendingImages(data.images.uploaded); processedImages = { ...data.images, uploaded: uploadedImages }; } catch (error: unknown) { const errorMessage = getErrorMessage(error); logger.error('Company image upload failed', { action: 'operator_creation', error: errorMessage }); throw new Error(`Failed to upload images: ${errorMessage}`); } } const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'operator', content: { action: 'create' }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'operator', action_type: 'create', item_data: { ...extractChangedFields(data, {}), company_type: 'operator', images: processedImages as unknown as Json }, status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } export async function submitOperatorUpdate( companyId: string, data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { const { data: existingCompany, error: fetchError } = await supabase .from('companies') .select('*') .eq('id', companyId) .single(); if (fetchError) throw new Error(`Failed to fetch operator: ${fetchError.message}`); if (!existingCompany) throw new Error('Operator not found'); // CRITICAL: Block new photo uploads on edits if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) { throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.'); } let processedImages = data.images; const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'operator', content: { action: 'edit', company_id: companyId }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'operator', action_type: 'edit', item_data: { ...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)), status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } export async function submitPropertyOwnerCreation( data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { try { const uploadedImages = await uploadPendingImages(data.images.uploaded); processedImages = { ...data.images, uploaded: uploadedImages }; } catch (error: unknown) { const errorMessage = getErrorMessage(error); logger.error('Company image upload failed', { action: 'property_owner_creation', error: errorMessage }); throw new Error(`Failed to upload images: ${errorMessage}`); } } const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'property_owner', content: { action: 'create' }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'property_owner', action_type: 'create', item_data: { ...extractChangedFields(data, {}), company_type: 'property_owner', images: processedImages as unknown as Json }, status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } export async function submitPropertyOwnerUpdate( companyId: string, data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { const { data: existingCompany, error: fetchError } = await supabase .from('companies') .select('*') .eq('id', companyId) .single(); if (fetchError) throw new Error(`Failed to fetch property owner: ${fetchError.message}`); if (!existingCompany) throw new Error('Property owner not found'); // CRITICAL: Block new photo uploads on edits if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) { throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.'); } let processedImages = data.images; const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'property_owner', content: { action: 'edit', company_id: companyId }, status: 'pending' as const }) .select() .single(); if (submissionError) throw submissionError; const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'property_owner', action_type: 'edit', item_data: { ...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)), status: 'pending' as const, order_index: 0 }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; } /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * * Submits a new timeline event for an entity through the moderation queue. * * DO NOT write directly to entity_timeline_events: * ❌ await supabase.from('entity_timeline_events').insert(data) // BYPASSES MODERATION! * ✅ await submitTimelineEvent(entityType, entityId, data, userId) // CORRECT * * Flow: User Submit → Moderation Queue → Approval → Database Write * * @param entityType - Type of entity (park, ride, company) * @param entityId - ID of the entity * @param data - The timeline event form data * @param userId - The ID of the user submitting * @returns Object containing submitted boolean and submissionId */ export async function submitTimelineEvent( entityType: EntityType, entityId: string, data: TimelineEventFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Validate user if (!userId) { throw new Error('User ID is required for timeline event submission'); } // Create submission content (minimal reference data only) const content: Json = { action: 'create', entity_type: entityType, entity_id: entityId, }; // Create the main submission record // Use atomic RPC function to create submission + items in transaction const itemData: Record = { entity_type: entityType, entity_id: entityId, 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, }; const items = [{ item_type: 'timeline_event', action_type: 'create', item_data: itemData, order_index: 0, }]; const { data: submissionId, error } = await supabase .rpc('create_submission_with_items', { p_user_id: userId, p_submission_type: 'timeline_event', p_content: content, p_items: items as unknown as Json[], }); if (error || !submissionId) { const errorMessage = getErrorMessage(error); logger.error('Timeline event submission failed', { action: 'create_timeline_event', userId, error: errorMessage }); throw new Error('Failed to submit timeline event for review'); } return { submitted: true, submissionId: submissionId, }; } /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * * Submits an update to an existing timeline event through the moderation queue. * * @param eventId - ID of the existing timeline event * @param data - The updated timeline event data * @param userId - The ID of the user submitting the update * @returns Object containing submitted boolean and submissionId */ export async function submitTimelineEventUpdate( eventId: string, data: TimelineEventFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Fetch original event const { data: originalEvent, error: fetchError } = await supabase .from('entity_timeline_events') .select('*') .eq('id', eventId) .single(); if (fetchError || !originalEvent) { throw new Error('Failed to fetch original timeline event'); } // Extract only changed fields from form data const changedFields = extractChangedFields(data, originalEvent as Partial>); const itemData: Record = { ...changedFields, // Always include entity reference (for FK integrity) entity_type: originalEvent.entity_type, entity_id: originalEvent.entity_id, is_public: true, }; // Use atomic RPC function to create submission and item together const { data: result, error: rpcError } = await supabase.rpc( 'create_submission_with_items', { p_user_id: userId, p_submission_type: 'timeline_event', p_content: { action: 'edit', event_id: eventId, entity_type: originalEvent.entity_type, } as unknown as Json, p_items: [ { item_type: 'timeline_event', action_type: 'edit', item_data: itemData, original_data: originalEvent, status: 'pending' as const, order_index: 0, } ] as unknown as Json[], } ); if (rpcError || !result) { const errorMessage = getErrorMessage(rpcError); logger.error('Timeline event update failed', { action: 'update_timeline_event', eventId, error: errorMessage }); throw new Error('Failed to submit timeline event update'); } return { submitted: true, submissionId: result, }; } export async function deleteTimelineEvent( eventId: string, userId: string ): Promise { // First verify the event exists and user has permission const { data: event, error: fetchError } = await supabase .from('entity_timeline_events') .select('created_by, approved_by') .eq('id', eventId) .single(); if (fetchError) { const errorMessage = getErrorMessage(fetchError); logger.error('Timeline event fetch failed', { action: 'delete_timeline_event', eventId, error: errorMessage }); throw new Error('Timeline event not found'); } if (!event) { throw new Error('Timeline event not found'); } // Only allow deletion of own unapproved events if (event.created_by !== userId) { throw new Error('You can only delete your own timeline events'); } if (event.approved_by !== null) { throw new Error('Cannot delete approved timeline events'); } // Delete the event const { error: deleteError } = await supabase .from('entity_timeline_events') .delete() .eq('id', eventId); if (deleteError) { const errorMessage = getErrorMessage(deleteError); logger.error('Timeline event deletion failed', { action: 'delete_timeline_event', eventId, error: errorMessage }); throw new Error('Failed to delete timeline event'); } logger.info('Timeline event deleted', { action: 'delete_timeline_event', eventId, userId }); }