diff --git a/src/components/moderation/EntityEditPreview.tsx b/src/components/moderation/EntityEditPreview.tsx index 9b436461..6a522501 100644 --- a/src/components/moderation/EntityEditPreview.tsx +++ b/src/components/moderation/EntityEditPreview.tsx @@ -35,6 +35,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti const [cardImageUrl, setCardImageUrl] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); + const [isPhotoOperation, setIsPhotoOperation] = useState(false); useEffect(() => { fetchSubmissionItems(); @@ -57,6 +58,15 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti setItemData(firstItem.item_data); setOriginalData(firstItem.original_data); + // Check for photo edit/delete operations + if (firstItem.item_type === 'photo_edit' || firstItem.item_type === 'photo_delete') { + setIsPhotoOperation(true); + if (firstItem.item_type === 'photo_edit') { + setChangedFields(['caption']); + } + return; + } + // Parse changed fields const changed: string[] = []; const data = firstItem.item_data as any; @@ -121,6 +131,63 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti ); } + // Handle photo edit/delete operations + if (isPhotoOperation) { + const isEdit = changedFields.includes('caption'); + return ( +
+
+ + Photo + + + {isEdit ? 'Edit' : 'Delete'} + +
+ + {itemData?.cloudflare_image_url && ( + + + Photo to be modified + + + )} + + {isEdit && ( +
+
+ Old caption: + + {originalData?.caption || No caption} + +
+
+ New caption: + + {itemData?.new_caption || No caption} + +
+
+ )} + + {!isEdit && itemData?.reason && ( +
+ Reason: + {itemData.reason} +
+ )} + +
+ Click "Review Items" for full details +
+
+ ); + } + // Build photos array for modal const photos = []; if (bannerImageUrl) { diff --git a/src/components/upload/PhotoManagementDialog.tsx b/src/components/upload/PhotoManagementDialog.tsx index ee90b0dd..ef96cc00 100644 --- a/src/components/upload/PhotoManagementDialog.tsx +++ b/src/components/upload/PhotoManagementDialog.tsx @@ -77,55 +77,128 @@ export function PhotoManagementDialog({ - const deletePhoto = async (photoId: string) => { - if (!confirm('Are you sure you want to delete this photo?')) return; + const requestPhotoDelete = async (photoId: string, photo: Photo) => { + const reason = prompt('Please provide a reason for deleting this photo:'); + if (!reason) return; try { - const { error } = await supabase.from('photos').delete().eq('id', photoId); + // Get current user + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('Not authenticated'); - if (error) throw error; + // Create content submission + const { data: submission, error: submissionError } = await supabase + .from('content_submissions') + .insert([{ + user_id: user.id, + submission_type: 'photo_delete', + content: { + photo_id: photoId, + entity_type: entityType, + entity_id: entityId, + reason: reason + } + }]) + .select() + .single(); + + if (submissionError) throw submissionError; + + // Create submission item + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submission.id, + item_type: 'photo_delete', + item_data: { + photo_id: photoId, + entity_type: entityType, + entity_id: entityId, + cloudflare_image_url: photo.cloudflare_image_url, + caption: photo.caption, + reason: reason + }, + status: 'pending' + }); + + if (itemError) throw itemError; - await fetchPhotos(); toast({ - title: 'Success', - description: 'Photo deleted', + title: 'Delete request submitted', + description: 'Your photo deletion request has been submitted for moderation', }); - onUpdate?.(); + onOpenChange(false); } catch (error) { - console.error('Error deleting photo:', error); + console.error('Error requesting photo deletion:', error); toast({ title: 'Error', - description: 'Failed to delete photo', + description: 'Failed to submit deletion request', variant: 'destructive', }); } }; - const updatePhoto = async () => { + const requestPhotoEdit = async () => { if (!editingPhoto) return; try { - const { error } = await supabase - .from('photos') - .update({ - caption: editingPhoto.caption, - }) - .eq('id', editingPhoto.id); + // Get current user + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('Not authenticated'); - if (error) throw error; + // Get original photo data + const originalPhoto = photos.find(p => p.id === editingPhoto.id); + if (!originalPhoto) throw new Error('Original photo not found'); + + // Create content submission + const { data: submission, error: submissionError } = await supabase + .from('content_submissions') + .insert([{ + user_id: user.id, + submission_type: 'photo_edit', + content: { + photo_id: editingPhoto.id, + entity_type: entityType, + entity_id: entityId + } + }]) + .select() + .single(); + + if (submissionError) throw submissionError; + + // Create submission item + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submission.id, + item_type: 'photo_edit', + item_data: { + photo_id: editingPhoto.id, + entity_type: entityType, + entity_id: entityId, + new_caption: editingPhoto.caption, + cloudflare_image_url: editingPhoto.cloudflare_image_url, + }, + original_data: { + caption: originalPhoto.caption, + }, + status: 'pending' + }); + + if (itemError) throw itemError; - await fetchPhotos(); setEditingPhoto(null); toast({ - title: 'Success', - description: 'Photo updated', + title: 'Edit request submitted', + description: 'Your photo edit has been submitted for moderation', }); - onUpdate?.(); + onOpenChange(false); } catch (error) { - console.error('Error updating photo:', error); + console.error('Error requesting photo edit:', error); toast({ title: 'Error', - description: 'Failed to update photo', + description: 'Failed to submit edit request', variant: 'destructive', }); } @@ -167,7 +240,7 @@ export function PhotoManagementDialog({ - + @@ -223,16 +296,16 @@ export function PhotoManagementDialog({ className="flex-1 sm:flex-initial" > - Edit + Request Edit diff --git a/src/types/submissions.ts b/src/types/submissions.ts index 64f0b83c..52d36309 100644 --- a/src/types/submissions.ts +++ b/src/types/submissions.ts @@ -4,7 +4,9 @@ export type EntityType = | 'manufacturer' | 'operator' | 'designer' - | 'property_owner'; + | 'property_owner' + | 'photo_edit' + | 'photo_delete'; export interface PhotoSubmission { url: string; diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index 28ad833d..9bddaf82 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -120,6 +120,14 @@ serve(async (req) => { await approvePhotos(supabase, resolvedData, item.id); entityId = item.id; // Use item ID as entity ID for photos break; + case 'photo_edit': + await editPhoto(supabase, resolvedData); + entityId = resolvedData.photo_id; + break; + case 'photo_delete': + await deletePhoto(supabase, resolvedData); + entityId = resolvedData.photo_id; + break; default: throw new Error(`Unknown item type: ${item.item_type}`); } @@ -595,3 +603,25 @@ function extractImageId(url: string): string { const matches = url.match(/\/([^\/]+)\/public$/); return matches ? matches[1] : url; } + +async function editPhoto(supabase: any, data: any): Promise { + console.log(`Editing photo ${data.photo_id}`); + const { error } = await supabase + .from('photos') + .update({ + caption: data.new_caption, + }) + .eq('id', data.photo_id); + + if (error) throw new Error(`Failed to edit photo: ${error.message}`); +} + +async function deletePhoto(supabase: any, data: any): Promise { + console.log(`Deleting photo ${data.photo_id}`); + const { error } = await supabase + .from('photos') + .delete() + .eq('id', data.photo_id); + + if (error) throw new Error(`Failed to delete photo: ${error.message}`); +} diff --git a/supabase/migrations/20251002174244_ddacac58-98d8-454f-9e1b-658b9ac6b734.sql b/supabase/migrations/20251002174244_ddacac58-98d8-454f-9e1b-658b9ac6b734.sql new file mode 100644 index 00000000..075c0ec8 --- /dev/null +++ b/supabase/migrations/20251002174244_ddacac58-98d8-454f-9e1b-658b9ac6b734.sql @@ -0,0 +1,23 @@ +-- Restrict direct photo modifications - require moderation queue +-- Drop existing policies that allow direct modification +DROP POLICY IF EXISTS "Moderators can update photos" ON public.photos; +DROP POLICY IF EXISTS "Moderators can delete photos" ON public.photos; + +-- Keep read policies +-- Public read access to photos already exists + +-- Only service role (edge functions) can modify photos after approval +CREATE POLICY "Service role can insert photos" + ON public.photos FOR INSERT + TO service_role + WITH CHECK (true); + +CREATE POLICY "Service role can update photos" + ON public.photos FOR UPDATE + TO service_role + USING (true); + +CREATE POLICY "Service role can delete photos" + ON public.photos FOR DELETE + TO service_role + USING (true); \ No newline at end of file