Files
thrilltrack-explorer/src-old/lib/entitySubmissionHelpers.ts

3462 lines
121 KiB
TypeScript

import { supabase } from '@/lib/supabaseClient';
import type { Json } from '@/integrations/supabase/types';
import { ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { uploadPendingImages } from './imageUploadHelper';
import type { ProcessedImage } from './supabaseHelpers';
import { extractChangedFields } from './submissionChangeDetection';
import type { CompanyDatabaseRecord, TimelineEventDatabaseRecord } from '@/types/company-data';
import { logger } from './logger';
import { handleError } from './errorHandler';
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
import { breadcrumb } from './errorBreadcrumbs';
import { isRetryableError } from './retryHelpers';
import {
validateParkCreateFields,
validateRideCreateFields,
validateCompanyCreateFields,
validateRideModelCreateFields,
assertValid
} from './submissionValidation';
import { checkSubmissionRateLimit, recordSubmissionAttempt } from './submissionRateLimiter';
import { sanitizeErrorMessage } from './errorSanitizer';
import { reportRateLimitViolation, reportBanEvasionAttempt } from './pipelineAlerts';
// ============================================
// COMPOSITE SUBMISSION TYPES
// ============================================
interface CompositeSubmissionDependency {
type: 'park' | 'ride' | 'company' | 'ride_model';
data: any;
tempId: string;
companyType?: 'manufacturer' | 'designer' | 'operator' | 'property_owner';
parentTempId?: string; // For linking ride_model to manufacturer
}
interface CompositeSubmissionData {
primaryEntity: {
type: 'park' | 'ride';
data: any;
};
dependencies: CompositeSubmissionDependency[];
}
/**
* ═══════════════════════════════════════════════════════════════════
* SUBMISSION PATTERN STANDARD - CRITICAL PROJECT RULE
* ═══════════════════════════════════════════════════════════════════
*
* ⚠️ NEVER STORE JSON IN SQL COLUMNS ⚠️
*
* content_submissions.content should ONLY contain:
* ✅ action: 'create' | 'edit' | 'delete'
* ✅ Minimal reference IDs (entity_id, parent_id, etc.) - MAX 3 fields
* ❌ NO actual form data
* ❌ NO submission content
* ❌ NO large objects
*
* ALL actual data MUST go in:
* ✅ submission_items.item_data (new data)
* ✅ submission_items.original_data (for edits)
* ✅ Specialized relational tables:
* - photo_submissions + photo_submission_items
* - park_submissions
* - ride_submissions
* - company_submissions
* - ride_model_submissions
*
* If your data is relational, model it relationally.
* JSON blobs destroy:
* - Queryability (can't filter/join)
* - Performance (slower, larger)
* - Data integrity (no constraints)
* - Maintainability (impossible to refactor)
*
* EXAMPLES:
*
* ✅ CORRECT:
* content: { action: 'create' }
* content: { action: 'edit', park_id: uuid }
* content: { action: 'delete', photo_id: uuid }
*
* ❌ WRONG:
* content: { name: '...', description: '...', ...formData }
* content: { photos: [...], metadata: {...} }
* content: data // entire object dump
*
* ═══════════════════════════════════════════════════════════════════
*/
export interface ParkFormData {
name: string;
slug: string;
description?: string;
park_type: string;
status: string;
opening_date?: string;
opening_date_precision?: string;
closing_date?: string;
closing_date_precision?: string;
website_url?: string;
phone?: string;
email?: string;
operator_id?: string;
property_owner_id?: string;
// Location can be stored as object for new submissions or ID for editing
location?: {
name: string;
street_address?: string;
city?: string;
state_province?: string;
country: string;
postal_code?: string;
latitude: number;
longitude: number;
timezone?: string;
display_name: string;
};
location_id?: string;
images?: ImageAssignments;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
}
export interface RideFormData {
name: string;
slug: string;
description?: string;
category: string;
status: string;
park_id: string;
manufacturer_id?: string;
designer_id?: string;
ride_model_id?: string;
opening_date?: string;
opening_date_precision?: string;
closing_date?: string;
closing_date_precision?: string;
max_speed_kmh?: number;
max_height_meters?: number;
length_meters?: number;
duration_seconds?: number;
capacity_per_hour?: number;
height_requirement?: number;
age_requirement?: number;
inversions?: number;
drop_height_meters?: number;
max_g_force?: number;
intensity_level?: string;
coaster_type?: string;
seating_type?: string;
ride_sub_type?: string;
images?: ImageAssignments;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
}
export interface CompanyFormData {
name: string;
slug: string;
description?: string;
person_type: 'company' | 'individual' | 'firm' | 'organization';
founded_year?: number;
founded_date?: string;
founded_date_precision?: string;
headquarters_location?: string;
website_url?: string;
images?: ImageAssignments;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
}
interface TechnicalSpecification {
spec_name: string;
spec_value: string;
spec_type?: 'string' | 'number' | 'boolean' | 'date';
category?: string;
unit?: string;
display_order?: number;
}
export interface RideModelFormData {
name: string;
slug: string;
manufacturer_id: string;
category: string;
ride_type?: string;
description?: string;
images?: ImageAssignments;
banner_image_url?: string;
banner_image_id?: string;
card_image_url?: string;
card_image_id?: string;
_technical_specifications?: TechnicalSpecification[];
}
/**
* ═══════════════════════════════════════════════════════════════════
* RATE LIMITING HELPER
* ═══════════════════════════════════════════════════════════════════
*
* Checks rate limits before allowing submission creation
* Part of Sacred Pipeline Phase 3: Enhanced Error Handling
*/
function checkRateLimitOrThrow(userId: string, action: string): void {
const rateLimit = checkSubmissionRateLimit(userId);
if (!rateLimit.allowed) {
const sanitizedMessage = sanitizeErrorMessage(rateLimit.reason || 'Rate limit exceeded');
logger.warn('[RateLimit] Submission blocked', {
userId,
action,
reason: rateLimit.reason,
retryAfter: rateLimit.retryAfter,
});
// Report to system alerts for admin visibility
reportRateLimitViolation(userId, action, rateLimit.retryAfter || 60).catch(() => {
// Non-blocking - don't fail submission if alert fails
});
throw new Error(sanitizedMessage);
}
logger.info('[RateLimit] Submission allowed', {
userId,
action,
remaining: rateLimit.remaining,
});
}
/**
* ═══════════════════════════════════════════════════════════════════
* COMPOSITE SUBMISSION HANDLER
* ═══════════════════════════════════════════════════════════════════
*
* Creates a single submission containing multiple related entities
* (e.g., Park + Operator, Ride + Manufacturer + Model)
*
* All entities are submitted atomically through the moderation queue,
* with dependency tracking to ensure correct approval order.
*
* @param primaryEntity - The main entity being created (park or ride)
* @param dependencies - Array of dependent entities (companies, models)
* @param userId - The ID of the user submitting
* @returns Object containing submitted boolean and submissionId
*/
async function submitCompositeCreation(
primaryEntity: { type: 'park' | 'ride'; data: any },
dependencies: CompositeSubmissionDependency[],
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
const { withRetry } = await import('./retryHelpers');
try {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'composite_creation');
recordSubmissionAttempt(userId);
breadcrumb.userAction('Start composite submission', 'submitCompositeCreation', {
primaryType: primaryEntity.type,
dependencyCount: dependencies.length,
userId
});
// Check if user is banned with retry logic
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) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'composite_creation').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Upload all pending images for all entities
breadcrumb.userAction('Upload images', 'submitCompositeCreation', {
totalImages: dependencies.reduce((sum, dep) => sum + (dep.data.images?.uploaded?.length || 0), 0) +
(primaryEntity.data.images?.uploaded?.length || 0)
});
const uploadedEntities = await Promise.all([
...dependencies.map(async (dep, index) => {
try {
if (dep.data.images?.uploaded && dep.data.images.uploaded.length > 0) {
const uploadedImages = await uploadPendingImages(dep.data.images.uploaded);
return {
...dep,
data: {
...dep.data,
images: { ...dep.data.images, uploaded: uploadedImages }
}
};
}
return dep;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to upload images for ${dep.type} "${dep.data.name || 'unnamed'}": ${errorMsg}`
);
}
}),
(async () => {
try {
if (primaryEntity.data.images?.uploaded && primaryEntity.data.images.uploaded.length > 0) {
const uploadedImages = await uploadPendingImages(primaryEntity.data.images.uploaded);
return {
...primaryEntity,
data: {
...primaryEntity.data,
images: { ...primaryEntity.data.images, uploaded: uploadedImages }
}
};
}
return primaryEntity;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to upload images for ${primaryEntity.type} "${primaryEntity.data.name || 'unnamed'}": ${errorMsg}`
);
}
})()
]);
const uploadedDependencies = uploadedEntities.slice(0, -1) as CompositeSubmissionDependency[];
const uploadedPrimary = uploadedEntities[uploadedEntities.length - 1] as typeof primaryEntity;
// Validate dependencies structure
breadcrumb.stateChange('Validating dependencies', {
dependencyCount: uploadedDependencies.length
});
for (const dep of uploadedDependencies) {
if (!dep.type) throw new Error('Dependency missing type');
if (!dep.tempId) throw new Error('Dependency missing tempId');
if (!dep.data) throw new Error('Dependency missing data');
if (dep.type === 'company' && !dep.companyType) {
throw new Error(`Company dependency "${dep.data.name || 'unnamed'}" missing companyType`);
}
}
// Build submission items array with dependencies first
const submissionItems: any[] = [];
const tempIdMap = new Map<string, number>(); // Maps tempId to order_index
// Add dependency items (companies, models) first
let orderIndex = 0;
for (const dep of uploadedDependencies) {
const itemType = dep.type === 'company' ? dep.companyType : dep.type;
tempIdMap.set(dep.tempId, orderIndex);
// ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data
const { images, ...dataWithoutImages } = dep.data;
const itemData: any = {
...dataWithoutImages,
images: images as unknown as Json
};
// Add company_type for company entities
if (dep.type === 'company') {
itemData.company_type = dep.companyType;
}
// Add manufacturer dependency for ride models
if (dep.type === 'ride_model' && dep.parentTempId) {
const parentOrderIndex = tempIdMap.get(dep.parentTempId);
if (parentOrderIndex !== undefined) {
itemData._temp_manufacturer_ref = parentOrderIndex;
}
}
submissionItems.push({
item_type: itemType,
action_type: 'create' as const,
item_data: itemData,
status: 'pending' as const,
order_index: orderIndex,
depends_on: null // Dependencies don't have parents
});
orderIndex++;
}
// Add primary entity last
// ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data
const { images: primaryImages, ...primaryDataWithoutImages } = uploadedPrimary.data;
const primaryData: any = {
...primaryDataWithoutImages,
images: primaryImages as unknown as Json
};
// Store location reference for park submissions (will be created in relational table)
if (uploadedPrimary.type === 'park' && uploadedPrimary.data.location) {
primaryData._temp_location = uploadedPrimary.data.location;
delete primaryData.location; // Remove the original location object
console.log('[submitCompositeCreation] Stored location for relational insert:', primaryData._temp_location);
}
// Map temporary IDs to order indices for foreign keys
if (uploadedPrimary.type === 'park') {
if (uploadedPrimary.data.operator_id?.startsWith('temp-')) {
const opIndex = tempIdMap.get('temp-operator');
if (opIndex !== undefined) primaryData._temp_operator_ref = opIndex;
delete primaryData.operator_id;
}
if (uploadedPrimary.data.property_owner_id?.startsWith('temp-')) {
const ownerIndex = tempIdMap.get('temp-property-owner');
const operatorIndex = tempIdMap.get('temp-operator');
if (ownerIndex !== undefined) {
primaryData._temp_property_owner_ref = ownerIndex;
} else if (operatorIndex !== undefined) {
// Property owner references operator (operator is owner scenario)
primaryData._temp_property_owner_ref = operatorIndex;
}
delete primaryData.property_owner_id;
}
} else if (uploadedPrimary.type === 'ride') {
if (uploadedPrimary.data.park_id?.startsWith('temp-')) {
const parkIndex = tempIdMap.get(uploadedPrimary.data.park_id);
if (parkIndex !== undefined) primaryData._temp_park_ref = parkIndex;
delete primaryData.park_id;
}
if (uploadedPrimary.data.manufacturer_id?.startsWith('temp-')) {
const mfgIndex = tempIdMap.get(uploadedPrimary.data.manufacturer_id);
if (mfgIndex !== undefined) primaryData._temp_manufacturer_ref = mfgIndex;
delete primaryData.manufacturer_id;
}
if (uploadedPrimary.data.designer_id?.startsWith('temp-')) {
const designerIndex = tempIdMap.get(uploadedPrimary.data.designer_id);
if (designerIndex !== undefined) primaryData._temp_designer_ref = designerIndex;
delete primaryData.designer_id;
}
if (uploadedPrimary.data.ride_model_id?.startsWith('temp-')) {
const modelIndex = tempIdMap.get(uploadedPrimary.data.ride_model_id);
if (modelIndex !== undefined) primaryData._temp_ride_model_ref = modelIndex;
delete primaryData.ride_model_id;
}
}
// CRITICAL: Validate all temp refs were properly resolved
const validateTempRefs = () => {
const errors: string[] = [];
if (uploadedPrimary.type === 'park') {
if ('_temp_operator_ref' in primaryData && primaryData._temp_operator_ref === undefined) {
errors.push('Invalid operator reference - dependency not found');
}
if ('_temp_property_owner_ref' in primaryData && primaryData._temp_property_owner_ref === undefined) {
errors.push('Invalid property owner reference - dependency not found');
}
} else if (uploadedPrimary.type === 'ride') {
if ('_temp_park_ref' in primaryData && primaryData._temp_park_ref === undefined) {
errors.push('Invalid park reference - dependency not found');
}
if ('_temp_manufacturer_ref' in primaryData && primaryData._temp_manufacturer_ref === undefined) {
errors.push('Invalid manufacturer reference - dependency not found');
}
if ('_temp_designer_ref' in primaryData && primaryData._temp_designer_ref === undefined) {
errors.push('Invalid designer reference - dependency not found');
}
if ('_temp_ride_model_ref' in primaryData && primaryData._temp_ride_model_ref === undefined) {
errors.push('Invalid ride model reference - dependency not found');
}
}
if (errors.length > 0) {
// Report to system alerts (non-blocking)
import('./pipelineAlerts').then(async ({ reportTempRefError }) => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await reportTempRefError(uploadedPrimary.type, errors, user.id);
}
} catch (e) {
console.warn('Failed to report temp ref error:', e);
}
});
throw new Error(`Temp reference validation failed: ${errors.join(', ')}`);
}
};
validateTempRefs();
submissionItems.push({
item_type: uploadedPrimary.type,
action_type: 'create' as const,
item_data: primaryData,
status: 'pending' as const,
order_index: orderIndex,
depends_on: null // Will be set by RPC based on refs
});
// Pre-validation to catch issues early with actionable error messages
if (uploadedPrimary.type === 'park') {
if (!primaryData.name) throw new Error('Park name is required');
if (!primaryData.slug) throw new Error('Park slug is required');
if (!primaryData.park_type) throw new Error('Park type is required');
if (!primaryData.status) throw new Error('Park status is required');
} else if (uploadedPrimary.type === 'ride') {
if (!primaryData.name) throw new Error('Ride name is required');
if (!primaryData.slug) throw new Error('Ride slug is required');
if (!primaryData.status) throw new Error('Ride status is required');
}
// Validate dependencies
for (const dep of uploadedDependencies) {
if (dep.type === 'company') {
if (!dep.data.name) throw new Error(`${dep.companyType || 'Company'} name is required`);
if (!dep.data.slug) throw new Error(`${dep.companyType || 'Company'} slug is required`);
if (!dep.data.company_type && !dep.companyType) {
throw new Error('Company type is required');
}
}
}
// Use RPC to create submission with items atomically with retry logic
breadcrumb.apiCall('create_submission_with_items', 'RPC');
const { toast } = await import('@/hooks/use-toast');
const result = await withRetry(
async () => {
const { data, error } = await supabase.rpc('create_submission_with_items', {
p_user_id: userId,
p_submission_type: uploadedPrimary.type,
p_content: { action: 'create' } as unknown as Json,
p_items: submissionItems as unknown as Json[]
});
if (error) {
// Extract Supabase error details for better error logging
const supabaseError = error as { message?: string; code?: string; details?: string; hint?: string };
const errorMessage = supabaseError.message || 'Unknown error';
const errorCode = supabaseError.code;
const errorDetails = supabaseError.details;
const errorHint = supabaseError.hint;
// Create proper Error instance with enhanced context
const enhancedError = new Error(
`Composite submission failed: ${errorMessage}${errorDetails ? `\nDetails: ${errorDetails}` : ''}${errorHint ? `\nHint: ${errorHint}` : ''}`
);
// Attach Supabase metadata for retry logic
(enhancedError as any).supabaseCode = errorCode;
(enhancedError as any).supabaseDetails = errorDetails;
(enhancedError as any).supabaseHint = errorHint;
throw enhancedError;
}
return data;
},
{
maxAttempts: 3,
baseDelay: 1000,
maxDelay: 10000,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying composite submission', {
attempt,
maxAttempts: 3,
delay,
error: error instanceof Error ? error.message : String(error),
primaryType: uploadedPrimary.type,
dependencyCount: dependencies.length
});
// Show user feedback
toast({
title: 'Submission retry',
description: `Attempt ${attempt}/3 - Retrying in ${Math.round(delay / 1000)}s...`,
});
},
shouldRetry: (error) => {
// Don't retry validation errors
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('suspended')) return false;
if (message.includes('slug')) return false;
if (message.includes('already exists')) return false;
if (message.includes('duplicate')) return false;
if (message.includes('permission')) return false;
if (message.includes('forbidden')) return false;
if (message.includes('unauthorized')) return false;
}
// Use default retryable error detection from retryHelpers
return isRetryableError(error);
}
}
).catch((error) => {
// Final failure - log and throw
handleError(error, {
action: 'Composite submission',
metadata: {
primaryType: uploadedPrimary.type,
dependencyCount: dependencies.length,
supabaseCode: (error as any).supabaseCode,
supabaseDetails: (error as any).supabaseDetails,
supabaseHint: (error as any).supabaseHint,
retriesExhausted: true
},
});
throw error;
});
breadcrumb.stateChange('Composite submission successful', {
submissionId: result
});
return { submitted: true, submissionId: result };
} catch (error) {
// Ensure error is always an Error instance with context
const enrichedError = error instanceof Error
? error
: new Error(`Composite submission failed: ${String(error)}`);
// Attach metadata for better debugging
(enrichedError as any).originalError = error;
(enrichedError as any).primaryType = primaryEntity?.type;
(enrichedError as any).dependencyCount = dependencies?.length;
throw enrichedError;
}
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a new park for creation through the moderation queue.
* This is the ONLY correct way to create parks.
*
* DO NOT use direct database inserts:
* ❌ await supabase.from('parks').insert(data) // BYPASSES MODERATION!
* ✅ await submitParkCreation(data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
*
* @see docs/SUBMISSION_FLOW.md for complete documentation
* @param data - The park form data to submit
* @param userId - The ID of the user submitting the park
* @returns Object containing submitted boolean and submissionId
*/
export async function submitParkCreation(
data: ParkFormData & { _compositeSubmission?: any },
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'park_creation');
recordSubmissionAttempt(userId);
console.info('[submitParkCreation] Received data:', {
hasLocation: !!data.location,
hasLocationId: !!data.location_id,
locationData: data.location,
parkName: data.name,
hasComposite: !!data._compositeSubmission
});
// Validate required fields client-side
assertValid(validateParkCreateFields(data));
// Check for composite submission with dependencies
if (data._compositeSubmission) {
const dependencies: CompositeSubmissionDependency[] = [];
// Check if operator and owner are the same new entity
const operatorIsOwner =
data._compositeSubmission.new_operator &&
data._compositeSubmission.new_property_owner &&
data._compositeSubmission.new_operator === data._compositeSubmission.new_property_owner;
if (data._compositeSubmission.new_operator) {
dependencies.push({
type: 'company',
data: { ...data._compositeSubmission.new_operator, company_type: 'operator' },
tempId: 'temp-operator',
companyType: 'operator'
});
}
// Only add separate property owner if different from operator
if (data._compositeSubmission.new_property_owner && !operatorIsOwner) {
dependencies.push({
type: 'company',
data: { ...data._compositeSubmission.new_property_owner, company_type: 'property_owner' },
tempId: 'temp-property-owner',
companyType: 'property_owner'
});
}
if (dependencies.length > 0) {
// Copy location data into composite park data
const parkData = {
...data._compositeSubmission.park,
location: data.location,
location_id: data.location_id
};
return submitCompositeCreation(
{ type: 'park', data: parkData },
dependencies,
userId
);
}
}
// Standard single-entity creation with retry logic
const { withRetry } = await import('./retryHelpers');
// Check if user is banned (with quick retry for read operation)
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'park_creation').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Upload any pending local images first (no retry - handled internally)
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = {
...data.images,
uploaded: uploadedImages
};
} catch (error: unknown) {
handleError(error, {
action: 'Upload park images',
});
throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Create submission with retry logic
const result = await withRetry(
async () => {
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'park',
status: 'pending' as const
})
.select('id')
.single();
if (submissionError) throw submissionError;
// Get image URLs/IDs from processed images using assignments
const uploadedImages = processedImages?.uploaded || [];
const bannerIndex = processedImages?.banner_assignment;
const cardIndex = processedImages?.card_assignment;
const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null;
const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null;
// Insert into relational park_submissions table
const tempLocationData = data.location ? {
name: data.location.name,
street_address: data.location.street_address || null,
city: data.location.city || null,
state_province: data.location.state_province || null,
country: data.location.country,
latitude: data.location.latitude,
longitude: data.location.longitude,
timezone: data.location.timezone || null,
postal_code: data.location.postal_code || null,
display_name: data.location.display_name
} : null;
console.info('[submitParkCreation] Saving to park_submissions:', {
name: data.name,
hasLocation: !!data.location,
hasLocationId: !!data.location_id,
hasLocationData: !!tempLocationData
});
const { data: parkSubmission, error: parkSubmissionError } = await supabase
.from('park_submissions' as any)
.insert({
submission_id: submissionData.id,
name: data.name,
slug: data.slug,
description: data.description || null,
park_type: data.park_type,
status: data.status,
opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null,
closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null,
website_url: data.website_url || null,
phone: data.phone || null,
email: data.email || null,
operator_id: data.operator_id || null,
property_owner_id: data.property_owner_id || null,
location_id: data.location_id || null,
banner_image_url: bannerImage?.url || data.banner_image_url || null,
banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null,
card_image_url: cardImage?.url || data.card_image_url || null,
card_image_id: cardImage?.cloudflare_id || data.card_image_id || null
} as any)
.select('id')
.single();
if (parkSubmissionError) throw parkSubmissionError;
// Create location in relational table if provided
if (tempLocationData) {
const { error: locationError } = await supabase
.from('park_submission_locations' as any)
.insert({
park_submission_id: (parkSubmission as any).id,
...tempLocationData
} as any);
if (locationError) {
console.error('[submitParkCreation] Failed to create location:', locationError);
throw new Error(`Failed to save location data: ${locationError.message}`);
}
console.info('[submitParkCreation] Created park_submission_location', {
parkSubmissionId: (parkSubmission as any).id,
locationName: tempLocationData.name
});
}
// Create submission_items record linking to park_submissions
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'park',
action_type: 'create',
park_submission_id: (parkSubmission as any).id,
status: 'pending' as const,
order_index: 0
} as any);
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying park submission', { attempt, delay });
// Emit event for UI indicator
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'park' }
}));
},
shouldRetry: (error) => {
// Don't retry validation/business logic errors
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
if (message.includes('permission')) return false;
}
return isRetryableError(error);
}
}
).catch((error) => {
handleError(error, {
action: 'Park submission',
metadata: { retriesExhausted: true },
});
throw error;
});
return result;
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits an update to an existing park through the moderation queue.
* This is the ONLY correct way to update parks.
*
* DO NOT use direct database updates:
* ❌ await supabase.from('parks').update(data) // BYPASSES MODERATION!
* ✅ await submitParkUpdate(parkId, data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
*
* @see docs/SUBMISSION_FLOW.md for complete documentation
* @param parkId - The ID of the park to update
* @param data - The updated park form data
* @param userId - The ID of the user submitting the update
* @returns Object containing submitted boolean and submissionId
*/
export async function submitParkUpdate(
parkId: string,
data: ParkFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'park_update');
recordSubmissionAttempt(userId);
const { withRetry, isRetryableError } = await import('./retryHelpers');
// Check if user is banned - with retry for transient failures
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'park_update').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Fetch existing park data first - with retry for transient failures
const existingPark = await withRetry(
async () => {
const { data: existingPark, error: fetchError } = await supabase
.from('parks')
.select('id, name, slug, description, park_type, status, opening_date, opening_date_precision, closing_date, closing_date_precision, website_url, phone, email, location_id, operator_id, property_owner_id, banner_image_url, banner_image_id, card_image_url, card_image_id')
.eq('id', parkId)
.single();
if (fetchError) throw new Error(`Failed to fetch park: ${fetchError.message}`);
if (!existingPark) throw new Error('Park not found');
return existingPark;
},
{ maxAttempts: 2 }
);
// CRITICAL: Block new photo uploads on edits
// Photos can only be submitted during creation or via the photo gallery
if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) {
throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.');
}
// Only allow banner/card reassignments from existing photos
let processedImages = data.images;
// Main submission logic with retry and error handling
const retryId = crypto.randomUUID();
const result = await withRetry(
async () => {
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'park',
status: 'pending' as const
})
.select('id')
.single();
if (submissionError) throw submissionError;
// Extract changed fields
const changedFields = extractChangedFields(data, existingPark as any);
// Handle location data properly
let tempLocationData: any = null;
if (data.location) {
tempLocationData = {
name: data.location.name,
street_address: data.location.street_address || null,
city: data.location.city || null,
state_province: data.location.state_province || null,
country: data.location.country,
latitude: data.location.latitude,
longitude: data.location.longitude,
timezone: data.location.timezone || null,
postal_code: data.location.postal_code || null,
display_name: data.location.display_name
};
}
// ✅ FIXED: Insert into park_submissions table (relational pattern)
const { data: parkSubmission, error: parkSubmissionError } = await supabase
.from('park_submissions')
.insert({
submission_id: submissionData.id,
name: changedFields.name ?? existingPark.name,
slug: changedFields.slug ?? existingPark.slug,
description: changedFields.description !== undefined ? changedFields.description : existingPark.description,
park_type: changedFields.park_type ?? existingPark.park_type,
status: changedFields.status ?? existingPark.status,
opening_date: changedFields.opening_date !== undefined ? changedFields.opening_date : existingPark.opening_date,
opening_date_precision: changedFields.opening_date_precision !== undefined ? changedFields.opening_date_precision : existingPark.opening_date_precision,
closing_date: changedFields.closing_date !== undefined ? changedFields.closing_date : existingPark.closing_date,
closing_date_precision: changedFields.closing_date_precision !== undefined ? changedFields.closing_date_precision : existingPark.closing_date_precision,
website_url: changedFields.website_url !== undefined ? changedFields.website_url : existingPark.website_url,
phone: changedFields.phone !== undefined ? changedFields.phone : existingPark.phone,
email: changedFields.email !== undefined ? changedFields.email : existingPark.email,
operator_id: changedFields.operator_id !== undefined ? changedFields.operator_id : existingPark.operator_id,
property_owner_id: changedFields.property_owner_id !== undefined ? changedFields.property_owner_id : existingPark.property_owner_id,
location_id: changedFields.location_id !== undefined ? changedFields.location_id : existingPark.location_id,
banner_image_url: changedFields.banner_image_url !== undefined ? changedFields.banner_image_url : existingPark.banner_image_url,
banner_image_id: changedFields.banner_image_id !== undefined ? changedFields.banner_image_id : existingPark.banner_image_id,
card_image_url: changedFields.card_image_url !== undefined ? changedFields.card_image_url : existingPark.card_image_url,
card_image_id: changedFields.card_image_id !== undefined ? changedFields.card_image_id : existingPark.card_image_id,
})
.select('id')
.single();
if (parkSubmissionError) throw parkSubmissionError;
// Create location in relational table if provided
if (tempLocationData) {
const { error: locationError } = await supabase
.from('park_submission_locations' as any)
.insert({
park_submission_id: (parkSubmission as any).id,
...tempLocationData
} as any);
if (locationError) {
console.error('[submitParkEdit] Failed to create location:', locationError);
throw new Error(`Failed to save location data: ${locationError.message}`);
}
console.info('[submitParkEdit] Created park_submission_location', {
parkSubmissionId: (parkSubmission as any).id,
locationName: tempLocationData.name
});
}
// ✅ Create submission_items referencing park_submission (no JSON data)
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'park',
action_type: 'edit',
item_data: {
park_id: parkId, // Only reference IDs
images: processedImages as unknown as Json
},
original_data: JSON.parse(JSON.stringify(existingPark)),
status: 'pending' as const,
order_index: 0,
park_submission_id: parkSubmission.id
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying park update submission', {
attempt,
delay,
parkId,
error: error instanceof Error ? error.message : String(error)
});
// Emit event for UI retry indicator
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'park update' }
}));
},
shouldRetry: (error) => {
// Don't retry validation/business logic errors
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
if (message.includes('permission')) return false;
if (message.includes('not found')) return false;
if (message.includes('not allowed')) return false;
}
return isRetryableError(error);
}
}
).then((data) => {
// Emit success event
window.dispatchEvent(new CustomEvent('submission-retry-success', {
detail: { id: retryId }
}));
return data;
}).catch((error) => {
const errorId = handleError(error, {
action: 'Park update submission',
userId,
metadata: { retriesExhausted: true, parkId },
});
// Emit failure event
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
detail: { id: retryId, errorId }
}));
throw error;
});
return result;
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a new ride for creation through the moderation queue.
* This is the ONLY correct way to create rides.
*
* DO NOT use direct database inserts:
* ❌ await supabase.from('rides').insert(data) // BYPASSES MODERATION!
* ✅ await submitRideCreation(data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
*
* @see docs/SUBMISSION_FLOW.md for complete documentation
* @param data - The ride form data to submit
* @param userId - The ID of the user submitting the ride
* @returns Object containing submitted boolean and submissionId
*/
export async function submitRideCreation(
data: RideFormData & {
_tempNewPark?: any;
_tempNewManufacturer?: any;
_tempNewDesigner?: any;
_tempNewRideModel?: any;
},
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'ride_creation');
recordSubmissionAttempt(userId);
// Validate required fields client-side
assertValid(validateRideCreateFields(data));
// Check for composite submission with dependencies
if (data._tempNewPark || data._tempNewManufacturer || data._tempNewDesigner || data._tempNewRideModel) {
const dependencies: CompositeSubmissionDependency[] = [];
// Handle new park operator (from nested park)
if (data._tempNewPark?._tempNewOperator) {
dependencies.push({
type: 'company',
data: { ...data._tempNewPark._tempNewOperator, company_type: 'operator' },
tempId: 'temp-park-operator',
companyType: 'operator'
});
}
// Handle new park property owner (from nested park)
if (data._tempNewPark?._tempNewPropertyOwner) {
dependencies.push({
type: 'company',
data: { ...data._tempNewPark._tempNewPropertyOwner, company_type: 'property_owner' },
tempId: 'temp-park-owner',
companyType: 'property_owner'
});
}
// Handle new park (depends on operator/owner)
if (data._tempNewPark) {
dependencies.push({
type: 'park',
data: {
...data._tempNewPark,
operator_id: data._tempNewPark._tempNewOperator ? 'temp-park-operator' : data._tempNewPark.operator_id,
property_owner_id: data._tempNewPark._tempNewPropertyOwner ? 'temp-park-owner' : data._tempNewPark.property_owner_id,
_tempNewOperator: undefined,
_tempNewPropertyOwner: undefined
},
tempId: 'temp-park'
});
}
// Handle new manufacturer
if (data._tempNewManufacturer) {
dependencies.push({
type: 'company',
data: { ...data._tempNewManufacturer, company_type: 'manufacturer' },
tempId: 'temp-manufacturer',
companyType: 'manufacturer'
});
}
// Handle new designer
if (data._tempNewDesigner) {
dependencies.push({
type: 'company',
data: { ...data._tempNewDesigner, company_type: 'designer' },
tempId: 'temp-designer',
companyType: 'designer'
});
}
// Handle new ride model (depends on manufacturer)
if (data._tempNewRideModel) {
dependencies.push({
type: 'ride_model',
data: {
...data._tempNewRideModel,
manufacturer_id: data._tempNewManufacturer ? 'temp-manufacturer' : data._tempNewRideModel.manufacturer_id
},
tempId: 'temp-ride-model',
parentTempId: data._tempNewManufacturer ? 'temp-manufacturer' : undefined
});
}
if (dependencies.length > 0) {
// Prepare ride data with temp references
const rideData = {
...data,
park_id: data._tempNewPark ? 'temp-park' : data.park_id,
manufacturer_id: data._tempNewManufacturer ? 'temp-manufacturer' : data.manufacturer_id,
designer_id: data._tempNewDesigner ? 'temp-designer' : data.designer_id,
ride_model_id: data._tempNewRideModel ? 'temp-ride-model' : data.ride_model_id,
_tempNewPark: undefined,
_tempNewManufacturer: undefined,
_tempNewDesigner: undefined,
_tempNewRideModel: undefined
};
return submitCompositeCreation(
{ type: 'ride', data: rideData },
dependencies,
userId
);
}
}
// Standard single-entity creation with retry logic
const { withRetry } = await import('./retryHelpers');
// Check if user is banned (with quick retry for read operation)
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'ride_creation').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Upload any pending local images first (no retry - handled internally)
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = {
...data.images,
uploaded: uploadedImages
};
} catch (error: unknown) {
handleError(error, {
action: 'Upload ride images',
});
throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Create submission with retry logic
const retryId = crypto.randomUUID();
const result = await withRetry(
async () => {
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'ride',
status: 'pending' as const
})
.select('id')
.single();
if (submissionError) throw submissionError;
// Get image URLs/IDs from processed images using assignments
const uploadedImages = processedImages?.uploaded || [];
const bannerIndex = processedImages?.banner_assignment;
const cardIndex = processedImages?.card_assignment;
const bannerImage = (bannerIndex !== null && bannerIndex !== undefined) ? uploadedImages[bannerIndex] : null;
const cardImage = (cardIndex !== null && cardIndex !== undefined) ? uploadedImages[cardIndex] : null;
// Insert into relational ride_submissions table
const { data: rideSubmission, error: rideSubmissionError } = await supabase
.from('ride_submissions' as any)
.insert({
submission_id: submissionData.id,
park_id: data.park_id || null,
name: data.name,
slug: data.slug,
description: data.description || null,
category: data.category,
ride_sub_type: data.ride_sub_type || null,
status: data.status,
opening_date: data.opening_date ? new Date(data.opening_date).toISOString().split('T')[0] : null,
closing_date: data.closing_date ? new Date(data.closing_date).toISOString().split('T')[0] : null,
manufacturer_id: data.manufacturer_id || null,
designer_id: data.designer_id || null,
ride_model_id: data.ride_model_id || null,
height_requirement: data.height_requirement || null,
age_requirement: data.age_requirement || null,
capacity_per_hour: data.capacity_per_hour || null,
duration_seconds: data.duration_seconds || null,
max_speed_kmh: data.max_speed_kmh || null,
max_height_meters: data.max_height_meters || null,
length_meters: data.length_meters || null,
drop_height_meters: data.drop_height_meters || null,
inversions: data.inversions || 0,
max_g_force: data.max_g_force || null,
coaster_type: data.coaster_type || null,
seating_type: data.seating_type || null,
intensity_level: data.intensity_level || null,
banner_image_url: bannerImage?.url || data.banner_image_url || null,
banner_image_id: bannerImage?.cloudflare_id || data.banner_image_id || null,
card_image_url: cardImage?.url || data.card_image_url || null,
card_image_id: cardImage?.cloudflare_id || data.card_image_id || null,
image_url: null,
// Category-specific fields
track_material: (data as any).track_material || null,
support_material: (data as any).support_material || null,
propulsion_method: (data as any).propulsion_method || null,
water_depth_cm: (data as any).water_depth_cm || null,
splash_height_meters: (data as any).splash_height_meters || null,
wetness_level: (data as any).wetness_level || null,
flume_type: (data as any).flume_type || null,
boat_capacity: (data as any).boat_capacity || null,
theme_name: (data as any).theme_name || null,
story_description: (data as any).story_description || null,
show_duration_seconds: (data as any).show_duration_seconds || null,
animatronics_count: (data as any).animatronics_count || null,
projection_type: (data as any).projection_type || null,
ride_system: (data as any).ride_system || null,
scenes_count: (data as any).scenes_count || null,
rotation_type: (data as any).rotation_type || null,
motion_pattern: (data as any).motion_pattern || null,
platform_count: (data as any).platform_count || null,
swing_angle_degrees: (data as any).swing_angle_degrees || null,
rotation_speed_rpm: (data as any).rotation_speed_rpm || null,
arm_length_meters: (data as any).arm_length_meters || null,
max_height_reached_meters: (data as any).max_height_reached_meters || null,
min_age: (data as any).min_age || null,
max_age: (data as any).max_age || null,
educational_theme: (data as any).educational_theme || null,
character_theme: (data as any).character_theme || null,
transport_type: (data as any).transport_type || null,
route_length_meters: (data as any).route_length_meters || null,
stations_count: (data as any).stations_count || null,
vehicle_capacity: (data as any).vehicle_capacity || null,
vehicles_count: (data as any).vehicles_count || null,
round_trip_duration_seconds: (data as any).round_trip_duration_seconds || null
} as any)
.select('id')
.single();
if (rideSubmissionError) throw rideSubmissionError;
// Insert technical specifications if present
if ((data as any)._technical_specifications?.length > 0) {
const { error: techSpecError } = await supabase
.from('ride_technical_specs' as any)
.insert(
(data as any)._technical_specifications.map((spec: any) => ({
ride_submission_id: (rideSubmission as any).id,
spec_name: spec.spec_name,
spec_value: spec.spec_value,
spec_type: spec.spec_type,
category: spec.category || null,
unit: spec.unit || null
}))
);
if (techSpecError) {
logger.error('Failed to insert technical specs', { error: techSpecError });
throw techSpecError;
}
}
// Insert coaster statistics if present
if ((data as any)._coaster_statistics?.length > 0) {
const { error: statsError } = await supabase
.from('ride_coaster_stats' as any)
.insert(
(data as any)._coaster_statistics.map((stat: any) => ({
ride_submission_id: (rideSubmission as any).id,
stat_name: stat.stat_name,
stat_value: stat.stat_value,
unit: stat.unit || null,
category: stat.category || null
}))
);
if (statsError) {
logger.error('Failed to insert coaster stats', { error: statsError });
throw statsError;
}
}
// Insert name history if present
if ((data as any)._name_history?.length > 0) {
const { error: historyError } = await supabase
.from('ride_name_history_submissions' as any)
.insert(
(data as any)._name_history.map((name: any) => ({
ride_submission_id: (rideSubmission as any).id,
former_name: name.former_name,
date_changed: name.date_changed ? new Date(name.date_changed).toISOString().split('T')[0] : null,
reason: name.reason || null,
from_year: name.from_year || null,
to_year: name.to_year || null,
order_index: name.order_index || 0
}))
);
if (historyError) {
logger.error('Failed to insert name history', { error: historyError });
throw historyError;
}
}
// Create submission_items record linking to ride_submissions
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'ride',
action_type: 'create',
ride_submission_id: (rideSubmission as any).id,
status: 'pending' as const,
order_index: 0
} as any);
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying ride submission', { attempt, delay });
// Emit event for UI indicator
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride' }
}));
},
shouldRetry: (error) => {
// Don't retry validation/business logic errors
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
if (message.includes('permission')) return false;
}
return isRetryableError(error);
}
}
).then((data) => {
// Emit success event
window.dispatchEvent(new CustomEvent('submission-retry-success', {
detail: { id: retryId }
}));
return data;
}).catch((error) => {
const errorId = handleError(error, {
action: 'Ride submission',
metadata: { retriesExhausted: true },
});
// Emit failure event
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
detail: { id: retryId, errorId }
}));
throw error;
});
return result;
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits an update to an existing ride through the moderation queue.
* This is the ONLY correct way to update rides.
*
* DO NOT use direct database updates:
* ❌ await supabase.from('rides').update(data) // BYPASSES MODERATION!
* ✅ await submitRideUpdate(rideId, data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* Even moderators/admins must use this function to ensure proper versioning and audit trails.
*
* @see docs/SUBMISSION_FLOW.md for complete documentation
* @param rideId - The ID of the ride to update
* @param data - The updated ride form data
* @param userId - The ID of the user submitting the update
* @returns Object containing submitted boolean and submissionId
*/
export async function submitRideUpdate(
rideId: string,
data: RideFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'ride_update');
recordSubmissionAttempt(userId);
const { withRetry, isRetryableError } = await import('./retryHelpers');
// Check if user is banned - with retry for transient failures
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'ride_update').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Fetch existing ride data first - with retry for transient failures
const existingRide = await withRetry(
async () => {
const { data: existingRide, error: fetchError } = await supabase
.from('rides')
.select('*')
.eq('id', rideId)
.single();
if (fetchError) throw new Error(`Failed to fetch ride: ${fetchError.message}`);
if (!existingRide) throw new Error('Ride not found');
return existingRide;
},
{ maxAttempts: 2 }
);
// CRITICAL: Block new photo uploads on edits
// Photos can only be submitted during creation or via the photo gallery
if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) {
throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.');
}
// Only allow banner/card reassignments from existing photos
let processedImages = data.images;
// Main submission logic with retry and error handling
const retryId = crypto.randomUUID();
const result = await withRetry(
async () => {
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'ride',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
// Extract changed fields
const changedFields = extractChangedFields(data, existingRide as any);
// ✅ FIXED: Insert into ride_submissions table (relational pattern)
const { data: rideSubmission, error: rideSubmissionError } = await supabase
.from('ride_submissions')
.insert({
submission_id: submissionData.id,
name: changedFields.name ?? existingRide.name,
slug: changedFields.slug ?? existingRide.slug,
description: changedFields.description !== undefined ? changedFields.description : existingRide.description,
category: changedFields.category ?? existingRide.category,
status: changedFields.status ?? existingRide.status,
park_id: changedFields.park_id !== undefined ? changedFields.park_id : existingRide.park_id,
manufacturer_id: changedFields.manufacturer_id !== undefined ? changedFields.manufacturer_id : existingRide.manufacturer_id,
designer_id: changedFields.designer_id !== undefined ? changedFields.designer_id : existingRide.designer_id,
ride_model_id: changedFields.ride_model_id !== undefined ? changedFields.ride_model_id : existingRide.ride_model_id,
opening_date: changedFields.opening_date !== undefined ? changedFields.opening_date : existingRide.opening_date,
opening_date_precision: changedFields.opening_date_precision !== undefined ? changedFields.opening_date_precision : existingRide.opening_date_precision,
closing_date: changedFields.closing_date !== undefined ? changedFields.closing_date : existingRide.closing_date,
closing_date_precision: changedFields.closing_date_precision !== undefined ? changedFields.closing_date_precision : existingRide.closing_date_precision,
max_speed_kmh: changedFields.max_speed_kmh !== undefined ? changedFields.max_speed_kmh : existingRide.max_speed_kmh,
max_height_meters: changedFields.max_height_meters !== undefined ? changedFields.max_height_meters : existingRide.max_height_meters,
length_meters: changedFields.length_meters !== undefined ? changedFields.length_meters : existingRide.length_meters,
duration_seconds: changedFields.duration_seconds !== undefined ? changedFields.duration_seconds : existingRide.duration_seconds,
capacity_per_hour: changedFields.capacity_per_hour !== undefined ? changedFields.capacity_per_hour : existingRide.capacity_per_hour,
height_requirement: changedFields.height_requirement !== undefined ? changedFields.height_requirement : existingRide.height_requirement,
age_requirement: changedFields.age_requirement !== undefined ? changedFields.age_requirement : existingRide.age_requirement,
inversions: changedFields.inversions !== undefined ? changedFields.inversions : existingRide.inversions,
drop_height_meters: changedFields.drop_height_meters !== undefined ? changedFields.drop_height_meters : existingRide.drop_height_meters,
max_g_force: changedFields.max_g_force !== undefined ? changedFields.max_g_force : existingRide.max_g_force,
intensity_level: changedFields.intensity_level !== undefined ? changedFields.intensity_level : existingRide.intensity_level,
coaster_type: changedFields.coaster_type !== undefined ? changedFields.coaster_type : existingRide.coaster_type,
seating_type: changedFields.seating_type !== undefined ? changedFields.seating_type : existingRide.seating_type,
ride_sub_type: changedFields.ride_sub_type !== undefined ? changedFields.ride_sub_type : existingRide.ride_sub_type,
banner_image_url: changedFields.banner_image_url !== undefined ? changedFields.banner_image_url : existingRide.banner_image_url,
banner_image_id: changedFields.banner_image_id !== undefined ? changedFields.banner_image_id : existingRide.banner_image_id,
card_image_url: changedFields.card_image_url !== undefined ? changedFields.card_image_url : existingRide.card_image_url,
card_image_id: changedFields.card_image_id !== undefined ? changedFields.card_image_id : existingRide.card_image_id,
})
.select('id')
.single();
if (rideSubmissionError) throw rideSubmissionError;
// ✅ Create submission_items referencing ride_submission (no JSON data)
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'ride',
action_type: 'edit',
item_data: {
ride_id: rideId, // Only reference IDs
images: processedImages as unknown as Json
},
original_data: JSON.parse(JSON.stringify(existingRide)),
status: 'pending' as const,
order_index: 0,
ride_submission_id: rideSubmission.id
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying ride update submission', {
attempt,
delay,
rideId,
error: error instanceof Error ? error.message : String(error)
});
// Emit event for UI retry indicator
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { id: retryId, attempt, maxAttempts: 3, delay, type: 'ride update' }
}));
},
shouldRetry: (error) => {
// Don't retry validation/business logic errors
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
if (message.includes('permission')) return false;
if (message.includes('not found')) return false;
if (message.includes('not allowed')) return false;
}
return isRetryableError(error);
}
}
).then((data) => {
// Emit success event
window.dispatchEvent(new CustomEvent('submission-retry-success', {
detail: { id: retryId }
}));
return data;
}).catch((error) => {
const errorId = handleError(error, {
action: 'Ride update submission',
userId,
metadata: { retriesExhausted: true, rideId },
});
// Emit failure event
window.dispatchEvent(new CustomEvent('submission-retry-failed', {
detail: { id: retryId, errorId }
}));
throw error;
});
return result;
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a new ride model for creation through the moderation queue.
* This is the ONLY correct way to create ride models.
*
* DO NOT use direct database inserts:
* ❌ await supabase.from('ride_models').insert(data) // BYPASSES MODERATION!
* ✅ await submitRideModelCreation(data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Versioning → Live
*
* @param data - The ride model form data to submit
* @param userId - The ID of the user submitting the ride model
* @returns Object containing submitted boolean and submissionId
*/
export async function submitRideModelCreation(
data: RideModelFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'ride_model_creation');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start ride model submission', 'submitRideModelCreation', { userId });
// Validate required fields client-side
assertValid(validateRideModelCreateFields(data));
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'ride_model_creation').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Upload any pending local images first
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = {
...data.images,
uploaded: uploadedImages
};
} catch (error: unknown) {
handleError(error, {
action: 'Upload ride model images',
});
throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'ride_model',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
// Create the submission item with actual ride model data
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'ride_model',
action_type: 'create',
item_data: {
// ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data
...(() => {
const { images, ...dataWithoutImages } = data;
return dataWithoutImages;
})(),
images: processedImages as unknown as Json
},
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
// Insert into ride_model_submissions table for relational integrity
const { data: rideModelSubmissionData, error: rideModelSubmissionError } = await supabase
.from('ride_model_submissions')
.insert({
submission_id: submissionData.id,
name: data.name,
slug: data.slug,
manufacturer_id: data.manufacturer_id,
category: data.category,
ride_type: data.ride_type || data.category,
description: data.description || null,
banner_image_url: data.banner_image_url || null,
banner_image_id: data.banner_image_id || null,
card_image_url: data.card_image_url || null,
card_image_id: data.card_image_id || null
})
.select()
.single();
if (rideModelSubmissionError) {
logger.error('Failed to insert ride model submission', { error: rideModelSubmissionError });
throw rideModelSubmissionError;
}
// Insert technical specifications into submission table
if ((data as any)._technical_specifications?.length > 0) {
const { error: techSpecError } = await supabase
.from('ride_model_submission_technical_specifications')
.insert(
(data as any)._technical_specifications.map((spec: any) => ({
ride_model_submission_id: rideModelSubmissionData.id,
spec_name: spec.spec_name,
spec_value: spec.spec_value,
spec_unit: spec.spec_unit || null,
category: spec.category || null,
display_order: spec.display_order || 0
}))
);
if (techSpecError) {
logger.error('Failed to insert ride model technical specs', { error: techSpecError });
throw techSpecError;
}
logger.log('✅ Ride model technical specifications inserted:', (data as any)._technical_specifications.length);
}
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying ride model submission', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'ride_model' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a ride model update through the moderation queue.
* This is the ONLY correct way to update ride models.
*/
export async function submitRideModelUpdate(
rideModelId: string,
data: RideModelFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'ride_model_update');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start ride model update', 'submitRideModelUpdate', { userId, rideModelId });
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'ride_model_update').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Fetch existing ride model
const { data: existingModel, error: fetchError } = await supabase
.from('ride_models')
.select('*')
.eq('id', rideModelId)
.single();
if (fetchError) throw new Error(`Failed to fetch ride model: ${fetchError.message}`);
if (!existingModel) throw new Error('Ride model not found');
// CRITICAL: Block new photo uploads on edits
if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) {
throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.');
}
let processedImages = data.images;
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
// Create the main submission record
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'ride_model',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
// Create the submission item with actual ride model data
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'ride_model',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingModel as any),
ride_model_id: rideModelId, // Always include for relational integrity
images: processedImages as unknown as Json
},
original_data: JSON.parse(JSON.stringify(existingModel)),
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
// Insert into ride_model_submissions table for relational integrity
const { data: rideModelSubmissionData, error: rideModelSubmissionError } = await supabase
.from('ride_model_submissions')
.insert({
submission_id: submissionData.id,
name: data.name,
slug: data.slug,
manufacturer_id: data.manufacturer_id,
category: data.category,
ride_type: data.ride_type || data.category,
description: data.description || null,
banner_image_url: data.banner_image_url || null,
banner_image_id: data.banner_image_id || null,
card_image_url: data.card_image_url || null,
card_image_id: data.card_image_id || null
})
.select()
.single();
if (rideModelSubmissionError) {
logger.error('Failed to insert ride model update submission', { error: rideModelSubmissionError });
throw rideModelSubmissionError;
}
// Insert technical specifications into submission table
if ((data as any)._technical_specifications?.length > 0) {
const { error: techSpecError } = await supabase
.from('ride_model_submission_technical_specifications')
.insert(
(data as any)._technical_specifications.map((spec: any) => ({
ride_model_submission_id: rideModelSubmissionData.id,
spec_name: spec.spec_name,
spec_value: spec.spec_value,
spec_unit: spec.spec_unit || null,
category: spec.category || null,
display_order: spec.display_order || 0
}))
);
if (techSpecError) {
logger.error('Failed to insert ride model update technical specs', { error: techSpecError });
throw techSpecError;
}
logger.log('✅ Ride model update technical specifications inserted:', (data as any)._technical_specifications.length);
}
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying ride model update', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'ride_model_update' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a new manufacturer for creation through the moderation queue.
*/
export async function submitManufacturerCreation(
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'manufacturer_creation');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start manufacturer submission', 'submitManufacturerCreation', { userId });
// Validate required fields client-side
assertValid(validateCompanyCreateFields({ ...data, company_type: 'manufacturer' }));
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'manufacturer_creation').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Upload images
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error: unknown) {
handleError(error, {
action: 'Upload manufacturer images',
});
throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'manufacturer',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'manufacturer',
action_type: 'create',
item_data: {
// ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data
...(() => {
const { images, ...dataWithoutImages } = data;
return dataWithoutImages;
})(),
company_type: 'manufacturer',
images: processedImages as unknown as Json
},
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying manufacturer submission', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'manufacturer' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
export async function submitManufacturerUpdate(
companyId: string,
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'manufacturer_update');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start manufacturer update', 'submitManufacturerUpdate', { userId, companyId });
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'manufacturer_update').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
const { data: existingCompany, error: fetchError } = await supabase
.from('companies')
.select('*')
.eq('id', companyId)
.single();
if (fetchError) throw new Error(`Failed to fetch manufacturer: ${fetchError.message}`);
if (!existingCompany) throw new Error('Manufacturer not found');
// CRITICAL: Block new photo uploads on edits
if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) {
throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.');
}
let processedImages = data.images;
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'manufacturer',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'manufacturer',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingCompany as any),
company_id: companyId, // Always include for relational integrity
company_type: 'manufacturer', // Always include for entity type discrimination
images: processedImages as unknown as Json
},
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying manufacturer update', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'manufacturer_update' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
export async function submitDesignerCreation(
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'designer_creation');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start designer submission', 'submitDesignerCreation', { userId });
// Validate required fields client-side
assertValid(validateCompanyCreateFields({ ...data, company_type: 'designer' }));
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'designer_creation').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Upload images
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error: unknown) {
handleError(error, {
action: 'Upload designer images',
});
throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'designer',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'designer',
action_type: 'create',
item_data: {
// ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data
...(() => {
const { images, ...dataWithoutImages } = data;
return dataWithoutImages;
})(),
company_type: 'designer',
images: processedImages as unknown as Json
},
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying designer submission', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'designer' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
export async function submitDesignerUpdate(
companyId: string,
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'designer_update');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start designer update', 'submitDesignerUpdate', { userId, companyId });
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'designer_update').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
const { data: existingCompany, error: fetchError } = await supabase
.from('companies')
.select('*')
.eq('id', companyId)
.single();
if (fetchError) throw new Error(`Failed to fetch designer: ${fetchError.message}`);
if (!existingCompany) throw new Error('Designer not found');
// CRITICAL: Block new photo uploads on edits
if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) {
throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.');
}
let processedImages = data.images;
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'designer',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'designer',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingCompany as any),
company_id: companyId, // Always include for relational integrity
company_type: 'designer', // Always include for entity type discrimination
images: processedImages as unknown as Json
},
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying designer update', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'designer_update' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
export async function submitOperatorCreation(
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'operator_creation');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start operator submission', 'submitOperatorCreation', { userId });
// Validate required fields client-side
assertValid(validateCompanyCreateFields({ ...data, company_type: 'operator' }));
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'operator_creation').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Upload images
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error: unknown) {
handleError(error, {
action: 'Upload operator images',
});
throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'operator',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'operator',
action_type: 'create',
item_data: {
// ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data
...(() => {
const { images, ...dataWithoutImages } = data;
return dataWithoutImages;
})(),
company_type: 'operator',
images: processedImages as unknown as Json
},
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying operator submission', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'operator' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
export async function submitOperatorUpdate(
companyId: string,
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'operator_update');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start operator update', 'submitOperatorUpdate', { userId, companyId });
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'operator_update').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
const { data: existingCompany, error: fetchError } = await supabase
.from('companies')
.select('*')
.eq('id', companyId)
.single();
if (fetchError) throw new Error(`Failed to fetch operator: ${fetchError.message}`);
if (!existingCompany) throw new Error('Operator not found');
// CRITICAL: Block new photo uploads on edits
if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) {
throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.');
}
let processedImages = data.images;
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'operator',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'operator',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingCompany as any),
company_id: companyId, // Always include for relational integrity
company_type: 'operator', // Always include for entity type discrimination
images: processedImages as unknown as Json
},
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying operator update', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'operator_update' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
export async function submitPropertyOwnerCreation(
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'property_owner_creation');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start property owner submission', 'submitPropertyOwnerCreation', { userId });
// Validate required fields client-side
assertValid(validateCompanyCreateFields({ ...data, company_type: 'property_owner' }));
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'property_owner_creation').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// Upload images
let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) {
try {
const uploadedImages = await uploadPendingImages(data.images.uploaded);
processedImages = { ...data.images, uploaded: uploadedImages };
} catch (error: unknown) {
handleError(error, {
action: 'Upload property owner images',
});
throw new Error(`Failed to upload images: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'property_owner',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'property_owner',
action_type: 'create',
item_data: {
// ✅ FIXED: Don't use extractChangedFields for CREATE - include ALL data
...(() => {
const { images, ...dataWithoutImages } = data;
return dataWithoutImages;
})(),
company_type: 'property_owner',
images: processedImages as unknown as Json
},
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying property owner submission', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'property_owner' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
export async function submitPropertyOwnerUpdate(
companyId: string,
data: CompanyFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Rate limiting check
checkRateLimitOrThrow(userId, 'property_owner_update');
recordSubmissionAttempt(userId);
// Breadcrumb tracking
breadcrumb.userAction('Start property owner update', 'submitPropertyOwnerUpdate', { userId, companyId });
// Ban check with retry logic
const { withRetry } = await import('./retryHelpers');
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data: profile } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', userId)
.single();
return profile;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'property_owner_update').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
const { data: existingCompany, error: fetchError } = await supabase
.from('companies')
.select('*')
.eq('id', companyId)
.single();
if (fetchError) throw new Error(`Failed to fetch property owner: ${fetchError.message}`);
if (!existingCompany) throw new Error('Property owner not found');
// CRITICAL: Block new photo uploads on edits
if (data.images?.uploaded && data.images.uploaded.some(img => img.isLocal)) {
throw new Error('Photo uploads are not allowed during edits. Please use the photo gallery to submit additional photos.');
}
let processedImages = data.images;
// Submit with retry logic
breadcrumb.apiCall('content_submissions', 'INSERT');
const result = await withRetry(
async () => {
const { data: submissionData, error: submissionError } = await supabase
.from('content_submissions')
.insert({
user_id: userId,
submission_type: 'property_owner',
status: 'pending' as const
})
.select()
.single();
if (submissionError) throw submissionError;
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submissionData.id,
item_type: 'property_owner',
action_type: 'edit',
item_data: {
...extractChangedFields(data, existingCompany as any),
company_id: companyId, // Always include for relational integrity
company_type: 'property_owner', // Always include for entity type discrimination
images: processedImages as unknown as Json
},
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending' as const,
order_index: 0
});
if (itemError) throw itemError;
return { submitted: true, submissionId: submissionData.id };
},
{
maxAttempts: 3,
onRetry: (attempt, error, delay) => {
logger.warn('Retrying property owner update', { attempt, delay });
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: { attempt, maxAttempts: 3, delay, type: 'property_owner_update' }
}));
},
shouldRetry: (error) => {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('required')) return false;
if (message.includes('banned')) return false;
if (message.includes('slug')) return false;
}
return isRetryableError(error);
}
}
);
return result;
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits a new timeline event for an entity through the moderation queue.
*
* DO NOT write directly to entity_timeline_events:
* ❌ await supabase.from('entity_timeline_events').insert(data) // BYPASSES MODERATION!
* ✅ await submitTimelineEvent(entityType, entityId, data, userId) // CORRECT
*
* Flow: User Submit → Moderation Queue → Approval → Database Write
*
* @param entityType - Type of entity (park, ride, company)
* @param entityId - ID of the entity
* @param data - The timeline event form data
* @param userId - The ID of the user submitting
* @returns Object containing submitted boolean and submissionId
*/
export async function submitTimelineEvent(
entityType: EntityType,
entityId: string,
data: TimelineEventFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// ✅ Phase 4: Validate user
if (!userId) {
throw new Error('User ID is required for timeline event submission');
}
// ✅ Phase 4: Rate limiting check
checkRateLimitOrThrow(userId, 'timeline_event_creation');
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');
}
if (!data.event_type) {
throw new Error('Timeline event type is required');
}
// ✅ Phase 4: Breadcrumb tracking
breadcrumb.userAction('Start timeline event submission', 'submitTimelineEvent', {
entityType,
entityId,
eventType: data.event_type,
userId
});
// ✅ 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) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'timeline_event_creation').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// ✅ 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 (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,
submissionId: submissionData.id,
};
}
/**
* ⚠️ CRITICAL SECURITY PATTERN ⚠️
*
* Submits an update to an existing timeline event through the moderation queue.
*
* @param eventId - ID of the existing timeline event
* @param data - The updated timeline event data
* @param userId - The ID of the user submitting the update
* @returns Object containing submitted boolean and submissionId
*/
export async function submitTimelineEventUpdate(
eventId: string,
data: TimelineEventFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// ✅ 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) {
// Report ban evasion attempt
reportBanEvasionAttempt(userId, 'timeline_event_update').catch(() => {
// Non-blocking - don't fail if alert fails
});
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>>);
// ✅ 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 (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
});
}
}
);
// ✅ 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 (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
});
}
}
);
// ✅ 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 (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,
submissionId: submissionData.id,
};
}
export async function deleteTimelineEvent(
eventId: string,
userId: string
): Promise<void> {
// First verify the event exists and user has permission
const { data: event, error: fetchError } = await supabase
.from('entity_timeline_events')
.select('created_by, approved_by')
.eq('id', eventId)
.single();
if (fetchError) {
handleError(fetchError, {
action: 'Delete timeline event',
metadata: { eventId },
});
throw new Error('Timeline event not found');
}
if (!event) {
throw new Error('Timeline event not found');
}
// Only allow deletion of own unapproved events
if (event.created_by !== userId) {
throw new Error('You can only delete your own timeline events');
}
if (event.approved_by !== null) {
throw new Error('Cannot delete approved timeline events');
}
// Delete the event
const { error: deleteError } = await supabase
.from('entity_timeline_events')
.delete()
.eq('id', eventId);
if (deleteError) {
handleError(deleteError, {
action: 'Delete timeline event',
metadata: { eventId },
});
throw new Error('Failed to delete timeline event');
}
logger.info('Timeline event deleted', {
action: 'delete_timeline_event',
eventId,
userId
});
}