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, isRateLimitError, extractRetryAfter } from './retryHelpers'; import { validateParkCreateFields, validateRideCreateFields, validateCompanyCreateFields, validateRideModelCreateFields, assertValid } from './submissionValidation'; import { checkSubmissionRateLimit, recordSubmissionAttempt } from './submissionRateLimiter'; import { sanitizeErrorMessage } from './errorSanitizer'; import { reportRateLimitViolation, reportBanEvasionAttempt } from './pipelineAlerts'; // ============================================ // 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; opening_date_precision?: string; closing_date?: string; closing_date_precision?: 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; opening_date_precision?: string; closing_date?: string; closing_date_precision?: 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; } interface TechnicalSpecification { spec_name: string; spec_value: string; spec_type?: 'string' | 'number' | 'boolean' | 'date'; category?: string; unit?: string; display_order?: number; } 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; _technical_specifications?: TechnicalSpecification[]; } /** * ═══════════════════════════════════════════════════════════════════ * RATE LIMITING HELPER * ═══════════════════════════════════════════════════════════════════ * * Checks rate limits before allowing submission creation * Part of Sacred Pipeline Phase 3: Enhanced Error Handling */ function checkRateLimitOrThrow(userId: string, action: string): void { const rateLimit = checkSubmissionRateLimit(userId); if (!rateLimit.allowed) { const sanitizedMessage = sanitizeErrorMessage(rateLimit.reason || 'Rate limit exceeded'); logger.warn('[RateLimit] Submission blocked', { userId, action, reason: rateLimit.reason, retryAfter: rateLimit.retryAfter, }); // Report to system alerts for admin visibility reportRateLimitViolation(userId, action, rateLimit.retryAfter || 60).catch(() => { // Non-blocking - don't fail submission if alert fails }); throw new Error(sanitizedMessage); } logger.info('[RateLimit] Submission allowed', { userId, action, remaining: rateLimit.remaining, }); } /** * ═══════════════════════════════════════════════════════════════════ * 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 }> { const { withRetry } = await import('./retryHelpers'); try { // Phase 3: Rate limiting check checkRateLimitOrThrow(userId, 'composite_creation'); recordSubmissionAttempt(userId); breadcrumb.userAction('Start composite submission', 'submitCompositeCreation', { primaryType: primaryEntity.type, dependencyCount: dependencies.length, userId }); // Check if user is banned with retry logic breadcrumb.apiCall('profiles', 'SELECT'); const profile = await withRetry( async () => { const { data, error } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (error) throw error; return data; }, { maxAttempts: 2 } ); if (profile?.banned) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'composite_creation').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } // 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 }; // Store location reference for park submissions (will be created in relational table) if (uploadedPrimary.type === 'park' && uploadedPrimary.data.location) { primaryData._temp_location = uploadedPrimary.data.location; delete primaryData.location; // Remove the original location object console.log('[submitCompositeCreation] Stored location for relational insert:', primaryData._temp_location); } // 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; } } // CRITICAL: Validate all temp refs were properly resolved const validateTempRefs = () => { const errors: string[] = []; if (uploadedPrimary.type === 'park') { if ('_temp_operator_ref' in primaryData && primaryData._temp_operator_ref === undefined) { errors.push('Invalid operator reference - dependency not found'); } if ('_temp_property_owner_ref' in primaryData && primaryData._temp_property_owner_ref === undefined) { errors.push('Invalid property owner reference - dependency not found'); } } else if (uploadedPrimary.type === 'ride') { if ('_temp_park_ref' in primaryData && primaryData._temp_park_ref === undefined) { errors.push('Invalid park reference - dependency not found'); } if ('_temp_manufacturer_ref' in primaryData && primaryData._temp_manufacturer_ref === undefined) { errors.push('Invalid manufacturer reference - dependency not found'); } if ('_temp_designer_ref' in primaryData && primaryData._temp_designer_ref === undefined) { errors.push('Invalid designer reference - dependency not found'); } if ('_temp_ride_model_ref' in primaryData && primaryData._temp_ride_model_ref === undefined) { errors.push('Invalid ride model reference - dependency not found'); } } if (errors.length > 0) { // Report to system alerts (non-blocking) import('./pipelineAlerts').then(async ({ reportTempRefError }) => { try { const { data: { user } } = await supabase.auth.getUser(); if (user) { await reportTempRefError(uploadedPrimary.type, errors, user.id); } } catch (e) { console.warn('Failed to report temp ref error:', e); } }); throw new Error(`Temp reference validation failed: ${errors.join(', ')}`); } }; validateTempRefs(); 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 { 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 }> { // Phase 3: Rate limiting check checkRateLimitOrThrow(userId, 'park_creation'); recordSubmissionAttempt(userId); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'park_creation').catch(() => { // Non-blocking - don't fail if alert fails }); 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 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; // 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, hasLocationData: !!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, 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 location in relational table if provided if (tempLocationData) { const { error: locationError } = await supabase .from('park_submission_locations' as any) .insert({ park_submission_id: (parkSubmission as any).id, ...tempLocationData } as any); if (locationError) { console.error('[submitParkCreation] Failed to create location:', locationError); throw new Error(`Failed to save location data: ${locationError.message}`); } console.info('[submitParkCreation] Created park_submission_location', { parkSubmissionId: (parkSubmission as any).id, locationName: tempLocationData.name }); } // 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, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying park submission', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); // Emit event for UI indicator with rate limit info window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'park', isRateLimit, retryAfter } })); }, 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('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; } 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 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 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 }> { // Phase 3: Rate limiting check checkRateLimitOrThrow(userId, 'park_update'); recordSubmissionAttempt(userId); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'park_update').catch(() => { // Non-blocking - don't fail if alert fails }); 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; // Extract changed fields const changedFields = extractChangedFields(data, existingPark as any); // Handle location data properly let tempLocationData: any = null; if (data.location) { tempLocationData = { 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 }; } // ✅ FIXED: Insert into park_submissions table (relational pattern) const { data: parkSubmission, error: parkSubmissionError } = await supabase .from('park_submissions') .insert({ submission_id: submissionData.id, name: changedFields.name ?? existingPark.name, slug: changedFields.slug ?? existingPark.slug, description: changedFields.description !== undefined ? changedFields.description : existingPark.description, park_type: changedFields.park_type ?? existingPark.park_type, status: changedFields.status ?? existingPark.status, opening_date: changedFields.opening_date !== undefined ? changedFields.opening_date : existingPark.opening_date, opening_date_precision: changedFields.opening_date_precision !== undefined ? changedFields.opening_date_precision : existingPark.opening_date_precision, closing_date: changedFields.closing_date !== undefined ? changedFields.closing_date : existingPark.closing_date, closing_date_precision: changedFields.closing_date_precision !== undefined ? changedFields.closing_date_precision : existingPark.closing_date_precision, website_url: changedFields.website_url !== undefined ? changedFields.website_url : existingPark.website_url, phone: changedFields.phone !== undefined ? changedFields.phone : existingPark.phone, email: changedFields.email !== undefined ? changedFields.email : existingPark.email, operator_id: changedFields.operator_id !== undefined ? changedFields.operator_id : existingPark.operator_id, property_owner_id: changedFields.property_owner_id !== undefined ? changedFields.property_owner_id : existingPark.property_owner_id, location_id: changedFields.location_id !== undefined ? changedFields.location_id : existingPark.location_id, banner_image_url: changedFields.banner_image_url !== undefined ? changedFields.banner_image_url : existingPark.banner_image_url, banner_image_id: changedFields.banner_image_id !== undefined ? changedFields.banner_image_id : existingPark.banner_image_id, card_image_url: changedFields.card_image_url !== undefined ? changedFields.card_image_url : existingPark.card_image_url, card_image_id: changedFields.card_image_id !== undefined ? changedFields.card_image_id : existingPark.card_image_id, }) .select('id') .single(); if (parkSubmissionError) throw parkSubmissionError; // Create location in relational table if provided if (tempLocationData) { const { error: locationError } = await supabase .from('park_submission_locations' as any) .insert({ park_submission_id: (parkSubmission as any).id, ...tempLocationData } as any); if (locationError) { console.error('[submitParkEdit] Failed to create location:', locationError); throw new Error(`Failed to save location data: ${locationError.message}`); } console.info('[submitParkEdit] Created park_submission_location', { parkSubmissionId: (parkSubmission as any).id, locationName: tempLocationData.name }); } // ✅ Create submission_items referencing park_submission (no JSON data) const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'park', action_type: 'edit', item_data: { park_id: parkId, // Only reference IDs images: processedImages as unknown as Json }, original_data: JSON.parse(JSON.stringify(existingPark)), status: 'pending' as const, order_index: 0, park_submission_id: parkSubmission.id }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying park update submission', { attempt, delay, parkId, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); // Emit event for UI retry indicator with rate limit info window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'park update', isRateLimit, retryAfter } })); }, 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 }> { // Phase 3: Rate limiting check checkRateLimitOrThrow(userId, 'ride_creation'); recordSubmissionAttempt(userId); // 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'ride_creation').catch(() => { // Non-blocking - don't fail if alert fails }); 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, // Category-specific fields track_material: (data as any).track_material || null, support_material: (data as any).support_material || null, propulsion_method: (data as any).propulsion_method || null, water_depth_cm: (data as any).water_depth_cm || null, splash_height_meters: (data as any).splash_height_meters || null, wetness_level: (data as any).wetness_level || null, flume_type: (data as any).flume_type || null, boat_capacity: (data as any).boat_capacity || null, theme_name: (data as any).theme_name || null, story_description: (data as any).story_description || null, show_duration_seconds: (data as any).show_duration_seconds || null, animatronics_count: (data as any).animatronics_count || null, projection_type: (data as any).projection_type || null, ride_system: (data as any).ride_system || null, scenes_count: (data as any).scenes_count || null, rotation_type: (data as any).rotation_type || null, motion_pattern: (data as any).motion_pattern || null, platform_count: (data as any).platform_count || null, swing_angle_degrees: (data as any).swing_angle_degrees || null, rotation_speed_rpm: (data as any).rotation_speed_rpm || null, arm_length_meters: (data as any).arm_length_meters || null, max_height_reached_meters: (data as any).max_height_reached_meters || null, min_age: (data as any).min_age || null, max_age: (data as any).max_age || null, educational_theme: (data as any).educational_theme || null, character_theme: (data as any).character_theme || null, transport_type: (data as any).transport_type || null, route_length_meters: (data as any).route_length_meters || null, stations_count: (data as any).stations_count || null, vehicle_capacity: (data as any).vehicle_capacity || null, vehicles_count: (data as any).vehicles_count || null, round_trip_duration_seconds: (data as any).round_trip_duration_seconds || null } as any) .select('id') .single(); if (rideSubmissionError) throw rideSubmissionError; // Insert technical specifications if present if ((data as any)._technical_specifications?.length > 0) { const { error: techSpecError } = await supabase .from('ride_technical_specs' as any) .insert( (data as any)._technical_specifications.map((spec: any) => ({ ride_submission_id: (rideSubmission as any).id, spec_name: spec.spec_name, spec_value: spec.spec_value, spec_type: spec.spec_type, category: spec.category || null, unit: spec.unit || null })) ); if (techSpecError) { logger.error('Failed to insert technical specs', { error: techSpecError }); throw techSpecError; } } // Insert coaster statistics if present if ((data as any)._coaster_statistics?.length > 0) { const { error: statsError } = await supabase .from('ride_coaster_stats' as any) .insert( (data as any)._coaster_statistics.map((stat: any) => ({ ride_submission_id: (rideSubmission as any).id, stat_name: stat.stat_name, stat_value: stat.stat_value, unit: stat.unit || null, category: stat.category || null })) ); if (statsError) { logger.error('Failed to insert coaster stats', { error: statsError }); throw statsError; } } // Insert name history if present if ((data as any)._name_history?.length > 0) { const { error: historyError } = await supabase .from('ride_name_history_submissions' as any) .insert( (data as any)._name_history.map((name: any) => ({ ride_submission_id: (rideSubmission as any).id, former_name: name.former_name, date_changed: name.date_changed ? new Date(name.date_changed).toISOString().split('T')[0] : null, reason: name.reason || null, from_year: name.from_year || null, to_year: name.to_year || null, order_index: name.order_index || 0 })) ); if (historyError) { logger.error('Failed to insert name history', { error: historyError }); throw historyError; } } // 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, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying ride submission', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); // Emit event for UI indicator with rate limit info window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride', isRateLimit, retryAfter } })); }, 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('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; } 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 }> { // Phase 3: Rate limiting check checkRateLimitOrThrow(userId, 'ride_update'); recordSubmissionAttempt(userId); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'ride_update').catch(() => { // Non-blocking - don't fail if alert fails }); 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; // Extract changed fields const changedFields = extractChangedFields(data, existingRide as any); // ✅ FIXED: Insert into ride_submissions table (relational pattern) const { data: rideSubmission, error: rideSubmissionError } = await supabase .from('ride_submissions') .insert({ submission_id: submissionData.id, name: changedFields.name ?? existingRide.name, slug: changedFields.slug ?? existingRide.slug, description: changedFields.description !== undefined ? changedFields.description : existingRide.description, category: changedFields.category ?? existingRide.category, status: changedFields.status ?? existingRide.status, park_id: changedFields.park_id !== undefined ? changedFields.park_id : existingRide.park_id, manufacturer_id: changedFields.manufacturer_id !== undefined ? changedFields.manufacturer_id : existingRide.manufacturer_id, designer_id: changedFields.designer_id !== undefined ? changedFields.designer_id : existingRide.designer_id, ride_model_id: changedFields.ride_model_id !== undefined ? changedFields.ride_model_id : existingRide.ride_model_id, opening_date: changedFields.opening_date !== undefined ? changedFields.opening_date : existingRide.opening_date, opening_date_precision: changedFields.opening_date_precision !== undefined ? changedFields.opening_date_precision : existingRide.opening_date_precision, closing_date: changedFields.closing_date !== undefined ? changedFields.closing_date : existingRide.closing_date, closing_date_precision: changedFields.closing_date_precision !== undefined ? changedFields.closing_date_precision : existingRide.closing_date_precision, max_speed_kmh: changedFields.max_speed_kmh !== undefined ? changedFields.max_speed_kmh : existingRide.max_speed_kmh, max_height_meters: changedFields.max_height_meters !== undefined ? changedFields.max_height_meters : existingRide.max_height_meters, length_meters: changedFields.length_meters !== undefined ? changedFields.length_meters : existingRide.length_meters, duration_seconds: changedFields.duration_seconds !== undefined ? changedFields.duration_seconds : existingRide.duration_seconds, capacity_per_hour: changedFields.capacity_per_hour !== undefined ? changedFields.capacity_per_hour : existingRide.capacity_per_hour, height_requirement: changedFields.height_requirement !== undefined ? changedFields.height_requirement : existingRide.height_requirement, age_requirement: changedFields.age_requirement !== undefined ? changedFields.age_requirement : existingRide.age_requirement, inversions: changedFields.inversions !== undefined ? changedFields.inversions : existingRide.inversions, drop_height_meters: changedFields.drop_height_meters !== undefined ? changedFields.drop_height_meters : existingRide.drop_height_meters, max_g_force: changedFields.max_g_force !== undefined ? changedFields.max_g_force : existingRide.max_g_force, intensity_level: changedFields.intensity_level !== undefined ? changedFields.intensity_level : existingRide.intensity_level, coaster_type: changedFields.coaster_type !== undefined ? changedFields.coaster_type : existingRide.coaster_type, seating_type: changedFields.seating_type !== undefined ? changedFields.seating_type : existingRide.seating_type, ride_sub_type: changedFields.ride_sub_type !== undefined ? changedFields.ride_sub_type : existingRide.ride_sub_type, banner_image_url: changedFields.banner_image_url !== undefined ? changedFields.banner_image_url : existingRide.banner_image_url, banner_image_id: changedFields.banner_image_id !== undefined ? changedFields.banner_image_id : existingRide.banner_image_id, card_image_url: changedFields.card_image_url !== undefined ? changedFields.card_image_url : existingRide.card_image_url, card_image_id: changedFields.card_image_id !== undefined ? changedFields.card_image_id : existingRide.card_image_id, }) .select('id') .single(); if (rideSubmissionError) throw rideSubmissionError; // ✅ Create submission_items referencing ride_submission (no JSON data) const { error: itemError } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'ride', action_type: 'edit', item_data: { ride_id: rideId, // Only reference IDs images: processedImages as unknown as Json }, original_data: JSON.parse(JSON.stringify(existingRide)), status: 'pending' as const, order_index: 0, ride_submission_id: rideSubmission.id }); if (itemError) throw itemError; return { submitted: true, submissionId: submissionData.id }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying ride update submission', { attempt, delay, rideId, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); // Emit event for UI retry indicator with rate limit info window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride update', isRateLimit, retryAfter } })); }, 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('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; 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 }> { // Rate limiting check checkRateLimitOrThrow(userId, 'ride_model_creation'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start ride model submission', 'submitRideModelCreation', { userId }); // Validate required fields client-side assertValid(validateRideModelCreateFields(data)); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'ride_model_creation').catch(() => { // Non-blocking - don't fail if alert fails }); 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'}`); } } // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); 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_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; // Insert into ride_model_submissions table for relational integrity const { data: rideModelSubmissionData, error: rideModelSubmissionError } = await supabase .from('ride_model_submissions') .insert({ submission_id: submissionData.id, name: data.name, slug: data.slug, manufacturer_id: data.manufacturer_id, category: data.category, ride_type: data.ride_type || data.category, description: data.description || null, banner_image_url: data.banner_image_url || null, banner_image_id: data.banner_image_id || null, card_image_url: data.card_image_url || null, card_image_id: data.card_image_id || null }) .select() .single(); if (rideModelSubmissionError) { logger.error('Failed to insert ride model submission', { error: rideModelSubmissionError }); throw rideModelSubmissionError; } // Insert technical specifications into submission table if ((data as any)._technical_specifications?.length > 0) { const { error: techSpecError } = await supabase .from('ride_model_submission_technical_specifications') .insert( (data as any)._technical_specifications.map((spec: any) => ({ ride_model_submission_id: rideModelSubmissionData.id, spec_name: spec.spec_name, spec_value: spec.spec_value, spec_unit: spec.spec_unit || null, category: spec.category || null, display_order: spec.display_order || 0 })) ); if (techSpecError) { logger.error('Failed to insert ride model technical specs', { error: techSpecError }); throw techSpecError; } logger.log('✅ Ride model technical specifications inserted:', (data as any)._technical_specifications.length); } return { submitted: true, submissionId: submissionData.id }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying ride model submission', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride_model', isRateLimit, retryAfter } })); }, shouldRetry: (error) => { 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; } 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 model 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 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 }> { // Rate limiting check checkRateLimitOrThrow(userId, 'ride_model_update'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start ride model update', 'submitRideModelUpdate', { userId, rideModelId }); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'ride_model_update').catch(() => { // Non-blocking - don't fail if alert fails }); 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; // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); 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_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; // Insert into ride_model_submissions table for relational integrity const { data: rideModelSubmissionData, error: rideModelSubmissionError } = await supabase .from('ride_model_submissions') .insert({ submission_id: submissionData.id, name: data.name, slug: data.slug, manufacturer_id: data.manufacturer_id, category: data.category, ride_type: data.ride_type || data.category, description: data.description || null, banner_image_url: data.banner_image_url || null, banner_image_id: data.banner_image_id || null, card_image_url: data.card_image_url || null, card_image_id: data.card_image_id || null }) .select() .single(); if (rideModelSubmissionError) { logger.error('Failed to insert ride model update submission', { error: rideModelSubmissionError }); throw rideModelSubmissionError; } // Insert technical specifications into submission table if ((data as any)._technical_specifications?.length > 0) { const { error: techSpecError } = await supabase .from('ride_model_submission_technical_specifications') .insert( (data as any)._technical_specifications.map((spec: any) => ({ ride_model_submission_id: rideModelSubmissionData.id, spec_name: spec.spec_name, spec_value: spec.spec_value, spec_unit: spec.spec_unit || null, category: spec.category || null, display_order: spec.display_order || 0 })) ); if (techSpecError) { logger.error('Failed to insert ride model update technical specs', { error: techSpecError }); throw techSpecError; } logger.log('✅ Ride model update technical specifications inserted:', (data as any)._technical_specifications.length); } return { submitted: true, submissionId: submissionData.id }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying ride model update', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride_model_update', isRateLimit, retryAfter } })); }, shouldRetry: (error) => { 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; } return isRetryableError(error); } } ).then((data) => { window.dispatchEvent(new CustomEvent('submission-retry-success', { detail: { id: retryId } })); return data; }).catch((error) => { const errorId = handleError(error, { action: 'Ride model update submission', metadata: { retriesExhausted: true }, }); window.dispatchEvent(new CustomEvent('submission-retry-failed', { detail: { id: retryId, errorId } })); throw error; }); return result; } /** * ⚠️ 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 }> { // Rate limiting check checkRateLimitOrThrow(userId, 'manufacturer_creation'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start manufacturer submission', 'submitManufacturerCreation', { userId }); // Validate required fields client-side assertValid(validateCompanyCreateFields({ ...data, company_type: 'manufacturer' })); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'manufacturer_creation').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } // Upload images 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'}`); } } // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); const retryId = crypto.randomUUID(); const result = await withRetry( async () => { 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 }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying manufacturer submission', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'manufacturer', isRateLimit, retryAfter } })); }, shouldRetry: (error) => { 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; } return isRetryableError(error); } } ).then((data) => { window.dispatchEvent(new CustomEvent('submission-retry-success', { detail: { id: retryId } })); return data; }).catch((error) => { const errorId = handleError(error, { action: 'Manufacturer submission', metadata: { retriesExhausted: true }, }); window.dispatchEvent(new CustomEvent('submission-retry-failed', { detail: { id: retryId, errorId } })); throw error; }); return result; } export async function submitManufacturerUpdate( companyId: string, data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Rate limiting check checkRateLimitOrThrow(userId, 'manufacturer_update'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start manufacturer update', 'submitManufacturerUpdate', { userId, companyId }); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'manufacturer_update').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } 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; // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); const retryId = crypto.randomUUID(); const result = await withRetry( async () => { 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 }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying manufacturer update', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'manufacturer_update', isRateLimit, retryAfter } })); }, shouldRetry: (error) => { 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; } return isRetryableError(error); } } ); return result; } export async function submitDesignerCreation( data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Rate limiting check checkRateLimitOrThrow(userId, 'designer_creation'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start designer submission', 'submitDesignerCreation', { userId }); // Validate required fields client-side assertValid(validateCompanyCreateFields({ ...data, company_type: 'designer' })); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'designer_creation').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } // Upload images 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'}`); } } // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); const retryId = crypto.randomUUID(); const result = await withRetry( async () => { 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 }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying designer submission', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'designer', isRateLimit, retryAfter } })); }, shouldRetry: (error) => { 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; } return isRetryableError(error); } } ); return result; } export async function submitDesignerUpdate( companyId: string, data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Rate limiting check checkRateLimitOrThrow(userId, 'designer_update'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start designer update', 'submitDesignerUpdate', { userId, companyId }); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'designer_update').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } 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; // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); const retryId = crypto.randomUUID(); const result = await withRetry( async () => { 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 }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying designer update', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'designer_update', isRateLimit, retryAfter } })); }, shouldRetry: (error) => { 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; } return isRetryableError(error); } } ); return result; } export async function submitOperatorCreation( data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Rate limiting check checkRateLimitOrThrow(userId, 'operator_creation'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start operator submission', 'submitOperatorCreation', { userId }); // Validate required fields client-side assertValid(validateCompanyCreateFields({ ...data, company_type: 'operator' })); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'operator_creation').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } // Upload images 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'}`); } } // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); const retryId = crypto.randomUUID(); const result = await withRetry( async () => { 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 }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { logger.warn('Retrying operator submission', { attempt, delay, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'operator' } })); }, shouldRetry: (error) => { 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; } return isRetryableError(error); } } ).then((data) => { window.dispatchEvent(new CustomEvent('submission-retry-success', { detail: { id: retryId } })); return data; }).catch((error) => { const errorId = handleError(error, { action: 'Operator submission', metadata: { retriesExhausted: true }, }); window.dispatchEvent(new CustomEvent('submission-retry-failed', { detail: { id: retryId, errorId } })); throw error; }); return result; } export async function submitOperatorUpdate( companyId: string, data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Rate limiting check checkRateLimitOrThrow(userId, 'operator_update'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start operator update', 'submitOperatorUpdate', { userId, companyId }); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'operator_update').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } 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; // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); const retryId = crypto.randomUUID(); const result = await withRetry( async () => { 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 }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying operator update', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'operator_update', isRateLimit, retryAfter } })); }, shouldRetry: (error) => { 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; } return isRetryableError(error); } } ); return result; } export async function submitPropertyOwnerCreation( data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Rate limiting check checkRateLimitOrThrow(userId, 'property_owner_creation'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start property owner submission', 'submitPropertyOwnerCreation', { userId }); // Validate required fields client-side assertValid(validateCompanyCreateFields({ ...data, company_type: 'property_owner' })); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'property_owner_creation').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } // Upload images 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'}`); } } // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); const retryId = crypto.randomUUID(); const result = await withRetry( async () => { 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 }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { logger.warn('Retrying property owner submission', { attempt, delay, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'property_owner' } })); }, shouldRetry: (error) => { 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; } return isRetryableError(error); } } ).then((data) => { window.dispatchEvent(new CustomEvent('submission-retry-success', { detail: { id: retryId } })); return data; }).catch((error) => { const errorId = handleError(error, { action: 'Property owner submission', metadata: { retriesExhausted: true }, }); window.dispatchEvent(new CustomEvent('submission-retry-failed', { detail: { id: retryId, errorId } })); throw error; }); return result; } export async function submitPropertyOwnerUpdate( companyId: string, data: CompanyFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { // Rate limiting check checkRateLimitOrThrow(userId, 'property_owner_update'); recordSubmissionAttempt(userId); // Breadcrumb tracking breadcrumb.userAction('Start property owner update', 'submitPropertyOwnerUpdate', { userId, companyId }); // Ban check with retry logic const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); 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) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'property_owner_update').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } 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; // Submit with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); const retryId = crypto.randomUUID(); const result = await withRetry( async () => { 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 }; }, { maxAttempts: 3, baseDelay: 1000, onRetry: (attempt, error, delay) => { const isRateLimit = isRateLimitError(error); const retryAfter = isRateLimit ? extractRetryAfter(error) : null; logger.warn('Retrying property owner update', { attempt, delay, isRateLimit, retryAfter, error: error instanceof Error ? error.message : String(error) }); window.dispatchEvent(new CustomEvent('submission-retry', { detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'property_owner_update', isRateLimit, retryAfter } })); }, shouldRetry: (error) => { 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; } return isRetryableError(error); } } ); return result; } /** * ⚠️ 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 }> { // ✅ Phase 4: Validate user if (!userId) { throw new Error('User ID is required for timeline event submission'); } // ✅ Phase 4: Rate limiting check checkRateLimitOrThrow(userId, 'timeline_event_creation'); recordSubmissionAttempt(userId); // ✅ Phase 4: Validation if (!data.title?.trim()) { throw new Error('Timeline event title is required'); } if (!data.event_date) { throw new Error('Timeline event date is required'); } if (!data.event_type) { throw new Error('Timeline event type is required'); } // ✅ Phase 4: Breadcrumb tracking breadcrumb.userAction('Start timeline event submission', 'submitTimelineEvent', { entityType, entityId, eventType: data.event_type, userId }); // ✅ Phase 4: Ban check with retry breadcrumb.apiCall('profiles', 'SELECT'); const { withRetry } = await import('./retryHelpers'); const profile = await withRetry( async () => { const { data, error } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (error) throw error; return data; }, { maxAttempts: 2 } ); if (profile?.banned) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'timeline_event_creation').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } // ✅ Phase 4: Create submission with retry logic breadcrumb.apiCall('content_submissions', 'INSERT'); const submissionData = await withRetry( async () => { const { data, error } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'timeline_event', status: 'pending' as const }) .select('id') .single(); if (error) throw error; if (!data) throw new Error('Failed to create timeline event submission'); return data; }, { onRetry: (attempt, error, delay) => { logger.warn('Retrying timeline event submission creation', { attempt, delay, userId, eventType: data.event_type }); } } ); // ✅ Phase 4: Insert timeline_event_submission with retry breadcrumb.apiCall('timeline_event_submissions', 'INSERT'); const timelineSubmission = await withRetry( async () => { const { data: insertedData, error } = await supabase .from('timeline_event_submissions') .insert({ submission_id: submissionData.id, 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, }) .select('id') .single(); if (error) throw error; if (!insertedData) throw new Error('Failed to submit timeline event for review'); return insertedData; }, { onRetry: (attempt, error, delay) => { logger.warn('Retrying timeline event data insertion', { attempt, delay, submissionId: submissionData.id }); } } ); // ✅ Phase 4: Create submission_items with retry breadcrumb.apiCall('submission_items', 'INSERT'); await withRetry( async () => { const { error } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'timeline_event', action_type: 'create', item_data: { entity_type: entityType, entity_id: entityId } as Json, status: 'pending' as const, order_index: 0, timeline_event_submission_id: timelineSubmission.id }); if (error) throw error; }, { onRetry: (attempt, error, delay) => { logger.warn('Retrying timeline event submission item creation', { attempt, delay, submissionId: submissionData.id }); } } ); return { submitted: true, submissionId: submissionData.id, }; } /** * ⚠️ 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 }> { // ✅ Phase 4: Validate user if (!userId) { throw new Error('User ID is required for timeline event update'); } // ✅ Phase 4: Rate limiting check checkRateLimitOrThrow(userId, 'timeline_event_update'); recordSubmissionAttempt(userId); // ✅ Phase 4: Validation if (!data.title?.trim()) { throw new Error('Timeline event title is required'); } if (!data.event_date) { throw new Error('Timeline event date is required'); } // ✅ Phase 4: Breadcrumb tracking breadcrumb.userAction('Start timeline event update', 'submitTimelineEventUpdate', { eventId, userId }); // ✅ Phase 4: Ban check with retry const { withRetry } = await import('./retryHelpers'); breadcrumb.apiCall('profiles', 'SELECT'); const profile = await withRetry( async () => { const { data, error } = await supabase .from('profiles') .select('banned') .eq('user_id', userId) .single(); if (error) throw error; return data; }, { maxAttempts: 2 } ); if (profile?.banned) { // Report ban evasion attempt reportBanEvasionAttempt(userId, 'timeline_event_update').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } // Fetch original event with retry breadcrumb.apiCall('entity_timeline_events', 'SELECT'); const originalEvent = await withRetry( async () => { const { data, error } = await supabase .from('entity_timeline_events') .select('*') .eq('id', eventId) .single(); if (error) throw error; if (!data) throw new Error('Failed to fetch original timeline event'); return data; }, { maxAttempts: 2 } ); // Extract only changed fields from form data const changedFields = extractChangedFields(data, originalEvent as Partial>); // ✅ Phase 4: Create submission with retry breadcrumb.apiCall('content_submissions', 'INSERT'); const submissionData = await withRetry( async () => { const { data, error } = await supabase .from('content_submissions') .insert({ user_id: userId, submission_type: 'timeline_event', status: 'pending' as const }) .select('id') .single(); if (error) throw error; if (!data) throw new Error('Failed to create timeline event update submission'); return data; }, { onRetry: (attempt, error, delay) => { logger.warn('Retrying timeline event update submission', { attempt, delay, eventId, userId }); } } ); // ✅ Phase 4: Insert timeline_event_submission with retry breadcrumb.apiCall('timeline_event_submissions', 'INSERT'); const timelineSubmission = await withRetry( async () => { const { data: insertedData, error } = await supabase .from('timeline_event_submissions') .insert({ submission_id: submissionData.id, entity_type: originalEvent.entity_type, entity_id: originalEvent.entity_id, event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type, event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date, event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day', title: changedFields.title !== undefined ? changedFields.title : originalEvent.title, description: changedFields.description !== undefined ? changedFields.description : originalEvent.description, from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value, to_value: changedFields.to_value !== undefined ? changedFields.to_value : originalEvent.to_value, from_entity_id: changedFields.from_entity_id !== undefined ? changedFields.from_entity_id : originalEvent.from_entity_id, to_entity_id: changedFields.to_entity_id !== undefined ? changedFields.to_entity_id : originalEvent.to_entity_id, from_location_id: changedFields.from_location_id !== undefined ? changedFields.from_location_id : originalEvent.from_location_id, to_location_id: changedFields.to_location_id !== undefined ? changedFields.to_location_id : originalEvent.to_location_id, is_public: true, }) .select('id') .single(); if (error) throw error; if (!insertedData) throw new Error('Failed to submit timeline event update'); return insertedData; }, { onRetry: (attempt, error, delay) => { logger.warn('Retrying timeline event update data insertion', { attempt, delay, eventId, submissionId: submissionData.id }); } } ); // ✅ Phase 4: Create submission_items with retry breadcrumb.apiCall('submission_items', 'INSERT'); await withRetry( async () => { const { error } = await supabase .from('submission_items') .insert({ submission_id: submissionData.id, item_type: 'timeline_event', action_type: 'edit', item_data: { event_id: eventId, entity_type: originalEvent.entity_type, entity_id: originalEvent.entity_id } as Json, original_data: JSON.parse(JSON.stringify(originalEvent)), status: 'pending' as const, order_index: 0, timeline_event_submission_id: timelineSubmission.id }); if (error) throw error; }, { onRetry: (attempt, error, delay) => { logger.warn('Retrying timeline event update item creation', { attempt, delay, eventId, submissionId: submissionData.id }); } } ); breadcrumb.userAction('Timeline event update submitted', 'submitTimelineEventUpdate', { eventId, submissionId: submissionData.id }); return { submitted: true, submissionId: submissionData.id, }; } 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 }); }