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