mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -05:00
Implement Phase 1 of the JSONB violation fix by creating the `park_submission_locations` table. This includes migrating existing data from `park_submissions.temp_location_data` and updating relevant code to read and write to the new relational table. The `temp_location_data` column will be dropped after data migration.
2301 lines
81 KiB
TypeScript
2301 lines
81 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;
|
|
}> = [];
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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<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;
|
|
}
|
|
}
|