mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 13:31:12 -05:00
feat: Implement circuit breaker and retry logic
This commit is contained in:
@@ -530,19 +530,27 @@ export async function submitParkCreation(
|
||||
}
|
||||
}
|
||||
|
||||
// Standard single-entity creation
|
||||
// Check if user is banned
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
// Standard single-entity creation with retry logic
|
||||
const { withRetry } = await import('./retryHelpers');
|
||||
|
||||
// Check if user is banned (with quick retry for read operation)
|
||||
const profile = await withRetry(
|
||||
async () => {
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
return profile;
|
||||
},
|
||||
{ maxAttempts: 2 }
|
||||
);
|
||||
|
||||
if (profile?.banned) {
|
||||
throw new Error('Account suspended. Contact support for assistance.');
|
||||
}
|
||||
|
||||
// Upload any pending local images first
|
||||
// Upload any pending local images first (no retry - handled internally)
|
||||
let processedImages = data.images;
|
||||
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
||||
try {
|
||||
@@ -559,73 +567,110 @@ export async function submitParkCreation(
|
||||
}
|
||||
}
|
||||
|
||||
// Create the main submission record
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: 'park',
|
||||
content: {
|
||||
action: 'create'
|
||||
// Create submission with retry logic
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
// Create the main submission record
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: 'park',
|
||||
content: {
|
||||
action: 'create'
|
||||
},
|
||||
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 { 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 submission_items record linking to park_submissions
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: 'park',
|
||||
action_type: 'create',
|
||||
park_submission_id: (parkSubmission as any).id,
|
||||
status: 'pending' as const,
|
||||
order_index: 0
|
||||
} as any);
|
||||
|
||||
if (itemError) throw itemError;
|
||||
|
||||
return { submitted: true, submissionId: submissionData.id };
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying park submission', { attempt, delay });
|
||||
|
||||
// Emit event for UI indicator
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'park' }
|
||||
}));
|
||||
},
|
||||
status: 'pending' as const
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
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;
|
||||
}
|
||||
|
||||
const { isRetryableError } = require('./retryHelpers');
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
handleError(error, {
|
||||
action: 'Park submission',
|
||||
metadata: { retriesExhausted: true },
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
|
||||
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 { 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 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 };
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -847,19 +892,27 @@ export async function submitRideCreation(
|
||||
}
|
||||
}
|
||||
|
||||
// Standard single-entity creation
|
||||
// Check if user is banned
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
// Standard single-entity creation with retry logic
|
||||
const { withRetry } = await import('./retryHelpers');
|
||||
|
||||
// Check if user is banned (with quick retry for read operation)
|
||||
const profile = await withRetry(
|
||||
async () => {
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
return profile;
|
||||
},
|
||||
{ maxAttempts: 2 }
|
||||
);
|
||||
|
||||
if (profile?.banned) {
|
||||
throw new Error('Account suspended. Contact support for assistance.');
|
||||
}
|
||||
|
||||
// Upload any pending local images first
|
||||
// Upload any pending local images first (no retry - handled internally)
|
||||
let processedImages = data.images;
|
||||
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
||||
try {
|
||||
@@ -876,86 +929,123 @@ export async function submitRideCreation(
|
||||
}
|
||||
}
|
||||
|
||||
// Create the main submission record
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: 'ride',
|
||||
content: {
|
||||
action: 'create'
|
||||
// Create submission with retry logic
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
// Create the main submission record
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: 'ride',
|
||||
content: {
|
||||
action: 'create'
|
||||
},
|
||||
status: 'pending' as const
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (submissionError) throw submissionError;
|
||||
|
||||
// Get image URLs/IDs from processed images using assignments
|
||||
const uploadedImages = processedImages?.uploaded || [];
|
||||
const bannerIndex = processedImages?.banner_assignment;
|
||||
const cardIndex = processedImages?.card_assignment;
|
||||
|
||||
const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null;
|
||||
const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null;
|
||||
|
||||
// Insert into relational ride_submissions table
|
||||
const { data: rideSubmission, error: rideSubmissionError } = await supabase
|
||||
.from('ride_submissions' as any)
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
park_id: data.park_id || null,
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description || null,
|
||||
category: data.category,
|
||||
ride_sub_type: data.ride_sub_type || null,
|
||||
status: data.status,
|
||||
opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null,
|
||||
closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null,
|
||||
manufacturer_id: data.manufacturer_id || null,
|
||||
designer_id: data.designer_id || null,
|
||||
ride_model_id: data.ride_model_id || null,
|
||||
height_requirement: data.height_requirement || null,
|
||||
age_requirement: data.age_requirement || null,
|
||||
capacity_per_hour: data.capacity_per_hour || null,
|
||||
duration_seconds: data.duration_seconds || null,
|
||||
max_speed_kmh: data.max_speed_kmh || null,
|
||||
max_height_meters: data.max_height_meters || null,
|
||||
length_meters: data.length_meters || null,
|
||||
drop_height_meters: data.drop_height_meters || null,
|
||||
inversions: data.inversions || 0,
|
||||
max_g_force: data.max_g_force || null,
|
||||
coaster_type: data.coaster_type || null,
|
||||
seating_type: data.seating_type || null,
|
||||
intensity_level: data.intensity_level || null,
|
||||
banner_image_url: bannerImage?.url || data.banner_image_url || null,
|
||||
banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null,
|
||||
card_image_url: cardImage?.url || data.card_image_url || null,
|
||||
card_image_id: cardImage?.cloudflare_id || data.card_image_id || null,
|
||||
image_url: null
|
||||
} as any)
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (rideSubmissionError) throw rideSubmissionError;
|
||||
|
||||
// Create submission_items record linking to ride_submissions
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: 'ride',
|
||||
action_type: 'create',
|
||||
ride_submission_id: (rideSubmission as any).id,
|
||||
status: 'pending' as const,
|
||||
order_index: 0
|
||||
} as any);
|
||||
|
||||
if (itemError) throw itemError;
|
||||
|
||||
return { submitted: true, submissionId: submissionData.id };
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying ride submission', { attempt, delay });
|
||||
|
||||
// Emit event for UI indicator
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: 'ride' }
|
||||
}));
|
||||
},
|
||||
status: 'pending' as const
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
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;
|
||||
}
|
||||
|
||||
const { isRetryableError } = require('./retryHelpers');
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
handleError(error, {
|
||||
action: 'Ride submission',
|
||||
metadata: { retriesExhausted: true },
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (submissionError) throw submissionError;
|
||||
|
||||
// ✅ FIXED: Get image URLs/IDs from processed images using assignments
|
||||
const uploadedImages = processedImages?.uploaded || [];
|
||||
const bannerIndex = processedImages?.banner_assignment;
|
||||
const cardIndex = processedImages?.card_assignment;
|
||||
|
||||
const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null;
|
||||
const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null;
|
||||
|
||||
// Insert into relational ride_submissions table
|
||||
const { data: rideSubmission, error: rideSubmissionError } = await supabase
|
||||
.from('ride_submissions' as any)
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
park_id: data.park_id || null,
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description || null,
|
||||
category: data.category,
|
||||
ride_sub_type: data.ride_sub_type || null,
|
||||
status: data.status,
|
||||
opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null,
|
||||
closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null,
|
||||
manufacturer_id: data.manufacturer_id || null,
|
||||
designer_id: data.designer_id || null,
|
||||
ride_model_id: data.ride_model_id || null,
|
||||
height_requirement: data.height_requirement || null,
|
||||
age_requirement: data.age_requirement || null,
|
||||
capacity_per_hour: data.capacity_per_hour || null,
|
||||
duration_seconds: data.duration_seconds || null,
|
||||
max_speed_kmh: data.max_speed_kmh || null,
|
||||
max_height_meters: data.max_height_meters || null,
|
||||
length_meters: data.length_meters || null,
|
||||
drop_height_meters: data.drop_height_meters || null,
|
||||
inversions: data.inversions || 0,
|
||||
max_g_force: data.max_g_force || null,
|
||||
coaster_type: data.coaster_type || null,
|
||||
seating_type: data.seating_type || null,
|
||||
intensity_level: data.intensity_level || null,
|
||||
banner_image_url: bannerImage?.url || data.banner_image_url || null,
|
||||
banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null,
|
||||
card_image_url: cardImage?.url || data.card_image_url || null,
|
||||
card_image_id: cardImage?.cloudflare_id || data.card_image_id || null,
|
||||
image_url: null
|
||||
} as any)
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (rideSubmissionError) throw rideSubmissionError;
|
||||
|
||||
// Create submission_items record linking to ride_submissions
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: 'ride',
|
||||
action_type: 'create',
|
||||
ride_submission_id: (rideSubmission as any).id,
|
||||
status: 'pending' as const,
|
||||
order_index: 0
|
||||
} as any);
|
||||
|
||||
if (itemError) throw itemError;
|
||||
|
||||
return { submitted: true, submissionId: submissionData.id };
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user