// Force redeployment: v102 - Schema refresh for temp_location_data column import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { createErrorResponse } from "../_shared/errorSanitizer.ts"; import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { rateLimiters, withRateLimit } from "../_shared/rateLimiter.ts"; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; // ============================================================================ // VALIDATION FUNCTIONS (Inlined from validation.ts) // ============================================================================ interface ValidationResult { valid: boolean; errors: string[]; } interface StrictValidationResult { valid: boolean; blockingErrors: string[]; warnings: string[]; } function isValidUrl(url: string): boolean { try { new URL(url); return true; } catch { return false; } } function isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function validateEntityDataStrict( entityType: string, data: any, originalData?: any ): StrictValidationResult { const result: StrictValidationResult = { valid: true, blockingErrors: [], warnings: [] }; const isTimelineEvent = entityType === 'milestone' || entityType === 'timeline_event'; if (!isTimelineEvent) { if (!data.name?.trim()) { result.blockingErrors.push('Name is required'); } if (!data.slug?.trim()) { result.blockingErrors.push('Slug is required'); } if (data.slug && !/^[a-z0-9-]+$/.test(data.slug)) { result.blockingErrors.push('Slug must contain only lowercase letters, numbers, and hyphens'); } if (data.name && data.name.length > 200) { result.blockingErrors.push('Name must be less than 200 characters'); } if (data.description && data.description.length > 2000) { result.blockingErrors.push('Description must be less than 2000 characters'); } if (data.website_url && data.website_url !== '' && !isValidUrl(data.website_url)) { result.warnings.push('Website URL format may be invalid'); } if (data.email && data.email !== '' && !isValidEmail(data.email)) { result.warnings.push('Email format may be invalid'); } } else { if (data.description && data.description.length > 2000) { result.blockingErrors.push('Description must be less than 2000 characters'); } } switch (entityType) { case 'park': if (!data.park_type) { result.blockingErrors.push('Park type is required'); } if (!data.status) { result.blockingErrors.push('Status is required'); } const hasLocation = data.location_id !== null && data.location_id !== undefined; const hasTempLocation = data.temp_location_data !== null && data.temp_location_data !== undefined; const hadLocation = originalData?.location_id !== null && originalData?.location_id !== undefined; if (!hasLocation && !hasTempLocation && !hadLocation) { result.blockingErrors.push('Location is required for parks'); } if (hadLocation && data.location_id === null) { result.blockingErrors.push('Cannot remove location from a park - location is required'); } if (data.opening_date && data.closing_date) { const opening = new Date(data.opening_date); const closing = new Date(data.closing_date); if (closing < opening) { result.blockingErrors.push('Closing date must be after opening date'); } } break; case 'ride': if (!data.category) { result.blockingErrors.push('Category is required'); } if (!data.status) { result.blockingErrors.push('Status is required'); } const hasPark = data.park_id !== null && data.park_id !== undefined; const hadPark = originalData?.park_id !== null && originalData?.park_id !== undefined; if (!hasPark && !hadPark) { result.blockingErrors.push('Park is required for rides'); } if (hadPark && data.park_id === null) { result.blockingErrors.push('Cannot remove park from a ride - park is required'); } if (data.max_speed_kmh && (data.max_speed_kmh < 0 || data.max_speed_kmh > 300)) { result.blockingErrors.push('Max speed must be between 0 and 300 km/h'); } if (data.max_height_meters && (data.max_height_meters < 0 || data.max_height_meters > 200)) { result.blockingErrors.push('Max height must be between 0 and 200 meters'); } if (data.drop_height_meters && (data.drop_height_meters < 0 || data.drop_height_meters > 200)) { result.blockingErrors.push('Drop height must be between 0 and 200 meters'); } if (data.height_requirement && (data.height_requirement < 0 || data.height_requirement > 300)) { result.blockingErrors.push('Height requirement must be between 0 and 300 cm'); } break; case 'manufacturer': case 'designer': case 'operator': case 'property_owner': if (!data.company_type) { result.blockingErrors.push(`Company type is required (expected: ${entityType})`); } else if (data.company_type !== entityType) { result.blockingErrors.push(`Company type mismatch: expected '${entityType}' but got '${data.company_type}'`); } if (data.founded_year) { const year = parseInt(data.founded_year); const currentYear = new Date().getFullYear(); if (year < 1800 || year > currentYear) { result.warnings.push(`Founded year should be between 1800 and ${currentYear}`); } } break; case 'ride_model': if (!data.category) { result.blockingErrors.push('Category is required'); } if (!data.ride_type) { result.blockingErrors.push('Ride type is required'); } break; case 'photo': if (!data.cloudflare_image_id) { result.blockingErrors.push('Image ID is required'); } if (data.cloudflare_image_id && !/^[a-zA-Z0-9-]{36}$/.test(data.cloudflare_image_id)) { result.blockingErrors.push('Invalid Cloudflare image ID format'); } if (!data.entity_type) { result.blockingErrors.push('Entity type is required'); } const validPhotoEntityTypes = ['park', 'ride', 'company', 'ride_model']; if (data.entity_type && !validPhotoEntityTypes.includes(data.entity_type)) { result.blockingErrors.push(`Invalid entity type. Must be one of: ${validPhotoEntityTypes.join(', ')}`); } if (!data.entity_id) { result.blockingErrors.push('Entity ID is required'); } if (data.entity_id && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(data.entity_id)) { result.blockingErrors.push('Entity ID must be a valid UUID'); } if (data.caption && data.caption.length > 500) { result.blockingErrors.push('Caption must be less than 500 characters'); } break; case 'photo_edit': if (!data.photo_id) { result.blockingErrors.push('Photo ID is required'); } if (!data.entity_type) { result.blockingErrors.push('Entity type is required'); } if (!data.entity_id) { result.blockingErrors.push('Entity ID is required'); } if (data.caption && data.caption.length > 500) { result.blockingErrors.push('Caption must be less than 500 characters'); } if (data.title && data.title.length > 200) { result.blockingErrors.push('Title must be less than 200 characters'); } break; case 'photo_delete': if (!data.photo_id) { result.blockingErrors.push('Photo ID is required'); } if (!data.cloudflare_image_id && !data.photo_id) { result.blockingErrors.push('Photo identifier is required'); } if (!data.entity_type) { result.blockingErrors.push('Entity type is required'); } if (!data.entity_id) { result.blockingErrors.push('Entity ID is required'); } break; case 'milestone': case 'timeline_event': if (!data.title?.trim()) { result.blockingErrors.push('Event title is required'); } if (data.title && data.title.length > 200) { result.blockingErrors.push('Title must be less than 200 characters'); } if (!data.event_type) { result.blockingErrors.push('Event type is required'); } if (!data.event_date) { result.blockingErrors.push('Event date is required'); } if (data.event_date) { const eventDate = new Date(data.event_date); const maxFutureDate = new Date(); maxFutureDate.setFullYear(maxFutureDate.getFullYear() + 5); if (eventDate > maxFutureDate) { result.blockingErrors.push('Event date cannot be more than 5 years in the future'); } const minDate = new Date('1800-01-01'); if (eventDate < minDate) { result.blockingErrors.push('Event date cannot be before year 1800'); } } const changeEventTypes = ['name_change', 'location_change', 'status_change', 'ownership_change']; if (data.event_type && changeEventTypes.includes(data.event_type)) { if (!data.from_value && !data.to_value) { result.blockingErrors.push(`Change event (${data.event_type}) requires at least one of from_value or to_value`); } } if (!data.entity_type) { result.blockingErrors.push('Entity type is required'); } const validTimelineEntityTypes = ['park', 'ride', 'company', 'ride_model']; if (data.entity_type && !validTimelineEntityTypes.includes(data.entity_type)) { result.blockingErrors.push(`Invalid entity type. Must be one of: ${validTimelineEntityTypes.join(', ')}`); } if (!data.entity_id) { result.blockingErrors.push('Entity ID is required'); } if (data.entity_id && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(data.entity_id)) { result.blockingErrors.push('Entity ID must be a valid UUID'); } break; } result.valid = result.blockingErrors.length === 0; return result; } function validateEntityData(entityType: string, data: any): ValidationResult { const errors: string[] = []; const isTimelineEvent = entityType === 'milestone' || entityType === 'timeline_event'; if (!isTimelineEvent) { if (!data.name || data.name.trim().length === 0) { errors.push('Name is required'); } if (!data.slug || data.slug.trim().length === 0) { errors.push('Slug is required'); } if (data.slug && !/^[a-z0-9-]+$/.test(data.slug)) { errors.push('Slug must contain only lowercase letters, numbers, and hyphens'); } if (data.name && data.name.length > 200) { errors.push('Name must be less than 200 characters'); } if (data.description && data.description.length > 2000) { errors.push('Description must be less than 2000 characters'); } if (data.website_url && data.website_url !== '' && !data.website_url.startsWith('http')) { errors.push('Website URL must start with http:// or https://'); } if (data.email && data.email !== '' && !data.email.includes('@')) { errors.push('Invalid email format'); } } else { if (data.description && data.description.length > 2000) { errors.push('Description must be less than 2000 characters'); } } switch (entityType) { case 'park': if (!data.park_type) errors.push('Park type is required'); if (!data.status) errors.push('Status is required'); if (data.opening_date && data.closing_date) { const opening = new Date(data.opening_date); const closing = new Date(data.closing_date); if (closing < opening) { errors.push('Closing date must be after opening date'); } } break; case 'ride': if (!data.category) errors.push('Category is required'); if (!data.status) errors.push('Status is required'); if (data.max_speed_kmh && (data.max_speed_kmh < 0 || data.max_speed_kmh > 300)) { errors.push('Max speed must be between 0 and 300 km/h'); } if (data.max_height_meters && (data.max_height_meters < 0 || data.max_height_meters > 200)) { errors.push('Max height must be between 0 and 200 meters'); } if (data.drop_height_meters && (data.drop_height_meters < 0 || data.drop_height_meters > 200)) { errors.push('Drop height must be between 0 and 200 meters'); } if (data.height_requirement && (data.height_requirement < 0 || data.height_requirement > 300)) { errors.push('Height requirement must be between 0 and 300 cm'); } break; case 'manufacturer': case 'designer': case 'operator': case 'property_owner': if (!data.company_type) { errors.push(`Company type is required (expected: ${entityType})`); } else if (data.company_type !== entityType) { errors.push(`Company type mismatch: expected '${entityType}' but got '${data.company_type}'`); } if (data.founded_year) { const year = parseInt(data.founded_year); const currentYear = new Date().getFullYear(); if (year < 1800 || year > currentYear) { errors.push(`Founded year must be between 1800 and ${currentYear}`); } } break; case 'ride_model': if (!data.category) errors.push('Category is required'); if (!data.ride_type) errors.push('Ride type is required'); break; case 'photo': if (!data.cloudflare_image_id) errors.push('Image ID is required'); if (!data.entity_type) errors.push('Entity type is required'); if (!data.entity_id) errors.push('Entity ID is required'); if (data.caption && data.caption.length > 500) { errors.push('Caption must be less than 500 characters'); } break; case 'milestone': case 'timeline_event': if (!data.title || data.title.trim().length === 0) { errors.push('Event title is required'); } if (data.title && data.title.length > 200) { errors.push('Title must be less than 200 characters'); } if (!data.event_type) errors.push('Event type is required'); if (!data.event_date) errors.push('Event date is required'); if (!data.entity_type) errors.push('Entity type is required'); if (!data.entity_id) errors.push('Entity ID is required'); break; } return { valid: errors.length === 0, errors }; } // ============================================================================ // END VALIDATION FUNCTIONS // ============================================================================ interface ApprovalRequest { itemIds: string[]; submissionId: string; } // Allowed database fields for each entity type const RIDE_FIELDS = [ 'name', 'slug', 'description', 'park_id', 'ride_model_id', 'manufacturer_id', 'designer_id', 'category', 'status', 'opening_date', 'opening_date_precision', 'closing_date', 'closing_date_precision', 'height_requirement', 'age_requirement', 'capacity_per_hour', 'duration_seconds', 'max_speed_kmh', 'max_height_meters', 'length_meters', 'inversions', 'ride_sub_type', 'coaster_type', 'seating_type', 'intensity_level', 'track_material', 'drop_height_meters', 'max_g_force', 'image_url', 'banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id' ]; const PARK_FIELDS = [ 'name', 'slug', 'description', 'park_type', 'status', 'opening_date', 'opening_date_precision', 'closing_date', 'closing_date_precision', 'location_id', 'operator_id', 'property_owner_id', 'website_url', 'phone', 'email', 'banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id' ]; const COMPANY_FIELDS = [ 'name', 'slug', 'description', 'company_type', 'person_type', 'founded_year', 'headquarters_location', 'website_url', 'logo_url', 'banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id' ]; const RIDE_MODEL_FIELDS = [ 'name', 'slug', 'description', 'category', 'ride_type', 'manufacturer_id', 'banner_image_url', 'banner_image_id', 'card_image_url', 'card_image_id' ]; // Apply per-user rate limiting for moderators (10 approvals/minute per moderator) const approvalRateLimiter = rateLimiters.perUser(10); serve(withRateLimit(async (req) => { const tracking = startRequest(); // Start request tracking let authenticatedUserId: string | undefined = undefined; // Declare outside try block for catch access if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } try { edgeLogger.info('Processing selective approval request', { requestId: tracking.requestId, traceId: tracking.traceId }); // Verify authentication first with a client that respects RLS const authHeader = req.headers.get('Authorization'); if (!authHeader) { const duration = endRequest(tracking); edgeLogger.warn('Authentication missing', { requestId: tracking.requestId, duration }); return new Response( JSON.stringify({ error: 'Authentication required. Please log in.', requestId: tracking.requestId }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } // Create Supabase client with user's auth token to verify authentication const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''; const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? ''; const supabaseAuth = createClient(supabaseUrl, supabaseAnonKey, { global: { headers: { Authorization: authHeader } } }); // Verify JWT and get authenticated user const { data: { user }, error: authError } = await supabaseAuth.auth.getUser(); edgeLogger.info('User auth check', { action: 'approval_auth', hasUser: !!user, userId: user?.id }); if (authError || !user) { edgeLogger.error('Auth verification failed', { action: 'approval_auth', error: authError?.message, requestId: tracking.requestId }); const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'Invalid authentication token.', details: authError?.message || 'No user found', code: authError?.code, requestId: tracking.requestId }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } edgeLogger.info('Authentication successful', { action: 'approval_auth_success', userId: user.id }); // Check if user is banned const { data: profile, error: profileError } = await supabaseAuth .from('profiles') .select('banned') .eq('user_id', user.id) .single(); if (profileError || !profile) { edgeLogger.error('Profile check failed', { action: 'approval_profile_check', error: profileError?.message, requestId: tracking.requestId }); const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'Unable to verify user profile', requestId: tracking.requestId }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } if (profile.banned) { edgeLogger.warn('Banned user attempted approval', { action: 'approval_banned_user', userId: user.id, requestId: tracking.requestId }); const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'Account suspended', message: 'Your account has been suspended. Contact support for assistance.', requestId: tracking.requestId }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } // SECURITY NOTE: Service role key used later in this function // Reason: Need to bypass RLS to write approved changes to entity tables // (parks, rides, companies, ride_models) which have RLS policies // Security measures: User auth verified above, moderator role checked via RPC authenticatedUserId = user.id; // Create service role client for privileged operations (including role check) const supabase = createClient( Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' ); // Check if user has moderator permissions using service role to bypass RLS const { data: roles, error: rolesError } = await supabase .from('user_roles') .select('role') .eq('user_id', authenticatedUserId); edgeLogger.info('Role check query result', { action: 'approval_role_check', userId: authenticatedUserId, rolesCount: roles?.length }); if (rolesError) { edgeLogger.error('Role check failed', { action: 'approval_role_check', error: rolesError.message, requestId: tracking.requestId }); const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'Failed to verify user permissions.', requestId: tracking.requestId }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } const userRoles = roles?.map(r => r.role) || []; const isModerator = userRoles.includes('moderator') || userRoles.includes('admin') || userRoles.includes('superuser'); edgeLogger.info('Role check result', { action: 'approval_role_result', userId: authenticatedUserId, isModerator }); if (!isModerator) { edgeLogger.error('Insufficient permissions', { action: 'approval_role_insufficient', userId: authenticatedUserId, requestId: tracking.requestId }); const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'Insufficient permissions. Moderator role required.', requestId: tracking.requestId }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } edgeLogger.info('User is moderator', { action: 'approval_role_verified', userId: authenticatedUserId }); // Phase 2: AAL2 Enforcement - Check if user has MFA enrolled and requires AAL2 // Parse JWT directly from Authorization header to get AAL level const jwt = authHeader.replace('Bearer ', ''); const payload = JSON.parse(atob(jwt.split('.')[1])); const aal = payload.aal || 'aal1'; edgeLogger.info('Session AAL level', { action: 'approval_aal_check', userId: authenticatedUserId, aal }); // Check if user has MFA enrolled const { data: factorsData } = await supabaseAuth.auth.mfa.listFactors(); const hasMFA = factorsData?.totp?.some(f => f.status === 'verified') || false; edgeLogger.info('MFA status', { action: 'approval_mfa_check', userId: authenticatedUserId, hasMFA }); // Enforce AAL2 if MFA is enrolled if (hasMFA && aal !== 'aal2') { edgeLogger.error('AAL2 required but session is at AAL1', { action: 'approval_aal_violation', userId: authenticatedUserId, requestId: tracking.requestId }); const duration = endRequest(tracking); return new Response( JSON.stringify({ error: 'MFA verification required', code: 'AAL2_REQUIRED', message: 'Your role requires two-factor authentication. Please verify your identity to continue.', requestId: tracking.requestId }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } edgeLogger.info('AAL2 check passed', { action: 'approval_aal_pass', userId: authenticatedUserId, hasMFA, aal }); const { itemIds, submissionId }: ApprovalRequest = await req.json(); // UUID validation regex const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // Validate itemIds if (!itemIds || !Array.isArray(itemIds)) { return new Response( JSON.stringify({ error: 'itemIds is required and must be an array', requestId: tracking.requestId }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } if (itemIds.length === 0) { return new Response( JSON.stringify({ error: 'itemIds must be a non-empty array', requestId: tracking.requestId }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } // Validate submissionId if (!submissionId || typeof submissionId !== 'string' || submissionId.trim() === '') { return new Response( JSON.stringify({ error: 'submissionId is required and must be a non-empty string', requestId: tracking.requestId }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } if (!uuidRegex.test(submissionId)) { return new Response( JSON.stringify({ error: 'submissionId must be a valid UUID format', requestId: tracking.requestId }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } edgeLogger.info('Processing selective approval', { action: 'approval_start', itemCount: itemIds.length, userId: authenticatedUserId, submissionId }); // Fetch all items with relational data for the submission const { data: items, error: fetchError } = await supabase .from('submission_items') .select(` *, park_submission:park_submissions!submission_items_park_submission_id_fkey(*), ride_submission:ride_submissions!submission_items_ride_submission_id_fkey(*), company_submission:company_submissions!submission_items_company_submission_id_fkey(*), ride_model_submission:ride_model_submissions!submission_items_ride_model_submission_id_fkey(*), photo_submission:photo_submissions!submission_items_photo_submission_id_fkey( *, photo_items:photo_submission_items(*) ), timeline_event_submission:timeline_event_submissions!submission_items_timeline_event_submission_id_fkey(*) `) .in('id', itemIds); if (fetchError) { throw new Error(`Failed to fetch items: ${fetchError.message}`); } // Query temporary references for all submission items const { data: tempRefs, error: tempRefsError } = await supabase .from('submission_item_temp_refs') .select('submission_item_id, ref_type, ref_order_index') .in('submission_item_id', itemIds); if (tempRefsError) { edgeLogger.warn('Failed to fetch temp refs', { action: 'approval_fetch_temp_refs', submissionId, error: tempRefsError.message, requestId: tracking.requestId }); // Don't throw - continue with empty temp refs (backwards compatibility) } // Build a map: itemId -> { _temp_operator_ref: 0, _temp_park_ref: 1, ... } const tempRefsByItemId = new Map>(); for (const ref of tempRefs || []) { if (!tempRefsByItemId.has(ref.submission_item_id)) { tempRefsByItemId.set(ref.submission_item_id, {}); } const fieldName = `_temp_${ref.ref_type}_ref`; tempRefsByItemId.get(ref.submission_item_id)![fieldName] = ref.ref_order_index; } edgeLogger.info('Loaded temp refs', { action: 'approval_temp_refs_loaded', submissionId, itemsWithTempRefs: tempRefsByItemId.size, totalTempRefs: tempRefs?.length || 0, requestId: tracking.requestId }); // Get the submitter's user_id from the submission const { data: submission, error: submissionError } = await supabase .from('content_submissions') .select('user_id') .eq('id', submissionId) .single(); if (submissionError || !submission) { throw new Error(`Failed to fetch submission: ${submissionError?.message}`); } const submitterId = submission.user_id; // Topologically sort items by dependencies let sortedItems; try { sortedItems = topologicalSort(items); } catch (sortError: unknown) { const errorMessage = sortError instanceof Error ? sortError.message : 'Failed to sort items'; edgeLogger.error('Topological sort failed', { action: 'approval_sort_fail', submissionId, itemCount: items.length, userId: authenticatedUserId, error: errorMessage, requestId: tracking.requestId }); return new Response( JSON.stringify({ error: 'Invalid submission structure', message: errorMessage, details: 'The submission contains circular dependencies or missing required items', requestId: tracking.requestId }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } const dependencyMap = new Map(); const approvalResults: Array<{ itemId: string; entityId?: string | null; itemType: string; success: boolean; error?: string; isDependencyFailure?: boolean; }> = []; // Process items in order for (const item of sortedItems) { edgeLogger.info('Processing item', { action: 'approval_process_item', itemId: item.id, itemType: item.item_type }); // Extract data from relational tables based on item_type (OUTSIDE try-catch) let itemData: any; switch (item.item_type) { case 'park': itemData = { ...(item as any).park_submission, // Merge temp refs for this item ...(tempRefsByItemId.get(item.id) || {}) }; // DEBUG: Log what columns are present edgeLogger.info('Park item data loaded', { action: 'approval_park_data_debug', itemId: item.id, hasLocationId: !!itemData.location_id, hasTempLocationData: !!itemData.temp_location_data, tempLocationDataKeys: itemData.temp_location_data ? Object.keys(itemData.temp_location_data) : [], parkSubmissionKeys: Object.keys((item as any).park_submission || {}), requestId: tracking.requestId }); break; case 'ride': itemData = { ...(item as any).ride_submission, ...(tempRefsByItemId.get(item.id) || {}), // Ensure all category-specific fields are included track_material: (item as any).ride_submission?.track_material, support_material: (item as any).ride_submission?.support_material, propulsion_method: (item as any).ride_submission?.propulsion_method, water_depth_cm: (item as any).ride_submission?.water_depth_cm, splash_height_meters: (item as any).ride_submission?.splash_height_meters, wetness_level: (item as any).ride_submission?.wetness_level, flume_type: (item as any).ride_submission?.flume_type, boat_capacity: (item as any).ride_submission?.boat_capacity, theme_name: (item as any).ride_submission?.theme_name, story_description: (item as any).ride_submission?.story_description, show_duration_seconds: (item as any).ride_submission?.show_duration_seconds, animatronics_count: (item as any).ride_submission?.animatronics_count, projection_type: (item as any).ride_submission?.projection_type, ride_system: (item as any).ride_submission?.ride_system, scenes_count: (item as any).ride_submission?.scenes_count, rotation_type: (item as any).ride_submission?.rotation_type, motion_pattern: (item as any).ride_submission?.motion_pattern, platform_count: (item as any).ride_submission?.platform_count, swing_angle_degrees: (item as any).ride_submission?.swing_angle_degrees, rotation_speed_rpm: (item as any).ride_submission?.rotation_speed_rpm, arm_length_meters: (item as any).ride_submission?.arm_length_meters, max_height_reached_meters: (item as any).ride_submission?.max_height_reached_meters, min_age: (item as any).ride_submission?.min_age, max_age: (item as any).ride_submission?.max_age, educational_theme: (item as any).ride_submission?.educational_theme, character_theme: (item as any).ride_submission?.character_theme, transport_type: (item as any).ride_submission?.transport_type, route_length_meters: (item as any).ride_submission?.route_length_meters, stations_count: (item as any).ride_submission?.stations_count, vehicle_capacity: (item as any).ride_submission?.vehicle_capacity, vehicles_count: (item as any).ride_submission?.vehicles_count, round_trip_duration_seconds: (item as any).ride_submission?.round_trip_duration_seconds }; break; case 'manufacturer': case 'operator': case 'property_owner': case 'designer': itemData = { ...(item as any).company_submission, ...(tempRefsByItemId.get(item.id) || {}) }; break; case 'ride_model': itemData = { ...(item as any).ride_model_submission, ...(tempRefsByItemId.get(item.id) || {}) }; break; case 'photo': // Combine photo_submission with its photo_items array itemData = { ...(item as any).photo_submission, photos: (item as any).photo_submission?.photo_items || [], ...(tempRefsByItemId.get(item.id) || {}) }; break; default: // For timeline/other items not yet migrated, fall back to item_data (JSONB) itemData = item.item_data; } if (!itemData && item.item_data) { // Fallback to item_data if relational data not found (for backwards compatibility) itemData = item.item_data; } // Log if temp refs were found for this item if (tempRefsByItemId.has(item.id)) { edgeLogger.info('Item has temp refs', { action: 'approval_item_temp_refs', itemId: item.id, itemType: item.item_type, tempRefs: tempRefsByItemId.get(item.id), requestId: tracking.requestId }); } // Validate entity data BEFORE entering try-catch (so 400 returns immediately) const validation = validateEntityDataStrict(item.item_type, itemData, item.original_data); if (validation.blockingErrors.length > 0) { edgeLogger.error('Blocking validation errors', { action: 'approval_validation_fail', itemId: item.id, errors: validation.blockingErrors, requestId: tracking.requestId }); // Return 400 immediately - NOT caught by try-catch below return new Response(JSON.stringify({ success: false, message: 'Validation failed: Items have blocking errors that must be fixed', errors: validation.blockingErrors, failedItemId: item.id, failedItemType: item.item_type, requestId: tracking.requestId }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } }); } if (validation.warnings.length > 0) { edgeLogger.warn('Validation warnings', { action: 'approval_validation_warning', itemId: item.id, warnings: validation.warnings }); // Continue processing - warnings don't block approval } // Now enter try-catch ONLY for database operations try { // Set user context for versioning trigger // This allows create_relational_version() trigger to capture the submitter const { error: setUserIdError } = await supabase.rpc('set_config_value', { setting_name: 'app.current_user_id', setting_value: submitterId, is_local: false }); if (setUserIdError) { edgeLogger.error('Failed to set user context', { action: 'approval_set_context', error: setUserIdError.message, requestId: tracking.requestId }); } // Set submission ID for version tracking const { error: setSubmissionIdError } = await supabase.rpc('set_config_value', { setting_name: 'app.submission_id', setting_value: submissionId, is_local: false }); if (setSubmissionIdError) { edgeLogger.error('Failed to set submission context', { action: 'approval_set_context', error: setSubmissionIdError.message, requestId: tracking.requestId }); } // Resolve dependencies in item data const resolvedData = resolveDependencies(itemData, dependencyMap, sortedItems); // Add submitter ID to the data for photo tracking resolvedData._submitter_id = submitterId; let entityId: string | null = null; // Create entity based on type switch (item.item_type) { case 'park': entityId = await createPark(supabase, resolvedData); break; case 'ride': entityId = await createRide(supabase, resolvedData); break; case 'manufacturer': case 'operator': case 'property_owner': case 'designer': entityId = await createCompany(supabase, resolvedData, item.item_type); break; case 'ride_model': entityId = await createRideModel(supabase, resolvedData); break; case 'photo': await approvePhotos(supabase, resolvedData, item.id); entityId = item.id; // Use item ID as entity ID for photos break; case 'photo_edit': await editPhoto(supabase, resolvedData); entityId = resolvedData.photo_id; break; case 'photo_delete': await deletePhoto(supabase, resolvedData); entityId = resolvedData.photo_id; break; case 'milestone': case 'timeline_event': // Unified timeline event handling entityId = await createTimelineEvent(supabase, resolvedData, submitterId, authenticatedUserId, submissionId); break; default: throw new Error(`Unknown item type: ${item.item_type}`); } if (entityId) { dependencyMap.set(item.id, entityId); } // Store result for batch update later approvalResults.push({ itemId: item.id, entityId, itemType: item.item_type, success: true }); edgeLogger.info('Item approval success', { action: 'approval_item_success', itemId: item.id, entityId, itemType: item.item_type }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; edgeLogger.error('Item approval failed', { action: 'approval_item_fail', itemId: item.id, itemType: item.item_type, userId: authenticatedUserId, submissionId, error: errorMessage }); const isDependencyError = error instanceof Error && ( error.message.includes('Missing dependency') || error.message.includes('depends on') || error.message.includes('Circular dependency') ); approvalResults.push({ itemId: item.id, itemType: item.item_type, success: false, error: errorMessage, isDependencyFailure: isDependencyError }); } } // Batch update all approved items const approvedItemIds = approvalResults.filter(r => r.success).map(r => r.itemId); if (approvedItemIds.length > 0) { const approvedUpdates = approvalResults .filter(r => r.success) .map(r => ({ id: r.itemId, status: 'approved', approved_entity_id: r.entityId, updated_at: new Date().toISOString() })); for (const update of approvedUpdates) { const { error: batchApproveError } = await supabase .from('submission_items') .update({ status: update.status, approved_entity_id: update.approved_entity_id, updated_at: update.updated_at }) .eq('id', update.id); if (batchApproveError) { edgeLogger.error('Failed to approve item', { action: 'approval_batch_approve', itemId: update.id, error: batchApproveError.message }); } } } // ✅ CLEANUP: Delete temporary references for approved items // Reuse approvedItemIds from line 663 - already computed if (approvedItemIds.length > 0) { try { const { error: cleanupError } = await supabase .from('submission_item_temp_refs') .delete() .in('submission_item_id', approvedItemIds); if (cleanupError) { edgeLogger.warn('Failed to cleanup temp refs for approved items', { requestId: tracking.requestId, approvedItemIds, error: cleanupError.message }); // Don't throw - cleanup failure shouldn't block approval } else { edgeLogger.info('Cleaned up temp refs for approved items', { requestId: tracking.requestId, count: approvedItemIds.length }); } } catch (cleanupErr) { edgeLogger.warn('Exception during temp ref cleanup', { requestId: tracking.requestId, error: cleanupErr instanceof Error ? cleanupErr.message : 'Unknown error' }); // Continue - don't let cleanup errors affect approval } } // Batch update all rejected items const rejectedItemIds = approvalResults.filter(r => !r.success).map(r => r.itemId); if (rejectedItemIds.length > 0) { const rejectedUpdates = approvalResults .filter(r => !r.success) .map(r => ({ id: r.itemId, status: 'rejected', rejection_reason: r.error || 'Unknown error', updated_at: new Date().toISOString() })); for (const update of rejectedUpdates) { const { error: batchRejectError } = await supabase .from('submission_items') .update({ status: update.status, rejection_reason: update.rejection_reason, updated_at: update.updated_at }) .eq('id', update.id); if (batchRejectError) { edgeLogger.error('Failed to reject item', { action: 'approval_batch_reject', itemId: update.id, error: batchRejectError.message }); } } } // Check if any failures were dependency-related const hasDependencyFailure = approvalResults.some(r => !r.success && r.isDependencyFailure ); const allApproved = approvalResults.every(r => r.success); const someApproved = approvalResults.some(r => r.success); const allFailed = approvalResults.every(r => !r.success); // Determine final status: // - If dependency validation failed: keep pending for escalation // - If all approved: approved // - If some approved: partially_approved // - If all failed but no dependency issues: rejected (can retry) const finalStatus = hasDependencyFailure && !someApproved ? 'pending' // Keep pending for escalation only : allApproved ? 'approved' : allFailed ? 'rejected' // Total failure, allow retry : 'partially_approved'; // Mixed results const reviewerNotes = hasDependencyFailure && !someApproved ? 'Submission has unresolved dependencies. Escalation required.' : undefined; // Set moderator_id session variable for audit logging await supabase.rpc('set_config', { setting: 'app.moderator_id', value: authenticatedUserId, is_local: true }); const { error: updateError } = await supabase .from('content_submissions') .update({ status: finalStatus, reviewer_id: authenticatedUserId, reviewed_at: new Date().toISOString(), reviewer_notes: reviewerNotes, escalated: hasDependencyFailure && !someApproved ? true : undefined }) .eq('id', submissionId); if (updateError) { edgeLogger.error('Failed to update submission status', { action: 'approval_update_status', error: updateError.message, requestId: tracking.requestId }); } // Log audit trail for submission action try { const approvedCount = approvalResults.filter(r => r.success).length; const rejectedCount = approvalResults.filter(r => !r.success).length; await supabaseClient.rpc('log_admin_action', { _admin_user_id: authenticatedUserId, _target_user_id: submission.user_id, _action: finalStatus === 'approved' ? 'submission_approved' : finalStatus === 'partially_approved' ? 'submission_partially_approved' : 'submission_rejected', _details: { submission_id: submissionId, submission_type: submission.submission_type, items_approved: approvedCount, items_rejected: rejectedCount, total_items: approvalResults.length, final_status: finalStatus, has_dependency_failure: hasDependencyFailure, reviewer_notes: reviewerNotes } }); } catch (auditError) { // Log but don't fail the operation edgeLogger.error('Failed to log admin action', { action: 'approval_audit_log', error: auditError, requestId: tracking.requestId }); } const duration = endRequest(tracking); return new Response( JSON.stringify({ success: true, results: approvalResults, submissionStatus: finalStatus, requestId: tracking.requestId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId } } ); } catch (error: unknown) { const duration = endRequest(tracking); const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred'; edgeLogger.error('Approval process failed', { action: 'approval_process_error', error: errorMessage, userId: authenticatedUserId || 'unknown', requestId: tracking.requestId, duration }); return createErrorResponse( error, 500, corsHeaders, 'process-selective-approval' ); } }, approvalRateLimiter, corsHeaders)); // Helper functions function topologicalSort(items: any[]): any[] { const sorted: any[] = []; const visited = new Set(); const visiting = new Set(); const visit = (item: any) => { if (visited.has(item.id)) return; if (visiting.has(item.id)) { throw new Error( `Circular dependency detected: item ${item.id} (${item.item_type}) ` + `creates a dependency loop. This submission requires escalation.` ); } visiting.add(item.id); if (item.depends_on) { const parent = items.find(i => i.id === item.depends_on); if (!parent) { throw new Error( `Missing dependency: item ${item.id} (${item.item_type}) ` + `depends on ${item.depends_on} which is not in this submission or has not been approved. ` + `This submission requires escalation.` ); } visit(parent); } visiting.delete(item.id); visited.add(item.id); sorted.push(item); }; items.forEach(item => visit(item)); return sorted; } function resolveDependencies(data: any, dependencyMap: Map, sortedItems: any[]): any { if (typeof data !== 'object' || data === null) { return data; } if (Array.isArray(data)) { return data.map(item => resolveDependencies(item, dependencyMap, sortedItems)); } const resolved: any = { ...data }; // Phase 1: Resolve temporary index references FIRST // These reference items by their position in the sorted items array if (resolved._temp_manufacturer_ref !== undefined) { const refIndex = resolved._temp_manufacturer_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.manufacturer_id = dependencyMap.get(refItemId); edgeLogger.info('Resolved temp manufacturer ref', { action: 'dependency_resolve_temp_ref', refIndex, refItemId, resolvedId: resolved.manufacturer_id }); } } delete resolved._temp_manufacturer_ref; } // Resolve temporary references using sortedItems array if (resolved._temp_park_ref !== undefined) { const refIndex = resolved._temp_park_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.park_id = dependencyMap.get(refItemId); edgeLogger.info('Resolved temp park ref', { action: 'dependency_resolve_temp_ref', refIndex, refItemId, resolvedId: resolved.park_id }); } } delete resolved._temp_park_ref; } if (resolved._temp_manufacturer_ref !== undefined) { const refIndex = resolved._temp_manufacturer_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.manufacturer_id = dependencyMap.get(refItemId); edgeLogger.info('Resolved temp manufacturer ref', { action: 'dependency_resolve_temp_ref', refIndex, refItemId, resolvedId: resolved.manufacturer_id }); } } delete resolved._temp_manufacturer_ref; } if (resolved._temp_operator_ref !== undefined) { const refIndex = resolved._temp_operator_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.operator_id = dependencyMap.get(refItemId); edgeLogger.info('Resolved temp operator ref', { action: 'dependency_resolve_temp_ref', refIndex, refItemId, resolvedId: resolved.operator_id }); } } delete resolved._temp_operator_ref; } if (resolved._temp_property_owner_ref !== undefined) { const refIndex = resolved._temp_property_owner_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.property_owner_id = dependencyMap.get(refItemId); edgeLogger.info('Resolved temp property owner ref', { action: 'dependency_resolve_temp_ref', refIndex, refItemId, resolvedId: resolved.property_owner_id }); } } delete resolved._temp_property_owner_ref; } if (resolved._temp_ride_model_ref !== undefined) { const refIndex = resolved._temp_ride_model_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.ride_model_id = dependencyMap.get(refItemId); edgeLogger.info('Resolved temp ride model ref', { action: 'dependency_resolve_temp_ref', refIndex, refItemId, resolvedId: resolved.ride_model_id }); } } delete resolved._temp_ride_model_ref; } if (resolved._temp_designer_ref !== undefined) { const refIndex = resolved._temp_designer_ref; if (refIndex >= 0 && refIndex < sortedItems.length) { const refItemId = sortedItems[refIndex].id; if (dependencyMap.has(refItemId)) { resolved.designer_id = dependencyMap.get(refItemId); edgeLogger.info('Resolved temp designer ref', { action: 'dependency_resolve_temp_ref', refIndex, refItemId, resolvedId: resolved.designer_id }); } } delete resolved._temp_designer_ref; } // Phase 2: Resolve direct foreign key references // These are submission_item IDs that reference other items in the same submission const foreignKeys = [ 'park_id', 'manufacturer_id', 'designer_id', 'operator_id', 'property_owner_id', 'ride_model_id' ]; for (const key of foreignKeys) { if (resolved[key] && typeof resolved[key] === 'string' && dependencyMap.has(resolved[key])) { const oldValue = resolved[key]; resolved[key] = dependencyMap.get(resolved[key]); edgeLogger.info('Resolved direct foreign key', { action: 'dependency_resolve_fk', key, oldValue, newValue: resolved[key] }); } } // Phase 3: Recursively resolve nested objects for (const [key, value] of Object.entries(resolved)) { if (typeof value === 'object' && value !== null && !foreignKeys.includes(key)) { resolved[key] = resolveDependencies(value, dependencyMap, sortedItems); } } return resolved; } function sanitizeDateFields(data: any): any { const dateFields = ['opening_date', 'closing_date', 'date_changed', 'date_taken', 'visit_date']; const sanitized = { ...data }; for (const field of dateFields) { if (field in sanitized && sanitized[field] === '') { sanitized[field] = null; } } return sanitized; } function filterDatabaseFields(data: any, allowedFields: string[]): any { const filtered: any = {}; for (const field of allowedFields) { if (field in data && data[field] !== undefined) { filtered[field] = data[field]; } } return filtered; } function normalizeStatusValue(data: any): any { if (data.status) { // Map display values to database values const statusMap: Record = { 'Operating': 'operating', 'Seasonal': 'operating', 'Closed Temporarily': 'maintenance', 'Closed Permanently': 'closed', 'Under Construction': 'under_construction', 'Planned': 'under_construction', 'SBNO': 'sbno', // Also handle already-lowercase values 'operating': 'operating', 'closed': 'closed', 'under_construction': 'under_construction', 'maintenance': 'maintenance', 'sbno': 'sbno' }; data.status = statusMap[data.status] || 'operating'; } return data; } function normalizeParkTypeValue(data: any): any { if (data.park_type) { // Map display values to database values const parkTypeMap: Record = { // Display names 'Theme Park': 'theme_park', 'Amusement Park': 'amusement_park', 'Water Park': 'water_park', 'Family Entertainment': 'family_entertainment', // Already lowercase values (for new submissions) 'theme_park': 'theme_park', 'amusement_park': 'amusement_park', 'water_park': 'water_park', 'family_entertainment': 'family_entertainment' }; data.park_type = parkTypeMap[data.park_type] || data.park_type; } return data; } async function createPark(supabase: any, data: any): Promise { const submitterId = data._submitter_id; let uploadedPhotos: any[] = []; // Create location if temp_location_data exists and location_id is missing if (data.temp_location_data && !data.location_id) { edgeLogger.info('Creating location from temp data', { action: 'approval_create_location', locationName: data.temp_location_data.name }); const { data: newLocation, error: locationError } = await supabase .from('locations') .insert({ name: data.temp_location_data.name, street_address: data.temp_location_data.street_address || null, city: data.temp_location_data.city, state_province: data.temp_location_data.state_province, country: data.temp_location_data.country, latitude: data.temp_location_data.latitude, longitude: data.temp_location_data.longitude, timezone: data.temp_location_data.timezone, postal_code: data.temp_location_data.postal_code }) .select('id') .single(); if (locationError) { throw new Error(`Failed to create location: ${locationError.message}`); } data.location_id = newLocation.id; edgeLogger.info('Location created successfully', { action: 'approval_location_created', locationId: newLocation.id, locationName: data.temp_location_data.name }); } // Clean up temp data delete data.temp_location_data; // Transform images object if present if (data.images) { const { uploaded, banner_assignment, card_assignment } = data.images; if (uploaded && Array.isArray(uploaded)) { // Store uploaded photos for later insertion into photos table uploadedPhotos = uploaded; // Assign banner image if (banner_assignment !== undefined && uploaded[banner_assignment]) { data.banner_image_id = uploaded[banner_assignment].cloudflare_id; data.banner_image_url = uploaded[banner_assignment].url; } // Assign card image if (card_assignment !== undefined && uploaded[card_assignment]) { data.card_image_id = uploaded[card_assignment].cloudflare_id; data.card_image_url = uploaded[card_assignment].url; } } // Remove images object delete data.images; } // Remove internal fields delete data._submitter_id; let parkId: string; // Check if this is an edit (has park_id) or a new creation if (data.park_id) { edgeLogger.info('Updating existing park', { action: 'approval_update_park', parkId: data.park_id }); parkId = data.park_id; delete data.park_id; // Remove ID from update data // ✅ FIXED: Handle location updates from temp_location_data if (data.temp_location_data && !data.location_id) { edgeLogger.info('Creating location from temp data for update', { action: 'approval_create_location_update', locationName: data.temp_location_data.name }); const { data: newLocation, error: locationError } = await supabase .from('locations') .insert({ name: data.temp_location_data.name, street_address: data.temp_location_data.street_address || null, city: data.temp_location_data.city, state_province: data.temp_location_data.state_province, country: data.temp_location_data.country, latitude: data.temp_location_data.latitude, longitude: data.temp_location_data.longitude, timezone: data.temp_location_data.timezone, postal_code: data.temp_location_data.postal_code }) .select('id') .single(); if (locationError) { throw new Error(`Failed to create location: ${locationError.message}`); } data.location_id = newLocation.id; } delete data.temp_location_data; const normalizedData = normalizeParkTypeValue(normalizeStatusValue(data)); const sanitizedData = sanitizeDateFields(normalizedData); const filteredData = filterDatabaseFields(sanitizedData, PARK_FIELDS); const { error } = await supabase .from('parks') .update(filteredData) .eq('id', parkId); if (error) throw new Error(`Failed to update park: ${error.message}`); } else { edgeLogger.info('Creating new park', { action: 'approval_create_park' }); const normalizedData = normalizeParkTypeValue(normalizeStatusValue(data)); const sanitizedData = sanitizeDateFields(normalizedData); const filteredData = filterDatabaseFields(sanitizedData, PARK_FIELDS); const { data: park, error } = await supabase .from('parks') .insert(filteredData) .select('id') .single(); if (error) throw new Error(`Failed to create park: ${error.message}`); parkId = park.id; } // Insert photos into photos table if (uploadedPhotos.length > 0 && submitterId) { edgeLogger.info('Inserting photos for park', { action: 'approval_insert_photos', photoCount: uploadedPhotos.length, parkId }); for (let i = 0; i < uploadedPhotos.length; i++) { const photo = uploadedPhotos[i]; if (photo.cloudflare_id && photo.url) { const { error: photoError } = await supabase.from('photos').insert({ entity_id: parkId, entity_type: 'park', cloudflare_image_id: photo.cloudflare_id, cloudflare_image_url: photo.url, caption: photo.caption || null, title: null, submitted_by: submitterId, approved_at: new Date().toISOString(), order_index: i, }); if (photoError) { edgeLogger.error('Failed to insert photo', { action: 'approval_insert_photo', photoIndex: i, error: photoError.message }); } } } } return parkId; } async function createRide(supabase: any, data: any): Promise { const submitterId = data._submitter_id; let uploadedPhotos: any[] = []; // Extract relational data before transformation const technicalSpecifications = data._technical_specifications || []; const coasterStatistics = data._coaster_statistics || []; const nameHistory = data._name_history || []; // Transform images object if present if (data.images) { const { uploaded, banner_assignment, card_assignment } = data.images; if (uploaded && Array.isArray(uploaded)) { // Store uploaded photos for later insertion into photos table uploadedPhotos = uploaded; // Assign banner image if (banner_assignment !== undefined && uploaded[banner_assignment]) { data.banner_image_id = uploaded[banner_assignment].cloudflare_id; data.banner_image_url = uploaded[banner_assignment].url; } // Assign card image if (card_assignment !== undefined && uploaded[card_assignment]) { data.card_image_id = uploaded[card_assignment].cloudflare_id; data.card_image_url = uploaded[card_assignment].url; } } // Remove images object delete data.images; } // Remove internal fields and store park_id before filtering delete data._submitter_id; delete data._technical_specifications; delete data._coaster_statistics; delete data._name_history; const parkId = data.park_id; let rideId: string; // Check if this is an edit (has ride_id) or a new creation if (data.ride_id) { edgeLogger.info('Updating existing ride', { action: 'approval_update_ride', rideId: data.ride_id }); rideId = data.ride_id; delete data.ride_id; // Remove ID from update data const normalizedData = normalizeStatusValue(data); const sanitizedData = sanitizeDateFields(normalizedData); const filteredData = filterDatabaseFields(sanitizedData, RIDE_FIELDS); const { error } = await supabase .from('rides') .update(filteredData) .eq('id', rideId); if (error) throw new Error(`Failed to update ride: ${error.message}`); // ✅ FIXED: Handle nested data updates (technical specs, coaster stats, name history) // For updates, we typically replace all related data rather than merge // Delete existing and insert new if (technicalSpecifications.length > 0) { // Delete existing specs await supabase .from('ride_technical_specifications') .delete() .eq('ride_id', rideId); // Insert new specs const techSpecsToInsert = technicalSpecifications.map((spec: any) => ({ ride_id: rideId, 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 })); const { error: techSpecError } = await supabase .from('ride_technical_specifications') .insert(techSpecsToInsert); if (techSpecError) { edgeLogger.error('Failed to update technical specifications', { action: 'approval_update_specs', error: techSpecError.message, rideId }); } } if (coasterStatistics.length > 0) { // Delete existing stats await supabase .from('ride_coaster_stats') .delete() .eq('ride_id', rideId); // Insert new stats const statsToInsert = coasterStatistics.map((stat: any) => ({ ride_id: rideId, stat_name: stat.stat_name, stat_value: stat.stat_value, unit: stat.unit || null, category: stat.category || null, description: stat.description || null, display_order: stat.display_order || 0 })); const { error: statsError } = await supabase .from('ride_coaster_stats') .insert(statsToInsert); if (statsError) { edgeLogger.error('Failed to update coaster statistics', { action: 'approval_update_stats', error: statsError.message, rideId }); } } if (nameHistory.length > 0) { // Delete existing name history await supabase .from('ride_name_history') .delete() .eq('ride_id', rideId); // Insert new name history const namesToInsert = nameHistory.map((name: any) => ({ ride_id: rideId, former_name: name.former_name, date_changed: name.date_changed || null, reason: name.reason || null, from_year: name.from_year || null, to_year: name.to_year || null, order_index: name.order_index || 0 })); const { error: namesError } = await supabase .from('ride_name_history') .insert(namesToInsert); if (namesError) { edgeLogger.error('Failed to update name history', { action: 'approval_update_names', error: namesError.message, rideId }); } } // Update park ride counts after successful ride update if (parkId) { edgeLogger.info('Updating ride counts for park', { action: 'approval_update_counts', parkId }); const { error: countError } = await supabase.rpc('update_park_ride_counts', { target_park_id: parkId }); if (countError) { edgeLogger.error('Failed to update park counts', { action: 'approval_update_counts', error: countError.message, parkId }); } } } else { edgeLogger.info('Creating new ride', { action: 'approval_create_ride' }); const normalizedData = normalizeStatusValue(data); const sanitizedData = sanitizeDateFields(normalizedData); const filteredData = filterDatabaseFields(sanitizedData, RIDE_FIELDS); const { data: ride, error } = await supabase .from('rides') .insert(filteredData) .select('id') .single(); if (error) throw new Error(`Failed to create ride: ${error.message}`); rideId = ride.id; // Update park ride counts after successful ride creation if (parkId) { edgeLogger.info('Updating ride counts for park', { action: 'approval_update_counts', parkId }); const { error: countError } = await supabase.rpc('update_park_ride_counts', { target_park_id: parkId }); if (countError) { edgeLogger.error('Failed to update park counts', { action: 'approval_update_counts', error: countError.message, parkId }); } } } // Insert photos into photos table if (uploadedPhotos.length > 0 && submitterId) { edgeLogger.info('Inserting photos for ride', { action: 'approval_insert_photos', photoCount: uploadedPhotos.length, rideId }); for (let i = 0; i < uploadedPhotos.length; i++) { const photo = uploadedPhotos[i]; if (photo.cloudflare_id && photo.url) { const { error: photoError } = await supabase.from('photos').insert({ entity_id: rideId, entity_type: 'ride', cloudflare_image_id: photo.cloudflare_id, cloudflare_image_url: photo.url, caption: photo.caption || null, title: null, submitted_by: submitterId, approved_at: new Date().toISOString(), order_index: i, }); if (photoError) { edgeLogger.error('Failed to insert photo', { action: 'approval_insert_photo', photoIndex: i, error: photoError.message }); } } } } // Insert technical specifications if (technicalSpecifications.length > 0) { edgeLogger.info('Inserting technical specs for ride', { action: 'approval_insert_specs', specCount: technicalSpecifications.length, rideId }); const techSpecsToInsert = technicalSpecifications.map((spec: any) => ({ ride_id: rideId, 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 })); const { error: techSpecError } = await supabase .from('ride_technical_specifications') .insert(techSpecsToInsert); if (techSpecError) { edgeLogger.error('Failed to insert technical specifications', { action: 'approval_insert_specs', error: techSpecError.message, rideId }); } } // Insert coaster statistics if (coasterStatistics.length > 0) { edgeLogger.info('Inserting coaster stats for ride', { action: 'approval_insert_stats', statCount: coasterStatistics.length, rideId }); const statsToInsert = coasterStatistics.map((stat: any) => ({ ride_id: rideId, stat_name: stat.stat_name, stat_value: stat.stat_value, unit: stat.unit || null, category: stat.category || null, description: stat.description || null, display_order: stat.display_order || 0 })); const { error: statsError } = await supabase .from('ride_coaster_stats') .insert(statsToInsert); if (statsError) { edgeLogger.error('Failed to insert coaster statistics', { action: 'approval_insert_stats', error: statsError.message, rideId }); } } // Insert name history if (nameHistory.length > 0) { edgeLogger.info('Inserting name history for ride', { action: 'approval_insert_names', nameCount: nameHistory.length, rideId }); const namesToInsert = nameHistory.map((name: any) => ({ ride_id: rideId, former_name: name.former_name, date_changed: name.date_changed || null, reason: name.reason || null, from_year: name.from_year || null, to_year: name.to_year || null, order_index: name.order_index || 0 })); const { error: namesError } = await supabase .from('ride_name_history') .insert(namesToInsert); if (namesError) { edgeLogger.error('Failed to insert name history', { action: 'approval_insert_names', error: namesError.message, rideId }); } } return rideId; } async function createCompany(supabase: any, data: any, companyType: string): Promise { // Transform images object if present if (data.images) { const { uploaded, banner_assignment, card_assignment } = data.images; if (uploaded && Array.isArray(uploaded)) { // Assign banner image if (banner_assignment !== undefined && uploaded[banner_assignment]) { data.banner_image_id = uploaded[banner_assignment].cloudflare_id; data.banner_image_url = uploaded[banner_assignment].url; } // Assign card image if (card_assignment !== undefined && uploaded[card_assignment]) { data.card_image_id = uploaded[card_assignment].cloudflare_id; data.card_image_url = uploaded[card_assignment].url; } } // Remove images object delete data.images; } // Check if this is an edit (has company_id or id) or a new creation const companyId = data.company_id || data.id; if (companyId) { edgeLogger.info('Updating existing company', { action: 'approval_update_company', companyId }); const updateData = sanitizeDateFields({ ...data, company_type: companyType }); delete updateData.company_id; delete updateData.id; // Remove ID from update data const filteredData = filterDatabaseFields(updateData, COMPANY_FIELDS); const { error } = await supabase .from('companies') .update(filteredData) .eq('id', companyId); if (error) throw new Error(`Failed to update company: ${error.message}`); return companyId; } else { edgeLogger.info('Creating new company', { action: 'approval_create_company' }); const companyData = sanitizeDateFields({ ...data, company_type: companyType }); const filteredData = filterDatabaseFields(companyData, COMPANY_FIELDS); const { data: company, error } = await supabase .from('companies') .insert(filteredData) .select('id') .single(); if (error) throw new Error(`Failed to create company: ${error.message}`); return company.id; } } async function createRideModel(supabase: any, data: any): Promise { let rideModelId: string; // Extract relational data before transformation let technicalSpecifications = data._technical_specifications || []; // If no inline specs provided, fetch from submission table if (technicalSpecifications.length === 0 && data.submission_id) { const { data: submissionData } = await supabase .from('ride_model_submissions') .select('id') .eq('submission_id', data.submission_id) .single(); if (submissionData) { const { data: submissionSpecs } = await supabase .from('ride_model_submission_technical_specifications') .select('*') .eq('ride_model_submission_id', submissionData.id); if (submissionSpecs && submissionSpecs.length > 0) { edgeLogger.info('Fetched technical specs from submission table', { count: submissionSpecs.length }); technicalSpecifications = submissionSpecs; } } } // Remove internal fields delete data._technical_specifications; // Check if this is an edit (has ride_model_id) or a new creation if (data.ride_model_id) { edgeLogger.info('Updating existing ride model', { action: 'approval_update_model', rideModelId: data.ride_model_id }); rideModelId = data.ride_model_id; delete data.ride_model_id; // Remove ID from update data const sanitizedData = sanitizeDateFields(data); const filteredData = filterDatabaseFields(sanitizedData, RIDE_MODEL_FIELDS); const { error } = await supabase .from('ride_models') .update(filteredData) .eq('id', rideModelId); if (error) throw new Error(`Failed to update ride model: ${error.message}`); } else { edgeLogger.info('Creating new ride model', { action: 'approval_create_model' }); // Validate required fields if (!data.manufacturer_id) { throw new Error('Ride model must be associated with a manufacturer'); } if (!data.name || !data.slug) { throw new Error('Ride model must have a name and slug'); } const sanitizedData = sanitizeDateFields(data); const filteredData = filterDatabaseFields(sanitizedData, RIDE_MODEL_FIELDS); const { data: model, error } = await supabase .from('ride_models') .insert(filteredData) .select('id') .single(); if (error) throw new Error(`Failed to create ride model: ${error.message}`); rideModelId = model.id; } // Insert technical specifications if (technicalSpecifications.length > 0) { edgeLogger.info('Inserting technical specs for ride model', { action: 'approval_insert_model_specs', specCount: technicalSpecifications.length, rideModelId }); const techSpecsToInsert = technicalSpecifications.map((spec: any) => ({ ride_model_id: rideModelId, 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 })); const { error: techSpecError } = await supabase .from('ride_model_technical_specifications') .insert(techSpecsToInsert); if (techSpecError) { edgeLogger.error('Failed to insert technical specifications', { action: 'approval_insert_model_specs', error: techSpecError.message, rideModelId }); } } return rideModelId; } async function approvePhotos(supabase: any, data: any, submissionItemId: string): Promise { const photos = data.photos || []; for (const photo of photos) { const photoData = { entity_id: data.entity_id, entity_type: data.context, cloudflare_image_id: extractImageId(photo.url), cloudflare_image_url: photo.url, title: photo.title, caption: photo.caption, date_taken: photo.date, order_index: photo.order, submission_id: submissionItemId }; const { error } = await supabase.from('photos').insert(photoData); if (error) { edgeLogger.error('Failed to insert photo', { action: 'approval_insert_photo', error: error.message }); throw new Error(`Failed to insert photo: ${error.message}`); } } } function extractImageId(url: string): string { const matches = url.match(/\/([^\/]+)\/public$/); return matches ? matches[1] : url; } async function editPhoto(supabase: any, data: any): Promise { edgeLogger.info('Editing photo', { action: 'approval_edit_photo', photoId: data.photo_id }); const { error } = await supabase .from('photos') .update({ caption: data.new_caption, }) .eq('id', data.photo_id); if (error) throw new Error(`Failed to edit photo: ${error.message}`); } async function deletePhoto(supabase: any, data: any): Promise { edgeLogger.info('Deleting photo', { action: 'approval_delete_photo', photoId: data.photo_id }); const { error } = await supabase .from('photos') .delete() .eq('id', data.photo_id); if (error) throw new Error(`Failed to delete photo: ${error.message}`); } async function createTimelineEvent( supabase: any, data: any, submitterId: string, approvingUserId: string, submissionId: string ): Promise { // Determine if this is an edit based on presence of event_id in data // Note: Timeline events from frontend use 'id' field, not 'event_id' const eventId = data.id || data.event_id; if (eventId) { edgeLogger.info('Updating existing timeline event', { action: 'approval_update_timeline', eventId }); // Prepare update data (exclude ID and audit fields) const updateData: any = { event_type: data.event_type, event_date: data.event_date, 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, }; // Remove undefined/null values Object.keys(updateData).forEach(key => updateData[key] === undefined && delete updateData[key] ); const { error } = await supabase .from('entity_timeline_events') .update(updateData) .eq('id', eventId); if (error) throw new Error(`Failed to update timeline event: ${error.message}`); return eventId; } else { edgeLogger.info('Creating new timeline event', { action: 'approval_create_timeline' }); const eventData = { entity_id: data.entity_id, entity_type: data.entity_type, event_type: data.event_type, event_date: data.event_date, 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, created_by: submitterId, approved_by: approvingUserId, submission_id: submissionId, }; const { data: event, error } = await supabase .from('entity_timeline_events') .insert(eventData) .select('id') .single(); if (error) throw new Error(`Failed to create timeline event: ${error.message}`); return event.id; } }