mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 00:11:13 -05:00
Refactor: Complete Photo System Refactor
This commit is contained in:
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Edit, MapPin, Zap, Building2, Image, Package } from 'lucide-react';
|
import { Edit, MapPin, Zap, Building2, Image, Package } from 'lucide-react';
|
||||||
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay';
|
||||||
|
|
||||||
interface ItemReviewCardProps {
|
interface ItemReviewCardProps {
|
||||||
item: SubmissionItemWithDeps;
|
item: SubmissionItemWithDeps;
|
||||||
@@ -94,21 +95,8 @@ export function ItemReviewCard({ item, onEdit, onStatusChange }: ItemReviewCardP
|
|||||||
case 'photo':
|
case 'photo':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className={`grid gap-2 ${isMobile ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
{/* Fetch and display from photo_submission_items */}
|
||||||
{data.photos?.slice(0, isMobile ? 2 : 3).map((photo: any, idx: number) => (
|
<PhotoSubmissionDisplay submissionId={data.submission_id} />
|
||||||
<img
|
|
||||||
key={idx}
|
|
||||||
src={photo.url}
|
|
||||||
alt={photo.caption || 'Submission photo'}
|
|
||||||
className={`w-full object-cover rounded ${isMobile ? 'h-24' : 'h-20'}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{data.photos?.length > (isMobile ? 2 : 3) && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
+{data.photos.length - (isMobile ? 2 : 3)} more photo(s)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -503,38 +503,29 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
// Handle photo submissions - create photos records when approved
|
// Handle photo submissions - create photos records when approved
|
||||||
if (action === 'approved' && item.type === 'content_submission' && item.submission_type === 'photo') {
|
if (action === 'approved' && item.type === 'content_submission' && item.submission_type === 'photo') {
|
||||||
console.log('🖼️ [PHOTO APPROVAL] Starting photo submission approval');
|
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 {
|
try {
|
||||||
// Photo submissions have a simple structure: content contains { context, entity_id, photos }
|
// Fetch photo submission from new relational tables
|
||||||
const content = item.content;
|
const { data: photoSubmission, error: fetchError } = await supabase
|
||||||
const photosArray = content.photos;
|
.from('photo_submissions')
|
||||||
const entityId = content.entity_id;
|
.select(`
|
||||||
const entityType = content.context;
|
*,
|
||||||
|
items:photo_submission_items(*),
|
||||||
|
submission:content_submissions!inner(user_id, status)
|
||||||
|
`)
|
||||||
|
.eq('submission_id', item.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
console.log('🖼️ [PHOTO APPROVAL] Extracted data:', {
|
console.log('🖼️ [PHOTO APPROVAL] Fetched photo submission:', photoSubmission);
|
||||||
photosArray,
|
|
||||||
entityId,
|
|
||||||
entityType,
|
|
||||||
hasPhotosArray: !!photosArray,
|
|
||||||
photosCount: photosArray?.length
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!photosArray || !Array.isArray(photosArray) || photosArray.length === 0) {
|
if (fetchError || !photoSubmission) {
|
||||||
console.error('🖼️ [PHOTO APPROVAL] ERROR: No photos found in submission');
|
console.error('🖼️ [PHOTO APPROVAL] ERROR: Failed to fetch photo submission:', fetchError);
|
||||||
throw new Error('No photos found in submission');
|
throw new Error('Failed to fetch photo submission data');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entityId || !entityType) {
|
if (!photoSubmission.items || photoSubmission.items.length === 0) {
|
||||||
console.error('🖼️ [PHOTO APPROVAL] ERROR: Invalid entity information', {
|
console.error('🖼️ [PHOTO APPROVAL] ERROR: No photo items found');
|
||||||
entityId,
|
throw new Error('No photos found in submission');
|
||||||
entityType,
|
|
||||||
contentKeys: Object.keys(content),
|
|
||||||
fullContent: content
|
|
||||||
});
|
|
||||||
throw new Error('Invalid entity information in photo submission');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if photos already exist for this submission (in case of re-approval)
|
// Check if photos already exist for this submission (in case of re-approval)
|
||||||
@@ -559,75 +550,38 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
})
|
})
|
||||||
.eq('id', item.id);
|
.eq('id', item.id);
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
} else {
|
||||||
|
// Create new photo records from photo_submission_items
|
||||||
toast({
|
const photoRecords = photoSubmission.items.map((item) => ({
|
||||||
title: "Photos Re-Approved",
|
entity_id: photoSubmission.entity_id,
|
||||||
description: `Photo submission re-approved (${existingPhotos.length} existing photo(s))`,
|
entity_type: photoSubmission.entity_type,
|
||||||
});
|
cloudflare_image_id: item.cloudflare_image_id,
|
||||||
|
cloudflare_image_url: item.cloudflare_image_url,
|
||||||
fetchItems(activeEntityFilter, activeStatusFilter);
|
title: item.title || null,
|
||||||
return;
|
caption: item.caption || null,
|
||||||
}
|
date_taken: item.date_taken || null,
|
||||||
|
order_index: item.order_index,
|
||||||
// Helper function to extract Cloudflare image ID from URL
|
submission_id: photoSubmission.submission_id,
|
||||||
const extractImageId = (url: string): string | null => {
|
submitted_by: photoSubmission.submission?.user_id,
|
||||||
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,
|
|
||||||
approved_by: user?.id,
|
approved_by: user?.id,
|
||||||
approved_at: new Date().toISOString(),
|
approved_at: new Date().toISOString(),
|
||||||
};
|
}));
|
||||||
console.log('🖼️ [PHOTO APPROVAL] Photo record to insert:', record);
|
|
||||||
return record;
|
console.log('🖼️ [PHOTO APPROVAL] Creating photo records:', photoRecords);
|
||||||
});
|
|
||||||
|
const { data: createdPhotos, error: insertError } = await supabase
|
||||||
console.log('🖼️ [PHOTO APPROVAL] Attempting to insert photo records:', photoRecords);
|
.from('photos')
|
||||||
|
.insert(photoRecords)
|
||||||
const { data: createdPhotos, error: photoError } = await supabase
|
.select();
|
||||||
.from('photos')
|
|
||||||
.insert(photoRecords)
|
if (insertError) {
|
||||||
.select();
|
console.error('🖼️ [PHOTO APPROVAL] ERROR: Failed to insert photos:', insertError);
|
||||||
|
throw insertError;
|
||||||
if (photoError) {
|
}
|
||||||
console.error('🖼️ [PHOTO APPROVAL] Database error creating photos:', photoError);
|
|
||||||
throw new Error(`Failed to create photos: ${photoError.message}`);
|
console.log('🖼️ [PHOTO APPROVAL] ✅ Successfully created photos:', createdPhotos);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🖼️ [PHOTO APPROVAL] Successfully created photo records:', createdPhotos);
|
|
||||||
|
|
||||||
// Update submission status
|
// Update submission status
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
@@ -644,11 +598,11 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
throw updateError;
|
throw updateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🖼️ [PHOTO APPROVAL] ✅ Complete! Created', createdPhotos.length, 'photos');
|
console.log('🖼️ [PHOTO APPROVAL] ✅ Complete! Photos approved and published');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Photos Approved",
|
title: "Photos Approved",
|
||||||
description: `Successfully approved ${createdPhotos.length} photo(s)`,
|
description: `Successfully approved and published ${photoSubmission.items.length} photo(s)`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh the queue
|
// Refresh the queue
|
||||||
|
|||||||
64
src/components/moderation/PhotoSubmissionDisplay.tsx
Normal file
64
src/components/moderation/PhotoSubmissionDisplay.tsx
Normal file
@@ -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<PhotoSubmissionItem[]>([]);
|
||||||
|
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 <div className="text-sm text-muted-foreground">Loading photos...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return <div className="text-sm text-muted-foreground">No photos found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid gap-2 ${isMobile ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
||||||
|
{photos.slice(0, isMobile ? 2 : 3).map((photo) => (
|
||||||
|
<img
|
||||||
|
key={photo.id}
|
||||||
|
src={photo.cloudflare_image_url}
|
||||||
|
alt={photo.title || photo.caption || 'Submitted photo'}
|
||||||
|
className="w-full h-32 object-cover rounded-md"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{photos.length > (isMobile ? 2 : 3) && (
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center justify-center bg-muted rounded-md">
|
||||||
|
+{photos.length - (isMobile ? 2 : 3)} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ export function ReviewForm({
|
|||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
// Insert review first
|
||||||
const reviewData = {
|
const reviewData = {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
rating: data.rating,
|
rating: data.rating,
|
||||||
@@ -75,7 +76,6 @@ export function ReviewForm({
|
|||||||
content: data.content,
|
content: data.content,
|
||||||
visit_date: data.visit_date || null,
|
visit_date: data.visit_date || null,
|
||||||
wait_time_minutes: data.wait_time_minutes || null,
|
wait_time_minutes: data.wait_time_minutes || null,
|
||||||
photos: photos.length > 0 ? photos : null,
|
|
||||||
moderation_status: 'pending' as const,
|
moderation_status: 'pending' as const,
|
||||||
...(entityType === 'park' ? {
|
...(entityType === 'park' ? {
|
||||||
park_id: entityId
|
park_id: entityId
|
||||||
@@ -83,10 +83,34 @@ export function ReviewForm({
|
|||||||
ride_id: entityId
|
ride_id: entityId
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
const {
|
|
||||||
error
|
const { data: review, error: reviewError } = await supabase
|
||||||
} = await supabase.from('reviews').insert([reviewData]);
|
.from('reviews')
|
||||||
if (error) throw error;
|
.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({
|
toast({
|
||||||
title: "Review Submitted!",
|
title: "Review Submitted!",
|
||||||
description: "Thank you for your review. It will be published after moderation."
|
description: "Thank you for your review. It will be published after moderation."
|
||||||
|
|||||||
@@ -183,51 +183,67 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
|
|
||||||
setUploadProgress(null);
|
setUploadProgress(null);
|
||||||
|
|
||||||
// Submit to database with Cloudflare URLs
|
// Create content_submission record first
|
||||||
const submissionData = {
|
const { data: submissionData, error: submissionError } = await supabase
|
||||||
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
|
|
||||||
.from('content_submissions')
|
.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) {
|
if (submissionError || !submissionData) {
|
||||||
throw error;
|
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({
|
toast({
|
||||||
title: 'Submission Successful',
|
title: 'Submission Successful',
|
||||||
description: 'Your photos have been submitted for review. Thank you for contributing!',
|
description: 'Your photos have been submitted for review. Thank you for contributing!',
|
||||||
|
|||||||
45
src/types/photo-submissions.ts
Normal file
45
src/types/photo-submissions.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user