From 3832439d6740a06f8bdc2f5dd852d406711df6a4 Mon Sep 17 00:00:00 2001 From: pac7 <47831526-pac7@users.noreply.replit.com> Date: Wed, 8 Oct 2025 18:14:34 +0000 Subject: [PATCH] Improve image upload and test data generation functionalities Refactors `uploadPendingImages` to use `Promise.allSettled` for parallel uploads and implements JSON path queries in `clearTestData` and `getTestDataStats` for more robust test data management. Enhances `seed-test-data` function to support creating data conflicts and version chains, and adds validation for `imageId` format in `upload-image` function. Updates `AutocompleteSearch` to use a default search types constant. Replit-Commit-Author: Agent Replit-Commit-Session-Id: dc31cf9d-7a06-4420-8ade-e7b7f5200e71 Replit-Commit-Checkpoint-Type: intermediate_checkpoint --- .replit | 4 - src/components/search/AutocompleteSearch.tsx | 4 +- src/lib/imageUploadHelper.ts | 169 +++++++++++-------- src/lib/testDataGenerator.ts | 7 +- supabase/functions/seed-test-data/index.ts | 54 +++++- supabase/functions/upload-image/index.ts | 17 ++ 6 files changed, 164 insertions(+), 91 deletions(-) diff --git a/.replit b/.replit index 4eea8915..fc81a45d 100644 --- a/.replit +++ b/.replit @@ -33,7 +33,3 @@ outputType = "webview" [[ports]] localPort = 5000 externalPort = 80 - -[[ports]] -localPort = 37823 -externalPort = 3000 diff --git a/src/components/search/AutocompleteSearch.tsx b/src/components/search/AutocompleteSearch.tsx index 0598b288..733f2c5c 100644 --- a/src/components/search/AutocompleteSearch.tsx +++ b/src/components/search/AutocompleteSearch.tsx @@ -19,12 +19,14 @@ interface AutocompleteSearchProps { variant?: 'default' | 'hero'; } +const DEFAULT_SEARCH_TYPES: ('park' | 'ride' | 'company')[] = ['park', 'ride', 'company']; + export function AutocompleteSearch({ onResultSelect, onSearch, placeholder = "Search parks, rides, or companies...", className = "", - types = ['park', 'ride', 'company'], + types = DEFAULT_SEARCH_TYPES, limit = 8, showRecentSearches = true, variant = 'default' diff --git a/src/lib/imageUploadHelper.ts b/src/lib/imageUploadHelper.ts index e4577831..5c3549f9 100644 --- a/src/lib/imageUploadHelper.ts +++ b/src/lib/imageUploadHelper.ts @@ -9,92 +9,113 @@ export interface CloudflareUploadResponse { success: boolean; } +// Internal type to track upload status +interface UploadedImageWithFlag extends UploadedImage { + wasNewlyUploaded?: boolean; +} + /** * Uploads pending local images to Cloudflare via Supabase Edge Function * @param images Array of UploadedImage objects (mix of local and already uploaded) * @returns Array of UploadedImage objects with all images uploaded */ export async function uploadPendingImages(images: UploadedImage[]): Promise { - const uploadedImages: UploadedImage[] = []; - const newlyUploadedIds: string[] = []; // Track newly uploaded IDs for cleanup on error - let currentImageIndex = 0; + // Process all images in parallel for better performance using allSettled + const uploadPromises = images.map(async (image, index): Promise => { + if (image.isLocal && image.file) { + // Step 1: Get upload URL from our Supabase Edge Function + const { data: uploadUrlData, error: urlError } = await supabase.functions.invoke('upload-image', { + body: { action: 'get-upload-url' } + }); - try { - for (const image of images) { - if (image.isLocal && image.file) { - // Step 1: Get upload URL from our Supabase Edge Function - const { data: uploadUrlData, error: urlError } = await supabase.functions.invoke('upload-image', { - body: { action: 'get-upload-url' } - }); - - if (urlError || !uploadUrlData?.uploadURL) { - throw new Error(`Failed to get upload URL: ${urlError?.message || 'Unknown error'}`); - } - - // Step 2: Upload file directly to Cloudflare - const formData = new FormData(); - formData.append('file', image.file); - - const uploadResponse = await fetch(uploadUrlData.uploadURL, { - method: 'POST', - body: formData, - }); - - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text(); - throw new Error(`Upload failed (status ${uploadResponse.status}): ${errorText}`); - } - - const result: CloudflareUploadResponse = await uploadResponse.json(); - - if (!result.success || !result.result) { - throw new Error('Cloudflare upload returned unsuccessful response'); - } - - // Track this newly uploaded image - newlyUploadedIds.push(result.result.id); - - // Step 3: Return uploaded image metadata - uploadedImages.push({ - url: result.result.variants[0], // Use first variant (usually the original) - cloudflare_id: result.result.id, - caption: image.caption, - isLocal: false, - }); - - // Clean up object URL - URL.revokeObjectURL(image.url); - } else { - // Already uploaded, keep as is - uploadedImages.push({ - url: image.url, - cloudflare_id: image.cloudflare_id, - caption: image.caption, - isLocal: false, - }); + if (urlError || !uploadUrlData?.uploadURL) { + throw new Error(`Failed to get upload URL for image ${index + 1}: ${urlError?.message || 'Unknown error'}`); } - currentImageIndex++; - } - return uploadedImages; - } catch (error) { - // Cleanup: Attempt to delete newly uploaded images on error - if (newlyUploadedIds.length > 0) { - console.error(`Upload failed at image ${currentImageIndex + 1}. Cleaning up ${newlyUploadedIds.length} uploaded images...`); + // Step 2: Upload file directly to Cloudflare + const formData = new FormData(); + formData.append('file', image.file); + + const uploadResponse = await fetch(uploadUrlData.uploadURL, { + method: 'POST', + body: formData, + }); + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text(); + throw new Error(`Upload failed for image ${index + 1} (status ${uploadResponse.status}): ${errorText}`); + } + + const result: CloudflareUploadResponse = await uploadResponse.json(); + + if (!result.success || !result.result) { + throw new Error(`Cloudflare upload returned unsuccessful response for image ${index + 1}`); + } + + // Clean up object URL + URL.revokeObjectURL(image.url); + + // Step 3: Return uploaded image metadata with wasNewlyUploaded flag + return { + url: result.result.variants[0], // Use first variant (usually the original) + cloudflare_id: result.result.id, + caption: image.caption, + isLocal: false, + wasNewlyUploaded: true // Flag to track newly uploaded images + }; + } else { + // Already uploaded, keep as is + return { + url: image.url, + cloudflare_id: image.cloudflare_id, + caption: image.caption, + isLocal: false, + wasNewlyUploaded: false // Pre-existing image + }; + } + }); + + // Wait for all uploads to settle (succeed or fail) + const results = await Promise.allSettled(uploadPromises); + + // Separate successful and failed uploads + const successfulUploads: UploadedImageWithFlag[] = []; + const newlyUploadedImageIds: string[] = []; // Track ONLY newly uploaded images for cleanup + const errors: string[] = []; + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + const uploadedImage = result.value; + successfulUploads.push(uploadedImage); + // Only track newly uploaded images for potential cleanup + if (uploadedImage.wasNewlyUploaded && uploadedImage.cloudflare_id) { + newlyUploadedImageIds.push(uploadedImage.cloudflare_id); + } + } else { + errors.push(result.reason?.message || `Upload ${index + 1} failed`); + } + }); + + // If any uploads failed, clean up ONLY newly uploaded images and throw error + if (errors.length > 0) { + if (newlyUploadedImageIds.length > 0) { + console.error(`Some uploads failed. Cleaning up ${newlyUploadedImageIds.length} newly uploaded images...`); - // Attempt cleanup but don't throw if it fails - for (const imageId of newlyUploadedIds) { - try { - await supabase.functions.invoke('upload-image', { + // Attempt cleanup in parallel but don't throw if it fails + await Promise.allSettled( + newlyUploadedImageIds.map(imageId => + supabase.functions.invoke('upload-image', { body: { action: 'delete', imageId } - }); - } catch (cleanupError) { - console.error(`Failed to cleanup image ${imageId}:`, cleanupError); - } - } + }).catch(cleanupError => { + console.error(`Failed to cleanup image ${imageId}:`, cleanupError); + }) + ) + ); } - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Failed to upload image ${currentImageIndex + 1} of ${images.length}: ${errorMessage}`); + throw new Error(`Failed to upload ${errors.length} of ${images.length} images: ${errors.join('; ')}`); } + + // Remove the wasNewlyUploaded flag before returning + return successfulUploads.map(({ wasNewlyUploaded, ...image }) => image as UploadedImage); } diff --git a/src/lib/testDataGenerator.ts b/src/lib/testDataGenerator.ts index 0aac4b1d..2c138f15 100644 --- a/src/lib/testDataGenerator.ts +++ b/src/lib/testDataGenerator.ts @@ -212,12 +212,12 @@ export function generateRandomRideModel(manufacturerId: string, counter: number) // Cleanup utilities export async function clearTestData(): Promise<{ deleted: number }> { try { - // Find all test submissions + // Find all test submissions using proper JSON path query const { data: testSubmissions, error: fetchError } = await supabase .from('content_submissions') .select('id') .eq('status', 'pending') - .contains('content', { metadata: { is_test_data: true } }); + .eq('content->metadata->>is_test_data', 'true'); if (fetchError) throw fetchError; if (!testSubmissions || testSubmissions.length === 0) { @@ -249,10 +249,11 @@ export async function clearTestData(): Promise<{ deleted: number }> { } export async function getTestDataStats(): Promise<{ total: number; pending: number; approved: number }> { + // Use proper JSON path query for nested metadata const { data, error } = await supabase .from('content_submissions') .select('status') - .contains('content', { metadata: { is_test_data: true } }); + .eq('content->metadata->>is_test_data', 'true'); if (error) throw error; diff --git a/supabase/functions/seed-test-data/index.ts b/supabase/functions/seed-test-data/index.ts index 0dbb1559..8864e549 100644 --- a/supabase/functions/seed-test-data/index.ts +++ b/supabase/functions/seed-test-data/index.ts @@ -75,7 +75,7 @@ Deno.serve(async (req) => { } // Parse request - const { preset = 'small', entityTypes = [], includeDependencies = true, includeEscalated = false, includeExpiredLocks = false }: SeedOptions = await req.json(); + const { preset = 'small', entityTypes = [], includeDependencies = true, includeConflicts = false, includeVersionChains = false, includeEscalated = false, includeExpiredLocks = false }: SeedOptions = await req.json(); const plan = PRESETS[preset]; if (!plan) { @@ -86,9 +86,11 @@ Deno.serve(async (req) => { } const startTime = Date.now(); - const summary = { parks: 0, rides: 0, companies: 0, rideModels: 0 }; + const summary = { parks: 0, rides: 0, companies: 0, rideModels: 0, conflicts: 0, versionChains: 0 }; const createdParks: string[] = []; const createdCompanies: Record = { manufacturer: [], operator: [], designer: [], property_owner: [] }; + const createdParkSlugs: string[] = []; + const createdRideSlugs: string[] = []; // Helper to create submission async function createSubmission(userId: string, type: string, itemData: any, options: { escalated?: boolean; expiredLock?: boolean } = {}) { @@ -177,10 +179,25 @@ Deno.serve(async (req) => { // Create parks if (entityTypes.includes('parks')) { for (let i = 0; i < plan.parks; i++) { + // Determine if this should be a conflict or version chain + const shouldConflict = includeConflicts && createdParkSlugs.length > 0 && Math.random() < 0.15; + const shouldVersionChain = includeVersionChains && createdParkSlugs.length > 0 && Math.random() < 0.15; + + let slug = `test-park-${i + 1}`; + if (shouldConflict) { + // Reuse an existing slug to create a conflict + slug = createdParkSlugs[Math.floor(Math.random() * createdParkSlugs.length)]; + summary.conflicts++; + } else if (shouldVersionChain) { + // Reuse an existing slug for a version chain with different data + slug = createdParkSlugs[Math.floor(Math.random() * createdParkSlugs.length)]; + summary.versionChains++; + } + const parkData = { - name: `Test Park ${i + 1}`, - slug: `test-park-${i + 1}`, - description: 'Test park description', + name: shouldVersionChain ? `Test Park ${slug} (Updated)` : `Test Park ${i + 1}`, + slug: slug, + description: shouldVersionChain ? 'Updated test park description' : 'Test park description', park_type: ['theme_park', 'amusement_park', 'water_park'][Math.floor(Math.random() * 3)], status: 'operating', opening_date: '2000-01-01' @@ -192,7 +209,10 @@ Deno.serve(async (req) => { }; await createSubmission(user.id, 'park', parkData, options); - createdParks.push(`test-park-${i + 1}`); + createdParks.push(slug); + if (!shouldConflict && !shouldVersionChain) { + createdParkSlugs.push(slug); + } summary.parks++; } } @@ -222,6 +242,19 @@ Deno.serve(async (req) => { // Create rides (with dependencies if enabled) if (entityTypes.includes('rides') && includeDependencies && createdParks.length > 0) { for (let i = 0; i < plan.rides; i++) { + // Determine if this should be a conflict or version chain + const shouldConflict = includeConflicts && createdRideSlugs.length > 0 && Math.random() < 0.15; + const shouldVersionChain = includeVersionChains && createdRideSlugs.length > 0 && Math.random() < 0.15; + + let slug = `test-ride-${i + 1}`; + if (shouldConflict) { + slug = createdRideSlugs[Math.floor(Math.random() * createdRideSlugs.length)]; + summary.conflicts++; + } else if (shouldVersionChain) { + slug = createdRideSlugs[Math.floor(Math.random() * createdRideSlugs.length)]; + summary.versionChains++; + } + // Get random park ID from database const parkSlug = createdParks[Math.floor(Math.random() * createdParks.length)]; const { data: parkData } = await supabase @@ -231,9 +264,9 @@ Deno.serve(async (req) => { .maybeSingle(); const rideData = { - name: `Test Ride ${i + 1}`, - slug: `test-ride-${i + 1}`, - description: 'Test ride description', + name: shouldVersionChain ? `Test Ride ${slug} (Updated)` : `Test Ride ${i + 1}`, + slug: slug, + description: shouldVersionChain ? 'Updated test ride description' : 'Test ride description', category: ['roller_coaster', 'flat_ride', 'water_ride'][Math.floor(Math.random() * 3)], status: 'operating', park_id: parkData?.id || null, @@ -241,6 +274,9 @@ Deno.serve(async (req) => { }; await createSubmission(user.id, 'ride', rideData); + if (!shouldConflict && !shouldVersionChain) { + createdRideSlugs.push(slug); + } summary.rides++; } } diff --git a/supabase/functions/upload-image/index.ts b/supabase/functions/upload-image/index.ts index 0b804da8..36d8eb5d 100644 --- a/supabase/functions/upload-image/index.ts +++ b/supabase/functions/upload-image/index.ts @@ -129,6 +129,23 @@ serve(async (req) => { ) } + // Validate imageId format - Cloudflare accepts UUIDs and alphanumeric IDs + // Allow: alphanumeric, hyphens, underscores (common ID formats) + // Reject: special characters that could cause injection or path traversal + const validImageIdPattern = /^[a-zA-Z0-9_-]{1,100}$/; + if (!validImageIdPattern.test(imageId)) { + return new Response( + JSON.stringify({ + error: 'Invalid imageId format', + message: 'imageId must be alphanumeric with optional hyphens/underscores (max 100 chars)' + }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ) + } + let deleteResponse; try { deleteResponse = await fetch(