mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 10:11:13 -05:00
Implement submission protections
This commit is contained in:
@@ -93,6 +93,13 @@ export function SubmissionReviewManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetchedItems = await fetchSubmissionItems(submissionId);
|
const fetchedItems = await fetchSubmissionItems(submissionId);
|
||||||
|
|
||||||
|
// Protection 2: Detect empty submissions
|
||||||
|
if (!fetchedItems || fetchedItems.length === 0) {
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const itemsWithDeps = buildDependencyTree(fetchedItems);
|
const itemsWithDeps = buildDependencyTree(fetchedItems);
|
||||||
setItems(itemsWithDeps);
|
setItems(itemsWithDeps);
|
||||||
|
|
||||||
@@ -524,6 +531,49 @@ export function SubmissionReviewManager({
|
|||||||
);
|
);
|
||||||
|
|
||||||
function ReviewContent() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 h-full">
|
<div className="flex flex-col gap-4 h-full">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -3590,10 +3590,23 @@ export type Database = {
|
|||||||
Args: { entity_type: string; keep_versions?: number }
|
Args: { entity_type: string; keep_versions?: number }
|
||||||
Returns: number
|
Returns: number
|
||||||
}
|
}
|
||||||
|
cleanup_orphaned_submissions: {
|
||||||
|
Args: Record<PropertyKey, never>
|
||||||
|
Returns: number
|
||||||
|
}
|
||||||
cleanup_rate_limits: {
|
cleanup_rate_limits: {
|
||||||
Args: Record<PropertyKey, never>
|
Args: Record<PropertyKey, never>
|
||||||
Returns: undefined
|
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: {
|
extend_submission_lock: {
|
||||||
Args: {
|
Args: {
|
||||||
extension_duration?: unknown
|
extension_duration?: unknown
|
||||||
|
|||||||
@@ -1097,24 +1097,7 @@ export async function submitTimelineEvent(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create the main submission record
|
// Create the main submission record
|
||||||
const { data: submission, error: submissionError } = await supabase
|
// Use atomic RPC function to create submission + items in transaction
|
||||||
.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
|
|
||||||
const itemData: Record<string, any> = {
|
const itemData: Record<string, any> = {
|
||||||
entity_type: entityType,
|
entity_type: entityType,
|
||||||
entity_id: entityId,
|
entity_id: entityId,
|
||||||
@@ -1129,28 +1112,32 @@ export async function submitTimelineEvent(
|
|||||||
to_entity_id: data.to_entity_id,
|
to_entity_id: data.to_entity_id,
|
||||||
from_location_id: data.from_location_id,
|
from_location_id: data.from_location_id,
|
||||||
to_location_id: data.to_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
|
const items = [{
|
||||||
.from('submission_items')
|
|
||||||
.insert({
|
|
||||||
submission_id: submission.id,
|
|
||||||
item_type: 'milestone',
|
item_type: 'milestone',
|
||||||
action_type: 'create',
|
action_type: 'create',
|
||||||
item_data: itemData as unknown as Json,
|
item_data: itemData,
|
||||||
status: 'pending',
|
|
||||||
order_index: 0,
|
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) {
|
if (error || !submissionId) {
|
||||||
console.error('Failed to create timeline event item:', itemError);
|
console.error('Failed to create timeline event submission:', error);
|
||||||
throw new Error('Failed to submit timeline event item for review');
|
throw new Error('Failed to submit timeline event for review');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
submitted: true,
|
submitted: true,
|
||||||
submissionId: submission.id,
|
submissionId: submissionId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user