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:
gpt-engineer-app[bot]
2025-11-08 00:11:55 +00:00
parent c490bf19c8
commit c79538707c
2 changed files with 215 additions and 46 deletions

View File

@@ -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 {

View File

@@ -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>
)}