Fix photo and timeline submission bulletproofing

Implement rate limiting, validation, retry logic, and ban checking for photo and timeline submissions. This includes updates to `UppyPhotoSubmissionUpload.tsx` and `entitySubmissionHelpers.ts`.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-07 19:27:30 +00:00
parent 91a5b0e7dd
commit 6731e074a7
2 changed files with 380 additions and 146 deletions

View File

@@ -2463,84 +2463,160 @@ export async function submitTimelineEvent(
data: TimelineEventFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Validate user
// ✅ Phase 4: Validate user
if (!userId) {
throw new Error('User ID is required for timeline event submission');
}
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'timeline_event',
status: 'pending' as const
})
.select('id')
.single();
// ✅ Phase 4: Rate limiting check
checkRateLimitOrThrow(userId, 'timeline_event_creation');
recordSubmissionAttempt(userId);
if (submissionError) {
handleError(submissionError, {
action: 'Submit timeline event',
userId,
});
throw new Error('Failed to create timeline event submission');
// ✅ Phase 4: Validation
if (!data.title?.trim()) {
throw new Error('Timeline event title is required');
}
if (!data.event_date) {
throw new Error('Timeline event date is required');
}
if (!data.event_type) {
throw new Error('Timeline event type is required');
}
// ✅ FIXED: Insert into timeline_event_submissions table (relational pattern)
const { data: timelineSubmission, error: timelineSubmissionError } = await supabase
.from('timeline_event_submissions')
.insert({
submission_id: submissionData.id,
entity_type: entityType,
entity_id: entityId,
event_type: data.event_type,
event_date: data.event_date.toISOString().split('T')[0],
event_date_precision: data.event_date_precision,
title: data.title,
description: data.description,
from_value: data.from_value,
to_value: data.to_value,
from_entity_id: data.from_entity_id,
to_entity_id: data.to_entity_id,
from_location_id: data.from_location_id,
to_location_id: data.to_location_id,
is_public: true,
})
.select('id')
.single();
// ✅ Phase 4: Breadcrumb tracking
breadcrumb.userAction('Start timeline event submission', 'submitTimelineEvent', {
entityType,
entityId,
eventType: data.event_type,
userId
});
if (timelineSubmissionError) {
handleError(timelineSubmissionError, {
action: 'Submit timeline event data',
userId,
});
throw new Error('Failed to submit timeline event for review');
// ✅ Phase 4: Ban check with retry
breadcrumb.apiCall('profiles', 'SELECT');
const { withRetry } = await import('./retryHelpers');
const profile = await withRetry(
async () => {
const { data, error } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
if (error) throw error;
return data;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
throw new Error('Account suspended. Contact support for assistance.');
}
// ✅ Create submission_items referencing timeline_event_submission (no JSON data)
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'timeline_event',
action_type: 'create',
item_data: {
entity_type: entityType,
entity_id: entityId
} as Json,
status: 'pending' as const,
order_index: 0,
timeline_event_submission_id: timelineSubmission.id
});
// ✅ Phase 4: Create submission with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const submissionData = await withRetry(
async () => {
const { data, error } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'timeline_event',
status: 'pending' as const
})
.select('id')
.single();
if (itemError) {
handleError(itemError, {
action: 'Create timeline event submission item',
userId,
});
throw new Error('Failed to link timeline event submission');
}
if (error) throw error;
if (!data) throw new Error('Failed to create timeline event submission');
return data;
},
{
onRetry: (attempt, error, delay) => {
logger.warn('Retrying timeline event submission creation', {
attempt,
delay,
userId,
eventType: data.event_type
});
}
}
);
// ✅ Phase 4: Insert timeline_event_submission with retry
breadcrumb.apiCall('timeline_event_submissions', 'INSERT');
const timelineSubmission = await withRetry(
async () => {
const { data: insertedData, error } = await supabase
.from('timeline_event_submissions')
.insert({
submission_id: submissionData.id,
entity_type: entityType,
entity_id: entityId,
event_type: data.event_type,
event_date: data.event_date.toISOString().split('T')[0],
event_date_precision: data.event_date_precision,
title: data.title,
description: data.description,
from_value: data.from_value,
to_value: data.to_value,
from_entity_id: data.from_entity_id,
to_entity_id: data.to_entity_id,
from_location_id: data.from_location_id,
to_location_id: data.to_location_id,
is_public: true,
})
.select('id')
.single();
if (error) throw error;
if (!insertedData) throw new Error('Failed to submit timeline event for review');
return insertedData;
},
{
onRetry: (attempt, error, delay) => {
logger.warn('Retrying timeline event data insertion', {
attempt,
delay,
submissionId: submissionData.id
});
}
}
);
// ✅ Phase 4: Create submission_items with retry
breadcrumb.apiCall('submission_items', 'INSERT');
await withRetry(
async () => {
const { error } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'timeline_event',
action_type: 'create',
item_data: {
entity_type: entityType,
entity_id: entityId
} as Json,
status: 'pending' as const,
order_index: 0,
timeline_event_submission_id: timelineSubmission.id
});
if (error) throw error;
},
{
onRetry: (attempt, error, delay) => {
logger.warn('Retrying timeline event submission item creation', {
attempt,
delay,
submissionId: submissionData.id
});
}
}
);
return {
submitted: true,
@@ -2563,95 +2639,185 @@ export async function submitTimelineEventUpdate(
data: TimelineEventFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Fetch original event
const { data: originalEvent, error: fetchError } = await supabase
.from('entity_timeline_events')
.select('*')
.eq('id', eventId)
.single();
if (fetchError || !originalEvent) {
throw new Error('Failed to fetch original timeline event');
// ✅ Phase 4: Validate user
if (!userId) {
throw new Error('User ID is required for timeline event update');
}
// ✅ Phase 4: Rate limiting check
checkRateLimitOrThrow(userId, 'timeline_event_update');
recordSubmissionAttempt(userId);
// ✅ Phase 4: Validation
if (!data.title?.trim()) {
throw new Error('Timeline event title is required');
}
if (!data.event_date) {
throw new Error('Timeline event date is required');
}
// ✅ Phase 4: Breadcrumb tracking
breadcrumb.userAction('Start timeline event update', 'submitTimelineEventUpdate', {
eventId,
userId
});
// ✅ Phase 4: Ban check with retry
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data, error } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
if (error) throw error;
return data;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
throw new Error('Account suspended. Contact support for assistance.');
}
// Fetch original event with retry
breadcrumb.apiCall('entity_timeline_events', 'SELECT');
const originalEvent = await withRetry(
async () => {
const { data, error } = await supabase
.from('entity_timeline_events')
.select('*')
.eq('id', eventId)
.single();
if (error) throw error;
if (!data) throw new Error('Failed to fetch original timeline event');
return data;
},
{ maxAttempts: 2 }
);
// Extract only changed fields from form data
const changedFields = extractChangedFields(data, originalEvent as Partial<Record<string, unknown>>);
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'timeline_event',
status: 'pending' as const
})
.select('id')
.single();
// ✅ Phase 4: Create submission with retry
breadcrumb.apiCall('content_submissions', 'INSERT');
const submissionData = await withRetry(
async () => {
const { data, error } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'timeline_event',
status: 'pending' as const
})
.select('id')
.single();
if (submissionError) {
handleError(submissionError, {
action: 'Update timeline event',
metadata: { eventId },
});
throw new Error('Failed to create timeline event update submission');
}
if (error) throw error;
if (!data) throw new Error('Failed to create timeline event update submission');
return data;
},
{
onRetry: (attempt, error, delay) => {
logger.warn('Retrying timeline event update submission', {
attempt,
delay,
eventId,
userId
});
}
}
);
// ✅ FIXED: Insert into timeline_event_submissions table (relational pattern)
const { data: timelineSubmission, error: timelineSubmissionError } = await supabase
.from('timeline_event_submissions')
.insert({
submission_id: submissionData.id,
entity_type: originalEvent.entity_type,
entity_id: originalEvent.entity_id,
event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type,
event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date,
event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day',
title: changedFields.title !== undefined ? changedFields.title : originalEvent.title,
description: changedFields.description !== undefined ? changedFields.description : originalEvent.description,
from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value,
to_value: changedFields.to_value !== undefined ? changedFields.to_value : originalEvent.to_value,
from_entity_id: changedFields.from_entity_id !== undefined ? changedFields.from_entity_id : originalEvent.from_entity_id,
to_entity_id: changedFields.to_entity_id !== undefined ? changedFields.to_entity_id : originalEvent.to_entity_id,
from_location_id: changedFields.from_location_id !== undefined ? changedFields.from_location_id : originalEvent.from_location_id,
to_location_id: changedFields.to_location_id !== undefined ? changedFields.to_location_id : originalEvent.to_location_id,
is_public: true,
})
.select('id')
.single();
// ✅ Phase 4: Insert timeline_event_submission with retry
breadcrumb.apiCall('timeline_event_submissions', 'INSERT');
const timelineSubmission = await withRetry(
async () => {
const { data: insertedData, error } = await supabase
.from('timeline_event_submissions')
.insert({
submission_id: submissionData.id,
entity_type: originalEvent.entity_type,
entity_id: originalEvent.entity_id,
event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type,
event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date,
event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day',
title: changedFields.title !== undefined ? changedFields.title : originalEvent.title,
description: changedFields.description !== undefined ? changedFields.description : originalEvent.description,
from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value,
to_value: changedFields.to_value !== undefined ? changedFields.to_value : originalEvent.to_value,
from_entity_id: changedFields.from_entity_id !== undefined ? changedFields.from_entity_id : originalEvent.from_entity_id,
to_entity_id: changedFields.to_entity_id !== undefined ? changedFields.to_entity_id : originalEvent.to_entity_id,
from_location_id: changedFields.from_location_id !== undefined ? changedFields.from_location_id : originalEvent.from_location_id,
to_location_id: changedFields.to_location_id !== undefined ? changedFields.to_location_id : originalEvent.to_location_id,
is_public: true,
})
.select('id')
.single();
if (timelineSubmissionError) {
handleError(timelineSubmissionError, {
action: 'Update timeline event data',
metadata: { eventId },
});
throw new Error('Failed to submit timeline event update');
}
if (error) throw error;
if (!insertedData) throw new Error('Failed to submit timeline event update');
return insertedData;
},
{
onRetry: (attempt, error, delay) => {
logger.warn('Retrying timeline event update data insertion', {
attempt,
delay,
eventId,
submissionId: submissionData.id
});
}
}
);
// ✅ Create submission_items referencing timeline_event_submission (no JSON data)
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'timeline_event',
action_type: 'edit',
item_data: {
event_id: eventId,
entity_type: originalEvent.entity_type,
entity_id: originalEvent.entity_id
} as Json,
original_data: JSON.parse(JSON.stringify(originalEvent)),
status: 'pending' as const,
order_index: 0,
timeline_event_submission_id: timelineSubmission.id
});
// ✅ Phase 4: Create submission_items with retry
breadcrumb.apiCall('submission_items', 'INSERT');
await withRetry(
async () => {
const { error } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'timeline_event',
action_type: 'edit',
item_data: {
event_id: eventId,
entity_type: originalEvent.entity_type,
entity_id: originalEvent.entity_id
} as Json,
original_data: JSON.parse(JSON.stringify(originalEvent)),
status: 'pending' as const,
order_index: 0,
timeline_event_submission_id: timelineSubmission.id
});
if (itemError) {
handleError(itemError, {
action: 'Create timeline event update submission item',
metadata: { eventId },
});
throw new Error('Failed to link timeline event update submission');
}
if (error) throw error;
},
{
onRetry: (attempt, error, delay) => {
logger.warn('Retrying timeline event update item creation', {
attempt,
delay,
eventId,
submissionId: submissionData.id
});
}
}
);
breadcrumb.userAction('Timeline event update submitted', 'submitTimelineEventUpdate', {
eventId,
submissionId: submissionData.id
});
return {
submitted: true,