mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:11:11 -05:00
Refactor photo upload pipeline
Implement comprehensive error recovery mechanisms for the photo upload pipeline in `UppyPhotoSubmissionUpload.tsx`. This includes adding exponential backoff to retries, graceful degradation for partial uploads, and cleanup for orphaned Cloudflare images. The changes also enhance error tracking and user feedback for failed uploads.
This commit is contained in:
@@ -18,6 +18,7 @@ export interface PhotoWithCaption {
|
||||
date?: Date; // Optional date for the photo
|
||||
order: number;
|
||||
uploadStatus?: 'pending' | 'uploading' | 'uploaded' | 'failed';
|
||||
cloudflare_id?: string; // Cloudflare Image ID after upload
|
||||
}
|
||||
|
||||
interface PhotoCaptionEditorProps {
|
||||
|
||||
@@ -14,14 +14,28 @@ import { PhotoCaptionEditor, PhotoWithCaption } from "./PhotoCaptionEditor";
|
||||
import { supabase } from "@/lib/supabaseClient";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Camera, CheckCircle, AlertCircle, Info } from "lucide-react";
|
||||
import { Camera, CheckCircle, AlertCircle, Info, XCircle } from "lucide-react";
|
||||
import { UppyPhotoSubmissionUploadProps } from "@/types/submissions";
|
||||
import { withRetry } from "@/lib/retryHelpers";
|
||||
import { withRetry, isRetryableError } from "@/lib/retryHelpers";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
||||
import { checkSubmissionRateLimit, recordSubmissionAttempt } from "@/lib/submissionRateLimiter";
|
||||
import { sanitizeErrorMessage } from "@/lib/errorSanitizer";
|
||||
|
||||
/**
|
||||
* Photo upload pipeline configuration
|
||||
* Bulletproof retry and recovery settings
|
||||
*/
|
||||
const UPLOAD_CONFIG = {
|
||||
MAX_UPLOAD_ATTEMPTS: 3,
|
||||
MAX_DB_ATTEMPTS: 3,
|
||||
POLLING_TIMEOUT_SECONDS: 30,
|
||||
POLLING_INTERVAL_MS: 1000,
|
||||
BASE_RETRY_DELAY: 1000,
|
||||
MAX_RETRY_DELAY: 10000,
|
||||
ALLOW_PARTIAL_SUCCESS: true, // Allow submission even if some photos fail
|
||||
} as const;
|
||||
|
||||
export function UppyPhotoSubmissionUpload({
|
||||
onSubmissionComplete,
|
||||
entityId,
|
||||
@@ -32,6 +46,8 @@ export function UppyPhotoSubmissionUpload({
|
||||
const [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
const [failedPhotos, setFailedPhotos] = useState<Array<{ index: number; error: string }>>([]);
|
||||
const [orphanedCloudflareIds, setOrphanedCloudflareIds] = useState<string[]>([]);
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -83,6 +99,9 @@ export function UppyPhotoSubmissionUpload({
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// ✅ Declare uploadedPhotos outside try block for error handling scope
|
||||
const uploadedPhotos: PhotoWithCaption[] = [];
|
||||
|
||||
try {
|
||||
// ✅ Phase 4: Rate limiting check
|
||||
const rateLimit = checkSubmissionRateLimit(user.id);
|
||||
@@ -132,23 +151,26 @@ export function UppyPhotoSubmissionUpload({
|
||||
breadcrumb.userAction('Upload images', 'handleSubmit', {
|
||||
totalImages: photos.length
|
||||
});
|
||||
// Upload all photos that haven't been uploaded yet
|
||||
const uploadedPhotos: PhotoWithCaption[] = [];
|
||||
|
||||
// ✅ Phase 4: Upload all photos with bulletproof error recovery
|
||||
const photosToUpload = photos.filter((p) => p.file);
|
||||
const uploadFailures: Array<{ index: number; error: string; photo: PhotoWithCaption }> = [];
|
||||
|
||||
if (photosToUpload.length > 0) {
|
||||
setUploadProgress({ current: 0, total: photosToUpload.length });
|
||||
setFailedPhotos([]);
|
||||
|
||||
for (let i = 0; i < photosToUpload.length; i++) {
|
||||
const photo = photosToUpload[i];
|
||||
const photoIndex = photos.indexOf(photo);
|
||||
setUploadProgress({ current: i + 1, total: photosToUpload.length });
|
||||
|
||||
// Update status
|
||||
setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "uploading" as const } : p)));
|
||||
|
||||
try {
|
||||
// Wrap Cloudflare upload in retry logic
|
||||
const cloudflareUrl = await withRetry(
|
||||
// ✅ Bulletproof: Explicit retry configuration with exponential backoff
|
||||
const cloudflareResult = await withRetry(
|
||||
async () => {
|
||||
// Get upload URL from edge function
|
||||
const { data: uploadData, error: uploadError } = await invokeWithTracking(
|
||||
@@ -174,12 +196,13 @@ export function UppyPhotoSubmissionUpload({
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("Failed to upload to Cloudflare");
|
||||
const errorText = await uploadResponse.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Cloudflare upload failed: ${errorText}`);
|
||||
}
|
||||
|
||||
// Poll for processing completion
|
||||
// ✅ Bulletproof: Configurable polling with timeout
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
const maxAttempts = UPLOAD_CONFIG.POLLING_TIMEOUT_SECONDS;
|
||||
let cloudflareUrl = "";
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
@@ -203,31 +226,50 @@ export function UppyPhotoSubmissionUpload({
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await new Promise((resolve) => setTimeout(resolve, UPLOAD_CONFIG.POLLING_INTERVAL_MS));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!cloudflareUrl) {
|
||||
throw new Error("Upload processing timeout");
|
||||
// Track orphaned upload for cleanup
|
||||
setOrphanedCloudflareIds(prev => [...prev, cloudflareId]);
|
||||
throw new Error("Upload processing timeout - image may be uploaded but not ready");
|
||||
}
|
||||
|
||||
return cloudflareUrl;
|
||||
return { cloudflareUrl, cloudflareId };
|
||||
},
|
||||
{
|
||||
maxAttempts: UPLOAD_CONFIG.MAX_UPLOAD_ATTEMPTS,
|
||||
baseDelay: UPLOAD_CONFIG.BASE_RETRY_DELAY,
|
||||
maxDelay: UPLOAD_CONFIG.MAX_RETRY_DELAY,
|
||||
shouldRetry: (error) => {
|
||||
// ✅ Bulletproof: Intelligent retry logic
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
// Don't retry validation errors or file too large
|
||||
if (message.includes('file is missing')) return false;
|
||||
if (message.includes('too large')) return false;
|
||||
if (message.includes('invalid file type')) return false;
|
||||
}
|
||||
return isRetryableError(error);
|
||||
},
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying photo upload', {
|
||||
attempt,
|
||||
maxAttempts: UPLOAD_CONFIG.MAX_UPLOAD_ATTEMPTS,
|
||||
delay,
|
||||
fileName: photo.file?.name
|
||||
fileName: photo.file?.name,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
// Emit event for UI indicator
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: {
|
||||
id: crypto.randomUUID(),
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
maxAttempts: UPLOAD_CONFIG.MAX_UPLOAD_ATTEMPTS,
|
||||
delay,
|
||||
type: 'photo upload'
|
||||
type: `photo upload: ${photo.file?.name || 'unnamed'}`
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -239,39 +281,90 @@ export function UppyPhotoSubmissionUpload({
|
||||
|
||||
uploadedPhotos.push({
|
||||
...photo,
|
||||
url: cloudflareUrl,
|
||||
url: cloudflareResult.cloudflareUrl,
|
||||
cloudflare_id: cloudflareResult.cloudflareId,
|
||||
uploadStatus: "uploaded" as const,
|
||||
});
|
||||
|
||||
// Update status
|
||||
setPhotos((prev) =>
|
||||
prev.map((p) => (p === photo ? { ...p, url: cloudflareUrl, uploadStatus: "uploaded" as const } : p)),
|
||||
prev.map((p) => (p === photo ? {
|
||||
...p,
|
||||
url: cloudflareResult.cloudflareUrl,
|
||||
cloudflare_id: cloudflareResult.cloudflareId,
|
||||
uploadStatus: "uploaded" as const
|
||||
} : p)),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
handleError(error, {
|
||||
action: 'Upload Photo Submission',
|
||||
userId: user.id,
|
||||
metadata: { photoTitle: photo.title, photoOrder: photo.order, fileName: photo.file?.name }
|
||||
|
||||
logger.info('Photo uploaded successfully', {
|
||||
fileName: photo.file?.name,
|
||||
cloudflareId: cloudflareResult.cloudflareId,
|
||||
photoIndex: i + 1,
|
||||
totalPhotos: photosToUpload.length
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = sanitizeErrorMessage(error);
|
||||
|
||||
logger.error('Photo upload failed after all retries', {
|
||||
fileName: photo.file?.name,
|
||||
photoIndex: i + 1,
|
||||
error: errorMsg,
|
||||
retriesExhausted: true
|
||||
});
|
||||
|
||||
handleError(error, {
|
||||
action: 'Upload Photo',
|
||||
userId: user.id,
|
||||
metadata: {
|
||||
photoTitle: photo.title,
|
||||
photoOrder: photo.order,
|
||||
fileName: photo.file?.name,
|
||||
retriesExhausted: true
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Graceful degradation: Track failure but continue
|
||||
uploadFailures.push({ index: photoIndex, error: errorMsg, photo });
|
||||
setFailedPhotos(prev => [...prev, { index: photoIndex, error: errorMsg }]);
|
||||
setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "failed" as const } : p)));
|
||||
|
||||
throw new Error(`Failed to upload ${photo.title || "photo"}: ${errorMsg}`);
|
||||
// ✅ Graceful degradation: Only throw if no partial success allowed
|
||||
if (!UPLOAD_CONFIG.ALLOW_PARTIAL_SUCCESS) {
|
||||
throw new Error(`Failed to upload ${photo.title || photo.file?.name || "photo"}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Graceful degradation: Check if we have any successful uploads
|
||||
if (uploadedPhotos.length === 0 && photosToUpload.length > 0) {
|
||||
throw new Error('All photo uploads failed. Please check your connection and try again.');
|
||||
}
|
||||
|
||||
setUploadProgress(null);
|
||||
|
||||
// ✅ Phase 4: Validate uploaded photos before DB insertion
|
||||
breadcrumb.userAction('Validate photos', 'handleSubmit', {
|
||||
uploadedCount: uploadedPhotos.length
|
||||
// ✅ Graceful degradation: Log upload summary
|
||||
logger.info('Photo upload phase complete', {
|
||||
totalPhotos: photosToUpload.length,
|
||||
successfulUploads: uploadedPhotos.length,
|
||||
failedUploads: uploadFailures.length,
|
||||
allowPartialSuccess: UPLOAD_CONFIG.ALLOW_PARTIAL_SUCCESS
|
||||
});
|
||||
|
||||
const allPhotos = [...uploadedPhotos, ...photos.filter(p => !p.file)];
|
||||
// ✅ Phase 4: Validate uploaded photos before DB insertion
|
||||
breadcrumb.userAction('Validate photos', 'handleSubmit', {
|
||||
uploadedCount: uploadedPhotos.length,
|
||||
failedCount: uploadFailures.length
|
||||
});
|
||||
|
||||
allPhotos.forEach((photo, index) => {
|
||||
// Only include successfully uploaded photos
|
||||
const successfulPhotos = photos.filter(p =>
|
||||
!p.file || // Already uploaded (no file)
|
||||
uploadedPhotos.some(up => up.order === p.order) // Successfully uploaded
|
||||
);
|
||||
|
||||
successfulPhotos.forEach((photo, index) => {
|
||||
if (!photo.url) {
|
||||
throw new Error(`Photo ${index + 1}: Missing URL`);
|
||||
}
|
||||
@@ -280,7 +373,7 @@ export function UppyPhotoSubmissionUpload({
|
||||
}
|
||||
});
|
||||
|
||||
// Create submission records with retry logic
|
||||
// ✅ Bulletproof: Create submission records with explicit retry configuration
|
||||
breadcrumb.apiCall('create_submission_with_items', 'RPC');
|
||||
await withRetry(
|
||||
async () => {
|
||||
@@ -290,12 +383,22 @@ export function UppyPhotoSubmissionUpload({
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
submission_type: "photo",
|
||||
content: {}, // Empty content, all data is in relational tables
|
||||
content: {
|
||||
partialSuccess: uploadFailures.length > 0,
|
||||
successfulPhotos: uploadedPhotos.length,
|
||||
failedPhotos: uploadFailures.length
|
||||
},
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (submissionError || !submissionData) {
|
||||
// ✅ Orphan cleanup: If DB fails, track uploaded images for cleanup
|
||||
uploadedPhotos.forEach(p => {
|
||||
if (p.cloudflare_id) {
|
||||
setOrphanedCloudflareIds(prev => [...prev, p.cloudflare_id!]);
|
||||
}
|
||||
});
|
||||
throw submissionError || new Error("Failed to create submission record");
|
||||
}
|
||||
|
||||
@@ -316,14 +419,11 @@ export function UppyPhotoSubmissionUpload({
|
||||
throw photoSubmissionError || new Error("Failed to create photo submission");
|
||||
}
|
||||
|
||||
// Insert all photo items
|
||||
const photoItems = photos.map((photo, index) => ({
|
||||
// Insert only successful photo items
|
||||
const photoItems = successfulPhotos.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,
|
||||
cloudflare_image_id: photo.cloudflare_id || photo.url.split("/").slice(-2, -1)[0] || "",
|
||||
cloudflare_image_url: photo.url,
|
||||
caption: photo.caption.trim() || null,
|
||||
title: photo.title?.trim() || null,
|
||||
filename: photo.file?.name || null,
|
||||
@@ -337,40 +437,99 @@ export function UppyPhotoSubmissionUpload({
|
||||
if (itemsError) {
|
||||
throw itemsError;
|
||||
}
|
||||
|
||||
logger.info('Photo submission created successfully', {
|
||||
submissionId: submissionData.id,
|
||||
photoCount: photoItems.length
|
||||
});
|
||||
},
|
||||
{
|
||||
maxAttempts: UPLOAD_CONFIG.MAX_DB_ATTEMPTS,
|
||||
baseDelay: UPLOAD_CONFIG.BASE_RETRY_DELAY,
|
||||
maxDelay: UPLOAD_CONFIG.MAX_RETRY_DELAY,
|
||||
shouldRetry: (error) => {
|
||||
// ✅ Bulletproof: Intelligent retry for DB operations
|
||||
if (error && typeof error === 'object') {
|
||||
const pgError = error as { code?: string };
|
||||
// Don't retry unique constraint violations or foreign key errors
|
||||
if (pgError.code === '23505') return false; // unique_violation
|
||||
if (pgError.code === '23503') return false; // foreign_key_violation
|
||||
}
|
||||
return isRetryableError(error);
|
||||
},
|
||||
onRetry: (attempt, error, delay) => {
|
||||
logger.warn('Retrying photo submission creation', { attempt, delay });
|
||||
logger.warn('Retrying photo submission DB insertion', {
|
||||
attempt,
|
||||
maxAttempts: UPLOAD_CONFIG.MAX_DB_ATTEMPTS,
|
||||
delay,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||
detail: {
|
||||
id: crypto.randomUUID(),
|
||||
attempt,
|
||||
maxAttempts: 3,
|
||||
maxAttempts: UPLOAD_CONFIG.MAX_DB_ATTEMPTS,
|
||||
delay,
|
||||
type: 'photo submission'
|
||||
type: 'photo submission database'
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ✅ Graceful degradation: Inform user about partial success
|
||||
if (uploadFailures.length > 0) {
|
||||
toast({
|
||||
title: "Partial Submission Successful",
|
||||
description: `${uploadedPhotos.length} photo(s) submitted successfully. ${uploadFailures.length} photo(s) failed to upload.`,
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
logger.warn('Partial photo submission success', {
|
||||
successCount: uploadedPhotos.length,
|
||||
failureCount: uploadFailures.length,
|
||||
failures: uploadFailures.map(f => ({ index: f.index, error: f.error }))
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Submission Successful",
|
||||
description: "Your photos have been submitted for review. Thank you for contributing!",
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup and reset form
|
||||
// ✅ Cleanup: Revoke blob URLs
|
||||
photos.forEach((photo) => {
|
||||
if (photo.url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(photo.url);
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Cleanup: Log orphaned Cloudflare images for manual cleanup
|
||||
if (orphanedCloudflareIds.length > 0) {
|
||||
logger.warn('Orphaned Cloudflare images detected', {
|
||||
cloudflareIds: orphanedCloudflareIds,
|
||||
count: orphanedCloudflareIds.length,
|
||||
note: 'These images were uploaded but submission failed - manual cleanup may be needed'
|
||||
});
|
||||
}
|
||||
|
||||
setTitle("");
|
||||
setPhotos([]);
|
||||
setFailedPhotos([]);
|
||||
setOrphanedCloudflareIds([]);
|
||||
onSubmissionComplete?.();
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
const errorMsg = sanitizeErrorMessage(error);
|
||||
|
||||
logger.error('Photo submission failed', {
|
||||
error: errorMsg,
|
||||
photoCount: photos.length,
|
||||
uploadedCount: uploadedPhotos.length,
|
||||
orphanedIds: orphanedCloudflareIds,
|
||||
retriesExhausted: true
|
||||
});
|
||||
|
||||
handleError(error, {
|
||||
action: 'Submit Photo Submission',
|
||||
userId: user?.id,
|
||||
@@ -378,6 +537,9 @@ export function UppyPhotoSubmissionUpload({
|
||||
entityType,
|
||||
entityId,
|
||||
photoCount: photos.length,
|
||||
uploadedPhotos: uploadedPhotos.length,
|
||||
failedPhotos: failedPhotos.length,
|
||||
orphanedCloudflareIds: orphanedCloudflareIds.length,
|
||||
retriesExhausted: true
|
||||
}
|
||||
});
|
||||
@@ -507,6 +669,12 @@ export function UppyPhotoSubmissionUpload({
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(uploadProgress.current / uploadProgress.total) * 100} />
|
||||
{failedPhotos.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-sm text-destructive bg-destructive/10 p-2 rounded">
|
||||
<XCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{failedPhotos.length} photo(s) failed - submission will continue with successful uploads</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user