Implement submission protections

This commit is contained in:
gpt-engineer-app[bot]
2025-10-17 17:12:06 +00:00
parent 5fe16e51b4
commit 62c8b7f2c3
4 changed files with 166 additions and 32 deletions

View File

@@ -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 (
<Alert variant="destructive" className="my-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This submission has no items and appears to be corrupted or incomplete.
This usually happens when the submission creation process was interrupted.
<div className="mt-2 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const { supabase } = await import('@/integrations/supabase/client');
await supabase
.from('content_submissions')
.delete()
.eq('id', submissionId);
toast({
title: 'Submission Archived',
description: 'The corrupted submission has been removed',
});
onComplete();
onOpenChange(false);
} catch (error) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive',
});
}
}}
>
Archive Submission
</Button>
</div>
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4 h-full">
<Tabs

View File

@@ -3590,10 +3590,23 @@ export type Database = {
Args: { entity_type: string; keep_versions?: number }
Returns: number
}
cleanup_orphaned_submissions: {
Args: Record<PropertyKey, never>
Returns: number
}
cleanup_rate_limits: {
Args: Record<PropertyKey, never>
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

View File

@@ -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<string, any> = {
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,
const items = [{
item_type: 'milestone',
action_type: 'create',
item_data: itemData as unknown as Json,
status: 'pending',
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,
};
}

View File

@@ -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;