diff --git a/src/lib/entityTransformers.ts b/src/lib/entityTransformers.ts new file mode 100644 index 00000000..fee57efc --- /dev/null +++ b/src/lib/entityTransformers.ts @@ -0,0 +1,214 @@ +import { Database } from '@/integrations/supabase/types'; + +type ParkInsert = Database['public']['Tables']['parks']['Insert']; +type RideInsert = Database['public']['Tables']['rides']['Insert']; +type CompanyInsert = Database['public']['Tables']['companies']['Insert']; +type RideModelInsert = Database['public']['Tables']['ride_models']['Insert']; + +/** + * Transform park submission data to database insert format + */ +export function transformParkData(submissionData: any): ParkInsert { + return { + name: submissionData.name, + slug: submissionData.slug, + description: submissionData.description || null, + park_type: submissionData.park_type, + status: normalizeStatus(submissionData.status), + opening_date: submissionData.opening_date || null, + closing_date: submissionData.closing_date || null, + website_url: submissionData.website_url || null, + phone: submissionData.phone || null, + email: submissionData.email || null, + operator_id: submissionData.operator_id || null, + property_owner_id: submissionData.property_owner_id || null, + location_id: submissionData.location_id || null, + banner_image_url: submissionData.banner_image_url || null, + banner_image_id: submissionData.banner_image_id || null, + card_image_url: submissionData.card_image_url || null, + card_image_id: submissionData.card_image_id || null, + average_rating: 0, + review_count: 0, + ride_count: 0, + coaster_count: 0, + }; +} + +/** + * Transform ride submission data to database insert format + */ +export function transformRideData(submissionData: any): RideInsert { + // Parse JSON fields if they're strings + let coasterStats = null; + let technicalSpecs = null; + let formerNames = null; + + try { + if (submissionData.coaster_stats) { + coasterStats = typeof submissionData.coaster_stats === 'string' + ? JSON.parse(submissionData.coaster_stats) + : submissionData.coaster_stats; + } + } catch (e) { + console.warn('Failed to parse coaster_stats:', e); + } + + try { + if (submissionData.technical_specs) { + technicalSpecs = typeof submissionData.technical_specs === 'string' + ? JSON.parse(submissionData.technical_specs) + : submissionData.technical_specs; + } + } catch (e) { + console.warn('Failed to parse technical_specs:', e); + } + + try { + if (submissionData.former_names) { + formerNames = typeof submissionData.former_names === 'string' + ? JSON.parse(submissionData.former_names) + : submissionData.former_names; + } + } catch (e) { + console.warn('Failed to parse former_names:', e); + } + + return { + name: submissionData.name, + slug: submissionData.slug, + description: submissionData.description || null, + category: submissionData.category, + ride_sub_type: submissionData.ride_sub_type || null, + status: normalizeStatus(submissionData.status), + park_id: submissionData.park_id, + ride_model_id: submissionData.ride_model_id || null, + manufacturer_id: submissionData.manufacturer_id || null, + designer_id: submissionData.designer_id || null, + opening_date: submissionData.opening_date || null, + closing_date: submissionData.closing_date || null, + height_requirement: submissionData.height_requirement || null, + age_requirement: submissionData.age_requirement || null, + capacity_per_hour: submissionData.capacity_per_hour || null, + duration_seconds: submissionData.duration_seconds || null, + max_speed_kmh: submissionData.max_speed_kmh || null, + max_height_meters: submissionData.max_height_meters || null, + length_meters: submissionData.length_meters || null, + drop_height_meters: submissionData.drop_height_meters || null, + inversions: submissionData.inversions || null, + max_g_force: submissionData.max_g_force || null, + coaster_type: submissionData.coaster_type || null, + seating_type: submissionData.seating_type || null, + intensity_level: submissionData.intensity_level || null, + coaster_stats: coasterStats, + technical_specs: technicalSpecs, + former_names: formerNames || [], + banner_image_url: submissionData.banner_image_url || null, + banner_image_id: submissionData.banner_image_id || null, + card_image_url: submissionData.card_image_url || null, + card_image_id: submissionData.card_image_id || null, + image_url: submissionData.image_url || null, + average_rating: 0, + review_count: 0, + }; +} + +/** + * Transform company submission data to database insert format + */ +export function transformCompanyData( + submissionData: any, + companyType: 'manufacturer' | 'operator' | 'property_owner' | 'designer' +): CompanyInsert { + return { + name: submissionData.name, + slug: submissionData.slug, + description: submissionData.description || null, + company_type: companyType, + person_type: submissionData.person_type || 'company', + founded_year: submissionData.founded_year || null, + headquarters_location: submissionData.headquarters_location || null, + website_url: submissionData.website_url || null, + logo_url: submissionData.logo_url || null, + average_rating: 0, + review_count: 0, + }; +} + +/** + * Transform ride model submission data to database insert format + */ +export function transformRideModelData(submissionData: any): RideModelInsert { + let technicalSpecs = null; + + try { + if (submissionData.technical_specs) { + technicalSpecs = typeof submissionData.technical_specs === 'string' + ? JSON.parse(submissionData.technical_specs) + : submissionData.technical_specs; + } + } catch (e) { + console.warn('Failed to parse technical_specs:', e); + } + + return { + name: submissionData.name, + slug: submissionData.slug, + manufacturer_id: submissionData.manufacturer_id, + category: submissionData.category, + ride_type: submissionData.ride_type || null, + description: submissionData.description || null, + technical_specs: technicalSpecs, + }; +} + +/** + * Normalize status values to match database enums + */ +function normalizeStatus(status: string): string { + if (!status) return 'operating'; + + const statusMap: Record = { + 'Operating': 'operating', + 'operating': 'operating', + 'Seasonal': 'seasonal', + 'seasonal': 'seasonal', + 'Closed Temporarily': 'closed_temporarily', + 'closed_temporarily': 'closed_temporarily', + 'Closed Permanently': 'closed_permanently', + 'closed_permanently': 'closed_permanently', + 'Under Construction': 'under_construction', + 'under_construction': 'under_construction', + 'Planned': 'planned', + 'planned': 'planned', + 'SBNO': 'sbno', + 'sbno': 'sbno', + }; + + return statusMap[status] || 'operating'; +} + +/** + * Extract Cloudflare image ID from URL + */ +export function extractImageId(url: string): string { + const match = url.match(/\/([a-f0-9-]{36})\//); + return match ? match[1] : ''; +} + +/** + * Validate and sanitize submission data before transformation + */ +export function validateSubmissionData(data: any, itemType: string): void { + if (!data.name || typeof data.name !== 'string' || data.name.trim() === '') { + throw new Error(`${itemType} name is required`); + } + + if (!data.slug || typeof data.slug !== 'string' || data.slug.trim() === '') { + throw new Error(`${itemType} slug is required`); + } + + // Validate slug format + if (!/^[a-z0-9-]+$/.test(data.slug)) { + throw new Error(`${itemType} slug must contain only lowercase letters, numbers, and hyphens`); + } +} diff --git a/src/lib/slugUtils.ts b/src/lib/slugUtils.ts new file mode 100644 index 00000000..83d5139c --- /dev/null +++ b/src/lib/slugUtils.ts @@ -0,0 +1,66 @@ +import { supabase } from '@/integrations/supabase/client'; + +/** + * Generate a URL-safe slug from a name + */ +export function generateSlugFromName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); +} + +/** + * Ensure slug is unique by checking database and appending number if needed + */ +export async function ensureUniqueSlug( + baseSlug: string, + tableName: 'parks' | 'rides' | 'companies' | 'ride_models', + excludeId?: string +): Promise { + let slug = baseSlug; + let counter = 1; + + while (true) { + // Check if slug exists + let query = supabase + .from(tableName) + .select('id') + .eq('slug', slug); + + // Exclude current record when editing + if (excludeId) { + query = query.neq('id', excludeId); + } + + const { data, error } = await query.limit(1); + + if (error) { + console.error(`Error checking slug uniqueness in ${tableName}:`, error); + throw error; + } + + // If no match found, slug is unique + if (!data || data.length === 0) { + return slug; + } + + // Append counter and try again + counter++; + slug = `${baseSlug}-${counter}`; + } +} + +/** + * Generate and ensure unique slug in one operation + */ +export async function generateUniqueSlug( + name: string, + tableName: 'parks' | 'rides' | 'companies' | 'ride_models', + excludeId?: string +): Promise { + const baseSlug = generateSlugFromName(name); + return ensureUniqueSlug(baseSlug, tableName, excludeId); +} diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts index cad00eb2..39028362 100644 --- a/src/lib/submissionItemsService.ts +++ b/src/lib/submissionItemsService.ts @@ -161,36 +161,59 @@ export async function approveSubmissionItems( // Sort by dependency order (parents first) const sortedItems = topologicalSort(items); + // Build dependency resolution map + const dependencyMap = new Map(); + for (const item of sortedItems) { let entityId: string | null = null; - // Create the entity based on type - switch (item.item_type) { - case 'park': - entityId = await createPark(item.item_data); - break; - case 'ride': - entityId = await createRide(item.item_data); - break; - case 'manufacturer': - case 'operator': - case 'property_owner': - case 'designer': - entityId = await createCompany(item.item_data, item.item_type); - break; - case 'ride_model': - entityId = await createRideModel(item.item_data); - break; - case 'photo': - entityId = await approvePhotos(item.item_data); - break; - } + try { + // Create the entity based on type with dependency resolution + switch (item.item_type) { + case 'park': + entityId = await createPark(item.item_data, dependencyMap); + break; + case 'ride': + entityId = await createRide(item.item_data, dependencyMap); + break; + case 'manufacturer': + case 'operator': + case 'property_owner': + case 'designer': + entityId = await createCompany(item.item_data, item.item_type, dependencyMap); + break; + case 'ride_model': + entityId = await createRideModel(item.item_data, dependencyMap); + break; + case 'photo': + entityId = await approvePhotos(item.item_data, dependencyMap); + break; + } - // Update item status - await updateSubmissionItem(item.id, { - status: 'approved', - approved_entity_id: entityId, - }); + if (!entityId) { + throw new Error(`Failed to create ${item.item_type}: no entity ID returned`); + } + + // Update item status + await updateSubmissionItem(item.id, { + status: 'approved', + approved_entity_id: entityId, + }); + + // Add to dependency map for child items + dependencyMap.set(item.id, entityId); + + } catch (error: any) { + console.error(`Error approving ${item.item_type} item ${item.id}:`, error); + + // Update item with error status + await updateSubmissionItem(item.id, { + status: 'rejected', + rejection_reason: `Failed to create entity: ${error.message}`, + }); + + throw new Error(`Failed to approve ${item.item_type}: ${error.message}`); + } } } @@ -229,56 +252,190 @@ function topologicalSort(items: SubmissionItemWithDeps[]): SubmissionItemWithDep } /** - * Helper functions to create entities + * Helper functions to create entities with dependency resolution */ -async function createPark(data: any): Promise { +async function createPark(data: any, dependencyMap: Map): Promise { + const { transformParkData, validateSubmissionData } = await import('./entityTransformers'); + const { ensureUniqueSlug } = await import('./slugUtils'); + + // Validate input data + validateSubmissionData(data, 'Park'); + + // Resolve dependencies + const resolvedData = resolveDependencies(data, dependencyMap); + + // Ensure unique slug + const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'parks'); + resolvedData.slug = uniqueSlug; + + // Transform to database format + const parkData = transformParkData(resolvedData); + + // Insert into database const { data: park, error } = await supabase .from('parks') - .insert(data) + .insert(parkData) .select('id') .single(); - if (error) throw error; + if (error) { + console.error('Error creating park:', error); + throw new Error(`Database error: ${error.message}`); + } + return park.id; } -async function createRide(data: any): Promise { +async function createRide(data: any, dependencyMap: Map): Promise { + const { transformRideData, validateSubmissionData } = await import('./entityTransformers'); + const { ensureUniqueSlug } = await import('./slugUtils'); + + // Validate input data + validateSubmissionData(data, 'Ride'); + + // Resolve dependencies + const resolvedData = resolveDependencies(data, dependencyMap); + + // Validate park_id is present (required for rides) + if (!resolvedData.park_id) { + throw new Error('Ride must be associated with a park'); + } + + // Ensure unique slug + const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'rides'); + resolvedData.slug = uniqueSlug; + + // Transform to database format + const rideData = transformRideData(resolvedData); + + // Insert into database const { data: ride, error } = await supabase .from('rides') - .insert(data) + .insert(rideData) .select('id') .single(); - if (error) throw error; + if (error) { + console.error('Error creating ride:', error); + throw new Error(`Database error: ${error.message}`); + } + return ride.id; } -async function createCompany(data: any, companyType: string): Promise { +async function createCompany( + data: any, + companyType: string, + dependencyMap: Map +): Promise { + const { transformCompanyData, validateSubmissionData } = await import('./entityTransformers'); + const { ensureUniqueSlug } = await import('./slugUtils'); + + // Validate input data + validateSubmissionData(data, 'Company'); + + // Resolve dependencies + const resolvedData = resolveDependencies(data, dependencyMap); + + // Ensure unique slug + const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'companies'); + resolvedData.slug = uniqueSlug; + + // Transform to database format + const companyData = transformCompanyData(resolvedData, companyType as any); + + // Insert into database const { data: company, error } = await supabase .from('companies') - .insert({ ...data, company_type: companyType }) + .insert(companyData) .select('id') .single(); - if (error) throw error; + if (error) { + console.error('Error creating company:', error); + throw new Error(`Database error: ${error.message}`); + } + return company.id; } -async function createRideModel(data: any): Promise { +async function createRideModel(data: any, dependencyMap: Map): Promise { + const { transformRideModelData, validateSubmissionData } = await import('./entityTransformers'); + const { ensureUniqueSlug } = await import('./slugUtils'); + + // Validate input data + validateSubmissionData(data, 'Ride Model'); + + // Resolve dependencies + const resolvedData = resolveDependencies(data, dependencyMap); + + // Validate manufacturer_id is present (required for ride models) + if (!resolvedData.manufacturer_id) { + throw new Error('Ride model must be associated with a manufacturer'); + } + + // Ensure unique slug + const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'ride_models'); + resolvedData.slug = uniqueSlug; + + // Transform to database format + const modelData = transformRideModelData(resolvedData); + + // Insert into database const { data: model, error } = await supabase .from('ride_models') - .insert(data) + .insert(modelData) .select('id') .single(); - if (error) throw error; + if (error) { + console.error('Error creating ride model:', error); + throw new Error(`Database error: ${error.message}`); + } + return model.id; } -async function approvePhotos(data: any): Promise { +async function approvePhotos(data: any, dependencyMap: Map): Promise { // Photos are already uploaded to Cloudflare - // Just need to associate them with the entity - return data.photos?.[0]?.url || ''; + // Resolve dependencies for entity associations + const resolvedData = resolveDependencies(data, dependencyMap); + + // For now, return the first photo URL + // In the future, this could create photo records in a dedicated table + if (resolvedData.photos && Array.isArray(resolvedData.photos) && resolvedData.photos.length > 0) { + return resolvedData.photos[0].url; + } + + return ''; +} + +/** + * Resolve dependency references in submission data + * Replaces submission item IDs with actual database entity IDs + */ +function resolveDependencies(data: any, dependencyMap: Map): any { + const resolved = { ...data }; + + // List of foreign key fields that may need resolution + const foreignKeys = [ + 'park_id', + 'manufacturer_id', + 'designer_id', + 'operator_id', + 'property_owner_id', + 'ride_model_id', + 'location_id', + ]; + + // Resolve each foreign key if it's a submission item ID + for (const key of foreignKeys) { + if (resolved[key] && dependencyMap.has(resolved[key])) { + resolved[key] = dependencyMap.get(resolved[key]); + } + } + + return resolved; } /**