mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -05:00
Approve tool use
This commit is contained in:
@@ -9,7 +9,8 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { toast } from 'sonner';
|
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 { deleteTimelineEvent } from '@/lib/entitySubmissionHelpers';
|
||||||
import type { EntityType, TimelineEvent } from '@/types/timeline';
|
import type { EntityType, TimelineEvent } from '@/types/timeline';
|
||||||
|
|
||||||
@@ -114,11 +115,11 @@ export function EntityTimelineManager({
|
|||||||
toast.success('Event deleted', {
|
toast.success('Event deleted', {
|
||||||
description: 'Your timeline event has been deleted successfully.'
|
description: 'Your timeline event has been deleted successfully.'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
const errorMsg = getErrorMessage(error);
|
handleError(error, {
|
||||||
console.error('Delete error:', errorMsg);
|
action: 'Delete Timeline Event',
|
||||||
toast.error('Failed to delete event', {
|
userId: user?.id,
|
||||||
description: errorMsg
|
metadata: { eventId }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import { Loader2, Trash } from 'lucide-react';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { submitTimelineEvent, submitTimelineEventUpdate, deleteTimelineEvent } from '@/lib/entitySubmissionHelpers';
|
import { submitTimelineEvent, submitTimelineEventUpdate, deleteTimelineEvent } from '@/lib/entitySubmissionHelpers';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
import type {
|
import type {
|
||||||
EntityType,
|
EntityType,
|
||||||
TimelineEventFormData,
|
TimelineEventFormData,
|
||||||
@@ -170,12 +170,11 @@ export function TimelineEventEditorDialog({
|
|||||||
form.reset();
|
form.reset();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to submit timeline event:', error);
|
handleError(error, {
|
||||||
toast({
|
action: isEditing ? 'Update Timeline Event' : 'Submit Timeline Event',
|
||||||
title: 'Submission failed',
|
userId: user?.id,
|
||||||
description: error instanceof Error ? error.message : 'Failed to submit timeline event',
|
metadata: { entityType, entityId }
|
||||||
variant: 'destructive',
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -196,13 +195,11 @@ export function TimelineEventEditorDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
const errorMsg = getErrorMessage(error);
|
handleError(error, {
|
||||||
console.error('Delete error:', errorMsg);
|
action: 'Delete Timeline Event',
|
||||||
toast({
|
userId: user?.id,
|
||||||
title: 'Failed to delete event',
|
metadata: { eventId: existingEvent?.id }
|
||||||
description: errorMsg,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
|
|||||||
@@ -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: {
|
user_blocks: {
|
||||||
Row: {
|
Row: {
|
||||||
blocked_id: string
|
blocked_id: string
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { extractChangedFields } from './submissionChangeDetection';
|
|||||||
import type { CompanyDatabaseRecord, TimelineEventDatabaseRecord } from '@/types/company-data';
|
import type { CompanyDatabaseRecord, TimelineEventDatabaseRecord } from '@/types/company-data';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { getErrorMessage } from './errorHandler';
|
import { getErrorMessage } from './errorHandler';
|
||||||
|
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ═══════════════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -153,9 +154,6 @@ export interface RideModelFormData {
|
|||||||
card_image_id?: string;
|
card_image_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import timeline types
|
|
||||||
import type { TimelineEventFormData, TimelineSubmissionData, EntityType } from '@/types/timeline';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
|
||||||
*
|
*
|
||||||
@@ -1183,7 +1181,7 @@ export async function submitTimelineEvent(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const items = [{
|
const items = [{
|
||||||
item_type: 'milestone',
|
item_type: 'timeline_event',
|
||||||
action_type: 'create',
|
action_type: 'create',
|
||||||
item_data: itemData,
|
item_data: itemData,
|
||||||
order_index: 0,
|
order_index: 0,
|
||||||
@@ -1192,7 +1190,7 @@ export async function submitTimelineEvent(
|
|||||||
const { data: submissionId, error } = await supabase
|
const { data: submissionId, error } = await supabase
|
||||||
.rpc('create_submission_with_items', {
|
.rpc('create_submission_with_items', {
|
||||||
p_user_id: userId,
|
p_user_id: userId,
|
||||||
p_submission_type: 'milestone',
|
p_submission_type: 'timeline_event',
|
||||||
p_content: content,
|
p_content: content,
|
||||||
p_items: items as unknown as Json[],
|
p_items: items as unknown as Json[],
|
||||||
});
|
});
|
||||||
@@ -1255,7 +1253,7 @@ export async function submitTimelineEventUpdate(
|
|||||||
'create_submission_with_items',
|
'create_submission_with_items',
|
||||||
{
|
{
|
||||||
p_user_id: userId,
|
p_user_id: userId,
|
||||||
p_submission_type: 'milestone',
|
p_submission_type: 'timeline_event',
|
||||||
p_content: {
|
p_content: {
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
@@ -1263,7 +1261,7 @@ export async function submitTimelineEventUpdate(
|
|||||||
} as unknown as Json,
|
} as unknown as Json,
|
||||||
p_items: [
|
p_items: [
|
||||||
{
|
{
|
||||||
item_type: 'milestone',
|
item_type: 'timeline_event',
|
||||||
action_type: 'edit',
|
action_type: 'edit',
|
||||||
item_data: itemData,
|
item_data: itemData,
|
||||||
original_data: originalEvent,
|
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(
|
export async function deleteTimelineEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
userId: string
|
userId: string
|
||||||
|
|||||||
@@ -90,3 +90,21 @@ export interface RideModelSubmissionData {
|
|||||||
card_image_url?: string | null;
|
card_image_url?: string | null;
|
||||||
card_image_id?: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ serve(async (req) => {
|
|||||||
entityId = resolvedData.photo_id;
|
entityId = resolvedData.photo_id;
|
||||||
break;
|
break;
|
||||||
case 'milestone':
|
case 'milestone':
|
||||||
case 'timeline_event': // Keep for backward compatibility
|
case 'timeline_event': // Unified timeline event handling
|
||||||
entityId = await createTimelineEvent(supabase, resolvedData, submitterId, authenticatedUserId, submissionId);
|
entityId = await createTimelineEvent(supabase, resolvedData, submitterId, authenticatedUserId, submissionId);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user