diff --git a/src/components/admin/ParkForm.tsx b/src/components/admin/ParkForm.tsx index 5e032a27..cfcf0fda 100644 --- a/src/components/admin/ParkForm.tsx +++ b/src/components/admin/ParkForm.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; +import { entitySchemas } from '@/lib/entityValidationSchemas'; import { validateSubmissionHandler } from '@/lib/entityFormValidation'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; diff --git a/src/lib/entityValidationSchemas.ts b/src/lib/entityValidationSchemas.ts index 0bc73800..cf0bf40c 100644 --- a/src/lib/entityValidationSchemas.ts +++ b/src/lib/entityValidationSchemas.ts @@ -27,6 +27,15 @@ export const parkValidationSchema = z.object({ email: z.string().email('Invalid email format').optional().or(z.literal('')), operator_id: z.string().uuid().optional(), property_owner_id: z.string().uuid().optional(), + banner_image_id: z.string().optional(), + banner_image_url: z.string().optional(), + card_image_id: z.string().optional(), + card_image_url: z.string().optional(), + images: z.object({ + uploaded: z.array(z.any()), + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional(), + }).optional(), }).refine((data) => { if (data.closing_date && data.opening_date) { return new Date(data.closing_date) >= new Date(data.opening_date); @@ -45,6 +54,8 @@ export const rideValidationSchema = z.object({ category: z.string().min(1, 'Category is required'), ride_sub_type: z.string().max(100, 'Sub type must be less than 100 characters').optional(), status: z.string().min(1, 'Status is required'), + park_id: z.string().uuid().optional(), + designer_id: z.string().uuid().optional(), opening_date: z.string().optional(), closing_date: z.string().optional(), height_requirement: z.number().min(0, 'Height requirement must be positive').max(300, 'Height requirement must be less than 300cm').optional(), @@ -57,6 +68,20 @@ export const rideValidationSchema = z.object({ inversions: z.number().min(0, 'Inversions must be positive').optional(), manufacturer_id: z.string().uuid().optional(), ride_model_id: z.string().uuid().optional(), + coaster_type: z.string().optional(), + seating_type: z.string().optional(), + intensity_level: z.string().optional(), + drop_height_meters: z.number().min(0, 'Drop height must be positive').max(200, 'Drop height must be less than 200 meters').optional(), + max_g_force: z.number().optional(), + banner_image_id: z.string().optional(), + banner_image_url: z.string().optional(), + card_image_id: z.string().optional(), + card_image_url: z.string().optional(), + images: z.object({ + uploaded: z.array(z.any()), + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional(), + }).optional(), }); // Company Schema (Manufacturer, Designer, Operator, Property Owner) @@ -69,6 +94,15 @@ export const companyValidationSchema = z.object({ founded_year: z.number().min(1800, 'Founded year must be after 1800').max(currentYear, `Founded year cannot be in the future`).optional(), headquarters_location: z.string().max(200, 'Location must be less than 200 characters').optional(), website_url: z.string().url('Invalid URL format').optional().or(z.literal('')), + banner_image_id: z.string().optional(), + banner_image_url: z.string().optional(), + card_image_id: z.string().optional(), + card_image_url: z.string().optional(), + images: z.object({ + uploaded: z.array(z.any()), + banner_assignment: z.number().nullable().optional(), + card_assignment: z.number().nullable().optional(), + }).optional(), }); // Ride Model Schema diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index 00c25a6f..00c539e5 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -1,5 +1,6 @@ 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 } from "./validation.ts"; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -205,6 +206,12 @@ serve(async (req) => { try { console.log(`Processing item ${item.id} of type ${item.item_type}`); + // Validate entity data before processing + const validation = validateEntityData(item.item_type, item.item_data); + if (!validation.valid) { + throw new Error(`Validation failed: ${validation.errors.join(', ')}`); + } + // Set user context for versioning trigger // This allows auto_create_entity_version() to capture the submitter const { error: setConfigError } = await supabase.rpc('set_config_value', { diff --git a/supabase/functions/process-selective-approval/validation.ts b/supabase/functions/process-selective-approval/validation.ts new file mode 100644 index 00000000..5e09ca34 --- /dev/null +++ b/supabase/functions/process-selective-approval/validation.ts @@ -0,0 +1,104 @@ +/** + * Server-side validation for entity data + * This provides a final safety layer before database writes + */ + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +/** + * Validate entity data before database write + */ +export function validateEntityData(entityType: string, data: any): ValidationResult { + const errors: string[] = []; + + // Common validations for all entities + 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'); + } + + // 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'); + 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; + } + + return { + valid: errors.length === 0, + errors + }; +}