mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 14:11:13 -05:00
feat: Implement circuit breaker and retry logic
This commit is contained in:
@@ -17,6 +17,7 @@ import { AdminErrorBoundary } from "@/components/error/AdminErrorBoundary";
|
||||
import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary";
|
||||
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
||||
import { handleError } from "@/lib/errorHandler";
|
||||
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
|
||||
|
||||
// Core routes (eager-loaded for best UX)
|
||||
import Index from "./pages/Index";
|
||||
@@ -133,6 +134,7 @@ function AppContent(): React.JSX.Element {
|
||||
<TooltipProvider>
|
||||
<NavigationTracker />
|
||||
<LocationAutoDetectProvider />
|
||||
<RetryStatusIndicator />
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<div className="min-h-screen flex flex-col">
|
||||
|
||||
75
src/components/ui/retry-status-indicator.tsx
Normal file
75
src/components/ui/retry-status-indicator.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
interface RetryStatus {
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
delay: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global retry status indicator
|
||||
* Shows visual feedback when submissions are being retried due to transient failures
|
||||
*/
|
||||
export function RetryStatusIndicator() {
|
||||
const [retryStatus, setRetryStatus] = useState<RetryStatus | null>(null);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRetry = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<RetryStatus>;
|
||||
const { attempt, maxAttempts, delay, type } = customEvent.detail;
|
||||
setRetryStatus({ attempt, maxAttempts, delay, type });
|
||||
setCountdown(delay);
|
||||
};
|
||||
|
||||
window.addEventListener('submission-retry', handleRetry);
|
||||
return () => window.removeEventListener('submission-retry', handleRetry);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 100) {
|
||||
setRetryStatus(null);
|
||||
return 0;
|
||||
}
|
||||
return prev - 100;
|
||||
});
|
||||
}, 100);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
if (!retryStatus) return null;
|
||||
|
||||
const progress = ((retryStatus.delay - countdown) / retryStatus.delay) * 100;
|
||||
|
||||
return (
|
||||
<Card className="fixed bottom-4 right-4 z-50 p-4 shadow-lg border-amber-500 bg-amber-50 dark:bg-amber-950 w-80 animate-in slide-in-from-bottom-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
Retrying submission...
|
||||
</p>
|
||||
<span className="text-xs font-mono text-amber-700 dark:text-amber-300">
|
||||
{retryStatus.attempt}/{retryStatus.maxAttempts}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Network issue detected. Retrying {retryStatus.type} submission in {Math.ceil(countdown / 1000)}s
|
||||
</p>
|
||||
|
||||
<Progress value={progress} className="h-1" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
151
src/lib/circuitBreaker.ts
Normal file
151
src/lib/circuitBreaker.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Circuit Breaker Pattern Implementation
|
||||
*
|
||||
* Prevents cascade failures by temporarily blocking requests when service is degraded.
|
||||
* Implements three states: CLOSED (normal), OPEN (blocking), HALF_OPEN (testing recovery)
|
||||
*
|
||||
* @see https://martinfowler.com/bliki/CircuitBreaker.html
|
||||
*/
|
||||
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface CircuitBreakerConfig {
|
||||
/** Number of failures before opening circuit (default: 5) */
|
||||
failureThreshold: number;
|
||||
/** Milliseconds to wait before testing recovery (default: 60000 = 1 min) */
|
||||
resetTimeout: number;
|
||||
/** Milliseconds window to track failures (default: 120000 = 2 min) */
|
||||
monitoringWindow: number;
|
||||
}
|
||||
|
||||
export enum CircuitState {
|
||||
CLOSED = 'closed', // Normal operation, requests pass through
|
||||
OPEN = 'open', // Failures detected, block all requests
|
||||
HALF_OPEN = 'half_open' // Testing if service recovered
|
||||
}
|
||||
|
||||
export class CircuitBreaker {
|
||||
private state: CircuitState = CircuitState.CLOSED;
|
||||
private failures: number[] = []; // Timestamps of recent failures
|
||||
private lastFailureTime: number | null = null;
|
||||
private successCount: number = 0;
|
||||
private readonly config: Required<CircuitBreakerConfig>;
|
||||
|
||||
constructor(config: Partial<CircuitBreakerConfig> = {}) {
|
||||
this.config = {
|
||||
failureThreshold: config.failureThreshold ?? 5,
|
||||
resetTimeout: config.resetTimeout ?? 60000,
|
||||
monitoringWindow: config.monitoringWindow ?? 120000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function through the circuit breaker
|
||||
* @throws Error if circuit is OPEN (service unavailable)
|
||||
*/
|
||||
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// If circuit is OPEN, check if we should transition to HALF_OPEN
|
||||
if (this.state === CircuitState.OPEN) {
|
||||
const timeSinceFailure = Date.now() - (this.lastFailureTime || 0);
|
||||
|
||||
if (timeSinceFailure > this.config.resetTimeout) {
|
||||
this.state = CircuitState.HALF_OPEN;
|
||||
this.successCount = 0;
|
||||
logger.info('Circuit breaker transitioning to HALF_OPEN', {
|
||||
timeSinceFailure,
|
||||
resetTimeout: this.config.resetTimeout
|
||||
});
|
||||
} else {
|
||||
const timeRemaining = Math.ceil((this.config.resetTimeout - timeSinceFailure) / 1000);
|
||||
throw new Error(
|
||||
`Service temporarily unavailable. Our systems detected an outage and are protecting server resources. Please try again in ${timeRemaining} seconds.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.onFailure();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private onSuccess(): void {
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
this.successCount++;
|
||||
|
||||
// After 2 successful requests, close the circuit
|
||||
if (this.successCount >= 2) {
|
||||
this.state = CircuitState.CLOSED;
|
||||
this.failures = [];
|
||||
this.lastFailureTime = null;
|
||||
logger.info('Circuit breaker CLOSED - service recovered', {
|
||||
successCount: this.successCount
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onFailure(): void {
|
||||
const now = Date.now();
|
||||
this.lastFailureTime = now;
|
||||
|
||||
// Remove failures outside monitoring window
|
||||
this.failures = this.failures.filter(
|
||||
(timestamp) => now - timestamp < this.config.monitoringWindow
|
||||
);
|
||||
|
||||
this.failures.push(now);
|
||||
|
||||
// Open circuit if threshold exceeded
|
||||
if (this.failures.length >= this.config.failureThreshold) {
|
||||
this.state = CircuitState.OPEN;
|
||||
logger.error('Circuit breaker OPENED', {
|
||||
failures: this.failures.length,
|
||||
threshold: this.config.failureThreshold,
|
||||
monitoringWindow: this.config.monitoringWindow,
|
||||
resetTimeout: this.config.resetTimeout
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current circuit state
|
||||
*/
|
||||
getState(): CircuitState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of recent failures
|
||||
*/
|
||||
getFailureCount(): number {
|
||||
const now = Date.now();
|
||||
return this.failures.filter(
|
||||
(timestamp) => now - timestamp < this.config.monitoringWindow
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reset the circuit (testing/debugging only)
|
||||
*/
|
||||
reset(): void {
|
||||
this.state = CircuitState.CLOSED;
|
||||
this.failures = [];
|
||||
this.lastFailureTime = null;
|
||||
this.successCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton circuit breaker for Supabase operations
|
||||
* Shared across all submission flows to detect service-wide outages
|
||||
*/
|
||||
export const supabaseCircuitBreaker = new CircuitBreaker({
|
||||
failureThreshold: 5,
|
||||
resetTimeout: 60000, // 1 minute
|
||||
monitoringWindow: 120000 // 2 minutes
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import type { Json } from '@/integrations/supabase/types';
|
||||
import { uploadPendingImages } from './imageUploadHelper';
|
||||
import { CompanyFormData, TempCompanyData } from '@/types/company';
|
||||
import { handleError } from './errorHandler';
|
||||
import { withRetry } from './retryHelpers';
|
||||
import { logger } from './logger';
|
||||
|
||||
export type { CompanyFormData, TempCompanyData };
|
||||
|
||||
@@ -11,12 +13,18 @@ export async function submitCompanyCreation(
|
||||
companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner',
|
||||
userId: string
|
||||
) {
|
||||
// Check if user is banned
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
// 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.');
|
||||
@@ -40,46 +48,83 @@ export async function submitCompanyCreation(
|
||||
}
|
||||
}
|
||||
|
||||
// Create the main submission record
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: companyType,
|
||||
content: {
|
||||
action: 'create'
|
||||
},
|
||||
status: 'pending' as const
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
// 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: companyType,
|
||||
content: {
|
||||
action: 'create'
|
||||
},
|
||||
status: 'pending' as const
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (submissionError) throw submissionError;
|
||||
if (submissionError) throw submissionError;
|
||||
|
||||
// Create the submission item with actual company data
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: companyType,
|
||||
item_data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description,
|
||||
person_type: data.person_type,
|
||||
website_url: data.website_url,
|
||||
founded_year: data.founded_year,
|
||||
headquarters_location: data.headquarters_location,
|
||||
company_type: companyType,
|
||||
images: processedImages as unknown as Json
|
||||
// Create the submission item with actual company data
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: companyType,
|
||||
item_data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description,
|
||||
person_type: data.person_type,
|
||||
website_url: data.website_url,
|
||||
founded_year: data.founded_year,
|
||||
headquarters_location: data.headquarters_location,
|
||||
company_type: companyType,
|
||||
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,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying company submission', { attempt, delay, companyType });
|
||||
|
||||
// Emit event for UI indicator
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: companyType }
|
||||
}));
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
const { isRetryableError } = require('./retryHelpers');
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
handleError(error, {
|
||||
action: `${companyType} submission`,
|
||||
metadata: { retriesExhausted: true },
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (itemError) throw itemError;
|
||||
|
||||
return { submitted: true, submissionId: submissionData.id };
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function submitCompanyUpdate(
|
||||
@@ -87,12 +132,18 @@ export async function submitCompanyUpdate(
|
||||
data: CompanyFormData,
|
||||
userId: string
|
||||
) {
|
||||
// Check if user is banned
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
// 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.');
|
||||
@@ -126,46 +177,83 @@ export async function submitCompanyUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
// Create the main submission record
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
submission_type: existingCompany.company_type,
|
||||
content: {
|
||||
action: 'edit',
|
||||
company_id: companyId
|
||||
},
|
||||
status: 'pending' as const
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
// 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: existingCompany.company_type,
|
||||
content: {
|
||||
action: 'edit',
|
||||
company_id: companyId
|
||||
},
|
||||
status: 'pending' as const
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (submissionError) throw submissionError;
|
||||
if (submissionError) throw submissionError;
|
||||
|
||||
// Create the submission item with actual company data AND original data
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: existingCompany.company_type,
|
||||
item_data: {
|
||||
company_id: companyId,
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description,
|
||||
person_type: data.person_type,
|
||||
website_url: data.website_url,
|
||||
founded_year: data.founded_year,
|
||||
headquarters_location: data.headquarters_location,
|
||||
images: processedImages as unknown as Json
|
||||
// Create the submission item with actual company data AND original data
|
||||
const { error: itemError } = await supabase
|
||||
.from('submission_items')
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
item_type: existingCompany.company_type,
|
||||
item_data: {
|
||||
company_id: companyId,
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description,
|
||||
person_type: data.person_type,
|
||||
website_url: data.website_url,
|
||||
founded_year: data.founded_year,
|
||||
headquarters_location: data.headquarters_location,
|
||||
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,
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying company update', { attempt, delay, companyId });
|
||||
|
||||
// Emit event for UI indicator
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: { attempt, maxAttempts: 3, delay, type: `${existingCompany.company_type} update` }
|
||||
}));
|
||||
},
|
||||
original_data: JSON.parse(JSON.stringify(existingCompany)),
|
||||
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;
|
||||
}
|
||||
|
||||
const { isRetryableError } = require('./retryHelpers');
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
handleError(error, {
|
||||
action: `${existingCompany.company_type} update`,
|
||||
metadata: { retriesExhausted: true, companyId },
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (itemError) throw itemError;
|
||||
|
||||
return { submitted: true, submissionId: submissionData.id };
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -118,6 +118,7 @@ export const handleError = (
|
||||
isRetry: context.metadata?.isRetry || false,
|
||||
attempt: context.metadata?.attempt,
|
||||
retriesExhausted: context.metadata?.retriesExhausted || false,
|
||||
circuitBreakerState: context.metadata?.circuitState,
|
||||
}),
|
||||
p_timezone: envContext.timezone,
|
||||
p_referrer: document.referrer || undefined,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { logger } from './logger';
|
||||
import { supabaseCircuitBreaker } from './circuitBreaker';
|
||||
|
||||
export interface RetryOptions {
|
||||
/** Maximum number of attempts (default: 3) */
|
||||
@@ -135,8 +136,10 @@ export async function withRetry<T>(
|
||||
|
||||
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
|
||||
try {
|
||||
// Execute the function
|
||||
const result = await fn();
|
||||
// Execute the function through circuit breaker
|
||||
const result = await supabaseCircuitBreaker.execute(async () => {
|
||||
return await fn();
|
||||
});
|
||||
|
||||
// Log successful retry if not first attempt
|
||||
if (attempt > 0) {
|
||||
@@ -150,6 +153,15 @@ export async function withRetry<T>(
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Check if circuit breaker blocked the request
|
||||
if (error instanceof Error && error.message.includes('Circuit breaker is OPEN')) {
|
||||
logger.error('Circuit breaker prevented retry', {
|
||||
attempt: attempt + 1,
|
||||
circuitState: supabaseCircuitBreaker.getState()
|
||||
});
|
||||
throw error; // Don't retry if circuit is open
|
||||
}
|
||||
|
||||
// Check if we should retry
|
||||
const isLastAttempt = attempt === config.maxAttempts - 1;
|
||||
const shouldRetry = config.shouldRetry(error);
|
||||
|
||||
Reference in New Issue
Block a user