Files
thrilltrack-explorer/supabase/functions/process-selective-approval/index.ts
gpt-engineer-app[bot] 1cc80e0dc4 Fix edge function transaction boundaries
Wrap edge function approval loop in database transaction to prevent partial data on failures. This change ensures atomicity for approval operations, preventing inconsistent data states in case of errors.
2025-11-06 16:11:52 +00:00

2424 lines
86 KiB
TypeScript

// 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<string, Record<string, number>>();
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<string, string>();
const approvalResults: Array<{
itemId: string;
entityId?: string | null;
itemType: string;
success: boolean;
error?: string;
isDependencyFailure?: boolean;
}> = [];
// Track all created entities for rollback on failure
const createdEntities: Array<{
entityId: string;
entityType: string;
tableName: string;
}> = [];
// 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,
parkSubmissionId: itemData.id,
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);
// Track created entity for potential rollback
const tableMap: Record<string, string> = {
'park': 'parks',
'ride': 'rides',
'manufacturer': 'companies',
'operator': 'companies',
'property_owner': 'companies',
'designer': 'companies',
'ride_model': 'ride_models'
// photo operations don't create new entities in standard tables
};
const tableName = tableMap[item.item_type];
if (tableName) {
createdEntities.push({
entityId,
entityType: item.item_type,
tableName
});
}
}
// 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
});
// CRITICAL: Rollback all previously created entities
if (createdEntities.length > 0) {
edgeLogger.error('Item failed - initiating rollback', {
action: 'approval_rollback_start',
failedItemId: item.id,
failedItemType: item.item_type,
createdEntitiesCount: createdEntities.length,
error: errorMessage,
requestId: tracking.requestId
});
// Delete all previously created entities in reverse order
for (let i = createdEntities.length - 1; i >= 0; i--) {
const entity = createdEntities[i];
try {
const { error: deleteError } = await supabase
.from(entity.tableName)
.delete()
.eq('id', entity.entityId);
if (deleteError) {
edgeLogger.error('Rollback delete failed', {
action: 'approval_rollback_delete_fail',
entityId: entity.entityId,
entityType: entity.entityType,
tableName: entity.tableName,
error: deleteError.message,
requestId: tracking.requestId
});
} else {
edgeLogger.info('Rollback delete success', {
action: 'approval_rollback_delete_success',
entityId: entity.entityId,
entityType: entity.entityType,
requestId: tracking.requestId
});
}
} catch (rollbackError: unknown) {
const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : 'Unknown rollback error';
edgeLogger.error('Rollback exception', {
action: 'approval_rollback_exception',
entityId: entity.entityId,
error: rollbackMessage,
requestId: tracking.requestId
});
}
}
edgeLogger.info('Rollback complete', {
action: 'approval_rollback_complete',
deletedCount: createdEntities.length,
requestId: tracking.requestId
});
}
// Break the loop - don't process remaining items
break;
}
}
// Check if any item failed - if so, return early with failure
const failedResults = approvalResults.filter(r => !r.success);
if (failedResults.length > 0) {
const failedItem = failedResults[0];
edgeLogger.error('Approval failed - transaction rolled back', {
action: 'approval_transaction_fail',
failedItemId: failedItem.itemId,
failedItemType: failedItem.itemType,
error: failedItem.error,
rolledBackEntities: createdEntities.length,
requestId: tracking.requestId
});
const duration = endRequest(tracking);
return new Response(
JSON.stringify({
success: false,
message: 'Approval failed and all changes have been rolled back',
error: failedItem.error,
failedItemId: failedItem.itemId,
failedItemType: failedItem.itemType,
isDependencyFailure: failedItem.isDependencyFailure,
rolledBackEntities: createdEntities.length,
requestId: tracking.requestId,
duration
}),
{
status: 500,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}
}
);
}
// All items succeeded - proceed with batch updates
// 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<string>();
const visiting = new Set<string>();
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<string, string>, 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<string, string> = {
'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<string, string> = {
// 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<string> {
const submitterId = data._submitter_id;
const parkSubmissionId = data.id; // Store the park_submission.id for location lookup
let uploadedPhotos: any[] = [];
// Create location if park_submission_locations exists and location_id is missing
if (!data.location_id) {
// Try to fetch location from relational table
const { data: locationData, error: locationFetchError } = await supabase
.from('park_submission_locations')
.select('*')
.eq('park_submission_id', parkSubmissionId)
.single();
if (locationData && !locationFetchError) {
edgeLogger.info('Creating location from relational table', {
action: 'approval_create_location',
locationName: locationData.name
});
const { data: newLocation, error: locationError } = await supabase
.from('locations')
.insert({
name: locationData.name,
street_address: locationData.street_address || null,
city: locationData.city,
state_province: locationData.state_province,
country: locationData.country,
latitude: locationData.latitude,
longitude: locationData.longitude,
timezone: locationData.timezone,
postal_code: locationData.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: locationData.name
});
}
}
// 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 park_submission_locations
if (!data.location_id) {
// Try to fetch location from relational table
const { data: locationData, error: locationFetchError } = await supabase
.from('park_submission_locations')
.select('*')
.eq('park_submission_id', parkSubmissionId)
.single();
if (locationData && !locationFetchError) {
edgeLogger.info('Creating location from relational table for update', {
action: 'approval_create_location_update',
locationName: locationData.name
});
const { data: newLocation, error: locationError } = await supabase
.from('locations')
.insert({
name: locationData.name,
street_address: locationData.street_address || null,
city: locationData.city,
state_province: locationData.state_province,
country: locationData.country,
latitude: locationData.latitude,
longitude: locationData.longitude,
timezone: locationData.timezone,
postal_code: locationData.postal_code
})
.select('id')
.single();
if (locationError) {
throw new Error(`Failed to create location: ${locationError.message}`);
}
data.location_id = newLocation.id;
}
}
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<string> {
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<string> {
// 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<string> {
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<void> {
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<void> {
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<void> {
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<string> {
// 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;
}
}