mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -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
|
date?: Date; // Optional date for the photo
|
||||||
order: number;
|
order: number;
|
||||||
uploadStatus?: 'pending' | 'uploading' | 'uploaded' | 'failed';
|
uploadStatus?: 'pending' | 'uploading' | 'uploaded' | 'failed';
|
||||||
|
cloudflare_id?: string; // Cloudflare Image ID after upload
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PhotoCaptionEditorProps {
|
interface PhotoCaptionEditorProps {
|
||||||
|
|||||||
@@ -14,14 +14,28 @@ import { PhotoCaptionEditor, PhotoWithCaption } from "./PhotoCaptionEditor";
|
|||||||
import { supabase } from "@/lib/supabaseClient";
|
import { supabase } from "@/lib/supabaseClient";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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 { UppyPhotoSubmissionUploadProps } from "@/types/submissions";
|
||||||
import { withRetry } from "@/lib/retryHelpers";
|
import { withRetry, isRetryableError } from "@/lib/retryHelpers";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
||||||
import { checkSubmissionRateLimit, recordSubmissionAttempt } from "@/lib/submissionRateLimiter";
|
import { checkSubmissionRateLimit, recordSubmissionAttempt } from "@/lib/submissionRateLimiter";
|
||||||
import { sanitizeErrorMessage } from "@/lib/errorSanitizer";
|
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({
|
export function UppyPhotoSubmissionUpload({
|
||||||
onSubmissionComplete,
|
onSubmissionComplete,
|
||||||
entityId,
|
entityId,
|
||||||
@@ -32,6 +46,8 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
const [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
|
const [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null);
|
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 { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -83,6 +99,9 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// ✅ Declare uploadedPhotos outside try block for error handling scope
|
||||||
|
const uploadedPhotos: PhotoWithCaption[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ✅ Phase 4: Rate limiting check
|
// ✅ Phase 4: Rate limiting check
|
||||||
const rateLimit = checkSubmissionRateLimit(user.id);
|
const rateLimit = checkSubmissionRateLimit(user.id);
|
||||||
@@ -132,23 +151,26 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
breadcrumb.userAction('Upload images', 'handleSubmit', {
|
breadcrumb.userAction('Upload images', 'handleSubmit', {
|
||||||
totalImages: photos.length
|
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 photosToUpload = photos.filter((p) => p.file);
|
||||||
|
const uploadFailures: Array<{ index: number; error: string; photo: PhotoWithCaption }> = [];
|
||||||
|
|
||||||
if (photosToUpload.length > 0) {
|
if (photosToUpload.length > 0) {
|
||||||
setUploadProgress({ current: 0, total: photosToUpload.length });
|
setUploadProgress({ current: 0, total: photosToUpload.length });
|
||||||
|
setFailedPhotos([]);
|
||||||
|
|
||||||
for (let i = 0; i < photosToUpload.length; i++) {
|
for (let i = 0; i < photosToUpload.length; i++) {
|
||||||
const photo = photosToUpload[i];
|
const photo = photosToUpload[i];
|
||||||
|
const photoIndex = photos.indexOf(photo);
|
||||||
setUploadProgress({ current: i + 1, total: photosToUpload.length });
|
setUploadProgress({ current: i + 1, total: photosToUpload.length });
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "uploading" as const } : p)));
|
setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "uploading" as const } : p)));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Wrap Cloudflare upload in retry logic
|
// ✅ Bulletproof: Explicit retry configuration with exponential backoff
|
||||||
const cloudflareUrl = await withRetry(
|
const cloudflareResult = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
// Get upload URL from edge function
|
// Get upload URL from edge function
|
||||||
const { data: uploadData, error: uploadError } = await invokeWithTracking(
|
const { data: uploadData, error: uploadError } = await invokeWithTracking(
|
||||||
@@ -174,12 +196,13 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!uploadResponse.ok) {
|
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;
|
let attempts = 0;
|
||||||
const maxAttempts = 30;
|
const maxAttempts = UPLOAD_CONFIG.POLLING_TIMEOUT_SECONDS;
|
||||||
let cloudflareUrl = "";
|
let cloudflareUrl = "";
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
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++;
|
attempts++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cloudflareUrl) {
|
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) => {
|
onRetry: (attempt, error, delay) => {
|
||||||
logger.warn('Retrying photo upload', {
|
logger.warn('Retrying photo upload', {
|
||||||
attempt,
|
attempt,
|
||||||
|
maxAttempts: UPLOAD_CONFIG.MAX_UPLOAD_ATTEMPTS,
|
||||||
delay,
|
delay,
|
||||||
fileName: photo.file?.name
|
fileName: photo.file?.name,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit event for UI indicator
|
// Emit event for UI indicator
|
||||||
window.dispatchEvent(new CustomEvent('submission-retry', {
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
detail: {
|
detail: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
attempt,
|
attempt,
|
||||||
maxAttempts: 3,
|
maxAttempts: UPLOAD_CONFIG.MAX_UPLOAD_ATTEMPTS,
|
||||||
delay,
|
delay,
|
||||||
type: 'photo upload'
|
type: `photo upload: ${photo.file?.name || 'unnamed'}`
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -239,39 +281,90 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
|
|
||||||
uploadedPhotos.push({
|
uploadedPhotos.push({
|
||||||
...photo,
|
...photo,
|
||||||
url: cloudflareUrl,
|
url: cloudflareResult.cloudflareUrl,
|
||||||
|
cloudflare_id: cloudflareResult.cloudflareId,
|
||||||
uploadStatus: "uploaded" as const,
|
uploadStatus: "uploaded" as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
setPhotos((prev) =>
|
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);
|
logger.info('Photo uploaded successfully', {
|
||||||
handleError(error, {
|
fileName: photo.file?.name,
|
||||||
action: 'Upload Photo Submission',
|
cloudflareId: cloudflareResult.cloudflareId,
|
||||||
userId: user.id,
|
photoIndex: i + 1,
|
||||||
metadata: { photoTitle: photo.title, photoOrder: photo.order, fileName: photo.file?.name }
|
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)));
|
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);
|
setUploadProgress(null);
|
||||||
|
|
||||||
|
// ✅ 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
|
||||||
|
});
|
||||||
|
|
||||||
// ✅ Phase 4: Validate uploaded photos before DB insertion
|
// ✅ Phase 4: Validate uploaded photos before DB insertion
|
||||||
breadcrumb.userAction('Validate photos', 'handleSubmit', {
|
breadcrumb.userAction('Validate photos', 'handleSubmit', {
|
||||||
uploadedCount: uploadedPhotos.length
|
uploadedCount: uploadedPhotos.length,
|
||||||
|
failedCount: uploadFailures.length
|
||||||
});
|
});
|
||||||
|
|
||||||
const allPhotos = [...uploadedPhotos, ...photos.filter(p => !p.file)];
|
// 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
|
||||||
|
);
|
||||||
|
|
||||||
allPhotos.forEach((photo, index) => {
|
successfulPhotos.forEach((photo, index) => {
|
||||||
if (!photo.url) {
|
if (!photo.url) {
|
||||||
throw new Error(`Photo ${index + 1}: Missing 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');
|
breadcrumb.apiCall('create_submission_with_items', 'RPC');
|
||||||
await withRetry(
|
await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -290,12 +383,22 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
submission_type: "photo",
|
submission_type: "photo",
|
||||||
content: {}, // Empty content, all data is in relational tables
|
content: {
|
||||||
|
partialSuccess: uploadFailures.length > 0,
|
||||||
|
successfulPhotos: uploadedPhotos.length,
|
||||||
|
failedPhotos: uploadFailures.length
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (submissionError || !submissionData) {
|
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");
|
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");
|
throw photoSubmissionError || new Error("Failed to create photo submission");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert all photo items
|
// Insert only successful photo items
|
||||||
const photoItems = photos.map((photo, index) => ({
|
const photoItems = successfulPhotos.map((photo, index) => ({
|
||||||
photo_submission_id: photoSubmissionData.id,
|
photo_submission_id: photoSubmissionData.id,
|
||||||
cloudflare_image_id: photo.url.split("/").slice(-2, -1)[0] || "", // Extract ID from URL
|
cloudflare_image_id: photo.cloudflare_id || photo.url.split("/").slice(-2, -1)[0] || "",
|
||||||
cloudflare_image_url:
|
cloudflare_image_url: photo.url,
|
||||||
photo.uploadStatus === "uploaded"
|
|
||||||
? photo.url
|
|
||||||
: uploadedPhotos.find((p) => p.order === photo.order)?.url || photo.url,
|
|
||||||
caption: photo.caption.trim() || null,
|
caption: photo.caption.trim() || null,
|
||||||
title: photo.title?.trim() || null,
|
title: photo.title?.trim() || null,
|
||||||
filename: photo.file?.name || null,
|
filename: photo.file?.name || null,
|
||||||
@@ -337,40 +437,99 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
if (itemsError) {
|
if (itemsError) {
|
||||||
throw 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) => {
|
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', {
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
detail: {
|
detail: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
attempt,
|
attempt,
|
||||||
maxAttempts: 3,
|
maxAttempts: UPLOAD_CONFIG.MAX_DB_ATTEMPTS,
|
||||||
delay,
|
delay,
|
||||||
type: 'photo submission'
|
type: 'photo submission database'
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
toast({
|
// ✅ Graceful degradation: Inform user about partial success
|
||||||
title: "Submission Successful",
|
if (uploadFailures.length > 0) {
|
||||||
description: "Your photos have been submitted for review. Thank you for contributing!",
|
toast({
|
||||||
});
|
title: "Partial Submission Successful",
|
||||||
|
description: `${uploadedPhotos.length} photo(s) submitted successfully. ${uploadFailures.length} photo(s) failed to upload.`,
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
|
||||||
// Cleanup and reset form
|
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: Revoke blob URLs
|
||||||
photos.forEach((photo) => {
|
photos.forEach((photo) => {
|
||||||
if (photo.url.startsWith("blob:")) {
|
if (photo.url.startsWith("blob:")) {
|
||||||
URL.revokeObjectURL(photo.url);
|
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("");
|
setTitle("");
|
||||||
setPhotos([]);
|
setPhotos([]);
|
||||||
|
setFailedPhotos([]);
|
||||||
|
setOrphanedCloudflareIds([]);
|
||||||
onSubmissionComplete?.();
|
onSubmissionComplete?.();
|
||||||
} catch (error: unknown) {
|
} 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, {
|
handleError(error, {
|
||||||
action: 'Submit Photo Submission',
|
action: 'Submit Photo Submission',
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@@ -378,6 +537,9 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
entityType,
|
entityType,
|
||||||
entityId,
|
entityId,
|
||||||
photoCount: photos.length,
|
photoCount: photos.length,
|
||||||
|
uploadedPhotos: uploadedPhotos.length,
|
||||||
|
failedPhotos: failedPhotos.length,
|
||||||
|
orphanedCloudflareIds: orphanedCloudflareIds.length,
|
||||||
retriesExhausted: true
|
retriesExhausted: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -507,6 +669,12 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={(uploadProgress.current / uploadProgress.total) * 100} />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user