diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index a1d83ed9..d4a4f80b 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -93,6 +93,13 @@ export function SubmissionReviewManager({ } const fetchedItems = await fetchSubmissionItems(submissionId); + + // Protection 2: Detect empty submissions + if (!fetchedItems || fetchedItems.length === 0) { + setItems([]); + return; + } + const itemsWithDeps = buildDependencyTree(fetchedItems); setItems(itemsWithDeps); @@ -524,6 +531,49 @@ export function SubmissionReviewManager({ ); function ReviewContent() { + // Protection 2: UI detection of empty submissions + if (items.length === 0 && !loading) { + return ( + + + + This submission has no items and appears to be corrupted or incomplete. + This usually happens when the submission creation process was interrupted. +
+ +
+
+
+ ); + } + return (
+ Returns: number + } cleanup_rate_limits: { Args: Record Returns: undefined } + create_submission_with_items: { + Args: { + p_content: Json + p_items: Json[] + p_submission_type: string + p_user_id: string + } + Returns: string + } extend_submission_lock: { Args: { extension_duration?: unknown diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index 2bc6f2e0..31bc5635 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -1097,24 +1097,7 @@ export async function submitTimelineEvent( }; // Create the main submission record - const { data: submission, error: submissionError } = await supabase - .from('content_submissions') - .insert({ - user_id: userId, - submission_type: 'milestone', - 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 + // Use atomic RPC function to create submission + items in transaction const itemData: Record = { entity_type: entityType, entity_id: entityId, @@ -1129,28 +1112,32 @@ export async function submitTimelineEvent( to_entity_id: data.to_entity_id, from_location_id: data.from_location_id, to_location_id: data.to_location_id, - is_public: true, // All timeline events are public + is_public: true, }; - const { error: itemError } = await supabase - .from('submission_items') - .insert({ - submission_id: submission.id, - item_type: 'milestone', - action_type: 'create', - item_data: itemData as unknown as Json, - status: 'pending', - order_index: 0, + const items = [{ + item_type: 'milestone', + action_type: 'create', + item_data: itemData, + order_index: 0, + }]; + + const { data: submissionId, error } = await supabase + .rpc('create_submission_with_items', { + p_user_id: userId, + p_submission_type: 'milestone', + p_content: content, + p_items: items as any, }); - if (itemError) { - console.error('Failed to create timeline event item:', itemError); - throw new Error('Failed to submit timeline event item for review'); + if (error || !submissionId) { + console.error('Failed to create timeline event submission:', error); + throw new Error('Failed to submit timeline event for review'); } return { submitted: true, - submissionId: submission.id, + submissionId: submissionId, }; } diff --git a/supabase/migrations/20251017171115_4b513da1-302e-40b3-a10d-78446576677c.sql b/supabase/migrations/20251017171115_4b513da1-302e-40b3-a10d-78446576677c.sql new file mode 100644 index 00000000..27cebe12 --- /dev/null +++ b/supabase/migrations/20251017171115_4b513da1-302e-40b3-a10d-78446576677c.sql @@ -0,0 +1,84 @@ +-- Protection 1: Transaction support for atomic submission + items creation +CREATE OR REPLACE FUNCTION create_submission_with_items( + p_user_id UUID, + p_submission_type TEXT, + p_content JSONB, + p_items JSONB[] +) RETURNS UUID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_submission_id UUID; + v_item JSONB; +BEGIN + -- Insert submission + INSERT INTO content_submissions (user_id, submission_type, content, status, approval_mode) + VALUES (p_user_id, p_submission_type, p_content, 'pending', 'full') + RETURNING id INTO v_submission_id; + + -- Validate we have at least one item + IF array_length(p_items, 1) IS NULL OR array_length(p_items, 1) = 0 THEN + RAISE EXCEPTION 'Cannot create submission without items'; + END IF; + + -- Insert all items atomically (fails entire transaction if any fail) + FOREACH v_item IN ARRAY p_items + LOOP + INSERT INTO submission_items ( + submission_id, + item_type, + action_type, + item_data, + original_data, + status, + order_index, + depends_on + ) VALUES ( + v_submission_id, + (v_item->>'item_type')::TEXT, + (v_item->>'action_type')::TEXT, + v_item->'item_data', + v_item->'original_data', + 'pending', + COALESCE((v_item->>'order_index')::INTEGER, 0), + (v_item->>'depends_on')::UUID + ); + END LOOP; + + RETURN v_submission_id; +END; +$$; + +-- Protection 3: Cleanup job for orphaned submissions +CREATE OR REPLACE FUNCTION cleanup_orphaned_submissions() +RETURNS INTEGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + -- Delete submissions older than 1 hour with no items + DELETE FROM content_submissions + WHERE id IN ( + SELECT cs.id + FROM content_submissions cs + LEFT JOIN submission_items si ON si.submission_id = cs.id + WHERE si.id IS NULL + AND cs.created_at < NOW() - INTERVAL '1 hour' + AND cs.status = 'pending' + ); + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + + RAISE NOTICE 'Cleaned up % orphaned submissions', deleted_count; + RETURN deleted_count; +END; +$$; + +-- Grant execute permissions +GRANT EXECUTE ON FUNCTION create_submission_with_items TO authenticated; +GRANT EXECUTE ON FUNCTION cleanup_orphaned_submissions TO service_role; \ No newline at end of file