diff --git a/src/components/moderation/TimelineEventPreview.tsx b/src/components/moderation/TimelineEventPreview.tsx new file mode 100644 index 00000000..582a4fec --- /dev/null +++ b/src/components/moderation/TimelineEventPreview.tsx @@ -0,0 +1,60 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Calendar, Tag } from 'lucide-react'; +import type { TimelineSubmissionData } from '@/types/timeline'; + +interface TimelineEventPreviewProps { + data: TimelineSubmissionData; +} + +export function TimelineEventPreview({ data }: TimelineEventPreviewProps) { + const formatEventType = (type: string) => { + return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); + }; + + return ( + + + + + Timeline Event: {data.title} + + + + + + Event Type: + + {formatEventType(data.event_type)} + + + + Date: + + + {new Date(data.event_date).toLocaleDateString()} + ({data.event_date_precision}) + + + + + {(data.from_value || data.to_value) && ( + + Change: + + {data.from_value || '—'} → {data.to_value || '—'} + + + )} + + {data.description && ( + + Description: + + {data.description} + + + )} + + + ); +} diff --git a/src/components/timeline/EntityTimelineManager.tsx b/src/components/timeline/EntityTimelineManager.tsx new file mode 100644 index 00000000..a00edfe1 --- /dev/null +++ b/src/components/timeline/EntityTimelineManager.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { TimelineEventEditorDialog } from './TimelineEventEditorDialog'; +import { EntityHistoryTimeline } from '@/components/history/EntityHistoryTimeline'; +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import type { EntityType, TimelineEvent } from '@/types/timeline'; + +interface EntityTimelineManagerProps { + entityType: EntityType; + entityId: string; + entityName: string; +} + +export function EntityTimelineManager({ + entityType, + entityId, + entityName, +}: EntityTimelineManagerProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + // Fetch timeline events + const { data: events, refetch } = useQuery({ + queryKey: ['timeline-events', entityType, entityId], + queryFn: async () => { + const { data, error } = await supabase + .from('entity_timeline_events') + .select('*') + .eq('entity_type', entityType) + .eq('entity_id', entityId) + .eq('is_public', true) + .not('approved_by', 'is', null) + .order('event_date', { ascending: false }); + + if (error) throw error; + return data as TimelineEvent[]; + }, + }); + + // Convert to HistoryEvent format for display + const historyEvents = events?.map((event) => { + // Map timeline event types to history event types + let mappedType: 'name_change' | 'status_change' | 'ownership_change' | 'relocation' | 'milestone' = 'milestone'; + + if (event.event_type === 'name_change') mappedType = 'name_change'; + else if (event.event_type === 'status_change') mappedType = 'status_change'; + else if (event.event_type === 'operator_change' || event.event_type === 'owner_change') mappedType = 'ownership_change'; + else if (event.event_type === 'location_change') mappedType = 'relocation'; + + return { + date: event.event_date, + title: event.title, + description: event.description, + type: mappedType, + from: event.from_value, + to: event.to_value, + }; + }) || []; + + return ( + + + + + Timeline & History + + Historical events and milestones for {entityName} + + + setIsDialogOpen(true)} size="sm"> + + Add Event + + + + + + + + refetch()} + /> + + ); +} diff --git a/src/components/timeline/TimelineEventEditorDialog.tsx b/src/components/timeline/TimelineEventEditorDialog.tsx new file mode 100644 index 00000000..d63cef60 --- /dev/null +++ b/src/components/timeline/TimelineEventEditorDialog.tsx @@ -0,0 +1,386 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +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 { useAuth } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/use-toast'; +import { submitTimelineEvent, submitTimelineEventUpdate } from '@/lib/entitySubmissionHelpers'; +import type { + EntityType, + TimelineEventFormData, +} from '@/types/timeline'; + +// Validation schema +const timelineEventSchema = z.object({ + event_type: z.enum([ + 'name_change', + 'operator_change', + 'owner_change', + 'location_change', + 'status_change', + 'closure', + 'reopening', + 'renovation', + 'expansion', + 'acquisition', + 'milestone', + 'other', + ]), + event_date: z.date({ + message: 'Event date is required', + }), + event_date_precision: z.enum(['day', 'month', 'year']).default('day'), + title: z.string().min(1, 'Title is required').max(200, 'Title is too long'), + description: z.string().max(1000, 'Description is too long').optional(), + + // Conditional fields + from_value: z.string().max(200).optional(), + to_value: z.string().max(200).optional(), + from_entity_id: z.string().uuid().optional(), + to_entity_id: z.string().uuid().optional(), + from_location_id: z.string().uuid().optional(), + to_location_id: z.string().uuid().optional(), + + is_public: z.boolean().default(true), +}); + +interface TimelineEventEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + entityType: EntityType; + entityId: string; + entityName: string; + existingEvent?: any; + onSuccess?: () => void; +} + +export function TimelineEventEditorDialog({ + open, + onOpenChange, + entityType, + entityId, + entityName, + existingEvent, + onSuccess, +}: TimelineEventEditorDialogProps) { + const { user } = useAuth(); + const { toast } = useToast(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isEditing = !!existingEvent; + + const form = useForm({ + resolver: zodResolver(timelineEventSchema), + defaultValues: existingEvent ? { + event_type: existingEvent.event_type, + event_date: new Date(existingEvent.event_date), + event_date_precision: existingEvent.event_date_precision, + title: existingEvent.title, + description: existingEvent.description || '', + from_value: existingEvent.from_value || '', + to_value: existingEvent.to_value || '', + from_entity_id: existingEvent.from_entity_id || '', + to_entity_id: existingEvent.to_entity_id || '', + from_location_id: existingEvent.from_location_id || '', + to_location_id: existingEvent.to_location_id || '', + is_public: existingEvent.is_public, + } : { + event_type: 'milestone', + event_date: new Date(), + event_date_precision: 'day', + title: '', + description: '', + is_public: true, + }, + }); + + const selectedEventType = form.watch('event_type'); + + const onSubmit = async (data: TimelineEventFormData) => { + if (!user) { + toast({ + title: 'Authentication required', + description: 'You must be logged in to submit timeline events.', + variant: 'destructive', + }); + return; + } + + setIsSubmitting(true); + try { + if (isEditing) { + await submitTimelineEventUpdate(existingEvent.id, data, user.id); + toast({ + title: 'Timeline event update submitted', + description: 'Your update has been submitted for moderator review.', + }); + } else { + await submitTimelineEvent(entityType, entityId, data, user.id); + toast({ + title: 'Timeline event submitted', + description: 'Your timeline event has been submitted for moderator review.', + }); + } + + form.reset(); + onOpenChange(false); + onSuccess?.(); + } catch (error) { + console.error('Failed to submit timeline event:', error); + toast({ + title: 'Submission failed', + description: error instanceof Error ? error.message : 'Failed to submit timeline event', + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + // Event type configurations for conditional field rendering + const showFromTo = [ + 'name_change', + 'operator_change', + 'owner_change', + 'status_change', + ].includes(selectedEventType); + + return ( + + + + + {isEditing ? 'Edit' : 'Add'} Timeline Event + + + Add a historical milestone or event for {entityName}. + All submissions go through moderator review. + + + + + + ( + + Event Type + + + + + + + + Name Change + Operator Change + Ownership Change + Relocation + Status Change + Closure + Reopening + Renovation + Expansion + Acquisition + Milestone + Other + + + + + )} + /> + + ( + + Event Title + + + + + A brief, descriptive title for this event + + + + )} + /> + + + ( + + Event Date + + + + )} + /> + + ( + + Date Precision + + + + + + + + Exact Day + Month Only + Year Only + + + + + )} + /> + + + {showFromTo && ( + + ( + + From + + + + + + )} + /> + + ( + + To + + + + + + )} + /> + + )} + + ( + + Description (Optional) + + + + + Provide additional details, context, or sources + + + + )} + /> + + ( + + + Public Event + + Make this event visible to all users + + + + + + + )} + /> + + + onOpenChange(false)} + disabled={isSubmitting} + > + Cancel + + + {isSubmitting && } + {isEditing ? 'Submit Update' : 'Submit for Review'} + + + + + + + ); +} diff --git a/src/components/timeline/index.ts b/src/components/timeline/index.ts new file mode 100644 index 00000000..23776193 --- /dev/null +++ b/src/components/timeline/index.ts @@ -0,0 +1,9 @@ +/** + * Timeline Components + * + * Components for managing entity timeline/historical milestones. + * All timeline events flow through the moderation queue. + */ + +export { TimelineEventEditorDialog } from './TimelineEventEditorDialog'; +export { EntityTimelineManager } from './EntityTimelineManager'; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 09686d5d..dc824810 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -584,6 +584,100 @@ export type Database = { } Relationships: [] } + entity_timeline_events: { + Row: { + approved_by: string | null + created_at: string + created_by: string | null + description: string | null + display_order: number | null + entity_id: string + entity_type: string + event_date: string + event_date_precision: string | null + event_type: string + from_entity_id: string | null + from_location_id: string | null + from_value: string | null + id: string + is_public: boolean + submission_id: string | null + title: string + to_entity_id: string | null + to_location_id: string | null + to_value: string | null + updated_at: string + } + Insert: { + approved_by?: string | null + created_at?: string + created_by?: string | null + description?: string | null + display_order?: number | null + entity_id: string + entity_type: string + event_date: string + event_date_precision?: string | null + event_type: string + from_entity_id?: string | null + from_location_id?: string | null + from_value?: string | null + id?: string + is_public?: boolean + submission_id?: string | null + title: string + to_entity_id?: string | null + to_location_id?: string | null + to_value?: string | null + updated_at?: string + } + Update: { + approved_by?: string | null + created_at?: string + created_by?: string | null + description?: string | null + display_order?: number | null + entity_id?: string + entity_type?: string + event_date?: string + event_date_precision?: string | null + event_type?: string + from_entity_id?: string | null + from_location_id?: string | null + from_value?: string | null + id?: string + is_public?: boolean + submission_id?: string | null + title?: string + to_entity_id?: string | null + to_location_id?: string | null + to_value?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "entity_timeline_events_from_location_id_fkey" + columns: ["from_location_id"] + isOneToOne: false + referencedRelation: "locations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "entity_timeline_events_submission_id_fkey" + columns: ["submission_id"] + isOneToOne: false + referencedRelation: "content_submissions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "entity_timeline_events_to_location_id_fkey" + columns: ["to_location_id"] + isOneToOne: false + referencedRelation: "locations" + referencedColumns: ["id"] + }, + ] + } entity_versions_archive: { Row: { change_reason: string | null diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index 1d4d6da5..9923253b 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -134,6 +134,9 @@ export interface CompanyFormData { card_image_id?: string; } +// Import timeline types +import type { TimelineEventFormData, TimelineSubmissionData, EntityType } from '@/types/timeline'; + /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * @@ -896,3 +899,185 @@ export async function submitPropertyOwnerUpdate( return { submitted: true, submissionId: submissionData.id }; } + +/** + * ⚠️ CRITICAL SECURITY PATTERN ⚠️ + * + * Submits a new timeline event for an entity through the moderation queue. + * + * DO NOT write directly to entity_timeline_events: + * ❌ await supabase.from('entity_timeline_events').insert(data) // BYPASSES MODERATION! + * ✅ await submitTimelineEvent(entityType, entityId, data, userId) // CORRECT + * + * Flow: User Submit → Moderation Queue → Approval → Database Write + * + * @param entityType - Type of entity (park, ride, company) + * @param entityId - ID of the entity + * @param data - The timeline event form data + * @param userId - The ID of the user submitting + * @returns Object containing submitted boolean and submissionId + */ +export async function submitTimelineEvent( + entityType: EntityType, + entityId: string, + data: TimelineEventFormData, + userId: string +): Promise<{ submitted: boolean; submissionId: string }> { + // Validate user + if (!userId) { + throw new Error('User ID is required for timeline event submission'); + } + + // Create submission content (minimal reference data only) + const content: Json = { + action: 'create', + entity_type: entityType, + entity_id: entityId, + }; + + // Create the main submission record + const { data: submission, error: submissionError } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: 'timeline_event', + content, + status: 'pending', + approval_mode: 'full', + }) + .select() + .single(); + + if (submissionError || !submission) { + console.error('Failed to create timeline event submission:', submissionError); + throw new Error('Failed to submit timeline event for review'); + } + + // Create submission item with actual data + const itemData: Record = { + entity_type: entityType, + entity_id: entityId, + event_type: data.event_type, + event_date: data.event_date.toISOString().split('T')[0], + event_date_precision: data.event_date_precision, + title: data.title, + description: data.description, + from_value: data.from_value, + to_value: data.to_value, + from_entity_id: data.from_entity_id, + to_entity_id: data.to_entity_id, + from_location_id: data.from_location_id, + to_location_id: data.to_location_id, + is_public: data.is_public ?? true, + }; + + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submission.id, + item_type: 'timeline_event', + action_type: 'create', + item_data: itemData as unknown as Json, + status: 'pending', + order_index: 0, + }); + + if (itemError) { + console.error('Failed to create timeline event item:', itemError); + throw new Error('Failed to submit timeline event item for review'); + } + + return { + submitted: true, + submissionId: submission.id, + }; +} + +/** + * ⚠️ CRITICAL SECURITY PATTERN ⚠️ + * + * Submits an update to an existing timeline event through the moderation queue. + * + * @param eventId - ID of the existing timeline event + * @param data - The updated timeline event data + * @param userId - The ID of the user submitting the update + * @returns Object containing submitted boolean and submissionId + */ +export async function submitTimelineEventUpdate( + eventId: string, + data: TimelineEventFormData, + userId: string +): Promise<{ submitted: boolean; submissionId: string }> { + // Fetch original event + const { data: originalEvent, error: fetchError } = await supabase + .from('entity_timeline_events') + .select('*') + .eq('id', eventId) + .single(); + + if (fetchError || !originalEvent) { + throw new Error('Failed to fetch original timeline event'); + } + + // Create submission + const content: Json = { + action: 'edit', + event_id: eventId, + entity_type: originalEvent.entity_type, + }; + + const { data: submission, error: submissionError } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: 'timeline_event', + content, + status: 'pending', + approval_mode: 'full', + }) + .select() + .single(); + + if (submissionError || !submission) { + throw new Error('Failed to create timeline event update submission'); + } + + // Create submission item + const itemData: Record = { + entity_type: originalEvent.entity_type, + entity_id: originalEvent.entity_id, + event_type: data.event_type, + event_date: data.event_date.toISOString().split('T')[0], + event_date_precision: data.event_date_precision, + title: data.title, + description: data.description, + from_value: data.from_value, + to_value: data.to_value, + from_entity_id: data.from_entity_id, + to_entity_id: data.to_entity_id, + from_location_id: data.from_location_id, + to_location_id: data.to_location_id, + is_public: data.is_public ?? true, + }; + + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submission.id, + item_type: 'timeline_event', + action_type: 'edit', + item_data: itemData as unknown as Json, + original_data: originalEvent as unknown as Json, + status: 'pending', + order_index: 0, + }); + + if (itemError) { + throw new Error('Failed to submit timeline event update item'); + } + + return { + submitted: true, + submissionId: submission.id, + }; +} diff --git a/src/types/timeline.ts b/src/types/timeline.ts new file mode 100644 index 00000000..fbe23c1c --- /dev/null +++ b/src/types/timeline.ts @@ -0,0 +1,86 @@ +/** + * Timeline Event Types + * + * Type definitions for entity timeline/historical milestone system. + * All timeline events flow through moderation queue before being stored. + */ + +export type TimelineEventType = + | 'name_change' + | 'operator_change' + | 'owner_change' + | 'location_change' + | 'status_change' + | 'closure' + | 'reopening' + | 'renovation' + | 'expansion' + | 'acquisition' + | 'milestone' + | 'other'; + +export type EntityType = 'park' | 'ride' | 'company'; + +export type DatePrecision = 'day' | 'month' | 'year'; + +/** + * Timeline event stored in database after approval + */ +export interface TimelineEvent { + id: string; + entity_id: string; + entity_type: EntityType; + event_type: TimelineEventType; + event_date: string; + event_date_precision: DatePrecision; + title: string; + description?: string; + + // Type-specific relational data + from_value?: string; + to_value?: string; + from_entity_id?: string; + to_entity_id?: string; + from_location_id?: string; + to_location_id?: string; + + // Metadata + is_public: boolean; + display_order: number; + created_by?: string; + approved_by?: string; + submission_id?: string; + + created_at: string; + updated_at: string; +} + +/** + * Form data for creating/editing timeline events + */ +export interface TimelineEventFormData { + event_type: TimelineEventType; + event_date: Date; + event_date_precision: DatePrecision; + title: string; + description?: string; + + // Conditional fields based on event_type + from_value?: string; + to_value?: string; + from_entity_id?: string; + to_entity_id?: string; + from_location_id?: string; + to_location_id?: string; + + is_public?: boolean; +} + +/** + * Complete submission data for timeline events + * Includes entity reference and all form data + */ +export interface TimelineSubmissionData extends TimelineEventFormData { + entity_id: string; + entity_type: EntityType; +} diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index 222eaf74..ea842f17 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -290,6 +290,9 @@ serve(async (req) => { await deletePhoto(supabase, resolvedData); entityId = resolvedData.photo_id; break; + case 'timeline_event': + entityId = await createTimelineEvent(supabase, resolvedData, submitterId, authenticatedUserId, submissionId); + break; default: throw new Error(`Unknown item type: ${item.item_type}`); } @@ -872,3 +875,42 @@ async function deletePhoto(supabase: any, data: any): Promise { if (error) throw new Error(`Failed to delete photo: ${error.message}`); } + +async function createTimelineEvent( + supabase: any, + data: any, + submitterId: string, + approvingUserId: string, + submissionId: string +): Promise { + console.log('Creating timeline event'); + + const eventData = { + entity_id: data.entity_id, + entity_type: data.entity_type, + event_type: data.event_type, + event_date: data.event_date, + event_date_precision: data.event_date_precision, + title: data.title, + description: data.description, + from_value: data.from_value, + to_value: data.to_value, + from_entity_id: data.from_entity_id, + to_entity_id: data.to_entity_id, + from_location_id: data.from_location_id, + to_location_id: data.to_location_id, + is_public: data.is_public ?? true, + created_by: submitterId, + approved_by: approvingUserId, + submission_id: submissionId, + }; + + const { data: event, error } = await supabase + .from('entity_timeline_events') + .insert(eventData) + .select('id') + .single(); + + if (error) throw new Error(`Failed to create timeline event: ${error.message}`); + return event.id; +} diff --git a/supabase/migrations/20251015192625_e5dbaea9-c017-4bd5-9803-e740adf3ca24.sql b/supabase/migrations/20251015192625_e5dbaea9-c017-4bd5-9803-e740adf3ca24.sql new file mode 100644 index 00000000..42a0b56d --- /dev/null +++ b/supabase/migrations/20251015192625_e5dbaea9-c017-4bd5-9803-e740adf3ca24.sql @@ -0,0 +1,66 @@ +-- Create entity_timeline_events table for historical milestones +CREATE TABLE public.entity_timeline_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_id UUID NOT NULL, + entity_type TEXT NOT NULL CHECK (entity_type IN ('park', 'ride', 'company')), + event_type TEXT NOT NULL CHECK (event_type IN ( + 'name_change', + 'operator_change', + 'owner_change', + 'location_change', + 'status_change', + 'closure', + 'reopening', + 'renovation', + 'expansion', + 'acquisition', + 'milestone', + 'other' + )), + event_date DATE NOT NULL, + event_date_precision TEXT DEFAULT 'day' CHECK (event_date_precision IN ('day', 'month', 'year')), + title TEXT NOT NULL, + description TEXT, + + -- Type-specific relational data (NO JSON!) + from_value TEXT, + to_value TEXT, + from_entity_id UUID, + to_entity_id UUID, + from_location_id UUID REFERENCES public.locations(id), + to_location_id UUID REFERENCES public.locations(id), + + -- Metadata + is_public BOOLEAN NOT NULL DEFAULT true, + display_order INTEGER DEFAULT 0, + created_by UUID REFERENCES auth.users(id), + approved_by UUID, + submission_id UUID REFERENCES public.content_submissions(id), + + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- Create indexes for performance +CREATE INDEX idx_timeline_events_entity ON public.entity_timeline_events(entity_type, entity_id); +CREATE INDEX idx_timeline_events_date ON public.entity_timeline_events(event_date DESC); +CREATE INDEX idx_timeline_events_submission ON public.entity_timeline_events(submission_id) WHERE submission_id IS NOT NULL; +CREATE INDEX idx_timeline_events_created_by ON public.entity_timeline_events(created_by); + +-- Enable RLS +ALTER TABLE public.entity_timeline_events ENABLE ROW LEVEL SECURITY; + +-- Public can view approved public events +CREATE POLICY "Public can view public timeline events" +ON public.entity_timeline_events FOR SELECT +USING (is_public = true AND approved_by IS NOT NULL); + +-- Users can view their own pending submissions +CREATE POLICY "Users can view their own timeline submissions" +ON public.entity_timeline_events FOR SELECT +USING (created_by = auth.uid() AND approved_by IS NULL); + +-- Service role can manage (for approval process) +CREATE POLICY "Service role can manage timeline events" +ON public.entity_timeline_events FOR ALL +USING (auth.jwt() ->> 'role' = 'service_role'); \ No newline at end of file
+ {formatEventType(data.event_type)} +
+ + {new Date(data.event_date).toLocaleDateString()} + ({data.event_date_precision}) +
+ {data.description} +