mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11:13 -05:00
Fix edge function bundle timeout
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
// Force redeployment: v100 - JWT verification disabled in config
|
||||
// Force redeployment: v101 - Inlined validation to fix bundling timeout
|
||||
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 { validateEntityData, validateEntityDataStrict } from "./validation.ts";
|
||||
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
|
||||
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
|
||||
import { rateLimiters, withRateLimit } from "../_shared/rateLimiter.ts";
|
||||
@@ -11,6 +10,392 @@ const corsHeaders = {
|
||||
'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 hadLocation = originalData?.location_id !== null && originalData?.location_id !== undefined;
|
||||
if (!hasLocation && !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;
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* Server-side validation for entity data
|
||||
* This provides a final safety layer before database writes
|
||||
*/
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface StrictValidationResult {
|
||||
valid: boolean;
|
||||
blockingErrors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict validation that separates blocking errors from warnings
|
||||
* Used by the approval flow to prevent invalid data from being approved
|
||||
*/
|
||||
export function validateEntityDataStrict(
|
||||
entityType: string,
|
||||
data: any,
|
||||
originalData?: any
|
||||
): StrictValidationResult {
|
||||
const result: StrictValidationResult = {
|
||||
valid: true,
|
||||
blockingErrors: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
// Skip name/slug validations for timeline events (they use title instead)
|
||||
const isTimelineEvent = entityType === 'milestone' || entityType === 'timeline_event';
|
||||
|
||||
// Common validations (blocking) - only for entities with name/slug
|
||||
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');
|
||||
}
|
||||
|
||||
// URL validation (warning)
|
||||
if (data.website_url && data.website_url !== '' && !isValidUrl(data.website_url)) {
|
||||
result.warnings.push('Website URL format may be invalid');
|
||||
}
|
||||
|
||||
// Email validation (warning)
|
||||
if (data.email && data.email !== '' && !isValidEmail(data.email)) {
|
||||
result.warnings.push('Email format may be invalid');
|
||||
}
|
||||
} else {
|
||||
// Validations specific to timeline events
|
||||
if (data.description && data.description.length > 2000) {
|
||||
result.blockingErrors.push('Description must be less than 2000 characters');
|
||||
}
|
||||
}
|
||||
|
||||
// Entity-specific validations
|
||||
switch (entityType) {
|
||||
case 'park':
|
||||
if (!data.park_type) {
|
||||
result.blockingErrors.push('Park type is required');
|
||||
}
|
||||
if (!data.status) {
|
||||
result.blockingErrors.push('Status is required');
|
||||
}
|
||||
// For edits, check if location exists in either new or original data
|
||||
const hasLocation = data.location_id !== null && data.location_id !== undefined;
|
||||
const hadLocation = originalData?.location_id !== null && originalData?.location_id !== undefined;
|
||||
if (!hasLocation && !hadLocation) {
|
||||
result.blockingErrors.push('Location is required for parks');
|
||||
}
|
||||
// Block explicit removal of required location
|
||||
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');
|
||||
}
|
||||
// For edits, check if park exists in either new or original data
|
||||
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');
|
||||
}
|
||||
// Block explicit removal of required park assignment
|
||||
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');
|
||||
}
|
||||
// Validate Cloudflare image ID format (standard UUID format)
|
||||
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');
|
||||
}
|
||||
// Validate entity type is one of the allowed types
|
||||
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');
|
||||
}
|
||||
// Validate entity_id is a valid UUID
|
||||
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');
|
||||
}
|
||||
// Validate event date is not too far in the future (max 5 years)
|
||||
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');
|
||||
}
|
||||
// Also validate it's not absurdly old (before 1800)
|
||||
const minDate = new Date('1800-01-01');
|
||||
if (eventDate < minDate) {
|
||||
result.blockingErrors.push('Event date cannot be before year 1800');
|
||||
}
|
||||
}
|
||||
// For change events (name_change, location_change, status_change), require from/to values
|
||||
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');
|
||||
}
|
||||
// Validate entity type is one of the allowed types
|
||||
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');
|
||||
}
|
||||
// Validate entity_id is a valid UUID
|
||||
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;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate entity data before database write (legacy function)
|
||||
*/
|
||||
export function validateEntityData(entityType: string, data: any): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Skip name/slug validations for timeline events
|
||||
const isTimelineEvent = entityType === 'milestone' || entityType === 'timeline_event';
|
||||
|
||||
// Common validations for entities with name/slug
|
||||
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 {
|
||||
// Validations for timeline events
|
||||
if (data.description && data.description.length > 2000) {
|
||||
errors.push('Description must be less than 2000 characters');
|
||||
}
|
||||
}
|
||||
|
||||
// Entity-specific validations
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user