diff --git a/src/components/moderation/ItemEditDialog.tsx b/src/components/moderation/ItemEditDialog.tsx index c26ee924..80ffc482 100644 --- a/src/components/moderation/ItemEditDialog.tsx +++ b/src/components/moderation/ItemEditDialog.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { toast } from '@/hooks/use-toast'; import { useIsMobile } from '@/hooks/use-mobile'; import { useUserRole } from '@/hooks/useUserRole'; @@ -21,22 +22,28 @@ import { RideModelForm } from '@/components/admin/RideModelForm'; import { Save, X, Edit } from 'lucide-react'; interface ItemEditDialogProps { - item: SubmissionItemWithDeps | null; + item?: SubmissionItemWithDeps | null; + items?: SubmissionItemWithDeps[]; open: boolean; onOpenChange: (open: boolean) => void; onComplete: () => void; } -export function ItemEditDialog({ item, open, onOpenChange, onComplete }: ItemEditDialogProps) { +export function ItemEditDialog({ item, items, open, onOpenChange, onComplete }: ItemEditDialogProps) { const [submitting, setSubmitting] = useState(false); + const [activeTab, setActiveTab] = useState(items?.[0]?.id || ''); const isMobile = useIsMobile(); const { isModerator } = useUserRole(); const { user } = useAuth(); const Container = isMobile ? Sheet : Dialog; + + // Phase 5: Bulk edit mode + const bulkEditMode = items && items.length > 1; + const currentItem = bulkEditMode ? items.find(i => i.id === activeTab) : item; - if (!item) return null; + if (!currentItem && !bulkEditMode) return null; - const handleSubmit = async (data: Record) => { + const handleSubmit = async (data: Record, itemId?: string) => { if (!user?.id) { toast({ title: 'Authentication Required', @@ -45,10 +52,13 @@ export function ItemEditDialog({ item, open, onOpenChange, onComplete }: ItemEdi }); return; } + + const targetItemId = itemId || currentItem?.id; + if (!targetItemId) return; setSubmitting(true); try { - await editSubmissionItem(item.id, data, user.id); + await editSubmissionItem(targetItemId, data, user.id); toast({ title: isModerator() ? 'Item Updated' : 'Edit Submitted', @@ -57,8 +67,19 @@ export function ItemEditDialog({ item, open, onOpenChange, onComplete }: ItemEdi : 'Your edit has been submitted and will be reviewed by a moderator.', }); - onComplete(); - onOpenChange(false); + if (bulkEditMode && items) { + // Move to next tab or complete + const currentIndex = items.findIndex(i => i.id === activeTab); + if (currentIndex < items.length - 1) { + setActiveTab(items[currentIndex + 1].id); + } else { + onComplete(); + onOpenChange(false); + } + } else { + onComplete(); + onOpenChange(false); + } } catch (error: unknown) { const errorMsg = getErrorMessage(error); toast({ @@ -85,10 +106,10 @@ export function ItemEditDialog({ item, open, onOpenChange, onComplete }: ItemEdi await handleSubmit(photoData); }; - const renderEditForm = () => { - const data = item.item_data; + const renderEditForm = (editItem: SubmissionItemWithDeps) => { + const data = editItem.item_data; - switch (item.item_type) { + switch (editItem.item_type) { case 'park': return ( - Edit {item.item_type.replace('_', ' ')} + {bulkEditMode ? `Edit ${items.length} Items` : `Edit ${currentItem?.item_type.replace('_', ' ')}`} - Make changes to this submission item + {bulkEditMode ? 'Edit multiple submission items using tabs' : 'Make changes to this submission item'}
- {renderEditForm()} + {bulkEditMode && items ? ( + + + {items.map((tabItem, index) => ( + + {index + 1}. {tabItem.item_type.replace('_', ' ')} + + ))} + + {items.map(tabItem => ( + + {renderEditForm(tabItem)} + + ))} + + ) : currentItem ? ( + renderEditForm(currentItem) + ) : null}
) : ( @@ -197,14 +235,31 @@ export function ItemEditDialog({ item, open, onOpenChange, onComplete }: ItemEdi - Edit {item.item_type.replace('_', ' ')} + {bulkEditMode ? `Edit ${items.length} Items` : `Edit ${currentItem?.item_type.replace('_', ' ')}`} - Make changes to this submission item + {bulkEditMode ? 'Edit multiple submission items using tabs' : 'Make changes to this submission item'}
- {renderEditForm()} + {bulkEditMode && items ? ( + + + {items.map((tabItem, index) => ( + + {index + 1}. {tabItem.item_type.replace('_', ' ')} + + ))} + + {items.map(tabItem => ( + + {renderEditForm(tabItem)} + + ))} + + ) : currentItem ? ( + renderEditForm(currentItem) + ) : null}
)} diff --git a/src/components/moderation/ItemSelectorDialog.tsx b/src/components/moderation/ItemSelectorDialog.tsx new file mode 100644 index 00000000..fbbf34e1 --- /dev/null +++ b/src/components/moderation/ItemSelectorDialog.tsx @@ -0,0 +1,115 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Edit, CheckCircle, XCircle, Clock } from 'lucide-react'; +import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService'; + +interface ItemSelectorDialogProps { + items: SubmissionItemWithDeps[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectItem: (item: SubmissionItemWithDeps) => void; + onBulkEdit?: () => void; +} + +export function ItemSelectorDialog({ + items, + open, + onOpenChange, + onSelectItem, + onBulkEdit +}: ItemSelectorDialogProps) { + const getStatusIcon = (status: string) => { + switch (status) { + case 'approved': + return ; + case 'rejected': + return ; + default: + return ; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'approved': + return 'bg-green-100 text-green-800 border-green-200'; + case 'rejected': + return 'bg-red-100 text-red-800 border-red-200'; + case 'pending': + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + return ( + + + + + + Select Item to Edit + + + This submission contains {items.length} items. Choose which one to edit, or edit all at once. + + + +
+ {/* Bulk Edit Option */} + {onBulkEdit && items.length > 1 && ( + + )} + + {/* Individual Items */} + {items.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index b4a773eb..df86940f 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -11,6 +11,7 @@ import { getErrorMessage } from '@/lib/errorHandler'; import { PhotoModal } from './PhotoModal'; import { SubmissionReviewManager } from './SubmissionReviewManager'; import { ItemEditDialog } from './ItemEditDialog'; +import { ItemSelectorDialog } from './ItemSelectorDialog'; import { useIsMobile } from '@/hooks/use-mobile'; import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useModerationQueueManager } from '@/hooks/moderation'; @@ -80,6 +81,10 @@ export const ModerationQueue = forwardRef(null); const [showItemEditDialog, setShowItemEditDialog] = useState(false); const [editingItem, setEditingItem] = useState(null); + const [showItemSelector, setShowItemSelector] = useState(false); + const [availableItems, setAvailableItems] = useState([]); + const [bulkEditMode, setBulkEditMode] = useState(false); + const [bulkEditItems, setBulkEditItems] = useState([]); // Confirmation dialog state const [confirmDialog, setConfirmDialog] = useState<{ @@ -176,6 +181,19 @@ export const ModerationQueue = forwardRef { + // Edit first claimed submission + const claimedItem = queueManager.items.find(item => + queueManager.queue.isLockedByMe(item.id, item.assigned_to, item.locked_until) + ); + if (claimedItem) { + handleOpenItemEditor(claimedItem.id); + } + }, + description: 'Edit claimed submission', + }, ], enabled: true, }); @@ -195,22 +213,55 @@ export const ModerationQueue = forwardRef item.status === 'pending'); - if (!itemToEdit && items.length > 0) { - itemToEdit = items[0]; - } - - if (itemToEdit) { - setEditingItem(itemToEdit); - setShowItemEditDialog(true); - } else { + if (!items || items.length === 0) { toast({ title: 'No Items Found', description: 'This submission has no items to edit', variant: 'destructive', }); + return; } + + // Phase 3: Multi-item selector for submissions with multiple items + if (items.length > 1) { + setAvailableItems(items); + setShowItemSelector(true); + } else { + // Single item - edit directly + setEditingItem(items[0]); + setShowItemEditDialog(true); + } + } catch (error: unknown) { + toast({ + title: 'Error', + description: getErrorMessage(error), + variant: 'destructive', + }); + } + }; + + const handleSelectItem = (item: SubmissionItemWithDeps) => { + setEditingItem(item); + setShowItemSelector(false); + setShowItemEditDialog(true); + }; + + const handleBulkEdit = async (submissionId: string) => { + try { + const items = await fetchSubmissionItems(submissionId); + + if (!items || items.length === 0) { + toast({ + title: 'No Items Found', + description: 'This submission has no items to edit', + variant: 'destructive', + }); + return; + } + + setBulkEditItems(items); + setBulkEditMode(true); + setShowItemEditDialog(true); } catch (error: unknown) { toast({ title: 'Error', @@ -435,23 +486,48 @@ export const ModerationQueue = forwardRef setReviewManagerOpen(false)} - /> - )} - - {editingItem && ( - { - setShowItemEditDialog(false); - setEditingItem(null); - await queueManager.refresh(); + onComplete={() => { + queueManager.refresh(); + setSelectedSubmissionId(null); }} /> )} + {/* Phase 3: Item Selector Dialog */} + { + setShowItemSelector(false); + setBulkEditItems(availableItems); + setBulkEditMode(true); + setShowItemEditDialog(true); + }} + /> + + {/* Phase 4 & 5: Enhanced Item Edit Dialog */} + { + setShowItemEditDialog(open); + if (!open) { + setEditingItem(null); + setBulkEditMode(false); + setBulkEditItems([]); + } + }} + onComplete={() => { + queueManager.refresh(); + setEditingItem(null); + setBulkEditMode(false); + setBulkEditItems([]); + }} + /> + {/* Confirmation Dialog */} - {isAdmin && isLockedByMe && ( + {isLockedByMe && ( -

Quick edit first pending item

+

Edit submission items (Press E)

)} diff --git a/src/hooks/useEditHistory.ts b/src/hooks/useEditHistory.ts new file mode 100644 index 00000000..10de3686 --- /dev/null +++ b/src/hooks/useEditHistory.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchEditHistory } from '@/lib/submissionItemsService'; + +/** + * Phase 4: Hook to fetch edit history for a submission item + */ +export function useEditHistory(itemId: string | null) { + return useQuery({ + queryKey: ['item-edit-history', itemId], + queryFn: () => { + if (!itemId) return []; + return fetchEditHistory(itemId); + }, + enabled: !!itemId, + staleTime: 30000, // 30 seconds + }); +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index ccc18db7..b8052c6e 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -4595,27 +4595,6 @@ export type Database = { } Relationships: [] } - item_edit_history_view: { - Row: { - changes: Json | null - edited_at: string | null - editor_avatar_url: string | null - editor_display_name: string | null - editor_id: string | null - editor_username: string | null - id: string | null - item_id: string | null - } - Relationships: [ - { - foreignKeyName: "item_edit_history_item_id_fkey" - columns: ["item_id"] - isOneToOne: false - referencedRelation: "submission_items" - referencedColumns: ["id"] - }, - ] - } moderation_sla_metrics: { Row: { avg_resolution_hours: number | null diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts index 3c5667bf..46f20907 100644 --- a/src/lib/submissionItemsService.ts +++ b/src/lib/submissionItemsService.ts @@ -1182,6 +1182,13 @@ export async function editSubmissionItem( ((currentItem.original_data && Object.keys(currentItem.original_data).length > 0) ? 'edit' : 'create'); if (isModerator) { + // Phase 4: Track changes for edit history + const changes = { + before: currentItem.item_data, + after: newData, + timestamp: new Date().toISOString(), + }; + // Moderators can edit directly const { error: updateError } = await supabase .from('submission_items') @@ -1195,6 +1202,24 @@ export async function editSubmissionItem( if (updateError) throw updateError; + // Phase 4: Record edit history + const { error: historyError } = await supabase + .from('item_edit_history') + .insert({ + item_id: itemId, + editor_id: userId, + changes: changes, + }); + + if (historyError) { + logger.error('Failed to record edit history', { + itemId, + editorId: userId, + error: historyError.message, + }); + // Don't fail the whole operation if history tracking fails + } + // CRITICAL: Create version history if this is an entity edit (not photo) // Only create version if this item has already been approved (has approved_entity_id) if (currentItem.item_type !== 'photo' && currentItem.approved_entity_id) { @@ -1309,3 +1334,37 @@ export async function escalateSubmission( } } } + +/** + * Phase 4: Fetch edit history for a submission item + * Returns all edits with editor information + */ +export async function fetchEditHistory(itemId: string) { + try { + const { data, error } = await supabase + .from('item_edit_history') + .select(` + id, + changes, + edited_at, + editor:profiles!item_edit_history_editor_id_fkey ( + user_id, + username, + display_name, + avatar_url + ) + `) + .eq('item_id', itemId) + .order('edited_at', { ascending: false }); + + if (error) throw error; + + return data || []; + } catch (error: unknown) { + logger.error('Error fetching edit history', { + itemId, + error: getErrorMessage(error), + }); + return []; + } +} diff --git a/supabase/migrations/20251102235247_c1f7f3d3-7c4c-4833-b203-dc93df92cb8e.sql b/supabase/migrations/20251102235247_c1f7f3d3-7c4c-4833-b203-dc93df92cb8e.sql new file mode 100644 index 00000000..c1356ee8 --- /dev/null +++ b/supabase/migrations/20251102235247_c1f7f3d3-7c4c-4833-b203-dc93df92cb8e.sql @@ -0,0 +1,5 @@ +-- Fix security issue: Remove the view and use direct queries instead +DROP VIEW IF EXISTS public.item_edit_history_view; + +-- The item_edit_history table with proper RLS is sufficient +-- Application code will join with profiles table as needed \ No newline at end of file