From 80826a83a864465849edbbfc84df3248ecc2a1e3 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:40:25 +0000 Subject: [PATCH] Fix migration for admin settings --- .../upload/UppyPhotoSubmissionUpload.tsx | 248 ++++++++------ src/lib/circuitBreaker.ts | 69 +++- src/lib/retryHelpers.ts | 94 +++++- src/pages/AdminSettings.tsx | 310 +++++++++++++++++- ...0_990fea87-8193-4e16-8274-6f0df8194e17.sql | 10 + 5 files changed, 619 insertions(+), 112 deletions(-) create mode 100644 supabase/migrations/20251105133710_990fea87-8193-4e16-8274-6f0df8194e17.sql diff --git a/src/components/upload/UppyPhotoSubmissionUpload.tsx b/src/components/upload/UppyPhotoSubmissionUpload.tsx index 4a190f09..e0beed62 100644 --- a/src/components/upload/UppyPhotoSubmissionUpload.tsx +++ b/src/components/upload/UppyPhotoSubmissionUpload.tsx @@ -16,6 +16,8 @@ import { useAuth } from "@/hooks/useAuth"; import { useToast } from "@/hooks/use-toast"; import { Camera, CheckCircle, AlertCircle, Info } from "lucide-react"; import { UppyPhotoSubmissionUploadProps } from "@/types/submissions"; +import { withRetry } from "@/lib/retryHelpers"; +import { logger } from "@/lib/logger"; export function UppyPhotoSubmissionUpload({ onSubmissionComplete, @@ -94,66 +96,92 @@ export function UppyPhotoSubmissionUpload({ setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "uploading" as const } : p))); try { - // Get upload URL from edge function - const { data: uploadData, error: uploadError } = await invokeWithTracking( - "upload-image", - { metadata: { requireSignedURLs: false }, variant: "public" }, - user?.id, - ); + // Wrap Cloudflare upload in retry logic + const cloudflareUrl = await withRetry( + async () => { + // Get upload URL from edge function + const { data: uploadData, error: uploadError } = await invokeWithTracking( + "upload-image", + { metadata: { requireSignedURLs: false }, variant: "public" }, + user?.id, + ); - if (uploadError) throw uploadError; + if (uploadError) throw uploadError; - const { uploadURL, id: cloudflareId } = uploadData; + const { uploadURL, id: cloudflareId } = uploadData; - // Upload file to Cloudflare - if (!photo.file) { - throw new Error("Photo file is missing"); - } - const formData = new FormData(); - formData.append("file", photo.file); + // Upload file to Cloudflare + if (!photo.file) { + throw new Error("Photo file is missing"); + } + const formData = new FormData(); + formData.append("file", photo.file); - const uploadResponse = await fetch(uploadURL, { - method: "POST", - body: formData, - }); + const uploadResponse = await fetch(uploadURL, { + method: "POST", + body: formData, + }); - if (!uploadResponse.ok) { - throw new Error("Failed to upload to Cloudflare"); - } + if (!uploadResponse.ok) { + throw new Error("Failed to upload to Cloudflare"); + } - // Poll for processing completion - let attempts = 0; - const maxAttempts = 30; - let cloudflareUrl = ""; + // Poll for processing completion + let attempts = 0; + const maxAttempts = 30; + let cloudflareUrl = ""; - while (attempts < maxAttempts) { - const { - data: { session }, - } = await supabase.auth.getSession(); - const supabaseUrl = "https://api.thrillwiki.com"; - const statusResponse = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${cloudflareId}`, { - headers: { - Authorization: `Bearer ${session?.access_token || ""}`, - apikey: - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4", - }, - }); + while (attempts < maxAttempts) { + const { + data: { session }, + } = await supabase.auth.getSession(); + const supabaseUrl = "https://api.thrillwiki.com"; + const statusResponse = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${cloudflareId}`, { + headers: { + Authorization: `Bearer ${session?.access_token || ""}`, + apikey: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4", + }, + }); - if (statusResponse.ok) { - const status = await statusResponse.json(); - if (status.uploaded && status.urls) { - cloudflareUrl = status.urls.public; - break; + if (statusResponse.ok) { + const status = await statusResponse.json(); + if (status.uploaded && status.urls) { + cloudflareUrl = status.urls.public; + break; + } + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + attempts++; + } + + if (!cloudflareUrl) { + throw new Error("Upload processing timeout"); + } + + return cloudflareUrl; + }, + { + onRetry: (attempt, error, delay) => { + logger.warn('Retrying photo upload', { + attempt, + delay, + fileName: photo.file?.name + }); + + // Emit event for UI indicator + window.dispatchEvent(new CustomEvent('submission-retry', { + detail: { + attempt, + maxAttempts: 3, + delay, + type: 'photo upload' + } + })); } } - - await new Promise((resolve) => setTimeout(resolve, 1000)); - attempts++; - } - - if (!cloudflareUrl) { - throw new Error("Upload processing timeout"); - } + ); // Revoke object URL URL.revokeObjectURL(photo.url); @@ -185,59 +213,78 @@ export function UppyPhotoSubmissionUpload({ setUploadProgress(null); - // Create content_submission record first - const { data: submissionData, error: submissionError } = await supabase - .from("content_submissions") - .insert({ - user_id: user.id, - submission_type: "photo", - content: {}, // Empty content, all data is in relational tables - }) - .select() - .single(); + // Create submission records with retry logic + await withRetry( + async () => { + // Create content_submission record first + const { data: submissionData, error: submissionError } = await supabase + .from("content_submissions") + .insert({ + user_id: user.id, + submission_type: "photo", + content: {}, // Empty content, all data is in relational tables + }) + .select() + .single(); - if (submissionError || !submissionData) { - throw submissionError || new Error("Failed to create submission record"); - } + if (submissionError || !submissionData) { + throw submissionError || new Error("Failed to create submission record"); + } - // Create photo_submission record - const { data: photoSubmissionData, error: photoSubmissionError } = await supabase - .from("photo_submissions") - .insert({ - submission_id: submissionData.id, - entity_type: entityType, - entity_id: entityId, - parent_id: parentId || null, - title: title.trim() || null, - }) - .select() - .single(); + // Create photo_submission record + const { data: photoSubmissionData, error: photoSubmissionError } = await supabase + .from("photo_submissions") + .insert({ + submission_id: submissionData.id, + entity_type: entityType, + entity_id: entityId, + parent_id: parentId || null, + title: title.trim() || null, + }) + .select() + .single(); - if (photoSubmissionError || !photoSubmissionData) { - throw photoSubmissionError || new Error("Failed to create photo submission"); - } + if (photoSubmissionError || !photoSubmissionData) { + throw photoSubmissionError || new Error("Failed to create photo submission"); + } - // Insert all photo items - const photoItems = photos.map((photo, index) => ({ - photo_submission_id: photoSubmissionData.id, - cloudflare_image_id: photo.url.split("/").slice(-2, -1)[0] || "", // Extract ID from URL - cloudflare_image_url: - photo.uploadStatus === "uploaded" - ? photo.url - : uploadedPhotos.find((p) => p.order === photo.order)?.url || photo.url, - caption: photo.caption.trim() || null, - title: photo.title?.trim() || null, - filename: photo.file?.name || null, - order_index: index, - file_size: photo.file?.size || null, - mime_type: photo.file?.type || null, - })); + // Insert all photo items + const photoItems = photos.map((photo, index) => ({ + photo_submission_id: photoSubmissionData.id, + cloudflare_image_id: photo.url.split("/").slice(-2, -1)[0] || "", // Extract ID from URL + cloudflare_image_url: + photo.uploadStatus === "uploaded" + ? photo.url + : uploadedPhotos.find((p) => p.order === photo.order)?.url || photo.url, + caption: photo.caption.trim() || null, + title: photo.title?.trim() || null, + filename: photo.file?.name || null, + order_index: index, + file_size: photo.file?.size || null, + mime_type: photo.file?.type || null, + })); - const { error: itemsError } = await supabase.from("photo_submission_items").insert(photoItems); + const { error: itemsError } = await supabase.from("photo_submission_items").insert(photoItems); - if (itemsError) { - throw itemsError; - } + if (itemsError) { + throw itemsError; + } + }, + { + onRetry: (attempt, error, delay) => { + logger.warn('Retrying photo submission creation', { attempt, delay }); + + window.dispatchEvent(new CustomEvent('submission-retry', { + detail: { + attempt, + maxAttempts: 3, + delay, + type: 'photo submission' + } + })); + } + } + ); toast({ title: "Submission Successful", @@ -259,7 +306,12 @@ export function UppyPhotoSubmissionUpload({ handleError(error, { action: 'Submit Photo Submission', userId: user?.id, - metadata: { entityType, entityId, photoCount: photos.length } + metadata: { + entityType, + entityId, + photoCount: photos.length, + retriesExhausted: true + } }); toast({ diff --git a/src/lib/circuitBreaker.ts b/src/lib/circuitBreaker.ts index 933442e8..8a10d4c0 100644 --- a/src/lib/circuitBreaker.ts +++ b/src/lib/circuitBreaker.ts @@ -8,6 +8,7 @@ */ import { logger } from './logger'; +import { supabase } from './supabaseClient'; export interface CircuitBreakerConfig { /** Number of failures before opening circuit (default: 5) */ @@ -29,7 +30,7 @@ export class CircuitBreaker { private failures: number[] = []; // Timestamps of recent failures private lastFailureTime: number | null = null; private successCount: number = 0; - private readonly config: Required; + private config: Required; constructor(config: Partial = {}) { this.config = { @@ -39,6 +40,18 @@ export class CircuitBreaker { }; } + /** + * Update configuration from admin settings + */ + async updateConfig(newConfig: Partial): Promise { + this.config = { + ...this.config, + ...newConfig + }; + + logger.info('Circuit breaker config updated', { config: this.config }); + } + /** * Execute a function through the circuit breaker * @throws Error if circuit is OPEN (service unavailable) @@ -140,12 +153,62 @@ export class CircuitBreaker { } } +/** + * Load circuit breaker configuration from admin settings + * Falls back to defaults if settings unavailable + */ +export async function loadCircuitBreakerConfig(): Promise { + try { + const { data: settings } = await supabase + .from('admin_settings') + .select('setting_key, setting_value') + .in('setting_key', [ + 'circuit_breaker.failure_threshold', + 'circuit_breaker.reset_timeout', + 'circuit_breaker.monitoring_window' + ]); + + if (!settings || settings.length === 0) { + return { + failureThreshold: 5, + resetTimeout: 60000, + monitoringWindow: 120000 + }; + } + + const config: any = {}; + settings.forEach(s => { + const key = s.setting_key.replace('circuit_breaker.', ''); + const camelKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase()); + config[camelKey] = parseInt(String(s.setting_value)); + }); + + return { + failureThreshold: config.failureThreshold ?? 5, + resetTimeout: config.resetTimeout ?? 60000, + monitoringWindow: config.monitoringWindow ?? 120000 + }; + } catch (error) { + logger.error('Failed to load circuit breaker config from admin settings', { error }); + return { + failureThreshold: 5, + resetTimeout: 60000, + monitoringWindow: 120000 + }; + } +} + /** * 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 + resetTimeout: 60000, + monitoringWindow: 120000 +}); + +// Load config from admin settings on startup +loadCircuitBreakerConfig().then(config => { + supabaseCircuitBreaker.updateConfig(config); }); diff --git a/src/lib/retryHelpers.ts b/src/lib/retryHelpers.ts index c9c616e7..186a0d0d 100644 --- a/src/lib/retryHelpers.ts +++ b/src/lib/retryHelpers.ts @@ -5,6 +5,7 @@ import { logger } from './logger'; import { supabaseCircuitBreaker } from './circuitBreaker'; +import { supabase } from './supabaseClient'; export interface RetryOptions { /** Maximum number of attempts (default: 3) */ @@ -97,6 +98,81 @@ function calculateBackoffDelay(attempt: number, options: Required) return cappedDelay; } +/** + * Load retry configuration from admin settings + */ +export async function loadRetryConfig(): Promise> { + try { + const { data: settings } = await supabase + .from('admin_settings') + .select('setting_key, setting_value') + .in('setting_key', [ + 'retry.max_attempts', + 'retry.base_delay', + 'retry.max_delay', + 'retry.backoff_multiplier' + ]); + + if (!settings || settings.length === 0) { + return getDefaultRetryConfig(); + } + + const config: any = {}; + settings.forEach(s => { + const key = s.setting_key.replace('retry.', ''); + const camelKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase()); + + if (key === 'backoff_multiplier') { + config[camelKey] = parseFloat(String(s.setting_value)); + } else { + config[camelKey] = parseInt(String(s.setting_value)); + } + }); + + return { + maxAttempts: config.maxAttempts ?? 3, + baseDelay: config.baseDelay ?? 1000, + maxDelay: config.maxDelay ?? 10000, + backoffMultiplier: config.backoffMultiplier ?? 2, + jitter: true, + onRetry: () => {}, + shouldRetry: isRetryableError + }; + } catch (error) { + logger.error('Failed to load retry config', { error }); + return getDefaultRetryConfig(); + } +} + +function getDefaultRetryConfig(): Required { + return { + maxAttempts: 3, + baseDelay: 1000, + maxDelay: 10000, + backoffMultiplier: 2, + jitter: true, + onRetry: () => {}, + shouldRetry: isRetryableError + }; +} + +// Cache admin config for 5 minutes +let cachedRetryConfig: Required | null = null; +let configCacheTime: number = 0; +const CONFIG_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +async function getCachedRetryConfig(): Promise> { + const now = Date.now(); + + if (cachedRetryConfig && (now - configCacheTime < CONFIG_CACHE_TTL)) { + return cachedRetryConfig; + } + + cachedRetryConfig = await loadRetryConfig(); + configCacheTime = now; + return cachedRetryConfig; +} + /** * Executes a function with retry logic and exponential backoff * @@ -122,14 +198,18 @@ export async function withRetry( fn: () => Promise, options?: RetryOptions ): Promise { + // Load config from admin settings + const adminConfig = await getCachedRetryConfig(); + + // Merge: options override admin settings const config: Required = { - maxAttempts: options?.maxAttempts ?? 3, - baseDelay: options?.baseDelay ?? 1000, - maxDelay: options?.maxDelay ?? 10000, - backoffMultiplier: options?.backoffMultiplier ?? 2, - jitter: options?.jitter ?? true, - onRetry: options?.onRetry ?? (() => {}), - shouldRetry: options?.shouldRetry ?? isRetryableError, + maxAttempts: options?.maxAttempts ?? adminConfig.maxAttempts, + baseDelay: options?.baseDelay ?? adminConfig.baseDelay, + maxDelay: options?.maxDelay ?? adminConfig.maxDelay, + backoffMultiplier: options?.backoffMultiplier ?? adminConfig.backoffMultiplier, + jitter: options?.jitter ?? adminConfig.jitter, + onRetry: options?.onRetry ?? adminConfig.onRetry, + shouldRetry: options?.shouldRetry ?? adminConfig.shouldRetry, }; let lastError: unknown; diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index fb46af70..ae1118e2 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -14,7 +14,7 @@ import { useAdminSettings } from '@/hooks/useAdminSettings'; import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility'; import { TestDataGenerator } from '@/components/admin/TestDataGenerator'; import { IntegrationTestRunner } from '@/components/admin/IntegrationTestRunner'; -import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube } from 'lucide-react'; +import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube, RefreshCw, Info, AlertCircle } from 'lucide-react'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; export default function AdminSettings() { @@ -372,6 +372,249 @@ export default function AdminSettings() { ); } + // Retry settings + if (setting.setting_key === 'retry.max_attempts') { + return ( + +
+
+ + +
+

+ How many times to retry failed operations (entity/photo submissions) +

+
+ + Current: {localValue} +
+
+
+ ); + } + + if (setting.setting_key === 'retry.base_delay') { + return ( + +
+
+ + +
+

+ Milliseconds to wait before first retry attempt +

+
+ + Current: {localValue}ms +
+
+
+ ); + } + + if (setting.setting_key === 'retry.max_delay') { + return ( + +
+
+ + +
+

+ Maximum delay between retry attempts (exponential backoff cap) +

+
+ + Current: {localValue}ms +
+
+
+ ); + } + + if (setting.setting_key === 'retry.backoff_multiplier') { + return ( + +
+
+ + +
+

+ Growth rate for exponential backoff between retries +

+
+ + Current: {localValue}x +
+
+
+ ); + } + + if (setting.setting_key === 'circuit_breaker.failure_threshold') { + return ( + +
+
+ + +
+

+ Number of consecutive failures before blocking all requests temporarily +

+
+ + Current: {localValue} +
+
+
+ ); + } + + if (setting.setting_key === 'circuit_breaker.reset_timeout') { + return ( + +
+
+ + +
+

+ How long to wait before testing if service has recovered +

+
+ + Current: {Math.floor(Number(localValue) / 1000)}s +
+
+
+ ); + } + + if (setting.setting_key === 'circuit_breaker.monitoring_window') { + return ( + +
+
+ + +
+

+ Time window to track failures for circuit breaker +

+
+ + Current: {Math.floor(Number(localValue) / 60000)}min +
+
+
+ ); + } + // Helper to check if value is boolean const isBooleanSetting = (value: any) => { return value === true || value === false || @@ -503,7 +746,7 @@ export default function AdminSettings() { - + Moderation @@ -516,6 +759,10 @@ export default function AdminSettings() { Notifications + + + Resilience + System @@ -612,6 +859,61 @@ export default function AdminSettings() { + + + + + + Resilience & Retry Configuration + + + Configure automatic retry behavior and circuit breaker settings for handling transient failures + + + +
+
+ +
+

About Retry & Circuit Breaker

+
    +
  • Retry Logic: Automatically retries failed operations (network issues, timeouts)
  • +
  • Circuit Breaker: Prevents system overload by blocking requests during outages
  • +
  • When to adjust: Increase retries for unstable networks, decrease for fast-fail scenarios
  • +
+
+
+
+ +
+

Retry Settings

+ {getSettingsByCategory('system') + .filter(s => s.setting_key.startsWith('retry.')) + .map(setting => )} +
+ +
+

Circuit Breaker Settings

+ {getSettingsByCategory('system') + .filter(s => s.setting_key.startsWith('circuit_breaker.')) + .map(setting => )} +
+ +
+
+ +
+

Configuration Changes

+

+ Settings take effect immediately but may be cached for up to 5 minutes in active sessions. Consider monitoring error logs after changes to verify behavior. +

+
+
+
+
+
+
+ @@ -624,8 +926,8 @@ export default function AdminSettings() { - {getSettingsByCategory('system').length > 0 ? ( - getSettingsByCategory('system').map((setting) => ( + {getSettingsByCategory('system').filter(s => !s.setting_key.startsWith('retry.') && !s.setting_key.startsWith('circuit_breaker.')).length > 0 ? ( + getSettingsByCategory('system').filter(s => !s.setting_key.startsWith('retry.') && !s.setting_key.startsWith('circuit_breaker.')).map((setting) => ( )) ) : ( diff --git a/supabase/migrations/20251105133710_990fea87-8193-4e16-8274-6f0df8194e17.sql b/supabase/migrations/20251105133710_990fea87-8193-4e16-8274-6f0df8194e17.sql new file mode 100644 index 00000000..f5aacec4 --- /dev/null +++ b/supabase/migrations/20251105133710_990fea87-8193-4e16-8274-6f0df8194e17.sql @@ -0,0 +1,10 @@ +-- Add retry configuration settings to admin_settings table +INSERT INTO admin_settings (setting_key, setting_value, category, description) VALUES + ('retry.max_attempts', '3', 'system', 'Maximum number of retry attempts for failed operations (1-10)'), + ('retry.base_delay', '1000', 'system', 'Base delay in milliseconds before first retry (100-5000)'), + ('retry.max_delay', '10000', 'system', 'Maximum delay in milliseconds between retries (1000-30000)'), + ('retry.backoff_multiplier', '2', 'system', 'Multiplier for exponential backoff (1.5-3.0)'), + ('circuit_breaker.failure_threshold', '5', 'system', 'Number of failures before circuit opens (3-20)'), + ('circuit_breaker.reset_timeout', '60000', 'system', 'Milliseconds to wait before testing recovery (30000-300000)'), + ('circuit_breaker.monitoring_window', '120000', 'system', 'Time window to track failures in milliseconds (60000-600000)') +ON CONFLICT (setting_key) DO NOTHING; \ No newline at end of file