From 1beb8ad2bed00e46ae11028ae70fa3ceb8719ded Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:19:35 +0000 Subject: [PATCH] Refactor: Complete Photo System Refactor --- src/components/moderation/ItemReviewCard.tsx | 18 +-- src/components/moderation/ModerationQueue.tsx | 140 ++++++------------ .../moderation/PhotoSubmissionDisplay.tsx | 64 ++++++++ src/components/reviews/ReviewForm.tsx | 34 ++++- .../upload/UppyPhotoSubmissionUpload.tsx | 98 +++++++----- src/types/photo-submissions.ts | 45 ++++++ ...4_3465c71f-8f18-4886-9806-eabffdb41887.sql | 14 ++ 7 files changed, 259 insertions(+), 154 deletions(-) create mode 100644 src/components/moderation/PhotoSubmissionDisplay.tsx create mode 100644 src/types/photo-submissions.ts create mode 100644 supabase/migrations/20251001231654_3465c71f-8f18-4886-9806-eabffdb41887.sql diff --git a/src/components/moderation/ItemReviewCard.tsx b/src/components/moderation/ItemReviewCard.tsx index f9bbd98c..956f4348 100644 --- a/src/components/moderation/ItemReviewCard.tsx +++ b/src/components/moderation/ItemReviewCard.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { Edit, MapPin, Zap, Building2, Image, Package } from 'lucide-react'; import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService'; import { useIsMobile } from '@/hooks/use-mobile'; +import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay'; interface ItemReviewCardProps { item: SubmissionItemWithDeps; @@ -94,21 +95,8 @@ export function ItemReviewCard({ item, onEdit, onStatusChange }: ItemReviewCardP case 'photo': return (
-
- {data.photos?.slice(0, isMobile ? 2 : 3).map((photo: any, idx: number) => ( - {photo.caption - ))} -
- {data.photos?.length > (isMobile ? 2 : 3) && ( -

- +{data.photos.length - (isMobile ? 2 : 3)} more photo(s) -

- )} + {/* Fetch and display from photo_submission_items */} +
); diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index ddd8f9a6..e56078cc 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -503,38 +503,29 @@ export const ModerationQueue = forwardRef((props, ref) => { // Handle photo submissions - create photos records when approved if (action === 'approved' && item.type === 'content_submission' && item.submission_type === 'photo') { console.log('🖼️ [PHOTO APPROVAL] Starting photo submission approval'); - console.log('🖼️ [PHOTO APPROVAL] Raw item:', item); - console.log('🖼️ [PHOTO APPROVAL] Item content type:', typeof item.content); - console.log('🖼️ [PHOTO APPROVAL] Item content keys:', Object.keys(item.content || {})); try { - // Photo submissions have a simple structure: content contains { context, entity_id, photos } - const content = item.content; - const photosArray = content.photos; - const entityId = content.entity_id; - const entityType = content.context; + // Fetch photo submission from new relational tables + const { data: photoSubmission, error: fetchError } = await supabase + .from('photo_submissions') + .select(` + *, + items:photo_submission_items(*), + submission:content_submissions!inner(user_id, status) + `) + .eq('submission_id', item.id) + .single(); - console.log('🖼️ [PHOTO APPROVAL] Extracted data:', { - photosArray, - entityId, - entityType, - hasPhotosArray: !!photosArray, - photosCount: photosArray?.length - }); + console.log('🖼️ [PHOTO APPROVAL] Fetched photo submission:', photoSubmission); - if (!photosArray || !Array.isArray(photosArray) || photosArray.length === 0) { - console.error('🖼️ [PHOTO APPROVAL] ERROR: No photos found in submission'); - throw new Error('No photos found in submission'); + if (fetchError || !photoSubmission) { + console.error('🖼️ [PHOTO APPROVAL] ERROR: Failed to fetch photo submission:', fetchError); + throw new Error('Failed to fetch photo submission data'); } - if (!entityId || !entityType) { - console.error('🖼️ [PHOTO APPROVAL] ERROR: Invalid entity information', { - entityId, - entityType, - contentKeys: Object.keys(content), - fullContent: content - }); - throw new Error('Invalid entity information in photo submission'); + if (!photoSubmission.items || photoSubmission.items.length === 0) { + console.error('🖼️ [PHOTO APPROVAL] ERROR: No photo items found'); + throw new Error('No photos found in submission'); } // Check if photos already exist for this submission (in case of re-approval) @@ -559,75 +550,38 @@ export const ModerationQueue = forwardRef((props, ref) => { }) .eq('id', item.id); - if (updateError) throw updateError; - - toast({ - title: "Photos Re-Approved", - description: `Photo submission re-approved (${existingPhotos.length} existing photo(s))`, - }); - - fetchItems(activeEntityFilter, activeStatusFilter); - return; - } - - // Helper function to extract Cloudflare image ID from URL - const extractImageId = (url: string): string | null => { - try { - // URL format: https://imagedelivery.net/{account_hash}/{image_id}/public - const match = url.match(/\/([a-f0-9-]{36})\/public/i); - return match ? match[1] : null; - } catch (error) { - console.error('🖼️ [PHOTO APPROVAL] Error extracting image ID:', error); - return null; - } - }; - - // Create photo records in the photos table - const photoRecords = photosArray.map((photo, index) => { - const cloudflareImageUrl = photo.cloudflare_image_url || photo.url; - const cloudflareImageId = photo.cloudflare_image_id || photo.imageId || extractImageId(cloudflareImageUrl); - - if (!cloudflareImageId || !cloudflareImageUrl) { - console.error('🖼️ [PHOTO APPROVAL] ERROR: Missing Cloudflare fields', { - photo, - cloudflareImageId, - cloudflareImageUrl - }); - throw new Error('Missing required Cloudflare image fields'); - } - - const record = { - entity_id: entityId, - entity_type: entityType, - cloudflare_image_id: cloudflareImageId, - cloudflare_image_url: cloudflareImageUrl, - title: photo.title || null, - caption: photo.caption || null, - date_taken: photo.date || null, - order_index: photo.order ?? index, - submission_id: item.id, - submitted_by: item.user_id, + } else { + // Create new photo records from photo_submission_items + const photoRecords = photoSubmission.items.map((item) => ({ + entity_id: photoSubmission.entity_id, + entity_type: photoSubmission.entity_type, + cloudflare_image_id: item.cloudflare_image_id, + cloudflare_image_url: item.cloudflare_image_url, + title: item.title || null, + caption: item.caption || null, + date_taken: item.date_taken || null, + order_index: item.order_index, + submission_id: photoSubmission.submission_id, + submitted_by: photoSubmission.submission?.user_id, approved_by: user?.id, approved_at: new Date().toISOString(), - }; - console.log('🖼️ [PHOTO APPROVAL] Photo record to insert:', record); - return record; - }); - - console.log('🖼️ [PHOTO APPROVAL] Attempting to insert photo records:', photoRecords); - - const { data: createdPhotos, error: photoError } = await supabase - .from('photos') - .insert(photoRecords) - .select(); - - if (photoError) { - console.error('🖼️ [PHOTO APPROVAL] Database error creating photos:', photoError); - throw new Error(`Failed to create photos: ${photoError.message}`); + })); + + console.log('🖼️ [PHOTO APPROVAL] Creating photo records:', photoRecords); + + const { data: createdPhotos, error: insertError } = await supabase + .from('photos') + .insert(photoRecords) + .select(); + + if (insertError) { + console.error('🖼️ [PHOTO APPROVAL] ERROR: Failed to insert photos:', insertError); + throw insertError; + } + + console.log('🖼️ [PHOTO APPROVAL] ✅ Successfully created photos:', createdPhotos); } - console.log('🖼️ [PHOTO APPROVAL] Successfully created photo records:', createdPhotos); - // Update submission status const { error: updateError } = await supabase .from('content_submissions') @@ -644,11 +598,11 @@ export const ModerationQueue = forwardRef((props, ref) => { throw updateError; } - console.log('🖼️ [PHOTO APPROVAL] ✅ Complete! Created', createdPhotos.length, 'photos'); + console.log('🖼️ [PHOTO APPROVAL] ✅ Complete! Photos approved and published'); toast({ title: "Photos Approved", - description: `Successfully approved ${createdPhotos.length} photo(s)`, + description: `Successfully approved and published ${photoSubmission.items.length} photo(s)`, }); // Refresh the queue diff --git a/src/components/moderation/PhotoSubmissionDisplay.tsx b/src/components/moderation/PhotoSubmissionDisplay.tsx new file mode 100644 index 00000000..6dc37d49 --- /dev/null +++ b/src/components/moderation/PhotoSubmissionDisplay.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { useIsMobile } from '@/hooks/use-mobile'; +import type { PhotoSubmissionItem } from '@/types/photo-submissions'; + +interface PhotoSubmissionDisplayProps { + submissionId: string; +} + +export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayProps) { + const isMobile = useIsMobile(); + const [photos, setPhotos] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchPhotos(); + }, [submissionId]); + + const fetchPhotos = async () => { + try { + const { data, error } = await supabase + .from('photo_submission_items') + .select(` + *, + photo_submission:photo_submissions!inner(submission_id) + `) + .eq('photo_submission.submission_id', submissionId) + .order('order_index'); + + if (error) throw error; + setPhotos(data || []); + } catch (error) { + console.error('Error fetching photo submission items:', error); + } finally { + setLoading(false); + } + }; + + if (loading) { + return
Loading photos...
; + } + + if (photos.length === 0) { + return
No photos found
; + } + + return ( +
+ {photos.slice(0, isMobile ? 2 : 3).map((photo) => ( + {photo.title + ))} + {photos.length > (isMobile ? 2 : 3) && ( +
+ +{photos.length - (isMobile ? 2 : 3)} more +
+ )} +
+ ); +} diff --git a/src/components/reviews/ReviewForm.tsx b/src/components/reviews/ReviewForm.tsx index 47183f14..ba745a59 100644 --- a/src/components/reviews/ReviewForm.tsx +++ b/src/components/reviews/ReviewForm.tsx @@ -68,6 +68,7 @@ export function ReviewForm({ } setSubmitting(true); try { + // Insert review first const reviewData = { user_id: user.id, rating: data.rating, @@ -75,7 +76,6 @@ export function ReviewForm({ content: data.content, visit_date: data.visit_date || null, wait_time_minutes: data.wait_time_minutes || null, - photos: photos.length > 0 ? photos : null, moderation_status: 'pending' as const, ...(entityType === 'park' ? { park_id: entityId @@ -83,10 +83,34 @@ export function ReviewForm({ ride_id: entityId }) }; - const { - error - } = await supabase.from('reviews').insert([reviewData]); - if (error) throw error; + + const { data: review, error: reviewError } = await supabase + .from('reviews') + .insert([reviewData]) + .select() + .single(); + + if (reviewError) throw reviewError; + + // Insert photos into review_photos table if any + if (photos.length > 0 && review) { + const photoRecords = photos.map((url, index) => ({ + review_id: review.id, + cloudflare_image_id: url.split('/').slice(-2, -1)[0] || '', // Extract ID from URL + cloudflare_image_url: url, + order_index: index, + })); + + const { error: photosError } = await supabase + .from('review_photos') + .insert(photoRecords); + + if (photosError) { + console.error('Error inserting review photos:', photosError); + // Don't throw - review is already created + } + } + toast({ title: "Review Submitted!", description: "Thank you for your review. It will be published after moderation." diff --git a/src/components/upload/UppyPhotoSubmissionUpload.tsx b/src/components/upload/UppyPhotoSubmissionUpload.tsx index 49d7f031..9759becf 100644 --- a/src/components/upload/UppyPhotoSubmissionUpload.tsx +++ b/src/components/upload/UppyPhotoSubmissionUpload.tsx @@ -183,51 +183,67 @@ export function UppyPhotoSubmissionUpload({ setUploadProgress(null); - // Submit to database with Cloudflare URLs - const submissionData = { - user_id: user.id, - submission_type: 'photo', - content: { - title: title.trim() || undefined, - photos: photos.map((photo, index) => ({ - url: photo.uploadStatus === 'uploaded' ? photo.url : uploadedPhotos.find(p => p.order === photo.order)?.url || photo.url, - caption: photo.caption.trim(), - title: photo.title?.trim(), - date: photo.date?.toISOString(), - order: index, - // Include file metadata for moderation queue - filename: photo.file?.name, - size: photo.file?.size, - type: photo.file?.type, - })), - // NEW STRUCTURE: Generic entity references - context: finalEntityType, - entity_id: finalEntityId, - // Legacy structure for backwards compatibility - ...(finalEntityType === 'ride' && { ride_id: finalEntityId }), - ...(finalEntityType === 'park' && { park_id: finalEntityId }), - ...(finalParentId && finalEntityType === 'ride' && { park_id: finalParentId }), - ...(['manufacturer', 'operator', 'designer', 'property_owner'].includes(finalEntityType) && { company_id: finalEntityId }), - }, - }; - - // Debug logging for verification - console.log('Photo Submission Data:', { - entity_id: finalEntityId, - context: finalEntityType, - parent_id: finalParentId, - photo_count: photos.length, - submission_data: submissionData - }); - - const { error } = await supabase + // Create content_submission record first + const { data: submissionData, error: submissionError } = await supabase .from('content_submissions') - .insert(submissionData); + .insert({ + user_id: user.id, + submission_type: 'photo', + content: {}, // Empty content, all data is in relational tables + }) + .select() + .single(); - if (error) { - throw error; + if (submissionError || !submissionData) { + 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: finalEntityType, + entity_id: finalEntityId, + parent_id: finalParentId || null, + title: title.trim() || null, + }) + .select() + .single(); + + if (photoSubmissionError || !photoSubmissionData) { + throw photoSubmissionError || new Error('Failed to create photo submission'); + } + + // Insert all photo items + const photoItems = photos.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, + 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; + } + + console.log('✅ Photo submission created:', { + submission_id: submissionData.id, + photo_submission_id: photoSubmissionData.id, + entity_type: finalEntityType, + entity_id: finalEntityId, + photo_count: photoItems.length, + }); + toast({ title: 'Submission Successful', description: 'Your photos have been submitted for review. Thank you for contributing!', diff --git a/src/types/photo-submissions.ts b/src/types/photo-submissions.ts new file mode 100644 index 00000000..fb555d56 --- /dev/null +++ b/src/types/photo-submissions.ts @@ -0,0 +1,45 @@ +// TypeScript interfaces for the new photo submission relational tables + +export interface PhotoSubmission { + id: string; + submission_id: string; + entity_type: 'park' | 'ride' | 'manufacturer' | 'operator' | 'designer' | 'property_owner'; + entity_id: string; + parent_id?: string; + title?: string; + created_at: string; + updated_at: string; +} + +export interface PhotoSubmissionItem { + id: string; + photo_submission_id: string; + cloudflare_image_id: string; + cloudflare_image_url: string; + caption?: string; + title?: string; + filename?: string; + order_index: number; + file_size?: number; + mime_type?: string; + date_taken?: string; + created_at: string; +} + +export interface ReviewPhoto { + id: string; + review_id: string; + cloudflare_image_id: string; + cloudflare_image_url: string; + caption?: string; + order_index: number; + created_at: string; +} + +export interface PhotoSubmissionWithItems extends PhotoSubmission { + items: PhotoSubmissionItem[]; + submission?: { + user_id: string; + status: string; + }; +} diff --git a/supabase/migrations/20251001231654_3465c71f-8f18-4886-9806-eabffdb41887.sql b/supabase/migrations/20251001231654_3465c71f-8f18-4886-9806-eabffdb41887.sql new file mode 100644 index 00000000..480ad560 --- /dev/null +++ b/supabase/migrations/20251001231654_3465c71f-8f18-4886-9806-eabffdb41887.sql @@ -0,0 +1,14 @@ +-- Fix security warning: Set search_path on extract_cf_image_id function +CREATE OR REPLACE FUNCTION extract_cf_image_id(url TEXT) +RETURNS TEXT +LANGUAGE plpgsql +IMMUTABLE +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + -- Extract ID from imagedelivery.net URL pattern + -- Pattern: https://imagedelivery.net/{account-hash}/{image-id}/{variant} + RETURN (regexp_match(url, '/([a-f0-9-]+)/[a-z0-9]+$'))[1]; +END; +$$; \ No newline at end of file