mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11:13 -05:00
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:
@@ -18,6 +18,9 @@ import { Camera, CheckCircle, AlertCircle, Info } from "lucide-react";
|
|||||||
import { UppyPhotoSubmissionUploadProps } from "@/types/submissions";
|
import { UppyPhotoSubmissionUploadProps } from "@/types/submissions";
|
||||||
import { withRetry } from "@/lib/retryHelpers";
|
import { withRetry } from "@/lib/retryHelpers";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
||||||
|
import { checkSubmissionRateLimit, recordSubmissionAttempt } from "@/lib/submissionRateLimiter";
|
||||||
|
import { sanitizeErrorMessage } from "@/lib/errorSanitizer";
|
||||||
|
|
||||||
export function UppyPhotoSubmissionUpload({
|
export function UppyPhotoSubmissionUpload({
|
||||||
onSubmissionComplete,
|
onSubmissionComplete,
|
||||||
@@ -81,6 +84,54 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ✅ Phase 4: Rate limiting check
|
||||||
|
const rateLimit = checkSubmissionRateLimit(user.id);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
const sanitizedMessage = sanitizeErrorMessage(rateLimit.reason || 'Rate limit exceeded');
|
||||||
|
logger.warn('[RateLimit] Photo submission blocked', {
|
||||||
|
userId: user.id,
|
||||||
|
reason: rateLimit.reason
|
||||||
|
});
|
||||||
|
throw new Error(sanitizedMessage);
|
||||||
|
}
|
||||||
|
recordSubmissionAttempt(user.id);
|
||||||
|
|
||||||
|
// ✅ Phase 4: Breadcrumb tracking
|
||||||
|
breadcrumb.userAction('Start photo submission', 'handleSubmit', {
|
||||||
|
photoCount: photos.length,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Phase 4: Ban check with retry
|
||||||
|
breadcrumb.apiCall('profiles', 'SELECT');
|
||||||
|
const profile = await withRetry(
|
||||||
|
async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('banned')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{ maxAttempts: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profile?.banned) {
|
||||||
|
throw new Error('Account suspended. Contact support for assistance.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Phase 4: Validate photos before processing
|
||||||
|
if (photos.some(p => !p.file)) {
|
||||||
|
throw new Error('All photos must have valid files');
|
||||||
|
}
|
||||||
|
|
||||||
|
breadcrumb.userAction('Upload images', 'handleSubmit', {
|
||||||
|
totalImages: photos.length
|
||||||
|
});
|
||||||
// Upload all photos that haven't been uploaded yet
|
// Upload all photos that haven't been uploaded yet
|
||||||
const uploadedPhotos: PhotoWithCaption[] = [];
|
const uploadedPhotos: PhotoWithCaption[] = [];
|
||||||
const photosToUpload = photos.filter((p) => p.file);
|
const photosToUpload = photos.filter((p) => p.file);
|
||||||
@@ -213,7 +264,24 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
|
|
||||||
setUploadProgress(null);
|
setUploadProgress(null);
|
||||||
|
|
||||||
|
// ✅ Phase 4: Validate uploaded photos before DB insertion
|
||||||
|
breadcrumb.userAction('Validate photos', 'handleSubmit', {
|
||||||
|
uploadedCount: uploadedPhotos.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const allPhotos = [...uploadedPhotos, ...photos.filter(p => !p.file)];
|
||||||
|
|
||||||
|
allPhotos.forEach((photo, index) => {
|
||||||
|
if (!photo.url) {
|
||||||
|
throw new Error(`Photo ${index + 1}: Missing URL`);
|
||||||
|
}
|
||||||
|
if (photo.uploadStatus === 'uploaded' && !photo.url.includes('/images/')) {
|
||||||
|
throw new Error(`Photo ${index + 1}: Invalid Cloudflare URL format`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Create submission records with retry logic
|
// Create submission records with retry logic
|
||||||
|
breadcrumb.apiCall('create_submission_with_items', 'RPC');
|
||||||
await withRetry(
|
await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
// Create content_submission record first
|
// Create content_submission record first
|
||||||
|
|||||||
@@ -2463,84 +2463,160 @@ export async function submitTimelineEvent(
|
|||||||
data: TimelineEventFormData,
|
data: TimelineEventFormData,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ submitted: boolean; submissionId: string }> {
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
||||||
// Validate user
|
// ✅ Phase 4: Validate user
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error('User ID is required for timeline event submission');
|
throw new Error('User ID is required for timeline event submission');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the main submission record
|
// ✅ Phase 4: Rate limiting check
|
||||||
const { data: submissionData, error: submissionError } = await supabase
|
checkRateLimitOrThrow(userId, 'timeline_event_creation');
|
||||||
.from('content_submissions')
|
recordSubmissionAttempt(userId);
|
||||||
.insert({
|
|
||||||
user_id: userId,
|
|
||||||
submission_type: 'timeline_event',
|
|
||||||
status: 'pending' as const
|
|
||||||
})
|
|
||||||
.select('id')
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (submissionError) {
|
// ✅ Phase 4: Validation
|
||||||
handleError(submissionError, {
|
if (!data.title?.trim()) {
|
||||||
action: 'Submit timeline event',
|
throw new Error('Timeline event title is required');
|
||||||
userId,
|
}
|
||||||
});
|
if (!data.event_date) {
|
||||||
throw new Error('Failed to create timeline event submission');
|
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)
|
// ✅ Phase 4: Breadcrumb tracking
|
||||||
const { data: timelineSubmission, error: timelineSubmissionError } = await supabase
|
breadcrumb.userAction('Start timeline event submission', 'submitTimelineEvent', {
|
||||||
.from('timeline_event_submissions')
|
entityType,
|
||||||
.insert({
|
entityId,
|
||||||
submission_id: submissionData.id,
|
eventType: data.event_type,
|
||||||
entity_type: entityType,
|
userId
|
||||||
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 (timelineSubmissionError) {
|
// ✅ Phase 4: Ban check with retry
|
||||||
handleError(timelineSubmissionError, {
|
breadcrumb.apiCall('profiles', 'SELECT');
|
||||||
action: 'Submit timeline event data',
|
const { withRetry } = await import('./retryHelpers');
|
||||||
userId,
|
|
||||||
});
|
const profile = await withRetry(
|
||||||
throw new Error('Failed to submit timeline event for review');
|
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)
|
// ✅ Phase 4: Create submission with retry logic
|
||||||
const { error: itemError } = await supabase
|
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||||
.from('submission_items')
|
const submissionData = await withRetry(
|
||||||
.insert({
|
async () => {
|
||||||
submission_id: submissionData.id,
|
const { data, error } = await supabase
|
||||||
item_type: 'timeline_event',
|
.from('content_submissions')
|
||||||
action_type: 'create',
|
.insert({
|
||||||
item_data: {
|
user_id: userId,
|
||||||
entity_type: entityType,
|
submission_type: 'timeline_event',
|
||||||
entity_id: entityId
|
status: 'pending' as const
|
||||||
} as Json,
|
})
|
||||||
status: 'pending' as const,
|
.select('id')
|
||||||
order_index: 0,
|
.single();
|
||||||
timeline_event_submission_id: timelineSubmission.id
|
|
||||||
});
|
|
||||||
|
|
||||||
if (itemError) {
|
if (error) throw error;
|
||||||
handleError(itemError, {
|
if (!data) throw new Error('Failed to create timeline event submission');
|
||||||
action: 'Create timeline event submission item',
|
|
||||||
userId,
|
return data;
|
||||||
});
|
},
|
||||||
throw new Error('Failed to link timeline event submission');
|
{
|
||||||
}
|
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 {
|
return {
|
||||||
submitted: true,
|
submitted: true,
|
||||||
@@ -2563,95 +2639,185 @@ export async function submitTimelineEventUpdate(
|
|||||||
data: TimelineEventFormData,
|
data: TimelineEventFormData,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ submitted: boolean; submissionId: string }> {
|
): Promise<{ submitted: boolean; submissionId: string }> {
|
||||||
// Fetch original event
|
// ✅ Phase 4: Validate user
|
||||||
const { data: originalEvent, error: fetchError } = await supabase
|
if (!userId) {
|
||||||
.from('entity_timeline_events')
|
throw new Error('User ID is required for timeline event update');
|
||||||
.select('*')
|
|
||||||
.eq('id', eventId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (fetchError || !originalEvent) {
|
|
||||||
throw new Error('Failed to fetch original timeline event');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 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
|
// Extract only changed fields from form data
|
||||||
const changedFields = extractChangedFields(data, originalEvent as Partial<Record<string, unknown>>);
|
const changedFields = extractChangedFields(data, originalEvent as Partial<Record<string, unknown>>);
|
||||||
|
|
||||||
// Create the main submission record
|
// ✅ Phase 4: Create submission with retry
|
||||||
const { data: submissionData, error: submissionError } = await supabase
|
breadcrumb.apiCall('content_submissions', 'INSERT');
|
||||||
.from('content_submissions')
|
const submissionData = await withRetry(
|
||||||
.insert({
|
async () => {
|
||||||
user_id: userId,
|
const { data, error } = await supabase
|
||||||
submission_type: 'timeline_event',
|
.from('content_submissions')
|
||||||
status: 'pending' as const
|
.insert({
|
||||||
})
|
user_id: userId,
|
||||||
.select('id')
|
submission_type: 'timeline_event',
|
||||||
.single();
|
status: 'pending' as const
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
if (submissionError) {
|
if (error) throw error;
|
||||||
handleError(submissionError, {
|
if (!data) throw new Error('Failed to create timeline event update submission');
|
||||||
action: 'Update timeline event',
|
|
||||||
metadata: { eventId },
|
return data;
|
||||||
});
|
},
|
||||||
throw new Error('Failed to create timeline event update submission');
|
{
|
||||||
}
|
onRetry: (attempt, error, delay) => {
|
||||||
|
logger.warn('Retrying timeline event update submission', {
|
||||||
|
attempt,
|
||||||
|
delay,
|
||||||
|
eventId,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ✅ FIXED: Insert into timeline_event_submissions table (relational pattern)
|
// ✅ Phase 4: Insert timeline_event_submission with retry
|
||||||
const { data: timelineSubmission, error: timelineSubmissionError } = await supabase
|
breadcrumb.apiCall('timeline_event_submissions', 'INSERT');
|
||||||
.from('timeline_event_submissions')
|
const timelineSubmission = await withRetry(
|
||||||
.insert({
|
async () => {
|
||||||
submission_id: submissionData.id,
|
const { data: insertedData, error } = await supabase
|
||||||
entity_type: originalEvent.entity_type,
|
.from('timeline_event_submissions')
|
||||||
entity_id: originalEvent.entity_id,
|
.insert({
|
||||||
event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type,
|
submission_id: submissionData.id,
|
||||||
event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date,
|
entity_type: originalEvent.entity_type,
|
||||||
event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day',
|
entity_id: originalEvent.entity_id,
|
||||||
title: changedFields.title !== undefined ? changedFields.title : originalEvent.title,
|
event_type: changedFields.event_type !== undefined ? changedFields.event_type : originalEvent.event_type,
|
||||||
description: changedFields.description !== undefined ? changedFields.description : originalEvent.description,
|
event_date: changedFields.event_date !== undefined ? (typeof changedFields.event_date === 'string' ? changedFields.event_date : changedFields.event_date.toISOString().split('T')[0]) : originalEvent.event_date,
|
||||||
from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value,
|
event_date_precision: (changedFields.event_date_precision !== undefined ? changedFields.event_date_precision : originalEvent.event_date_precision) || 'day',
|
||||||
to_value: changedFields.to_value !== undefined ? changedFields.to_value : originalEvent.to_value,
|
title: changedFields.title !== undefined ? changedFields.title : originalEvent.title,
|
||||||
from_entity_id: changedFields.from_entity_id !== undefined ? changedFields.from_entity_id : originalEvent.from_entity_id,
|
description: changedFields.description !== undefined ? changedFields.description : originalEvent.description,
|
||||||
to_entity_id: changedFields.to_entity_id !== undefined ? changedFields.to_entity_id : originalEvent.to_entity_id,
|
from_value: changedFields.from_value !== undefined ? changedFields.from_value : originalEvent.from_value,
|
||||||
from_location_id: changedFields.from_location_id !== undefined ? changedFields.from_location_id : originalEvent.from_location_id,
|
to_value: changedFields.to_value !== undefined ? changedFields.to_value : originalEvent.to_value,
|
||||||
to_location_id: changedFields.to_location_id !== undefined ? changedFields.to_location_id : originalEvent.to_location_id,
|
from_entity_id: changedFields.from_entity_id !== undefined ? changedFields.from_entity_id : originalEvent.from_entity_id,
|
||||||
is_public: true,
|
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,
|
||||||
.select('id')
|
to_location_id: changedFields.to_location_id !== undefined ? changedFields.to_location_id : originalEvent.to_location_id,
|
||||||
.single();
|
is_public: true,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
if (timelineSubmissionError) {
|
if (error) throw error;
|
||||||
handleError(timelineSubmissionError, {
|
if (!insertedData) throw new Error('Failed to submit timeline event update');
|
||||||
action: 'Update timeline event data',
|
|
||||||
metadata: { eventId },
|
return insertedData;
|
||||||
});
|
},
|
||||||
throw new Error('Failed to submit timeline event update');
|
{
|
||||||
}
|
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)
|
// ✅ Phase 4: Create submission_items with retry
|
||||||
const { error: itemError } = await supabase
|
breadcrumb.apiCall('submission_items', 'INSERT');
|
||||||
.from('submission_items')
|
await withRetry(
|
||||||
.insert({
|
async () => {
|
||||||
submission_id: submissionData.id,
|
const { error } = await supabase
|
||||||
item_type: 'timeline_event',
|
.from('submission_items')
|
||||||
action_type: 'edit',
|
.insert({
|
||||||
item_data: {
|
submission_id: submissionData.id,
|
||||||
event_id: eventId,
|
item_type: 'timeline_event',
|
||||||
entity_type: originalEvent.entity_type,
|
action_type: 'edit',
|
||||||
entity_id: originalEvent.entity_id
|
item_data: {
|
||||||
} as Json,
|
event_id: eventId,
|
||||||
original_data: JSON.parse(JSON.stringify(originalEvent)),
|
entity_type: originalEvent.entity_type,
|
||||||
status: 'pending' as const,
|
entity_id: originalEvent.entity_id
|
||||||
order_index: 0,
|
} as Json,
|
||||||
timeline_event_submission_id: timelineSubmission.id
|
original_data: JSON.parse(JSON.stringify(originalEvent)),
|
||||||
});
|
status: 'pending' as const,
|
||||||
|
order_index: 0,
|
||||||
|
timeline_event_submission_id: timelineSubmission.id
|
||||||
|
});
|
||||||
|
|
||||||
if (itemError) {
|
if (error) throw error;
|
||||||
handleError(itemError, {
|
},
|
||||||
action: 'Create timeline event update submission item',
|
{
|
||||||
metadata: { eventId },
|
onRetry: (attempt, error, delay) => {
|
||||||
});
|
logger.warn('Retrying timeline event update item creation', {
|
||||||
throw new Error('Failed to link timeline event update submission');
|
attempt,
|
||||||
}
|
delay,
|
||||||
|
eventId,
|
||||||
|
submissionId: submissionData.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
breadcrumb.userAction('Timeline event update submitted', 'submitTimelineEventUpdate', {
|
||||||
|
eventId,
|
||||||
|
submissionId: submissionData.id
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
submitted: true,
|
submitted: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user