import { supabase } from '@/lib/supabaseClient'; import { invokeWithTracking } from './edgeFunctionTracking'; import type { UploadedImage } from '@/components/upload/EntityMultiImageUploader'; import { handleError, handleNonCriticalError } from './errorHandler'; export interface CloudflareUploadResponse { result: { id: string; variants: string[]; }; success: boolean; } // Internal type to track upload status interface UploadedImageWithFlag extends UploadedImage { wasNewlyUploaded?: boolean; } // Upload timeout in milliseconds (30 seconds) const UPLOAD_TIMEOUT_MS = 30000; /** * Creates a promise that rejects after a timeout */ function withTimeout(promise: Promise, timeoutMs: number, operation: string): Promise { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs) ) ]); } /** * Uploads pending local images to Cloudflare via Supabase Edge Function * @param images Array of UploadedImage objects (mix of local and already uploaded) * @returns Array of UploadedImage objects with all images uploaded */ export async function uploadPendingImages(images: UploadedImage[]): Promise { // Process all images in parallel for better performance using allSettled const uploadPromises = images.map(async (image, index): Promise => { if (image.isLocal && image.file) { const fileName = image.file.name; // Step 1: Get upload URL from our Supabase Edge Function (with tracking and timeout) const { data: uploadUrlData, error: urlError, requestId } = await withTimeout( invokeWithTracking( 'upload-image', { action: 'get-upload-url' } ), UPLOAD_TIMEOUT_MS, 'Get upload URL' ); if (urlError || !uploadUrlData?.uploadURL) { const error = new Error(`Failed to get upload URL for "${fileName}": ${urlError?.message || 'Unknown error'}`); handleError(error, { action: 'Get Upload URL', metadata: { fileName, requestId } }); throw error; } // Step 2: Upload file directly to Cloudflare with retry on transient failures const formData = new FormData(); formData.append('file', image.file); const { withRetry } = await import('./retryHelpers'); const uploadResponse = await withRetry( () => withTimeout( fetch(uploadUrlData.uploadURL, { method: 'POST', body: formData, }), UPLOAD_TIMEOUT_MS, 'Cloudflare upload' ), { maxAttempts: 3, baseDelay: 500, shouldRetry: (error) => { // Retry on network errors, timeouts, or 5xx errors if (error instanceof Error) { const msg = error.message.toLowerCase(); if (msg.includes('timeout')) return true; if (msg.includes('network')) return true; if (msg.includes('failed to fetch')) return true; } return false; } } ); if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); const error = new Error(`Upload failed for "${fileName}" (status ${uploadResponse.status}): ${errorText}`); handleError(error, { action: 'Cloudflare Upload', metadata: { fileName, status: uploadResponse.status, timeout_ms: UPLOAD_TIMEOUT_MS } }); throw error; } const result: CloudflareUploadResponse = await uploadResponse.json(); if (!result.success || !result.result) { const error = new Error(`Cloudflare upload returned unsuccessful response for "${fileName}"`); handleError(error, { action: 'Cloudflare Upload', metadata: { fileName } }); throw error; } // Clean up object URL URL.revokeObjectURL(image.url); // Step 3: Return uploaded image metadata with wasNewlyUploaded flag return { url: `https://cdn.thrillwiki.com/images/${result.result.id}/public`, cloudflare_id: result.result.id, caption: image.caption, isLocal: false, wasNewlyUploaded: true // Flag to track newly uploaded images }; } else { // Already uploaded, keep as is return { url: image.url, cloudflare_id: image.cloudflare_id, caption: image.caption, isLocal: false, wasNewlyUploaded: false // Pre-existing image }; } }); // Wait for all uploads to settle (succeed or fail) const results = await Promise.allSettled(uploadPromises); // Separate successful and failed uploads const successfulUploads: UploadedImageWithFlag[] = []; const newlyUploadedImageIds: string[] = []; // Track ONLY newly uploaded images for cleanup const errors: string[] = []; results.forEach((result, index) => { if (result.status === 'fulfilled') { const uploadedImage = result.value; successfulUploads.push(uploadedImage); // Only track newly uploaded images for potential cleanup if (uploadedImage.wasNewlyUploaded && uploadedImage.cloudflare_id) { newlyUploadedImageIds.push(uploadedImage.cloudflare_id); } } else { errors.push(result.reason?.message || `Upload ${index + 1} failed`); } }); // If any uploads failed, clean up ONLY newly uploaded images and throw error if (errors.length > 0) { if (newlyUploadedImageIds.length > 0) { const cleanupError = new Error(`Some uploads failed, cleaning up ${newlyUploadedImageIds.length} newly uploaded images`); handleError(cleanupError, { action: 'Upload Cleanup', metadata: { newlyUploadedCount: newlyUploadedImageIds.length, failureCount: errors.length } }); // Attempt cleanup in parallel with detailed error tracking const cleanupResults = await Promise.allSettled( newlyUploadedImageIds.map(imageId => invokeWithTracking('upload-image', { action: 'delete', imageId, }) ) ); // Track cleanup failures silently (non-critical) const cleanupFailures = cleanupResults.filter(r => r.status === 'rejected'); if (cleanupFailures.length > 0) { handleNonCriticalError( new Error(`Failed to cleanup ${cleanupFailures.length} of ${newlyUploadedImageIds.length} images`), { action: 'Image Cleanup', metadata: { cleanupFailures: cleanupFailures.length, totalCleanup: newlyUploadedImageIds.length, orphanedImages: newlyUploadedImageIds.filter((_, i) => cleanupResults[i].status === 'rejected') } } ); } } const finalError = new Error(`Failed to upload ${errors.length} of ${images.length} images: ${errors.join('; ')}`); handleError(finalError, { action: 'Image Upload', metadata: { failureCount: errors.length, totalCount: images.length } }); throw finalError; } // Remove the wasNewlyUploaded flag before returning return successfulUploads.map(({ wasNewlyUploaded, ...image }) => image as UploadedImage); }