mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:51:13 -05:00
Implement Phase 4 by adding `recordSubmissionAttempt` and `withRetry` logic to the ban check for composite submissions. This ensures better error handling and prevents bypass of ban checks due to transient network issues.
3384 lines
118 KiB
TypeScript
3384 lines
118 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';
|
|
|
|
// ============================================
|
|
// 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,
|
|
});
|
|
|
|
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) {
|
|
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');
|
|
|
|
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) {
|
|
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');
|
|
|
|
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) {
|
|
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');
|
|
|
|
// 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) {
|
|
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');
|
|
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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
|
|
});
|
|
}
|