mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -05:00
Implement orphaned image cleanup, temp refs cleanup, deadlock retry, and lock cleanup. These fixes address critical areas of data integrity, resource management, and system resilience within the submission pipeline.
2760 lines
98 KiB
TypeScript
2760 lines
98 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";
|
|
import { withEdgeRetry, isDeadlockError } from "../_shared/retryHelper.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 });
|
|
|
|
// ============================================================================
|
|
// IDEMPOTENCY: Parse request and extract/generate idempotency key
|
|
// ============================================================================
|
|
|
|
const requestBody = await req.json();
|
|
const { itemIds, submissionId }: ApprovalRequest = requestBody;
|
|
|
|
// Extract idempotency key from header or generate deterministic key
|
|
const idempotencyKey = req.headers.get('X-Idempotency-Key') ||
|
|
`approval_${submissionId}_${itemIds.sort().join('_')}_${authenticatedUserId}`;
|
|
|
|
edgeLogger.info('Idempotency key extracted', {
|
|
action: 'approval_idempotency_key',
|
|
idempotencyKey,
|
|
hasCustomKey: !!req.headers.get('X-Idempotency-Key'),
|
|
requestId: tracking.requestId
|
|
});
|
|
|
|
// ============================================================================
|
|
// IDEMPOTENCY: Check for existing key within 24h window
|
|
// ============================================================================
|
|
|
|
const { data: existingKey, error: keyError } = await supabase
|
|
.from('submission_idempotency_keys')
|
|
.select('*')
|
|
.eq('idempotency_key', idempotencyKey)
|
|
.eq('moderator_id', authenticatedUserId)
|
|
.gte('expires_at', new Date().toISOString())
|
|
.maybeSingle();
|
|
|
|
if (keyError) {
|
|
edgeLogger.error('Failed to check idempotency key', {
|
|
action: 'approval_idempotency_check_error',
|
|
error: keyError.message,
|
|
requestId: tracking.requestId
|
|
});
|
|
// Don't fail - continue without idempotency protection
|
|
}
|
|
|
|
// ============================================================================
|
|
// CASE 1: Key exists and completed - return cached result
|
|
// ============================================================================
|
|
if (existingKey && existingKey.status === 'completed') {
|
|
const cacheAge = Date.now() - new Date(existingKey.created_at).getTime();
|
|
|
|
edgeLogger.info('Idempotency cache HIT - returning cached result', {
|
|
action: 'approval_idempotency_hit',
|
|
idempotencyKey,
|
|
originalRequestId: existingKey.request_id,
|
|
requestId: tracking.requestId,
|
|
cacheAgeMs: cacheAge,
|
|
originalDurationMs: existingKey.duration_ms
|
|
});
|
|
|
|
const duration = endRequest(tracking);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
...existingKey.result_data,
|
|
cached: true,
|
|
cacheAgeMs: cacheAge,
|
|
originalRequestId: existingKey.request_id,
|
|
originalTimestamp: existingKey.created_at,
|
|
requestId: tracking.requestId
|
|
}),
|
|
{
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-ID': tracking.requestId,
|
|
'X-Original-Request-ID': existingKey.request_id || '',
|
|
'X-Idempotency-Key': idempotencyKey,
|
|
'X-Cache-Status': 'HIT',
|
|
'X-Cache-Age-MS': cacheAge.toString()
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// CASE 2: Key exists and still processing - reject with 409 Conflict
|
|
// ============================================================================
|
|
if (existingKey && existingKey.status === 'processing') {
|
|
const processingTime = Date.now() - new Date(existingKey.created_at).getTime();
|
|
|
|
edgeLogger.warn('Duplicate request detected while processing', {
|
|
action: 'approval_idempotency_conflict',
|
|
idempotencyKey,
|
|
processingTimeMs: processingTime,
|
|
originalRequestId: existingKey.request_id,
|
|
requestId: tracking.requestId
|
|
});
|
|
|
|
const duration = endRequest(tracking);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Request already in progress',
|
|
code: 'DUPLICATE_REQUEST',
|
|
message: 'This approval is already being processed by another request. Please wait for the original request to complete.',
|
|
processingTimeMs: processingTime,
|
|
originalRequestId: existingKey.request_id,
|
|
originalTimestamp: existingKey.created_at,
|
|
requestId: tracking.requestId,
|
|
retryAfter: 5
|
|
}),
|
|
{
|
|
status: 409,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-ID': tracking.requestId,
|
|
'X-Original-Request-ID': existingKey.request_id || '',
|
|
'X-Idempotency-Key': idempotencyKey,
|
|
'Retry-After': '5',
|
|
'X-Processing-Time-MS': processingTime.toString()
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// CASE 3: Key exists and failed - allow retry (delete old key)
|
|
// ============================================================================
|
|
if (existingKey && existingKey.status === 'failed') {
|
|
const timeSinceFailure = Date.now() - new Date(existingKey.completed_at || existingKey.created_at).getTime();
|
|
|
|
edgeLogger.info('Retrying previously failed request', {
|
|
action: 'approval_idempotency_retry',
|
|
idempotencyKey,
|
|
previousError: existingKey.error_message,
|
|
timeSinceFailureMs: timeSinceFailure,
|
|
requestId: tracking.requestId
|
|
});
|
|
|
|
// Delete the failed key to allow fresh attempt
|
|
await supabase
|
|
.from('submission_idempotency_keys')
|
|
.delete()
|
|
.eq('id', existingKey.id);
|
|
}
|
|
|
|
// ============================================================================
|
|
// CASE 4: No existing key or retry - proceed with validation
|
|
// ============================================================================
|
|
edgeLogger.info('Idempotency check passed - proceeding with validation', {
|
|
action: 'approval_idempotency_pass',
|
|
idempotencyKey,
|
|
isRetry: !!existingKey,
|
|
requestId: tracking.requestId
|
|
});
|
|
|
|
// 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,
|
|
'X-Idempotency-Key': idempotencyKey
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
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,
|
|
'X-Idempotency-Key': idempotencyKey
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
'X-Idempotency-Key': idempotencyKey
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
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,
|
|
'X-Idempotency-Key': idempotencyKey
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
edgeLogger.info('Processing selective approval', { action: 'approval_start', itemCount: itemIds.length, userId: authenticatedUserId, submissionId });
|
|
|
|
// ============================================================================
|
|
// IDEMPOTENCY: Register processing key after validation passes
|
|
// ============================================================================
|
|
|
|
edgeLogger.info('Creating idempotency key in processing state', {
|
|
action: 'approval_idempotency_create',
|
|
idempotencyKey,
|
|
submissionId,
|
|
itemCount: itemIds.length,
|
|
requestId: tracking.requestId
|
|
});
|
|
|
|
const { error: createKeyError } = await supabase
|
|
.from('submission_idempotency_keys')
|
|
.insert({
|
|
idempotency_key: idempotencyKey,
|
|
submission_id: submissionId,
|
|
moderator_id: authenticatedUserId,
|
|
item_ids: itemIds,
|
|
status: 'processing',
|
|
request_id: tracking.requestId,
|
|
trace_id: tracking.traceId
|
|
});
|
|
|
|
if (createKeyError) {
|
|
// Race condition: another request created the key between our check and now
|
|
if (createKeyError.code === '23505') { // unique_violation
|
|
edgeLogger.warn('Race condition detected on key creation', {
|
|
action: 'approval_idempotency_race',
|
|
idempotencyKey,
|
|
requestId: tracking.requestId,
|
|
postgresCode: createKeyError.code
|
|
});
|
|
|
|
const duration = endRequest(tracking);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Duplicate request detected',
|
|
code: 'RACE_CONDITION',
|
|
message: 'Another request started processing this approval simultaneously. Please retry in a moment.',
|
|
requestId: tracking.requestId,
|
|
retryAfter: 2
|
|
}),
|
|
{
|
|
status: 409,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-ID': tracking.requestId,
|
|
'X-Idempotency-Key': idempotencyKey,
|
|
'Retry-After': '2'
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Other database errors - log but continue (degraded idempotency protection)
|
|
edgeLogger.error('Failed to create idempotency key', {
|
|
action: 'approval_idempotency_create_error',
|
|
error: createKeyError.message,
|
|
code: createKeyError.code,
|
|
requestId: tracking.requestId
|
|
});
|
|
// Don't throw - continue without idempotency protection
|
|
}
|
|
|
|
edgeLogger.info('Idempotency key registered successfully', {
|
|
action: 'approval_idempotency_registered',
|
|
idempotencyKey,
|
|
requestId: tracking.requestId
|
|
});
|
|
|
|
// 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
|
|
// Wrap entire approval loop in deadlock retry logic
|
|
await withEdgeRetry(
|
|
async () => {
|
|
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 });
|
|
}
|
|
},
|
|
{
|
|
maxAttempts: 3,
|
|
baseDelay: 500,
|
|
maxDelay: 2000,
|
|
backoffMultiplier: 2,
|
|
jitter: true,
|
|
shouldRetry: isDeadlockError
|
|
},
|
|
tracking.requestId,
|
|
'approval_transaction'
|
|
);
|
|
|
|
// 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);
|
|
|
|
// ============================================================================
|
|
// IDEMPOTENCY: Update key to 'completed' with cached result
|
|
// ============================================================================
|
|
|
|
const successResultData = {
|
|
success: true,
|
|
results: approvalResults,
|
|
submissionStatus: finalStatus
|
|
};
|
|
|
|
try {
|
|
const { error: updateKeyError } = await supabase
|
|
.from('submission_idempotency_keys')
|
|
.update({
|
|
status: 'completed',
|
|
result_data: successResultData,
|
|
completed_at: new Date().toISOString(),
|
|
duration_ms: duration
|
|
})
|
|
.eq('idempotency_key', idempotencyKey)
|
|
.eq('moderator_id', authenticatedUserId);
|
|
|
|
if (updateKeyError) {
|
|
edgeLogger.error('Failed to update idempotency key to completed', {
|
|
action: 'approval_idempotency_complete_error',
|
|
idempotencyKey,
|
|
error: updateKeyError.message,
|
|
requestId: tracking.requestId
|
|
});
|
|
// Don't throw - the approval succeeded, just cache failed
|
|
} else {
|
|
edgeLogger.info('Idempotency key updated to completed', {
|
|
action: 'approval_idempotency_completed',
|
|
idempotencyKey,
|
|
durationMs: duration,
|
|
resultSize: JSON.stringify(successResultData).length,
|
|
requestId: tracking.requestId
|
|
});
|
|
}
|
|
} catch (updateError) {
|
|
edgeLogger.error('Exception updating idempotency key', {
|
|
action: 'approval_idempotency_complete_exception',
|
|
error: updateError instanceof Error ? updateError.message : 'Unknown error',
|
|
requestId: tracking.requestId
|
|
});
|
|
// Continue - don't let cache update failures affect successful approval
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
...successResultData,
|
|
requestId: tracking.requestId
|
|
}),
|
|
{
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'X-Request-ID': tracking.requestId,
|
|
'X-Idempotency-Key': idempotencyKey,
|
|
'X-Cache-Status': 'MISS'
|
|
}
|
|
}
|
|
);
|
|
} catch (error: unknown) {
|
|
const duration = endRequest(tracking);
|
|
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
|
|
|
|
// ============================================================================
|
|
// IDEMPOTENCY: Update key to 'failed' to allow retries
|
|
// ============================================================================
|
|
|
|
// Only update if idempotencyKey was generated (not for early auth failures)
|
|
if (typeof idempotencyKey !== 'undefined') {
|
|
try {
|
|
const { error: updateKeyError } = await supabase
|
|
.from('submission_idempotency_keys')
|
|
.update({
|
|
status: 'failed',
|
|
error_message: errorMessage,
|
|
completed_at: new Date().toISOString(),
|
|
duration_ms: duration
|
|
})
|
|
.eq('idempotency_key', idempotencyKey)
|
|
.eq('moderator_id', authenticatedUserId || '');
|
|
|
|
if (updateKeyError) {
|
|
edgeLogger.error('Failed to update idempotency key to failed', {
|
|
action: 'approval_idempotency_fail_error',
|
|
idempotencyKey,
|
|
error: updateKeyError.message,
|
|
requestId: tracking.requestId
|
|
});
|
|
} else {
|
|
edgeLogger.info('Idempotency key updated to failed', {
|
|
action: 'approval_idempotency_failed',
|
|
idempotencyKey,
|
|
errorMessage,
|
|
durationMs: duration,
|
|
requestId: tracking.requestId
|
|
});
|
|
}
|
|
} catch (updateError) {
|
|
edgeLogger.error('Exception updating idempotency key to failed', {
|
|
action: 'approval_idempotency_fail_exception',
|
|
error: updateError instanceof Error ? updateError.message : 'Unknown error',
|
|
requestId: tracking.requestId
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|