From 625ba1a8e287011c7db0e57e67393a12a8b0900a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:44:17 +0000 Subject: [PATCH] Approve timeline integration plan --- src/components/history/EntityHistoryTabs.tsx | 14 +- .../timeline/EntityTimelineManager.tsx | 176 +++++++++++++++--- src/components/timeline/TimelineEventCard.tsx | 117 ++++++++++++ .../timeline/TimelineEventEditorDialog.tsx | 84 ++++++++- src/components/timeline/index.ts | 1 + src/lib/entitySubmissionHelpers.ts | 49 +++++ src/pages/DesignerDetail.tsx | 8 - src/pages/ManufacturerDetail.tsx | 8 - src/pages/OperatorDetail.tsx | 8 - src/pages/ParkDetail.tsx | 14 -- src/pages/PropertyOwnerDetail.tsx | 8 - src/pages/RideDetail.tsx | 14 -- ...1_f10072cf-23ab-45dd-8535-c228d91bc6c5.sql | 89 +++++++++ 13 files changed, 494 insertions(+), 96 deletions(-) create mode 100644 src/components/timeline/TimelineEventCard.tsx create mode 100644 supabase/migrations/20251015194121_f10072cf-23ab-45dd-8535-c228d91bc6c5.sql diff --git a/src/components/history/EntityHistoryTabs.tsx b/src/components/history/EntityHistoryTabs.tsx index b2a65833..028b9e1d 100644 --- a/src/components/history/EntityHistoryTabs.tsx +++ b/src/components/history/EntityHistoryTabs.tsx @@ -1,14 +1,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { EntityHistoryTimeline, HistoryEvent } from './EntityHistoryTimeline'; +import { EntityTimelineManager } from '@/components/timeline/EntityTimelineManager'; import { EntityVersionHistory } from '@/components/versioning/EntityVersionHistory'; import { FormerNamesSection } from './FormerNamesSection'; import { RideNameHistory } from '@/types/database'; +import type { EntityType } from '@/types/timeline'; interface EntityHistoryTabsProps { - entityType: 'park' | 'ride' | 'company'; + entityType: EntityType; entityId: string; entityName: string; - events: HistoryEvent[]; formerNames?: RideNameHistory[]; currentName?: string; } @@ -43,7 +43,6 @@ export function EntityHistoryTabs({ entityType, entityId, entityName, - events, formerNames, currentName, }: EntityHistoryTabsProps) { @@ -66,7 +65,12 @@ export function EntityHistoryTabs({ /> )} - + {/* Dynamic Timeline Manager with Edit/Delete */} + diff --git a/src/components/timeline/EntityTimelineManager.tsx b/src/components/timeline/EntityTimelineManager.tsx index a00edfe1..058e01ad 100644 --- a/src/components/timeline/EntityTimelineManager.tsx +++ b/src/components/timeline/EntityTimelineManager.tsx @@ -1,11 +1,15 @@ import { useState } from 'react'; -import { Plus } from 'lucide-react'; +import { Plus, Clock, CheckCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; import { TimelineEventEditorDialog } from './TimelineEventEditorDialog'; +import { TimelineEventCard } from './TimelineEventCard'; import { EntityHistoryTimeline } from '@/components/history/EntityHistoryTimeline'; import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from '@/hooks/useAuth'; +import { toast } from 'sonner'; +import { deleteTimelineEvent } from '@/lib/entitySubmissionHelpers'; import type { EntityType, TimelineEvent } from '@/types/timeline'; interface EntityTimelineManagerProps { @@ -20,10 +24,13 @@ export function EntityTimelineManager({ entityName, }: EntityTimelineManagerProps) { const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingEvent, setEditingEvent] = useState(null); + const [showPending, setShowPending] = useState(true); + const { user } = useAuth(); - // Fetch timeline events - const { data: events, refetch } = useQuery({ - queryKey: ['timeline-events', entityType, entityId], + // Fetch approved timeline events + const { data: approvedEvents, refetch: refetchApproved } = useQuery({ + queryKey: ['timeline-events', 'approved', entityType, entityId], queryFn: async () => { const { data, error } = await supabase .from('entity_timeline_events') @@ -39,9 +46,35 @@ export function EntityTimelineManager({ }, }); - // Convert to HistoryEvent format for display - const historyEvents = events?.map((event) => { - // Map timeline event types to history event types + // Fetch user's pending timeline events + const { data: pendingEvents, refetch: refetchPending } = useQuery({ + queryKey: ['timeline-events', 'pending', entityType, entityId, user?.id], + queryFn: async () => { + if (!user) return []; + + const { data, error } = await supabase + .from('entity_timeline_events') + .select('*') + .eq('entity_type', entityType) + .eq('entity_id', entityId) + .eq('created_by', user.id) + .is('approved_by', null) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data as TimelineEvent[]; + }, + enabled: !!user, + }); + + // Refetch both queries + const refetchAll = () => { + refetchApproved(); + refetchPending(); + }; + + // Convert approved events to HistoryEvent format for EntityHistoryTimeline + const historyEvents = approvedEvents?.map((event) => { let mappedType: 'name_change' | 'status_change' | 'ownership_change' | 'relocation' | 'milestone' = 'milestone'; if (event.event_type === 'name_change') mappedType = 'name_change'; @@ -59,34 +92,125 @@ export function EntityTimelineManager({ }; }) || []; + // Handle edit + const handleEdit = (event: TimelineEvent) => { + setEditingEvent(event); + setIsDialogOpen(true); + }; + + // Handle delete + const handleDelete = async (eventId: string) => { + if (!user) { + toast.error('Authentication required', { + description: 'Please sign in to delete timeline events.' + }); + return; + } + + try { + await deleteTimelineEvent(eventId, user.id); + refetchAll(); + toast.success('Event deleted', { + description: 'Your timeline event has been deleted successfully.' + }); + } catch (error: any) { + console.error('Delete error:', error); + toast.error('Failed to delete event', { + description: error.message || 'Please try again.' + }); + } + }; + + // Handle dialog close + const handleDialogChange = (open: boolean) => { + setIsDialogOpen(open); + if (!open) { + setEditingEvent(null); + } + }; + + // Handle success + const handleSuccess = () => { + refetchAll(); + setEditingEvent(null); + setIsDialogOpen(false); + }; + + const hasPendingEvents = pendingEvents && pendingEvents.length > 0; + return ( - - -
-
- Timeline & History - - Historical events and milestones for {entityName} - -
+
+ {/* Header with Add Event button */} +
+
+ {user && hasPendingEvents && ( + + )} +
+ {user && ( -
- - - - + )} +
+ {/* Pending Events Section */} + {user && hasPendingEvents && showPending && ( +
+
+ +

Pending Submissions

+
+
+ {pendingEvents.map((event) => ( + + ))} +
+ +
+ )} + + {/* Approved Timeline Events */} +
+
+ +

Timeline History

+
+ {historyEvents.length > 0 ? ( + + ) : ( +

+ No timeline events yet. Be the first to add one! +

+ )} +
+ + {/* Editor Dialog */} refetch()} + existingEvent={editingEvent} + onSuccess={handleSuccess} /> - +
); -} +} \ No newline at end of file diff --git a/src/components/timeline/TimelineEventCard.tsx b/src/components/timeline/TimelineEventCard.tsx new file mode 100644 index 00000000..e33ca45e --- /dev/null +++ b/src/components/timeline/TimelineEventCard.tsx @@ -0,0 +1,117 @@ +import { Edit, Trash, Clock, CheckCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { format } from 'date-fns'; +import type { TimelineEvent } from '@/types/timeline'; + +interface TimelineEventCardProps { + event: TimelineEvent; + onEdit?: (event: TimelineEvent) => void; + onDelete?: (eventId: string) => void; + canEdit: boolean; + canDelete: boolean; + isPending?: boolean; +} + +const formatEventDate = (date: string, precision: string = 'day') => { + const dateObj = new Date(date); + + switch (precision) { + case 'year': + return format(dateObj, 'yyyy'); + case 'month': + return format(dateObj, 'MMMM yyyy'); + case 'day': + default: + return format(dateObj, 'MMMM d, yyyy'); + } +}; + +const getEventTypeLabel = (type: string): string => { + return type.split('_').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' '); +}; + +export function TimelineEventCard({ + event, + onEdit, + onDelete, + canEdit, + canDelete, + isPending = false +}: TimelineEventCardProps) { + return ( + + +
+
+
+ + {getEventTypeLabel(event.event_type)} + + {isPending && ( + + + Pending Approval + + )} + {!isPending && ( + + + Approved + + )} +
+ +
+

{event.title}

+

+ {formatEventDate(event.event_date, event.event_date_precision)} +

+
+ + {event.description && ( +

{event.description}

+ )} + + {(event.from_value || event.to_value) && ( +
+ {event.from_value && From: {event.from_value}} + {event.from_value && event.to_value && } + {event.to_value && To: {event.to_value}} +
+ )} +
+ + {(canEdit || canDelete) && ( +
+ {canEdit && ( + + )} + {canDelete && ( + + )} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/timeline/TimelineEventEditorDialog.tsx b/src/components/timeline/TimelineEventEditorDialog.tsx index 9135b20f..836b2ad8 100644 --- a/src/components/timeline/TimelineEventEditorDialog.tsx +++ b/src/components/timeline/TimelineEventEditorDialog.tsx @@ -10,6 +10,17 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; import { Form, FormControl, @@ -31,10 +42,10 @@ import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { DatePicker } from '@/components/ui/date-picker'; import { Switch } from '@/components/ui/switch'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Trash } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import { useToast } from '@/hooks/use-toast'; -import { submitTimelineEvent, submitTimelineEventUpdate } from '@/lib/entitySubmissionHelpers'; +import { submitTimelineEvent, submitTimelineEventUpdate, deleteTimelineEvent } from '@/lib/entitySubmissionHelpers'; import type { EntityType, TimelineEventFormData, @@ -97,8 +108,12 @@ export function TimelineEventEditorDialog({ const { user } = useAuth(); const { toast } = useToast(); const [isSubmitting, setIsSubmitting] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const isEditing = !!existingEvent; + const dialogTitle = isEditing ? 'Edit Timeline Event' : 'Add Timeline Event'; + const submitButtonText = isEditing ? 'Update Event' : 'Submit for Review'; const form = useForm({ resolver: zodResolver(timelineEventSchema), @@ -168,6 +183,32 @@ export function TimelineEventEditorDialog({ } }; + const handleDelete = async () => { + if (!existingEvent || !user) return; + + setIsDeleting(true); + try { + await deleteTimelineEvent(existingEvent.id, user.id); + toast({ + title: 'Event deleted', + description: 'Your timeline event has been deleted successfully.', + }); + onSuccess?.(); + onOpenChange(false); + setShowDeleteConfirm(false); + form.reset(); + } catch (error: any) { + console.error('Delete error:', error); + toast({ + title: 'Failed to delete event', + description: error.message || 'Please try again.', + variant: 'destructive', + }); + } finally { + setIsDeleting(false); + } + }; + // Event type configurations for conditional field rendering const showFromTo = [ 'name_change', @@ -181,7 +222,7 @@ export function TimelineEventEditorDialog({ - {isEditing ? 'Edit' : 'Add'} Timeline Event + {dialogTitle} Add a historical milestone or event for {entityName}. @@ -366,7 +407,40 @@ export function TimelineEventEditorDialog({ )} /> - + + {isEditing && existingEvent?.approved_by === null && ( + + + + + + + Delete Timeline Event? + + This will permanently delete this timeline event. This action cannot be undone. + + + + Cancel + + {isDeleting ? "Deleting..." : "Delete"} + + + + + )} diff --git a/src/components/timeline/index.ts b/src/components/timeline/index.ts index 23776193..0816f92f 100644 --- a/src/components/timeline/index.ts +++ b/src/components/timeline/index.ts @@ -7,3 +7,4 @@ export { TimelineEventEditorDialog } from './TimelineEventEditorDialog'; export { EntityTimelineManager } from './EntityTimelineManager'; +export { TimelineEventCard } from './TimelineEventCard'; diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index 9923253b..a25ee943 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -1081,3 +1081,52 @@ export async function submitTimelineEventUpdate( submissionId: submission.id, }; } + +/** + * Delete a timeline event (only pending/own events) + * @param eventId - Timeline event ID + * @param userId - Current user ID + * @throws Error if event not found or cannot be deleted + */ +export async function deleteTimelineEvent( + eventId: string, + userId: string +): Promise { + // First verify the event exists and user has permission + const { data: event, error: fetchError } = await supabase + .from('entity_timeline_events') + .select('created_by, approved_by') + .eq('id', eventId) + .single(); + + if (fetchError) { + console.error('Error fetching timeline event:', fetchError); + throw new Error('Timeline event not found'); + } + + if (!event) { + throw new Error('Timeline event not found'); + } + + // Only allow deletion of own unapproved events + if (event.created_by !== userId) { + throw new Error('You can only delete your own timeline events'); + } + + if (event.approved_by !== null) { + throw new Error('Cannot delete approved timeline events'); + } + + // Delete the event + const { error: deleteError } = await supabase + .from('entity_timeline_events') + .delete() + .eq('id', eventId); + + if (deleteError) { + console.error('Error deleting timeline event:', deleteError); + throw new Error('Failed to delete timeline event'); + } + + console.log('✅ Timeline event deleted:', eventId); +} diff --git a/src/pages/DesignerDetail.tsx b/src/pages/DesignerDetail.tsx index 17cc8af3..a82caf14 100644 --- a/src/pages/DesignerDetail.tsx +++ b/src/pages/DesignerDetail.tsx @@ -331,14 +331,6 @@ export default function DesignerDetail() { entityType="company" entityId={designer.id} entityName={designer.name} - events={[ - ...(designer.founded_year ? [{ - date: `${designer.founded_year}`, - title: `${designer.name} Founded`, - description: `${designer.name} was established`, - type: 'milestone' as const - }] : []), - ]} />
diff --git a/src/pages/ManufacturerDetail.tsx b/src/pages/ManufacturerDetail.tsx index ba266b63..9ae3d368 100644 --- a/src/pages/ManufacturerDetail.tsx +++ b/src/pages/ManufacturerDetail.tsx @@ -365,14 +365,6 @@ export default function ManufacturerDetail() { entityType="company" entityId={manufacturer.id} entityName={manufacturer.name} - events={[ - ...(manufacturer.founded_year ? [{ - date: `${manufacturer.founded_year}`, - title: `${manufacturer.name} Founded`, - description: `${manufacturer.name} was established`, - type: 'milestone' as const - }] : []), - ]} /> diff --git a/src/pages/OperatorDetail.tsx b/src/pages/OperatorDetail.tsx index 74211069..c4dd6e3a 100644 --- a/src/pages/OperatorDetail.tsx +++ b/src/pages/OperatorDetail.tsx @@ -417,14 +417,6 @@ export default function OperatorDetail() { entityType="company" entityId={operator.id} entityName={operator.name} - events={[ - ...(operator.founded_year ? [{ - date: `${operator.founded_year}`, - title: `${operator.name} Founded`, - description: `${operator.name} was established`, - type: 'milestone' as const - }] : []), - ]} /> diff --git a/src/pages/ParkDetail.tsx b/src/pages/ParkDetail.tsx index abfed0bc..3dbcaded 100644 --- a/src/pages/ParkDetail.tsx +++ b/src/pages/ParkDetail.tsx @@ -637,20 +637,6 @@ export default function ParkDetail() { entityType="park" entityId={park.id} entityName={park.name} - events={[ - ...(park.opening_date ? [{ - date: park.opening_date, - title: `${park.name} Opened`, - description: `${park.name} opened to the public`, - type: 'milestone' as const - }] : []), - ...(park.closing_date ? [{ - date: park.closing_date, - title: `${park.name} Closed`, - description: `${park.name} ceased operation`, - type: 'status_change' as const - }] : []), - ]} /> diff --git a/src/pages/PropertyOwnerDetail.tsx b/src/pages/PropertyOwnerDetail.tsx index 463873dc..35616ce7 100644 --- a/src/pages/PropertyOwnerDetail.tsx +++ b/src/pages/PropertyOwnerDetail.tsx @@ -417,14 +417,6 @@ export default function PropertyOwnerDetail() { entityType="company" entityId={owner.id} entityName={owner.name} - events={[ - ...(owner.founded_year ? [{ - date: `${owner.founded_year}`, - title: `${owner.name} Founded`, - description: `${owner.name} was established`, - type: 'milestone' as const - }] : []), - ]} /> diff --git a/src/pages/RideDetail.tsx b/src/pages/RideDetail.tsx index 1bf60310..4eb5a694 100644 --- a/src/pages/RideDetail.tsx +++ b/src/pages/RideDetail.tsx @@ -707,20 +707,6 @@ export default function RideDetail() { entityName={ride.name} currentName={ride.name} formerNames={ride.name_history} - events={[ - ...(ride.opening_date ? [{ - date: ride.opening_date, - title: `${ride.name} Opened`, - description: `${ride.name} opened to the public at ${ride.park.name}`, - type: 'milestone' as const - }] : []), - ...(ride.closing_date ? [{ - date: ride.closing_date, - title: `${ride.name} Closed`, - description: `${ride.name} ceased operation`, - type: 'status_change' as const - }] : []), - ]} /> diff --git a/supabase/migrations/20251015194121_f10072cf-23ab-45dd-8535-c228d91bc6c5.sql b/supabase/migrations/20251015194121_f10072cf-23ab-45dd-8535-c228d91bc6c5.sql new file mode 100644 index 00000000..46b67469 --- /dev/null +++ b/supabase/migrations/20251015194121_f10072cf-23ab-45dd-8535-c228d91bc6c5.sql @@ -0,0 +1,89 @@ +-- Comprehensive RLS policies for entity_timeline_events + +-- Drop existing policies if any +DROP POLICY IF EXISTS "Public can view public timeline events" ON public.entity_timeline_events; +DROP POLICY IF EXISTS "Service role can manage timeline events" ON public.entity_timeline_events; +DROP POLICY IF EXISTS "Users can view their own timeline submissions" ON public.entity_timeline_events; + +-- Users can create timeline submissions (goes through moderation) +CREATE POLICY "Users can submit timeline events" +ON public.entity_timeline_events +FOR INSERT +TO authenticated +WITH CHECK ( + created_by = auth.uid() AND + approved_by IS NULL AND + submission_id IS NOT NULL +); + +-- Users can view their own pending submissions +CREATE POLICY "Users can view own pending timeline events" +ON public.entity_timeline_events +FOR SELECT +TO authenticated +USING ( + created_by = auth.uid() AND + approved_by IS NULL +); + +-- Users can update their own pending submissions +CREATE POLICY "Users can update own pending timeline events" +ON public.entity_timeline_events +FOR UPDATE +TO authenticated +USING ( + created_by = auth.uid() AND + approved_by IS NULL +) +WITH CHECK ( + created_by = auth.uid() AND + approved_by IS NULL +); + +-- Users can delete their own pending submissions only +CREATE POLICY "Users can delete own pending timeline events" +ON public.entity_timeline_events +FOR DELETE +TO authenticated +USING ( + created_by = auth.uid() AND + approved_by IS NULL +); + +-- Public can view approved timeline events +CREATE POLICY "Public can view approved timeline events" +ON public.entity_timeline_events +FOR SELECT +USING ( + is_public = true AND + approved_by IS NOT NULL +); + +-- Moderators can view all timeline events +CREATE POLICY "Moderators can view all timeline events" +ON public.entity_timeline_events +FOR SELECT +TO authenticated +USING (is_moderator(auth.uid())); + +-- Moderators can manage all timeline events with MFA +CREATE POLICY "Moderators can update timeline events" +ON public.entity_timeline_events +FOR UPDATE +TO authenticated +USING (is_moderator(auth.uid()) AND has_aal2()) +WITH CHECK (is_moderator(auth.uid()) AND has_aal2()); + +CREATE POLICY "Moderators can delete timeline events" +ON public.entity_timeline_events +FOR DELETE +TO authenticated +USING (is_moderator(auth.uid()) AND has_aal2()); + +-- Service role can manage all (for edge functions) +CREATE POLICY "Service role can manage timeline events" +ON public.entity_timeline_events +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); \ No newline at end of file