import { supabase } from '@/lib/supabaseClient'; 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 { handleError } from './errorHandler'; import type { TimelineEventFormData, EntityType } from '@/types/timeline'; import { breadcrumb } from './errorBreadcrumbs'; import { isRetryableError } from './retryHelpers'; import { validateParkCreateFields, validateRideCreateFields, validateCompanyCreateFields, validateRideModelCreateFields, assertValid } from './submissionValidation'; // ============================================ // 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; street_address?: 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 }> { try { breadcrumb.userAction('Start composite submission', 'submitCompositeCreation', { primaryType: primaryEntity.type, dependencyCount: dependencies.length, userId }); // Check if user is banned breadcrumb.apiCall('profiles', 'SELECT'); try { const { data: profile, error } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (error) { throw new Error(`Failed to check user status: ${error.message}`); } if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } } catch (error) { throw error instanceof Error ? error : new Error(`User check failed: ${String(error)}`); } // Upload all pending images for all entities breadcrumb.userAction('Upload images', 'submitCompositeCreation', { totalImages: dependencies.reduce((sum, dep) => sum + (dep.data.images?.uploaded?.length || 0), 0) + (primaryEntity.data.images?.uploaded?.length || 0) }); const uploadedEntities = await Promise.all([ ...dependencies.map(async (dep, index) => { try { 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; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); throw new Error( `Failed to upload images for ${dep.type} "${dep.data.name || 'unnamed'}": ${errorMsg}` ); } }), (async () => { try { 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; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); throw new Error( `Failed to upload images for ${primaryEntity.type} "${primaryEntity.data.name || 'unnamed'}": ${errorMsg}` ); } })() ]); const uploadedDependencies = uploadedEntities.slice(0, -1) as CompositeSubmissionDependency[]; const uploadedPrimary = uploadedEntities[uploadedEntities.length - 1] as typeof primaryEntity; // Validate dependencies structure breadcrumb.stateChange('Validating dependencies', { dependencyCount: uploadedDependencies.length }); for (const dep of uploadedDependencies) { if (!dep.type) throw new Error('Dependency missing type'); if (!dep.tempId) throw new Error('Dependency missing tempId'); if (!dep.data) throw new Error('Dependency missing data'); if (dep.type === 'company' && !dep.companyType) { throw new Error(`Company dependency "${dep.data.name || 'unnamed'}" missing companyType`); } } // 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); // ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data const { images, ...dataWithoutImages } = dep.data; const itemData: any = { ...dataWithoutImages, images: 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 // ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data const { images: primaryImages, ...primaryDataWithoutImages } = uploadedPrimary.data; const primaryData: any = { ...primaryDataWithoutImages, images: primaryImages as unknown as Json }; // Convert location object to temp_location_data for parks if (uploadedPrimary.type === 'park' && uploadedPrimary.data.location) { primaryData.temp_location_data = { name: uploadedPrimary.data.location.name, street_address: uploadedPrimary.data.location.street_address || null, city: uploadedPrimary.data.location.city || null, state_province: uploadedPrimary.data.location.state_province || null, country: uploadedPrimary.data.location.country, latitude: uploadedPrimary.data.location.latitude, longitude: uploadedPrimary.data.location.longitude, timezone: uploadedPrimary.data.location.timezone || null, postal_code: uploadedPrimary.data.location.postal_code || null, display_name: uploadedPrimary.data.location.display_name }; delete primaryData.location; // Remove the original location object console.log('[submitCompositeCreation] Converted location to temp_location_data:', primaryData.temp_location_data); } // 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 }); // Pre-validation to catch issues early with actionable error messages if (uploadedPrimary.type === 'park') { if (!primaryData.name) throw new Error('Park name is required'); if (!primaryData.slug) throw new Error('Park slug is required'); if (!primaryData.park_type) throw new Error('Park type is required'); if (!primaryData.status) throw new Error('Park status is required'); } else if (uploadedPrimary.type === 'ride') { if (!primaryData.name) throw new Error('Ride name is required'); if (!primaryData.slug) throw new Error('Ride slug is required'); if (!primaryData.status) throw new Error('Ride status is required'); } // Validate dependencies for (const dep of uploadedDependencies) { if (dep.type === 'company') { if (!dep.data.name) throw new Error(`${dep.companyType || 'Company'} name is required`); if (!dep.data.slug) throw new Error(`${dep.companyType || 'Company'} slug is required`); if (!dep.data.company_type && !dep.companyType) { throw new Error('Company type is required'); } } } // Use RPC to create submission with items atomically with retry logic breadcrumb.apiCall('create_submission_with_items', 'RPC'); const { withRetry } = await import('./retryHelpers'); const { toast } = await import('@/hooks/use-toast'); const result = await withRetry( async () => { const { data, 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) { // Extract Supabase error details for better error logging const supabaseError = error as { message?: string; code?: string; details?: string; hint?: string }; const errorMessage = supabaseError.message || 'Unknown error'; const errorCode = supabaseError.code; const errorDetails = supabaseError.details; const errorHint = supabaseError.hint; // Create proper Error instance with enhanced context const enhancedError = new Error( `Composite submission failed: ${errorMessage}${errorDetails ? `\nDetails: ${errorDetails}` : ''}${errorHint ? `\nHint: ${errorHint}` : ''}` ); // Attach Supabase metadata for retry logic (enhancedError as any).supabaseCode = errorCode; (enhancedError as any).supabaseDetails = errorDetails; (enhancedError as any).supabaseHint = errorHint; throw enhancedError; } return data; }, { maxAttempts: 3, baseDelay: 1000, maxDelay: 10000, onRetry: (attempt, error, delay) => { logger.warn('Retrying composite submission', { attempt, maxAttempts: 3, delay, error: error instanceof Error ? error.message : String(error), primaryType: uploadedPrimary.type, dependencyCount: dependencies.length }); // Show user feedback toast({ title: 'Submission retry', description: `Attempt ${attempt}/3 - Retrying in ${Math.round(delay / 1000)}s...`, }); }, shouldRetry: (error) => { // Don't retry validation errors if (error instanceof Error) { const message = error.message.toLowerCase(); if (message.includes('required')) return false; if (message.includes('banned')) return false; if (message.includes('suspended')) return false; if (message.includes('slug')) return false; if (message.includes('already exists')) return false; if (message.includes('duplicate')) return false; if (message.includes('permission')) return false; if (message.includes('forbidden')) return false; if (message.includes('unauthorized')) return false; } // Use default retryable error detection from retryHelpers return isRetryableError(error); } } ).catch((error) => { // Final failure - log and throw handleError(error, { action: 'Composite submission', metadata: { primaryType: uploadedPrimary.type, dependencyCount: dependencies.length, supabaseCode: (error as any).supabaseCode, supabaseDetails: (error as any).supabaseDetails, supabaseHint: (error as any).supabaseHint, retriesExhausted: true }, }); throw error; }); breadcrumb.stateChange('Composite submission successful', { submissionId: result }); return { submitted: true, submissionId: result }; } catch (error) { // Ensure error is always an Error instance with context const enrichedError = error instanceof Error ? error : new Error(`Composite submission failed: ${String(error)}`); // Attach metadata for better debugging (enrichedError as any).originalError = error; (enrichedError as any).primaryType = primaryEntity?.type; (enrichedError as any).dependencyCount = dependencies?.length; throw enrichedError; } } /** * ⚠️ 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 }> { console.info('[submitParkCreation] Received data:', { hasLocation: !!data.location, hasLocationId: !!data.location_id, locationData: data.location, parkName: data.name, hasComposite: !!data._compositeSubmission }); // Validate required fields client-side assertValid(validateParkCreateFields(data)); // 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) { // Copy location data into composite park data const parkData = { ...data._compositeSubmission.park, location: data.location, location_id: data.location_id }; return submitCompositeCreation( { type: 'park', data: parkData }, dependencies, userId ); } } // Standard single-entity creation with retry logic const { withRetry } = await import('./retryHelpers'); // Check if user is banned (with quick retry for read operation) const profile = await withRetry( async () => { const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); return profile; }, { maxAttempts: 2 } ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Upload any pending local images first (no retry - handled internally) 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) { handleError(error, { action: 'Upload park images', }); throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Create submission with retry logic const result = await withRetry( async () => { // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'park', status: 'pending' as const }) .select('id') .single(); if (submissionError) throw submissionError; // Get image URLs/IDs from processed images using assignments const uploadedImages = processedImages?.uploaded || []; const bannerIndex = processedImages?.banner_assignment; const cardIndex = processedImages?.card_assignment; const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null; const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null; // Insert into relational park_submissions table const tempLocationData = data.location ? { name: data.location.name, street_address: data.location.street_address || null, city: data.location.city || null, state_province: data.location.state_province || null, country: data.location.country, latitude: data.location.latitude, longitude: data.location.longitude, timezone: data.location.timezone || null, postal_code: data.location.postal_code || null, display_name: data.location.display_name } : null; console.info('[submitParkCreation] Saving to park_submissions:', { name: data.name, hasLocation: !!data.location, hasLocationId: !!data.location_id, temp_location_data: tempLocationData }); const { data: parkSubmission, error: parkSubmissionError } = await supabase .from('park_submissions' as any) .insert({ submission_id: submissionData.id, name: data.name, slug: data.slug, description: data.description || null, park_type: data.park_type, status: data.status, opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null, closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null, website_url: data.website_url || null, phone: data.phone || null, email: data.email || null, operator_id: data.operator_id || null, property_owner_id: data.property_owner_id || null, location_id: data.location_id || null, temp_location_data: tempLocationData, banner_image_url: bannerImage?.url || data.banner_image_url || null, banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null, card_image_url: cardImage?.url || data.card_image_url || null, card_image_id: cardImage?.cloudflare_id || data.card_image_id || null } as any) .select('id') .single(); if (parkSubmissionError) throw parkSubmissionError; // Create submission_items record linking to park_submissions const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'park', action_type: 'create', park_submission_id: (parkSubmission as any).id, status: 'pending' as const, order_index: 0 } as any); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; }, { maxAttempts: 3, onRetry: (attempt, error, delay) => { logger.warn('Retrying park submission', { attempt, delay }); // Emit event for UI indicator window.dispatchEvent(new CustomEvent('submission-retry', { detail: { attempt, maxAttempts: 3, delay, type: 'park' } })); }, shouldRetry: (error) => { // Don't retry validation/business logic errors if (error instanceof Error) { const message = error.message.toLowerCase(); if (message.includes('required')) return false; if (message.includes('banned')) return false; if (message.includes('slug')) return false; if (message.includes('permission')) return false; } return isRetryableError(error); } } ).catch((error) => { handleError(error, { action: 'Park submission', metadata: { retriesExhausted: true }, }); throw error; }); return result; } /** * ⚠️ 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 }> { const { withRetry, isRetryableError } = await import('./retryHelpers'); // Check if user is banned - with retry for transient failures const profile = await withRetry( async () => { const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); return profile; }, { maxAttempts: 2 } ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Fetch existing park data first - with retry for transient failures const existingPark = await withRetry( async () => { 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'); return existingPark; }, { maxAttempts: 2 } ); // 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; // Main submission logic with retry and error handling const retryId = crypto.randomUUID(); const result = await withRetry( async () => { // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'park', 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 }; }, { maxAttempts: 3, onRetry: (attempt, error, delay) => { logger.warn('Retrying park update submission', { attempt, delay, parkId, error: error instanceof Error ? error.message : String(error) }); // Emit event for UI retry indicator window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'park update' } })); }, shouldRetry: (error) => { // Don't retry validation/business logic errors if (error instanceof Error) { const message = error.message.toLowerCase(); if (message.includes('required')) return false; if (message.includes('banned')) return false; if (message.includes('slug')) return false; if (message.includes('permission')) return false; if (message.includes('not found')) return false; if (message.includes('not allowed')) return false; } return isRetryableError(error); } } ).then((data) => { // Emit success event window.dispatchEvent(new CustomEvent('submission-retry-success', { detail: { id: retryId } })); return data; }).catch((error) => { const errorId = handleError(error, { action: 'Park update submission', userId, metadata: { retriesExhausted: true, parkId }, }); // Emit failure event window.dispatchEvent(new CustomEvent('submission-retry-failed', { detail: { id: retryId, errorId } })); throw error; }); return result; } /** * ⚠️ 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 }> { // Validate required fields client-side assertValid(validateRideCreateFields(data)); // 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 with retry logic const { withRetry } = await import('./retryHelpers'); // Check if user is banned (with quick retry for read operation) const profile = await withRetry( async () => { const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); return profile; }, { maxAttempts: 2 } ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Upload any pending local images first (no retry - handled internally) 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) { handleError(error, { action: 'Upload ride images', }); throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Create submission with retry logic const retryId = crypto.randomUUID(); const result = await withRetry( async () => { // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'ride', status: 'pending' as const }) .select('id') .single(); if (submissionError) throw submissionError; // Get image URLs/IDs from processed images using assignments const uploadedImages = processedImages?.uploaded || []; const bannerIndex = processedImages?.banner_assignment; const cardIndex = processedImages?.card_assignment; const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null; const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null; // Insert into relational ride_submissions table const { data: rideSubmission, error: rideSubmissionError } = await supabase .from('ride_submissions' as any) .insert({ submission_id: submissionData.id, park_id: data.park_id || null, name: data.name, slug: data.slug, description: data.description || null, category: data.category, ride_sub_type: data.ride_sub_type || null, status: data.status, opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null, closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null, manufacturer_id: data.manufacturer_id || null, designer_id: data.designer_id || null, ride_model_id: data.ride_model_id || null, height_requirement: data.height_requirement || null, age_requirement: data.age_requirement || null, capacity_per_hour: data.capacity_per_hour || null, duration_seconds: data.duration_seconds || null, max_speed_kmh: data.max_speed_kmh || null, max_height_meters: data.max_height_meters || null, length_meters: data.length_meters || null, drop_height_meters: data.drop_height_meters || null, inversions: data.inversions || 0, max_g_force: data.max_g_force || null, coaster_type: data.coaster_type || null, seating_type: data.seating_type || null, intensity_level: data.intensity_level || null, banner_image_url: bannerImage?.url || data.banner_image_url || null, banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null, card_image_url: cardImage?.url || data.card_image_url || null, card_image_id: cardImage?.cloudflare_id || data.card_image_id || null, image_url: null } as any) .select('id') .single(); if (rideSubmissionError) throw rideSubmissionError; // Create submission_items record linking to ride_submissions const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'ride', action_type: 'create', ride_submission_id: (rideSubmission as any).id, status: 'pending' as const, order_index: 0 } as any); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; }, { maxAttempts: 3, onRetry: (attempt, error, delay) => { logger.warn('Retrying ride submission', { attempt, delay }); // Emit event for UI indicator window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride' } })); }, shouldRetry: (error) => { // Don't retry validation/business logic errors if (error instanceof Error) { const message = error.message.toLowerCase(); if (message.includes('required')) return false; if (message.includes('banned')) return false; if (message.includes('slug')) return false; if (message.includes('permission')) return false; } return isRetryableError(error); } } ).then((data) => { // Emit success event window.dispatchEvent(new CustomEvent('submission-retry-success', { detail: { id: retryId } })); return data; }).catch((error) => { const errorId = handleError(error, { action: 'Ride submission', metadata: { retriesExhausted: true }, }); // Emit failure event window.dispatchEvent(new CustomEvent('submission-retry-failed', { detail: { id: retryId, errorId } })); throw error; }); return result; } /** * ⚠️ 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 }> { const { withRetry, isRetryableError } = await import('./retryHelpers'); // Check if user is banned - with retry for transient failures const profile = await withRetry( async () => { const { data: profile } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); return profile; }, { maxAttempts: 2 } ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } // Fetch existing ride data first - with retry for transient failures const existingRide = await withRetry( async () => { 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'); return existingRide; }, { maxAttempts: 2 } ); // 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; // Main submission logic with retry and error handling const retryId = crypto.randomUUID(); const result = await withRetry( async () => { // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'ride', 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 }; }, { maxAttempts: 3, onRetry: (attempt, error, delay) => { logger.warn('Retrying ride update submission', { attempt, delay, rideId, error: error instanceof Error ? error.message : String(error) }); // Emit event for UI retry indicator window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride update' } })); }, shouldRetry: (error) => { // Don't retry validation/business logic errors if (error instanceof Error) { const message = error.message.toLowerCase(); if (message.includes('required')) return false; if (message.includes('banned')) return false; if (message.includes('slug')) return false; if (message.includes('permission')) return false; if (message.includes('not found')) return false; if (message.includes('not allowed')) return false; } return isRetryableError(error); } } ).then((data) => { // Emit success event window.dispatchEvent(new CustomEvent('submission-retry-success', { detail: { id: retryId } })); return data; }).catch((error) => { const errorId = handleError(error, { action: 'Ride update submission', userId, metadata: { retriesExhausted: true, rideId }, }); // Emit failure event window.dispatchEvent(new CustomEvent('submission-retry-failed', { detail: { id: retryId, errorId } })); throw error; }); return result; } /** * ⚠️ 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 }> { // Validate required fields client-side assertValid(validateRideModelCreateFields(data)); // 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) { handleError(error, { action: 'Upload ride model images', }); throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Create the main submission record const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'ride_model', 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: { // ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data ...(() => { const { images, ...dataWithoutImages } = data; return dataWithoutImages; })(), 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', 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 }> { // Validate required fields client-side assertValid(validateCompanyCreateFields({ ...data, company_type: 'manufacturer' })); 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) { handleError(error, { action: 'Upload manufacturer images', }); throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`); } } const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'manufacturer', 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: { // ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data ...(() => { const { images, ...dataWithoutImages } = data; return dataWithoutImages; })(), 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', 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 }> { // Validate required fields client-side assertValid(validateCompanyCreateFields({ ...data, company_type: 'designer' })); 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) { handleError(error, { action: 'Upload designer images', }); throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`); } } const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'designer', 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: { // ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data ...(() => { const { images, ...dataWithoutImages } = data; return dataWithoutImages; })(), 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', 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 }> { // Validate required fields client-side assertValid(validateCompanyCreateFields({ ...data, company_type: 'operator' })); 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) { handleError(error, { action: 'Upload operator images', }); throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`); } } const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'operator', 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: { // ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data ...(() => { const { images, ...dataWithoutImages } = data; return dataWithoutImages; })(), 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', 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 }> { // Validate required fields client-side assertValid(validateCompanyCreateFields({ ...data, company_type: 'property_owner' })); 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) { handleError(error, { action: 'Upload property owner images', }); throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`); } } const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'property_owner', 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: { // ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data ...(() => { const { images, ...dataWithoutImages } = data; return dataWithoutImages; })(), 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', 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) { handleError(error || new Error('No submission ID returned'), { action: 'Submit timeline event', userId, }); 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) { handleError(rpcError || new Error('No result returned'), { action: 'Update timeline event', metadata: { eventId }, }); 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) { handleError(fetchError, { action: 'Delete timeline event', metadata: { eventId }, }); 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) { handleError(deleteError, { action: 'Delete timeline event', metadata: { eventId }, }); throw new Error('Failed to delete timeline event'); } logger.info('Timeline event deleted', { action: 'delete_timeline_event', eventId, userId }); }