import React, { useState } from "react"; import { invokeWithTracking } from "@/lib/edgeFunctionTracking"; import { handleError, getErrorMessage } from "@/lib/errorHandler"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; import { UppyPhotoUploadLazy } from "./UppyPhotoUploadLazy"; 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, XCircle } from "lucide-react"; import { UppyPhotoSubmissionUploadProps } from "@/types/submissions"; 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"; import { reportBanEvasionAttempt } from "@/lib/pipelineAlerts"; /** * 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, entityType, parentId, }: UppyPhotoSubmissionUploadProps) { const [title, setTitle] = useState(""); const [photos, setPhotos] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null); const [failedPhotos, setFailedPhotos] = useState>([]); const [orphanedCloudflareIds, setOrphanedCloudflareIds] = useState([]); const { user } = useAuth(); const { toast } = useToast(); const handleFilesSelected = (files: File[]) => { // Convert files to photo objects with object URLs for preview const newPhotos: PhotoWithCaption[] = files.map((file, index) => ({ url: URL.createObjectURL(file), // Object URL for preview file, // Store the file for later upload caption: "", order: photos.length + index, uploadStatus: "pending" as const, })); setPhotos((prev) => [...prev, ...newPhotos]); }; const handlePhotosChange = (updatedPhotos: PhotoWithCaption[]) => { setPhotos(updatedPhotos); }; const handleRemovePhoto = (index: number) => { setPhotos((prev) => { const photo = prev[index]; // Revoke object URL if it exists if (photo.file && photo.url.startsWith("blob:")) { URL.revokeObjectURL(photo.url); } return prev.filter((_, i) => i !== index); }); }; const handleSubmit = async () => { if (!user) { toast({ variant: "destructive", title: "Authentication Required", description: "Please sign in to submit photos.", }); return; } if (photos.length === 0) { toast({ variant: "destructive", title: "No Photos", description: "Please upload at least one photo before submitting.", }); return; } 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); if (!rateLimit.allowed) { const sanitizedMessage = sanitizeErrorMessage(rateLimit.reason || 'Rate limit exceeded'); logger.warn('[RateLimit] Photo submission blocked', { userId: user.id, reason: rateLimit.reason }); throw new Error(sanitizedMessage); } recordSubmissionAttempt(user.id); // ✅ Phase 4: Breadcrumb tracking breadcrumb.userAction('Start photo submission', 'handleSubmit', { photoCount: photos.length, entityType, entityId, userId: user.id }); // ✅ Phase 4: Ban check with retry breadcrumb.apiCall('profiles', 'SELECT'); const profile = await withRetry( async () => { const { data, error } = await supabase .from('profiles') .select('banned') .eq('user_id', user.id) .single(); if (error) throw error; return data; }, { maxAttempts: 2 } ); if (profile?.banned) { // Report ban evasion attempt reportBanEvasionAttempt(user.id, 'photo_upload').catch(() => { // Non-blocking - don't fail if alert fails }); throw new Error('Account suspended. Contact support for assistance.'); } // ✅ Phase 4: Validate photos before processing if (photos.some(p => !p.file)) { throw new Error('All photos must have valid files'); } breadcrumb.userAction('Upload images', 'handleSubmit', { totalImages: photos.length }); // ✅ 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 { // ✅ 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( "upload-image", { metadata: { requireSignedURLs: false }, variant: "public" }, user?.id, ); if (uploadError) throw uploadError; 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); const uploadResponse = await fetch(uploadURL, { method: "POST", body: formData, }); if (!uploadResponse.ok) { const errorText = await uploadResponse.text().catch(() => 'Unknown error'); throw new Error(`Cloudflare upload failed: ${errorText}`); } // ✅ Bulletproof: Configurable polling with timeout let attempts = 0; const maxAttempts = UPLOAD_CONFIG.POLLING_TIMEOUT_SECONDS; 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", }, }); if (statusResponse.ok) { const status = await statusResponse.json(); if (status.uploaded && status.urls) { cloudflareUrl = status.urls.public; break; } } await new Promise((resolve) => setTimeout(resolve, UPLOAD_CONFIG.POLLING_INTERVAL_MS)); attempts++; } if (!cloudflareUrl) { // Track orphaned upload for cleanup setOrphanedCloudflareIds(prev => [...prev, cloudflareId]); throw new Error("Upload processing timeout - image may be uploaded but not ready"); } 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, 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: UPLOAD_CONFIG.MAX_UPLOAD_ATTEMPTS, delay, type: `photo upload: ${photo.file?.name || 'unnamed'}` } })); } } ); // Revoke object URL URL.revokeObjectURL(photo.url); uploadedPhotos.push({ ...photo, url: cloudflareResult.cloudflareUrl, cloudflare_id: cloudflareResult.cloudflareId, uploadStatus: "uploaded" as const, }); // Update status setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, url: cloudflareResult.cloudflareUrl, cloudflare_id: cloudflareResult.cloudflareId, uploadStatus: "uploaded" as const } : p)), ); 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))); // ✅ 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); // ✅ 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 breadcrumb.userAction('Validate photos', 'handleSubmit', { uploadedCount: uploadedPhotos.length, failedCount: uploadFailures.length }); // 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`); } if (photo.uploadStatus === 'uploaded' && !photo.url.includes('/images/')) { throw new Error(`Photo ${index + 1}: Invalid Cloudflare URL format`); } }); // ✅ Bulletproof: Create submission records with explicit retry configuration breadcrumb.apiCall('create_submission_with_items', 'RPC'); 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: { 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"); } // 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"); } // Insert only successful photo items const photoItems = successfulPhotos.map((photo, index) => ({ photo_submission_id: photoSubmissionData.id, 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, 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); 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 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: UPLOAD_CONFIG.MAX_DB_ATTEMPTS, delay, 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: 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 = 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, metadata: { entityType, entityId, photoCount: photos.length, uploadedPhotos: uploadedPhotos.length, failedPhotos: failedPhotos.length, orphanedCloudflareIds: orphanedCloudflareIds.length, retriesExhausted: true } }); toast({ variant: "destructive", title: "Submission Failed", description: errorMsg || "There was an error submitting your photos. Please try again.", }); } finally { setIsSubmitting(false); setUploadProgress(null); } }; // Cleanup on unmount React.useEffect(() => { return () => { photos.forEach((photo) => { if (photo.url.startsWith("blob:")) { URL.revokeObjectURL(photo.url); } }); }; }, []); const metadata = { submissionType: "photo", entityId, entityType, parentId, userId: user?.id, }; return (
Submit Photos Share your photos with the community. All submissions will be reviewed before being published.

Submission Guidelines:

  • • Photos should be clear and well-lit
  • • Maximum 10 images per submission
  • • Each image up to 25MB in size
  • • Review process takes 24-48 hours
setTitle(e.target.value)} placeholder="Give your photos a descriptive title (optional)" maxLength={100} disabled={isSubmitting} className="transition-all duration-200 focus:ring-2 focus:ring-primary/20" />

{title.length}/100 characters

{photos.length > 0 && ( {photos.length} photo{photos.length !== 1 ? "s" : ""} selected )}
{photos.length > 0 && ( <> )}
{uploadProgress && (
Uploading photos... {uploadProgress.current} of {uploadProgress.total}
{failedPhotos.length > 0 && (
{failedPhotos.length} photo(s) failed - submission will continue with successful uploads
)}
)}
Your submission will be reviewed and published within 24-48 hours
); }