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) => (
-

- ))}
-
- {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) => (
+

+ ))}
+ {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