diff --git a/src/components/upload/UppyPhotoSubmissionUpload.tsx b/src/components/upload/UppyPhotoSubmissionUpload.tsx index e0beed62..c3e5ca8f 100644 --- a/src/components/upload/UppyPhotoSubmissionUpload.tsx +++ b/src/components/upload/UppyPhotoSubmissionUpload.tsx @@ -18,6 +18,9 @@ import { Camera, CheckCircle, AlertCircle, Info } from "lucide-react"; import { UppyPhotoSubmissionUploadProps } from "@/types/submissions"; import { withRetry } from "@/lib/retryHelpers"; import { logger } from "@/lib/logger"; +import { breadcrumb } from "@/lib/errorBreadcrumbs"; +import { checkSubmissionRateLimit, recordSubmissionAttempt } from "@/lib/submissionRateLimiter"; +import { sanitizeErrorMessage } from "@/lib/errorSanitizer"; export function UppyPhotoSubmissionUpload({ onSubmissionComplete, @@ -81,6 +84,54 @@ export function UppyPhotoSubmissionUpload({ setIsSubmitting(true); 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) { + 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 + }); // Upload all photos that haven't been uploaded yet const uploadedPhotos: PhotoWithCaption[] = []; const photosToUpload = photos.filter((p) => p.file); @@ -213,7 +264,24 @@ export function UppyPhotoSubmissionUpload({ setUploadProgress(null); + // ✅ Phase 4: Validate uploaded photos before DB insertion + breadcrumb.userAction('Validate photos', 'handleSubmit', { + uploadedCount: uploadedPhotos.length + }); + + const allPhotos = [...uploadedPhotos, ...photos.filter(p => !p.file)]; + + allPhotos.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`); + } + }); + // Create submission records with retry logic + breadcrumb.apiCall('create_submission_with_items', 'RPC'); await withRetry( async () => { // Create content_submission record first diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index e03429a0..6b5764f6 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -2463,84 +2463,160 @@ export async function submitTimelineEvent( data: TimelineEventFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { - // Validate user + // ✅ Phase 4: Validate user if (!userId) { throw new Error('User ID is required for timeline event submission'); } - // Create the main submission record - const { data: submissionData, error: submissionError } = await supabase - .from('content_submissions') - .insert({ - user_id: userId, - submission_type: 'timeline_event', - status: 'pending' as const - }) - .select('id') - .single(); + // ✅ Phase 4: Rate limiting check + checkRateLimitOrThrow(userId, 'timeline_event_creation'); + recordSubmissionAttempt(userId); - if (submissionError) { - handleError(submissionError, { - action: 'Submit timeline event', - userId, - }); - throw new Error('Failed to create timeline event submission'); + // ✅ Phase 4: Validation + if (!data.title?.trim()) { + throw new Error('Timeline event title is required'); + } + if (!data.event_date) { + throw new Error('Timeline event date is required'); + } + if (!data.event_type) { + throw new Error('Timeline event type is required'); } - // ✅ FIXED: Insert into timeline_event_submissions table (relational pattern) - const { data: timelineSubmission, error: timelineSubmissionError } = await supabase - .from('timeline_event_submissions') - .insert({ - submission_id: submissionData.id, - entity_type: entityType, - entity_id: entityId, - event_type: data.event_type, - event_date: data.event_date.toISOString().split('T')[0], - event_date_precision: data.event_date_precision, - title: data.title, - description: data.description, - from_value: data.from_value, - to_value: data.to_value, - from_entity_id: data.from_entity_id, - to_entity_id: data.to_entity_id, - from_location_id: data.from_location_id, - to_location_id: data.to_location_id, - is_public: true, - }) - .select('id') - .single(); + // ✅ Phase 4: Breadcrumb tracking + breadcrumb.userAction('Start timeline event submission', 'submitTimelineEvent', { + entityType, + entityId, + eventType: data.event_type, + userId + }); - if (timelineSubmissionError) { - handleError(timelineSubmissionError, { - action: 'Submit timeline event data', - userId, - }); - throw new Error('Failed to submit timeline event for review'); + // ✅ Phase 4: Ban check with retry + breadcrumb.apiCall('profiles', 'SELECT'); + const { withRetry } = await import('./retryHelpers'); + + const profile = await withRetry( + async () => { + const { data, error } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (error) throw error; + return data; + }, + { maxAttempts: 2 } + ); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); } - // ✅ Create submission_items referencing timeline_event_submission (no JSON data) - const { error: itemError } = await supabase - .from('submission_items') - .insert({ - submission_id: submissionData.id, - item_type: 'timeline_event', - action_type: 'create', - item_data: { - entity_type: entityType, - entity_id: entityId - } as Json, - status: 'pending' as const, - order_index: 0, - timeline_event_submission_id: timelineSubmission.id - }); + // ✅ Phase 4: Create submission with retry logic + breadcrumb.apiCall('content_submissions', 'INSERT'); + const submissionData = await withRetry( + async () => { + const { data, error } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: 'timeline_event', + status: 'pending' as const + }) + .select('id') + .single(); - if (itemError) { - handleError(itemError, { - action: 'Create timeline event submission item', - userId, - }); - throw new Error('Failed to link timeline event submission'); - } + if (error) throw error; + if (!data) throw new Error('Failed to create timeline event submission'); + + return data; + }, + { + onRetry: (attempt, error, delay) => { + logger.warn('Retrying timeline event submission creation', { + attempt, + delay, + userId, + eventType: data.event_type + }); + } + } + ); + + // ✅ Phase 4: Insert timeline_event_submission with retry + breadcrumb.apiCall('timeline_event_submissions', 'INSERT'); + const timelineSubmission = await withRetry( + async () => { + const { data: insertedData, error } = await supabase + .from('timeline_event_submissions') + .insert({ + submission_id: submissionData.id, + entity_type: entityType, + entity_id: entityId, + event_type: data.event_type, + event_date: data.event_date.toISOString().split('T')[0], + event_date_precision: data.event_date_precision, + title: data.title, + description: data.description, + from_value: data.from_value, + to_value: data.to_value, + from_entity_id: data.from_entity_id, + to_entity_id: data.to_entity_id, + from_location_id: data.from_location_id, + to_location_id: data.to_location_id, + is_public: true, + }) + .select('id') + .single(); + + if (error) throw error; + if (!insertedData) throw new Error('Failed to submit timeline event for review'); + + return insertedData; + }, + { + onRetry: (attempt, error, delay) => { + logger.warn('Retrying timeline event data insertion', { + attempt, + delay, + submissionId: submissionData.id + }); + } + } + ); + + // ✅ Phase 4: Create submission_items with retry + breadcrumb.apiCall('submission_items', 'INSERT'); + await withRetry( + async () => { + const { error } = await supabase + .from('submission_items') + .insert({ + submission_id: submissionData.id, + item_type: 'timeline_event', + action_type: 'create', + item_data: { + entity_type: entityType, + entity_id: entityId + } as Json, + status: 'pending' as const, + order_index: 0, + timeline_event_submission_id: timelineSubmission.id + }); + + if (error) throw error; + }, + { + onRetry: (attempt, error, delay) => { + logger.warn('Retrying timeline event submission item creation', { + attempt, + delay, + submissionId: submissionData.id + }); + } + } + ); return { submitted: true, @@ -2563,95 +2639,185 @@ export async function submitTimelineEventUpdate( data: TimelineEventFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { - // Fetch original event - const { data: originalEvent, error: fetchError } = await supabase - .from('entity_timeline_events') - .select('*') - .eq('id', eventId) - .single(); - - if (fetchError || !originalEvent) { - throw new Error('Failed to fetch original timeline event'); + // ✅ Phase 4: Validate user + if (!userId) { + throw new Error('User ID is required for timeline event update'); } + // ✅ Phase 4: Rate limiting check + checkRateLimitOrThrow(userId, 'timeline_event_update'); + recordSubmissionAttempt(userId); + + // ✅ Phase 4: Validation + if (!data.title?.trim()) { + throw new Error('Timeline event title is required'); + } + if (!data.event_date) { + throw new Error('Timeline event date is required'); + } + + // ✅ Phase 4: Breadcrumb tracking + breadcrumb.userAction('Start timeline event update', 'submitTimelineEventUpdate', { + eventId, + userId + }); + + // ✅ Phase 4: Ban check with retry + const { withRetry } = await import('./retryHelpers'); + + breadcrumb.apiCall('profiles', 'SELECT'); + const profile = await withRetry( + async () => { + const { data, error } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (error) throw error; + return data; + }, + { maxAttempts: 2 } + ); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); + } + + // Fetch original event with retry + breadcrumb.apiCall('entity_timeline_events', 'SELECT'); + const originalEvent = await withRetry( + async () => { + const { data, error } = await supabase + .from('entity_timeline_events') + .select('*') + .eq('id', eventId) + .single(); + + if (error) throw error; + if (!data) throw new Error('Failed to fetch original timeline event'); + + return data; + }, + { maxAttempts: 2 } + ); + // Extract only changed fields from form data const changedFields = extractChangedFields(data, originalEvent as Partial>); - // Create the main submission record - const { data: submissionData, error: submissionError } = await supabase - .from('content_submissions') - .insert({ - user_id: userId, - submission_type: 'timeline_event', - status: 'pending' as const - }) - .select('id') - .single(); + // ✅ Phase 4: Create submission with retry + breadcrumb.apiCall('content_submissions', 'INSERT'); + const submissionData = await withRetry( + async () => { + const { data, error } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: 'timeline_event', + status: 'pending' as const + }) + .select('id') + .single(); - if (submissionError) { - handleError(submissionError, { - action: 'Update timeline event', - metadata: { eventId }, - }); - throw new Error('Failed to create timeline event update submission'); - } + if (error) throw error; + if (!data) throw new Error('Failed to create timeline event update submission'); + + return data; + }, + { + onRetry: (attempt, error, delay) => { + logger.warn('Retrying timeline event update submission', { + attempt, + delay, + eventId, + userId + }); + } + } + ); - // ✅ FIXED: Insert into timeline_event_submissions table (relational pattern) - const { data: timelineSubmission, error: timelineSubmissionError } = await supabase - .from('timeline_event_submissions') - .insert({ - submission_id: submissionData.id, - entity_type: originalEvent.entity_type, - entity_id: originalEvent.entity_id, - event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type, - event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date, - event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day', - title: changedFields.title !== undefined ? changedFields.title : originalEvent.title, - description: changedFields.description !== undefined ? changedFields.description : originalEvent.description, - from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value, - to_value: changedFields.to_value !== undefined ? changedFields.to_value : originalEvent.to_value, - from_entity_id: changedFields.from_entity_id !== undefined ? changedFields.from_entity_id : originalEvent.from_entity_id, - to_entity_id: changedFields.to_entity_id !== undefined ? changedFields.to_entity_id : originalEvent.to_entity_id, - from_location_id: changedFields.from_location_id !== undefined ? changedFields.from_location_id : originalEvent.from_location_id, - to_location_id: changedFields.to_location_id !== undefined ? changedFields.to_location_id : originalEvent.to_location_id, - is_public: true, - }) - .select('id') - .single(); + // ✅ Phase 4: Insert timeline_event_submission with retry + breadcrumb.apiCall('timeline_event_submissions', 'INSERT'); + const timelineSubmission = await withRetry( + async () => { + const { data: insertedData, error } = await supabase + .from('timeline_event_submissions') + .insert({ + submission_id: submissionData.id, + entity_type: originalEvent.entity_type, + entity_id: originalEvent.entity_id, + event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type, + event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date, + event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day', + title: changedFields.title !== undefined ? changedFields.title : originalEvent.title, + description: changedFields.description !== undefined ? changedFields.description : originalEvent.description, + from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value, + to_value: changedFields.to_value !== undefined ? changedFields.to_value : originalEvent.to_value, + from_entity_id: changedFields.from_entity_id !== undefined ? changedFields.from_entity_id : originalEvent.from_entity_id, + to_entity_id: changedFields.to_entity_id !== undefined ? changedFields.to_entity_id : originalEvent.to_entity_id, + from_location_id: changedFields.from_location_id !== undefined ? changedFields.from_location_id : originalEvent.from_location_id, + to_location_id: changedFields.to_location_id !== undefined ? changedFields.to_location_id : originalEvent.to_location_id, + is_public: true, + }) + .select('id') + .single(); - if (timelineSubmissionError) { - handleError(timelineSubmissionError, { - action: 'Update timeline event data', - metadata: { eventId }, - }); - throw new Error('Failed to submit timeline event update'); - } + if (error) throw error; + if (!insertedData) throw new Error('Failed to submit timeline event update'); + + return insertedData; + }, + { + onRetry: (attempt, error, delay) => { + logger.warn('Retrying timeline event update data insertion', { + attempt, + delay, + eventId, + submissionId: submissionData.id + }); + } + } + ); - // ✅ Create submission_items referencing timeline_event_submission (no JSON data) - const { error: itemError } = await supabase - .from('submission_items') - .insert({ - submission_id: submissionData.id, - item_type: 'timeline_event', - action_type: 'edit', - item_data: { - event_id: eventId, - entity_type: originalEvent.entity_type, - entity_id: originalEvent.entity_id - } as Json, - original_data: JSON.parse(JSON.stringify(originalEvent)), - status: 'pending' as const, - order_index: 0, - timeline_event_submission_id: timelineSubmission.id - }); + // ✅ Phase 4: Create submission_items with retry + breadcrumb.apiCall('submission_items', 'INSERT'); + await withRetry( + async () => { + const { error } = await supabase + .from('submission_items') + .insert({ + submission_id: submissionData.id, + item_type: 'timeline_event', + action_type: 'edit', + item_data: { + event_id: eventId, + entity_type: originalEvent.entity_type, + entity_id: originalEvent.entity_id + } as Json, + original_data: JSON.parse(JSON.stringify(originalEvent)), + status: 'pending' as const, + order_index: 0, + timeline_event_submission_id: timelineSubmission.id + }); - if (itemError) { - handleError(itemError, { - action: 'Create timeline event update submission item', - metadata: { eventId }, - }); - throw new Error('Failed to link timeline event update submission'); - } + if (error) throw error; + }, + { + onRetry: (attempt, error, delay) => { + logger.warn('Retrying timeline event update item creation', { + attempt, + delay, + eventId, + submissionId: submissionData.id + }); + } + } + ); + + breadcrumb.userAction('Timeline event update submitted', 'submitTimelineEventUpdate', { + eventId, + submissionId: submissionData.id + }); return { submitted: true,