diff --git a/src/components/timeline/EntityTimelineManager.tsx b/src/components/timeline/EntityTimelineManager.tsx index 0b124acb..dc0622ee 100644 --- a/src/components/timeline/EntityTimelineManager.tsx +++ b/src/components/timeline/EntityTimelineManager.tsx @@ -9,7 +9,8 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; import { toast } from 'sonner'; -import { getErrorMessage } from '@/lib/errorHandler'; +import { getErrorMessage, handleError } from '@/lib/errorHandler'; +import { logger } from '@/lib/logger'; import { deleteTimelineEvent } from '@/lib/entitySubmissionHelpers'; import type { EntityType, TimelineEvent } from '@/types/timeline'; @@ -114,11 +115,11 @@ export function EntityTimelineManager({ toast.success('Event deleted', { description: 'Your timeline event has been deleted successfully.' }); - } catch (error) { - const errorMsg = getErrorMessage(error); - console.error('Delete error:', errorMsg); - toast.error('Failed to delete event', { - description: errorMsg + } catch (error: unknown) { + handleError(error, { + action: 'Delete Timeline Event', + userId: user?.id, + metadata: { eventId } }); } }; diff --git a/src/components/timeline/TimelineEventEditorDialog.tsx b/src/components/timeline/TimelineEventEditorDialog.tsx index 8ac8f992..b9980aa7 100644 --- a/src/components/timeline/TimelineEventEditorDialog.tsx +++ b/src/components/timeline/TimelineEventEditorDialog.tsx @@ -46,7 +46,7 @@ import { Loader2, Trash } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import { useToast } from '@/hooks/use-toast'; import { submitTimelineEvent, submitTimelineEventUpdate, deleteTimelineEvent } from '@/lib/entitySubmissionHelpers'; -import { getErrorMessage } from '@/lib/errorHandler'; +import { handleError } from '@/lib/errorHandler'; import type { EntityType, TimelineEventFormData, @@ -170,12 +170,11 @@ export function TimelineEventEditorDialog({ 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', + } catch (error: unknown) { + handleError(error, { + action: isEditing ? 'Update Timeline Event' : 'Submit Timeline Event', + userId: user?.id, + metadata: { entityType, entityId } }); } finally { setIsSubmitting(false); @@ -196,13 +195,11 @@ export function TimelineEventEditorDialog({ onOpenChange(false); setShowDeleteConfirm(false); form.reset(); - } catch (error) { - const errorMsg = getErrorMessage(error); - console.error('Delete error:', errorMsg); - toast({ - title: 'Failed to delete event', - description: errorMsg, - variant: 'destructive', + } catch (error: unknown) { + handleError(error, { + action: 'Delete Timeline Event', + userId: user?.id, + metadata: { eventId: existingEvent?.id } }); } finally { setIsDeleting(false); diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 5011cbc6..4a8b1932 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -3235,6 +3235,94 @@ export type Database = { }, ] } + timeline_event_submissions: { + Row: { + created_at: string + description: string | null + display_order: number | null + entity_id: string + entity_type: string + event_date: string + event_date_precision: string + event_type: string + from_entity_id: string | null + from_location_id: string | null + from_value: string | null + id: string + is_public: boolean | null + submission_id: string + title: string + to_entity_id: string | null + to_location_id: string | null + to_value: string | null + updated_at: string + } + Insert: { + created_at?: string + description?: string | null + display_order?: number | null + entity_id: string + entity_type: string + event_date: string + event_date_precision: string + event_type: string + from_entity_id?: string | null + from_location_id?: string | null + from_value?: string | null + id?: string + is_public?: boolean | null + submission_id: string + title: string + to_entity_id?: string | null + to_location_id?: string | null + to_value?: string | null + updated_at?: string + } + Update: { + created_at?: string + description?: string | null + display_order?: number | null + entity_id?: string + entity_type?: string + event_date?: string + event_date_precision?: string + event_type?: string + from_entity_id?: string | null + from_location_id?: string | null + from_value?: string | null + id?: string + is_public?: boolean | null + submission_id?: string + title?: string + to_entity_id?: string | null + to_location_id?: string | null + to_value?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "timeline_event_submissions_from_location_id_fkey" + columns: ["from_location_id"] + isOneToOne: false + referencedRelation: "locations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "timeline_event_submissions_submission_id_fkey" + columns: ["submission_id"] + isOneToOne: false + referencedRelation: "content_submissions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "timeline_event_submissions_to_location_id_fkey" + columns: ["to_location_id"] + isOneToOne: false + referencedRelation: "locations" + referencedColumns: ["id"] + }, + ] + } user_blocks: { Row: { blocked_id: string diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index 6d4f7b48..d22fd1fd 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -7,6 +7,7 @@ import { extractChangedFields } from './submissionChangeDetection'; import type { CompanyDatabaseRecord, TimelineEventDatabaseRecord } from '@/types/company-data'; import { logger } from './logger'; import { getErrorMessage } from './errorHandler'; +import type { TimelineEventFormData, EntityType } from '@/types/timeline'; /** * ═══════════════════════════════════════════════════════════════════ @@ -153,9 +154,6 @@ export interface RideModelFormData { card_image_id?: string; } -// Import timeline types -import type { TimelineEventFormData, TimelineSubmissionData, EntityType } from '@/types/timeline'; - /** * ⚠️ CRITICAL SECURITY PATTERN ⚠️ * @@ -1183,7 +1181,7 @@ export async function submitTimelineEvent( }; const items = [{ - item_type: 'milestone', + item_type: 'timeline_event', action_type: 'create', item_data: itemData, order_index: 0, @@ -1192,7 +1190,7 @@ export async function submitTimelineEvent( const { data: submissionId, error } = await supabase .rpc('create_submission_with_items', { p_user_id: userId, - p_submission_type: 'milestone', + p_submission_type: 'timeline_event', p_content: content, p_items: items as unknown as Json[], }); @@ -1255,7 +1253,7 @@ export async function submitTimelineEventUpdate( 'create_submission_with_items', { p_user_id: userId, - p_submission_type: 'milestone', + p_submission_type: 'timeline_event', p_content: { action: 'edit', event_id: eventId, @@ -1263,7 +1261,7 @@ export async function submitTimelineEventUpdate( } as unknown as Json, p_items: [ { - item_type: 'milestone', + item_type: 'timeline_event', action_type: 'edit', item_data: itemData, original_data: originalEvent, @@ -1290,12 +1288,7 @@ export async function submitTimelineEventUpdate( }; } -/** - * 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 diff --git a/src/types/submission-data.ts b/src/types/submission-data.ts index 6f3facc3..f1003ceb 100644 --- a/src/types/submission-data.ts +++ b/src/types/submission-data.ts @@ -90,3 +90,21 @@ export interface RideModelSubmissionData { card_image_url?: string | null; card_image_id?: string | null; } + +export interface TimelineEventItemData { + entity_id: string; + entity_type: 'park' | 'ride' | 'company' | 'ride_model'; + event_type: string; + event_date: string; // ISO date + event_date_precision: 'day' | 'month' | 'year'; + title: string; + description?: string | null; + from_value?: string | null; + to_value?: string | null; + from_entity_id?: string | null; + to_entity_id?: string | null; + from_location_id?: string | null; + to_location_id?: string | null; + display_order?: number; + is_public?: boolean; +} diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index 043e6390..efe2c0bd 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -359,7 +359,7 @@ serve(async (req) => { entityId = resolvedData.photo_id; break; case 'milestone': - case 'timeline_event': // Keep for backward compatibility + case 'timeline_event': // Unified timeline event handling entityId = await createTimelineEvent(supabase, resolvedData, submitterId, authenticatedUserId, submissionId); break; default: diff --git a/supabase/migrations/20251020125418_1c81e2c8-a632-49cc-b965-c24aa9a9b9b7.sql b/supabase/migrations/20251020125418_1c81e2c8-a632-49cc-b965-c24aa9a9b9b7.sql new file mode 100644 index 00000000..4a07c1f9 --- /dev/null +++ b/supabase/migrations/20251020125418_1c81e2c8-a632-49cc-b965-c24aa9a9b9b7.sql @@ -0,0 +1,132 @@ +-- Phase 1: Timeline Event Submissions Infrastructure +-- Create timeline_event_submissions table (NO JSONB - fully relational) + +CREATE TABLE timeline_event_submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES content_submissions(id) ON DELETE CASCADE, + + -- Entity reference + entity_id UUID NOT NULL, + entity_type TEXT NOT NULL CHECK (entity_type IN ('park', 'ride', 'company', 'ride_model')), + + -- Event core data + event_type TEXT NOT NULL CHECK (event_type IN ( + 'opening', 'closing', 'name_change', 'relocation', 'renovation', + 'ownership_change', 'operator_change', 'status_change', 'milestone', 'other' + )), + event_date DATE NOT NULL, + event_date_precision TEXT NOT NULL CHECK (event_date_precision IN ('day', 'month', 'year')), + title TEXT NOT NULL CHECK (char_length(title) >= 1 AND char_length(title) <= 200), + description TEXT CHECK (description IS NULL OR char_length(description) <= 2000), + + -- Relational fields for specific event types (NO JSONB!) + from_value TEXT, + to_value TEXT, + from_entity_id UUID, + to_entity_id UUID, + from_location_id UUID REFERENCES locations(id), + to_location_id UUID REFERENCES locations(id), + + -- Display settings + display_order INTEGER DEFAULT 0, + is_public BOOLEAN DEFAULT TRUE, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for performance +CREATE INDEX idx_timeline_event_submissions_submission ON timeline_event_submissions(submission_id); +CREATE INDEX idx_timeline_event_submissions_entity ON timeline_event_submissions(entity_type, entity_id); +CREATE INDEX idx_timeline_event_submissions_date ON timeline_event_submissions(event_date); + +-- Enable RLS +ALTER TABLE timeline_event_submissions ENABLE ROW LEVEL SECURITY; + +-- RLS Policy: Users can insert their own timeline submissions +CREATE POLICY "Users can insert own timeline submissions" + ON timeline_event_submissions FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM content_submissions cs + WHERE cs.id = timeline_event_submissions.submission_id + AND cs.user_id = auth.uid() + ) + ); + +-- RLS Policy: Moderators can view all timeline submissions (with MFA) +CREATE POLICY "Moderators can view all timeline submissions" + ON timeline_event_submissions FOR SELECT + USING (is_moderator(auth.uid()) AND has_aal2()); + +-- RLS Policy: Users can view their own timeline submissions +CREATE POLICY "Users can view own timeline submissions" + ON timeline_event_submissions FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM content_submissions cs + WHERE cs.id = timeline_event_submissions.submission_id + AND cs.user_id = auth.uid() + ) + ); + +-- RLS Policy: Moderators can update timeline submissions (for review) +CREATE POLICY "Moderators can update timeline submissions" + ON timeline_event_submissions FOR UPDATE + USING (is_moderator(auth.uid()) AND has_aal2()) + WITH CHECK (is_moderator(auth.uid()) AND has_aal2()); + +-- RLS Policy: Moderators can delete timeline submissions +CREATE POLICY "Moderators can delete timeline submissions" + ON timeline_event_submissions FOR DELETE + USING (is_moderator(auth.uid()) AND has_aal2()); + +-- Update trigger for timestamps +CREATE TRIGGER update_timeline_event_submissions_updated_at + BEFORE UPDATE ON timeline_event_submissions + FOR EACH ROW + EXECUTE FUNCTION update_content_submissions_updated_at(); + +-- ===================================================== +-- Fix entity_timeline_events RLS (DENY direct writes) +-- ===================================================== + +-- Drop existing problematic policies if they exist +DROP POLICY IF EXISTS "Users can submit timeline events" ON entity_timeline_events; +DROP POLICY IF EXISTS "Users can delete own pending timeline events" ON entity_timeline_events; +DROP POLICY IF EXISTS "Users can update own pending timeline events" ON entity_timeline_events; +DROP POLICY IF EXISTS "Users can view own pending timeline events" ON entity_timeline_events; + +-- CRITICAL: Deny direct inserts from users (only service role via edge function) +CREATE POLICY "Deny direct inserts to timeline events" + ON entity_timeline_events FOR INSERT + WITH CHECK (FALSE); + +-- CRITICAL: Deny direct updates from users +CREATE POLICY "Deny direct updates to timeline events" + ON entity_timeline_events FOR UPDATE + USING (FALSE); + +-- Allow public to view approved events +DROP POLICY IF EXISTS "Public can view approved timeline events" ON entity_timeline_events; +CREATE POLICY "Public can view approved timeline events" + ON entity_timeline_events FOR SELECT + USING (is_public = TRUE AND approved_by IS NOT NULL); + +-- Moderators can view all events +DROP POLICY IF EXISTS "Moderators can view all timeline events" ON entity_timeline_events; +CREATE POLICY "Moderators can view all timeline events" + ON entity_timeline_events FOR SELECT + USING (is_moderator(auth.uid()) AND has_aal2()); + +-- Only moderators can delete (for cleanup/corrections) +DROP POLICY IF EXISTS "Moderators can delete timeline events" ON entity_timeline_events; +CREATE POLICY "Moderators can delete timeline events" + ON entity_timeline_events FOR DELETE + USING (is_moderator(auth.uid()) AND has_aal2()); + +-- Comment for clarity +COMMENT ON TABLE timeline_event_submissions IS 'Timeline event submissions go through moderation queue before being approved into entity_timeline_events'; +COMMENT ON POLICY "Deny direct inserts to timeline events" ON entity_timeline_events IS 'Only edge functions (service role) can insert approved timeline events'; +COMMENT ON POLICY "Deny direct updates to timeline events" ON entity_timeline_events IS 'Timeline events are immutable after approval - delete and recreate if needed'; \ No newline at end of file