diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index af545b5a..e381c8a5 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -698,26 +698,41 @@ export async function submitParkUpdate( data: ParkFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { - // Check if user is banned - const { data: profile } = await supabase - .from('profiles') - .select('banned') - .eq('user_id', userId) - .single(); + const { withRetry, isRetryableError } = await import('./retryHelpers'); + + // Check if user is banned - with retry for transient failures + const profile = await withRetry( + async () => { + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + return profile; + }, + { maxAttempts: 2 } + ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } - // Fetch existing park data first - 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(); + // 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'); + 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 @@ -728,43 +743,87 @@ export async function submitParkUpdate( // Only allow banner/card reassignments from existing photos let processedImages = data.images; - // Create the main submission record - const { data: submissionData, error: submissionError } = await supabase - .from('content_submissions') - .insert({ - user_id: userId, - submission_type: 'park', - content: { - action: 'edit', - park_id: parkId + // Main submission logic with retry and error handling + 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', + content: { + action: 'edit', + park_id: parkId + }, + status: 'pending' as const + }) + .select('id') + .single(); + + if (submissionError) throw submissionError; + + // Create the submission item with actual park data AND original data + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submissionData.id, + item_type: 'park', + action_type: 'edit', + item_data: JSON.parse(JSON.stringify({ + ...extractChangedFields(data, existingPark as any), + park_id: parkId, // Always include for relational integrity + images: processedImages + })) as Json, + original_data: JSON.parse(JSON.stringify(existingPark)), + status: 'pending' as const, + order_index: 0 + }); + + if (itemError) throw itemError; + + return { submitted: true, submissionId: submissionData.id }; + }, + { + maxAttempts: 3, + onRetry: (attempt, error, delay) => { + logger.warn('Retrying park update submission', { + attempt, + delay, + parkId, + error: error instanceof Error ? error.message : String(error) + }); + + // Emit event for UI retry indicator + window.dispatchEvent(new CustomEvent('submission-retry', { + detail: { attempt, maxAttempts: 3, delay, type: 'park update' } + })); }, - status: 'pending' as const - }) - .select('id') - .single(); - - if (submissionError) throw submissionError; - - // Create the submission item with actual park data AND original data - const { error: itemError } = await supabase - .from('submission_items') - .insert({ - submission_id: submissionData.id, - item_type: 'park', - action_type: 'edit', - item_data: JSON.parse(JSON.stringify({ - ...extractChangedFields(data, existingPark as any), - park_id: parkId, // Always include for relational integrity - images: processedImages - })) as Json, - original_data: JSON.parse(JSON.stringify(existingPark)), - status: 'pending' as const, - order_index: 0 + 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); + } + } + ).catch((error) => { + handleError(error, { + action: 'Park update submission', + userId, + metadata: { retriesExhausted: true, parkId }, }); + throw error; + }); - if (itemError) throw itemError; - - return { submitted: true, submissionId: submissionData.id }; + return result; } /** @@ -1073,26 +1132,41 @@ export async function submitRideUpdate( data: RideFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { - // Check if user is banned - const { data: profile } = await supabase - .from('profiles') - .select('banned') - .eq('user_id', userId) - .single(); + const { withRetry, isRetryableError } = await import('./retryHelpers'); + + // Check if user is banned - with retry for transient failures + const profile = await withRetry( + async () => { + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + return profile; + }, + { maxAttempts: 2 } + ); if (profile?.banned) { throw new Error('Account suspended. Contact support for assistance.'); } - // Fetch existing ride data first - const { data: existingRide, error: fetchError } = await supabase - .from('rides') - .select('*') - .eq('id', rideId) - .single(); + // 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'); + 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 @@ -1103,43 +1177,87 @@ export async function submitRideUpdate( // Only allow banner/card reassignments from existing photos let processedImages = data.images; - // Create the main submission record - const { data: submissionData, error: submissionError } = await supabase - .from('content_submissions') - .insert({ - user_id: userId, - submission_type: 'ride', - content: { - action: 'edit', - ride_id: rideId - }, - status: 'pending' as const - }) - .select() - .single(); + // Main submission logic with retry and error handling + 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', + content: { + action: 'edit', + ride_id: rideId + }, + status: 'pending' as const + }) + .select() + .single(); - if (submissionError) throw submissionError; + if (submissionError) throw submissionError; - // Create the submission item with actual ride data AND original data - const { error: itemError } = await supabase - .from('submission_items') - .insert({ - submission_id: submissionData.id, - item_type: 'ride', - action_type: 'edit', - item_data: { - ...extractChangedFields(data, existingRide as any), - ride_id: rideId, // Always include for relational integrity - images: processedImages as unknown as Json + // Create the submission item with actual ride data AND original data + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submissionData.id, + item_type: 'ride', + action_type: 'edit', + item_data: { + ...extractChangedFields(data, existingRide as any), + ride_id: rideId, // Always include for relational integrity + images: processedImages as unknown as Json + }, + original_data: JSON.parse(JSON.stringify(existingRide)), + status: 'pending' as const, + order_index: 0 + }); + + if (itemError) throw itemError; + + return { submitted: true, submissionId: submissionData.id }; + }, + { + maxAttempts: 3, + onRetry: (attempt, error, delay) => { + logger.warn('Retrying ride update submission', { + attempt, + delay, + rideId, + error: error instanceof Error ? error.message : String(error) + }); + + // Emit event for UI retry indicator + window.dispatchEvent(new CustomEvent('submission-retry', { + detail: { attempt, maxAttempts: 3, delay, type: 'ride update' } + })); }, - original_data: JSON.parse(JSON.stringify(existingRide)), - status: 'pending' as const, - order_index: 0 + 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); + } + } + ).catch((error) => { + handleError(error, { + action: 'Ride update submission', + userId, + metadata: { retriesExhausted: true, rideId }, }); + throw error; + }); - if (itemError) throw itemError; - - return { submitted: true, submissionId: submissionData.id }; + return result; } /**