Refactor: Remove item_edit_history_view

This commit is contained in:
gpt-engineer-app[bot]
2025-11-02 23:55:49 +00:00
parent 9c4b80e454
commit c59d8e40d5
8 changed files with 369 additions and 63 deletions

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
@@ -21,22 +22,28 @@ import { RideModelForm } from '@/components/admin/RideModelForm';
import { Save, X, Edit } from 'lucide-react'; import { Save, X, Edit } from 'lucide-react';
interface ItemEditDialogProps { interface ItemEditDialogProps {
item: SubmissionItemWithDeps | null; item?: SubmissionItemWithDeps | null;
items?: SubmissionItemWithDeps[];
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onComplete: () => 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 [submitting, setSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<string>(items?.[0]?.id || '');
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
const { user } = useAuth(); const { user } = useAuth();
const Container = isMobile ? Sheet : Dialog; const Container = isMobile ? Sheet : Dialog;
if (!item) return null; // Phase 5: Bulk edit mode
const bulkEditMode = items && items.length > 1;
const currentItem = bulkEditMode ? items.find(i => i.id === activeTab) : item;
const handleSubmit = async (data: Record<string, unknown>) => { if (!currentItem && !bulkEditMode) return null;
const handleSubmit = async (data: Record<string, unknown>, itemId?: string) => {
if (!user?.id) { if (!user?.id) {
toast({ toast({
title: 'Authentication Required', title: 'Authentication Required',
@@ -46,9 +53,12 @@ export function ItemEditDialog({ item, open, onOpenChange, onComplete }: ItemEdi
return; return;
} }
const targetItemId = itemId || currentItem?.id;
if (!targetItemId) return;
setSubmitting(true); setSubmitting(true);
try { try {
await editSubmissionItem(item.id, data, user.id); await editSubmissionItem(targetItemId, data, user.id);
toast({ toast({
title: isModerator() ? 'Item Updated' : 'Edit Submitted', 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.', : 'Your edit has been submitted and will be reviewed by a moderator.',
}); });
onComplete(); if (bulkEditMode && items) {
onOpenChange(false); // 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) { } catch (error: unknown) {
const errorMsg = getErrorMessage(error); const errorMsg = getErrorMessage(error);
toast({ toast({
@@ -85,10 +106,10 @@ export function ItemEditDialog({ item, open, onOpenChange, onComplete }: ItemEdi
await handleSubmit(photoData); await handleSubmit(photoData);
}; };
const renderEditForm = () => { const renderEditForm = (editItem: SubmissionItemWithDeps) => {
const data = item.item_data; const data = editItem.item_data;
switch (item.item_type) { switch (editItem.item_type) {
case 'park': case 'park':
return ( return (
<ParkForm <ParkForm
@@ -182,14 +203,31 @@ export function ItemEditDialog({ item, open, onOpenChange, onComplete }: ItemEdi
<SheetHeader> <SheetHeader>
<SheetTitle className="flex items-center gap-2"> <SheetTitle className="flex items-center gap-2">
<Edit className="w-5 h-5" /> <Edit className="w-5 h-5" />
Edit {item.item_type.replace('_', ' ')} {bulkEditMode ? `Edit ${items.length} Items` : `Edit ${currentItem?.item_type.replace('_', ' ')}`}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
Make changes to this submission item {bulkEditMode ? 'Edit multiple submission items using tabs' : 'Make changes to this submission item'}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="mt-6"> <div className="mt-6">
{renderEditForm()} {bulkEditMode && items ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${items.length}, 1fr)` }}>
{items.map((tabItem, index) => (
<TabsTrigger key={tabItem.id} value={tabItem.id}>
{index + 1}. {tabItem.item_type.replace('_', ' ')}
</TabsTrigger>
))}
</TabsList>
{items.map(tabItem => (
<TabsContent key={tabItem.id} value={tabItem.id} className="mt-4">
{renderEditForm(tabItem)}
</TabsContent>
))}
</Tabs>
) : currentItem ? (
renderEditForm(currentItem)
) : null}
</div> </div>
</SheetContent> </SheetContent>
) : ( ) : (
@@ -197,14 +235,31 @@ export function ItemEditDialog({ item, open, onOpenChange, onComplete }: ItemEdi
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Edit className="w-5 h-5" /> <Edit className="w-5 h-5" />
Edit {item.item_type.replace('_', ' ')} {bulkEditMode ? `Edit ${items.length} Items` : `Edit ${currentItem?.item_type.replace('_', ' ')}`}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Make changes to this submission item {bulkEditMode ? 'Edit multiple submission items using tabs' : 'Make changes to this submission item'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="mt-4"> <div className="mt-4">
{renderEditForm()} {bulkEditMode && items ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${items.length}, 1fr)` }}>
{items.map((tabItem, index) => (
<TabsTrigger key={tabItem.id} value={tabItem.id}>
{index + 1}. {tabItem.item_type.replace('_', ' ')}
</TabsTrigger>
))}
</TabsList>
{items.map(tabItem => (
<TabsContent key={tabItem.id} value={tabItem.id} className="mt-4">
{renderEditForm(tabItem)}
</TabsContent>
))}
</Tabs>
) : currentItem ? (
renderEditForm(currentItem)
) : null}
</div> </div>
</DialogContent> </DialogContent>
)} )}

View File

@@ -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 <CheckCircle className="h-4 w-4 text-green-600" />;
case 'rejected':
return <XCircle className="h-4 w-4 text-red-600" />;
default:
return <Clock className="h-4 w-4 text-yellow-600" />;
}
};
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit className="w-5 h-5" />
Select Item to Edit
</DialogTitle>
<DialogDescription>
This submission contains {items.length} items. Choose which one to edit, or edit all at once.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 mt-4">
{/* Bulk Edit Option */}
{onBulkEdit && items.length > 1 && (
<Button
variant="outline"
className="w-full justify-start h-auto py-4 border-2 border-primary/20 hover:border-primary/40"
onClick={onBulkEdit}
>
<Edit className="mr-3 h-5 w-5 text-primary" />
<div className="flex flex-col items-start">
<span className="font-semibold">Edit All Items ({items.length})</span>
<span className="text-xs text-muted-foreground">Use tabbed interface to edit multiple items</span>
</div>
</Button>
)}
{/* Individual Items */}
{items.map((item) => (
<Button
key={item.id}
variant="outline"
className="w-full justify-start h-auto py-4 hover:bg-accent"
onClick={() => onSelectItem(item)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<Edit className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex flex-col items-start">
<span className="font-medium capitalize">
{item.item_type.replace('_', ' ')}
</span>
{item.item_data.name && (
<span className="text-sm text-muted-foreground">
{String(item.item_data.name)}
</span>
)}
{item.dependencies && item.dependencies.length > 0 && (
<span className="text-xs text-muted-foreground mt-1">
Depends on {item.dependencies.length} item(s)
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{getStatusIcon(item.status)}
<Badge variant="outline" className={getStatusColor(item.status)}>
{item.status}
</Badge>
</div>
</div>
</Button>
))}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,6 +11,7 @@ import { getErrorMessage } from '@/lib/errorHandler';
import { PhotoModal } from './PhotoModal'; import { PhotoModal } from './PhotoModal';
import { SubmissionReviewManager } from './SubmissionReviewManager'; import { SubmissionReviewManager } from './SubmissionReviewManager';
import { ItemEditDialog } from './ItemEditDialog'; import { ItemEditDialog } from './ItemEditDialog';
import { ItemSelectorDialog } from './ItemSelectorDialog';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { useAdminSettings } from '@/hooks/useAdminSettings'; import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useModerationQueueManager } from '@/hooks/moderation'; import { useModerationQueueManager } from '@/hooks/moderation';
@@ -80,6 +81,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null); const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
const [showItemEditDialog, setShowItemEditDialog] = useState(false); const [showItemEditDialog, setShowItemEditDialog] = useState(false);
const [editingItem, setEditingItem] = useState<SubmissionItemWithDeps | null>(null); const [editingItem, setEditingItem] = useState<SubmissionItemWithDeps | null>(null);
const [showItemSelector, setShowItemSelector] = useState(false);
const [availableItems, setAvailableItems] = useState<SubmissionItemWithDeps[]>([]);
const [bulkEditMode, setBulkEditMode] = useState(false);
const [bulkEditItems, setBulkEditItems] = useState<SubmissionItemWithDeps[]>([]);
// Confirmation dialog state // Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState<{ const [confirmDialog, setConfirmDialog] = useState<{
@@ -176,6 +181,19 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
}, },
description: 'Focus filters', description: 'Focus filters',
}, },
{
key: 'e',
handler: () => {
// 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, enabled: true,
}); });
@@ -195,22 +213,55 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
try { try {
const items = await fetchSubmissionItems(submissionId); const items = await fetchSubmissionItems(submissionId);
// Find first pending item, fallback to first available if (!items || items.length === 0) {
let itemToEdit = items.find(item => item.status === 'pending');
if (!itemToEdit && items.length > 0) {
itemToEdit = items[0];
}
if (itemToEdit) {
setEditingItem(itemToEdit);
setShowItemEditDialog(true);
} else {
toast({ toast({
title: 'No Items Found', title: 'No Items Found',
description: 'This submission has no items to edit', description: 'This submission has no items to edit',
variant: 'destructive', 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) { } catch (error: unknown) {
toast({ toast({
title: 'Error', title: 'Error',
@@ -435,23 +486,48 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
submissionId={selectedSubmissionId} submissionId={selectedSubmissionId}
open={reviewManagerOpen} open={reviewManagerOpen}
onOpenChange={setReviewManagerOpen} onOpenChange={setReviewManagerOpen}
onComplete={() => setReviewManagerOpen(false)} onComplete={() => {
/> queueManager.refresh();
)} setSelectedSubmissionId(null);
{editingItem && (
<ItemEditDialog
item={editingItem}
open={showItemEditDialog}
onOpenChange={setShowItemEditDialog}
onComplete={async () => {
setShowItemEditDialog(false);
setEditingItem(null);
await queueManager.refresh();
}} }}
/> />
)} )}
{/* Phase 3: Item Selector Dialog */}
<ItemSelectorDialog
items={availableItems}
open={showItemSelector}
onOpenChange={setShowItemSelector}
onSelectItem={handleSelectItem}
onBulkEdit={() => {
setShowItemSelector(false);
setBulkEditItems(availableItems);
setBulkEditMode(true);
setShowItemEditDialog(true);
}}
/>
{/* Phase 4 & 5: Enhanced Item Edit Dialog */}
<ItemEditDialog
item={bulkEditMode ? null : editingItem}
items={bulkEditMode ? bulkEditItems : undefined}
open={showItemEditDialog}
onOpenChange={(open) => {
setShowItemEditDialog(open);
if (!open) {
setEditingItem(null);
setBulkEditMode(false);
setBulkEditItems([]);
}
}}
onComplete={() => {
queueManager.refresh();
setEditingItem(null);
setBulkEditMode(false);
setBulkEditItems([]);
}}
/>
{/* Confirmation Dialog */} {/* Confirmation Dialog */}
<ConfirmationDialog <ConfirmationDialog
open={confirmDialog.open} open={confirmDialog.open}

View File

@@ -256,13 +256,13 @@ export const QueueItemActions = memo(({
Review Items Review Items
</Button> </Button>
{isAdmin && isLockedByMe && ( {isLockedByMe && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
onClick={handleOpenItemEditor} onClick={handleOpenItemEditor}
disabled={actionLoading === item.id} disabled={actionLoading === item.id}
variant="ghost" variant="outline"
className={isMobile ? 'h-11' : ''} className={isMobile ? 'h-11' : ''}
size={isMobile ? "default" : "default"} size={isMobile ? "default" : "default"}
> >
@@ -271,7 +271,7 @@ export const QueueItemActions = memo(({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Quick edit first pending item</p> <p>Edit submission items (Press E)</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}

View File

@@ -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
});
}

View File

@@ -4595,27 +4595,6 @@ export type Database = {
} }
Relationships: [] 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: { moderation_sla_metrics: {
Row: { Row: {
avg_resolution_hours: number | null avg_resolution_hours: number | null

View File

@@ -1182,6 +1182,13 @@ export async function editSubmissionItem(
((currentItem.original_data && Object.keys(currentItem.original_data).length > 0) ? 'edit' : 'create'); ((currentItem.original_data && Object.keys(currentItem.original_data).length > 0) ? 'edit' : 'create');
if (isModerator) { 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 // Moderators can edit directly
const { error: updateError } = await supabase const { error: updateError } = await supabase
.from('submission_items') .from('submission_items')
@@ -1195,6 +1202,24 @@ export async function editSubmissionItem(
if (updateError) throw updateError; 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) // 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) // Only create version if this item has already been approved (has approved_entity_id)
if (currentItem.item_type !== 'photo' && currentItem.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 [];
}
}

View File

@@ -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