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