mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:51:13 -05:00
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
This commit is contained in:
4
.replit
4
.replit
@@ -33,7 +33,3 @@ outputType = "webview"
|
|||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 5000
|
localPort = 5000
|
||||||
externalPort = 80
|
externalPort = 80
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 37823
|
|
||||||
externalPort = 3000
|
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ interface AutocompleteSearchProps {
|
|||||||
variant?: 'default' | 'hero';
|
variant?: 'default' | 'hero';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SEARCH_TYPES: ('park' | 'ride' | 'company')[] = ['park', 'ride', 'company'];
|
||||||
|
|
||||||
export function AutocompleteSearch({
|
export function AutocompleteSearch({
|
||||||
onResultSelect,
|
onResultSelect,
|
||||||
onSearch,
|
onSearch,
|
||||||
placeholder = "Search parks, rides, or companies...",
|
placeholder = "Search parks, rides, or companies...",
|
||||||
className = "",
|
className = "",
|
||||||
types = ['park', 'ride', 'company'],
|
types = DEFAULT_SEARCH_TYPES,
|
||||||
limit = 8,
|
limit = 8,
|
||||||
showRecentSearches = true,
|
showRecentSearches = true,
|
||||||
variant = 'default'
|
variant = 'default'
|
||||||
|
|||||||
@@ -9,92 +9,113 @@ export interface CloudflareUploadResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Internal type to track upload status
|
||||||
|
interface UploadedImageWithFlag extends UploadedImage {
|
||||||
|
wasNewlyUploaded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads pending local images to Cloudflare via Supabase Edge Function
|
* Uploads pending local images to Cloudflare via Supabase Edge Function
|
||||||
* @param images Array of UploadedImage objects (mix of local and already uploaded)
|
* @param images Array of UploadedImage objects (mix of local and already uploaded)
|
||||||
* @returns Array of UploadedImage objects with all images uploaded
|
* @returns Array of UploadedImage objects with all images uploaded
|
||||||
*/
|
*/
|
||||||
export async function uploadPendingImages(images: UploadedImage[]): Promise<UploadedImage[]> {
|
export async function uploadPendingImages(images: UploadedImage[]): Promise<UploadedImage[]> {
|
||||||
const uploadedImages: UploadedImage[] = [];
|
// Process all images in parallel for better performance using allSettled
|
||||||
const newlyUploadedIds: string[] = []; // Track newly uploaded IDs for cleanup on error
|
const uploadPromises = images.map(async (image, index): Promise<UploadedImageWithFlag> => {
|
||||||
let currentImageIndex = 0;
|
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 {
|
if (urlError || !uploadUrlData?.uploadURL) {
|
||||||
for (const image of images) {
|
throw new Error(`Failed to get upload URL for image ${index + 1}: ${urlError?.message || 'Unknown error'}`);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
currentImageIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return uploadedImages;
|
// Step 2: Upload file directly to Cloudflare
|
||||||
} catch (error) {
|
const formData = new FormData();
|
||||||
// Cleanup: Attempt to delete newly uploaded images on error
|
formData.append('file', image.file);
|
||||||
if (newlyUploadedIds.length > 0) {
|
|
||||||
console.error(`Upload failed at image ${currentImageIndex + 1}. Cleaning up ${newlyUploadedIds.length} uploaded images...`);
|
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
|
// Attempt cleanup in parallel but don't throw if it fails
|
||||||
for (const imageId of newlyUploadedIds) {
|
await Promise.allSettled(
|
||||||
try {
|
newlyUploadedImageIds.map(imageId =>
|
||||||
await supabase.functions.invoke('upload-image', {
|
supabase.functions.invoke('upload-image', {
|
||||||
body: { action: 'delete', imageId }
|
body: { action: 'delete', imageId }
|
||||||
});
|
}).catch(cleanupError => {
|
||||||
} catch (cleanupError) {
|
console.error(`Failed to cleanup image ${imageId}:`, cleanupError);
|
||||||
console.error(`Failed to cleanup image ${imageId}:`, cleanupError);
|
})
|
||||||
}
|
)
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
throw new Error(`Failed to upload ${errors.length} of ${images.length} images: ${errors.join('; ')}`);
|
||||||
throw new Error(`Failed to upload image ${currentImageIndex + 1} of ${images.length}: ${errorMessage}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the wasNewlyUploaded flag before returning
|
||||||
|
return successfulUploads.map(({ wasNewlyUploaded, ...image }) => image as UploadedImage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,12 +212,12 @@ export function generateRandomRideModel(manufacturerId: string, counter: number)
|
|||||||
// Cleanup utilities
|
// Cleanup utilities
|
||||||
export async function clearTestData(): Promise<{ deleted: number }> {
|
export async function clearTestData(): Promise<{ deleted: number }> {
|
||||||
try {
|
try {
|
||||||
// Find all test submissions
|
// Find all test submissions using proper JSON path query
|
||||||
const { data: testSubmissions, error: fetchError } = await supabase
|
const { data: testSubmissions, error: fetchError } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('status', 'pending')
|
.eq('status', 'pending')
|
||||||
.contains('content', { metadata: { is_test_data: true } });
|
.eq('content->metadata->>is_test_data', 'true');
|
||||||
|
|
||||||
if (fetchError) throw fetchError;
|
if (fetchError) throw fetchError;
|
||||||
if (!testSubmissions || testSubmissions.length === 0) {
|
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 }> {
|
export async function getTestDataStats(): Promise<{ total: number; pending: number; approved: number }> {
|
||||||
|
// Use proper JSON path query for nested metadata
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
.select('status')
|
.select('status')
|
||||||
.contains('content', { metadata: { is_test_data: true } });
|
.eq('content->metadata->>is_test_data', 'true');
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ Deno.serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse request
|
// 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];
|
const plan = PRESETS[preset];
|
||||||
|
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
@@ -86,9 +86,11 @@ Deno.serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
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 createdParks: string[] = [];
|
||||||
const createdCompanies: Record<string, string[]> = { manufacturer: [], operator: [], designer: [], property_owner: [] };
|
const createdCompanies: Record<string, string[]> = { manufacturer: [], operator: [], designer: [], property_owner: [] };
|
||||||
|
const createdParkSlugs: string[] = [];
|
||||||
|
const createdRideSlugs: string[] = [];
|
||||||
|
|
||||||
// Helper to create submission
|
// Helper to create submission
|
||||||
async function createSubmission(userId: string, type: string, itemData: any, options: { escalated?: boolean; expiredLock?: boolean } = {}) {
|
async function createSubmission(userId: string, type: string, itemData: any, options: { escalated?: boolean; expiredLock?: boolean } = {}) {
|
||||||
@@ -177,10 +179,25 @@ Deno.serve(async (req) => {
|
|||||||
// Create parks
|
// Create parks
|
||||||
if (entityTypes.includes('parks')) {
|
if (entityTypes.includes('parks')) {
|
||||||
for (let i = 0; i < plan.parks; i++) {
|
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 = {
|
const parkData = {
|
||||||
name: `Test Park ${i + 1}`,
|
name: shouldVersionChain ? `Test Park ${slug} (Updated)` : `Test Park ${i + 1}`,
|
||||||
slug: `test-park-${i + 1}`,
|
slug: slug,
|
||||||
description: 'Test park description',
|
description: shouldVersionChain ? 'Updated test park description' : 'Test park description',
|
||||||
park_type: ['theme_park', 'amusement_park', 'water_park'][Math.floor(Math.random() * 3)],
|
park_type: ['theme_park', 'amusement_park', 'water_park'][Math.floor(Math.random() * 3)],
|
||||||
status: 'operating',
|
status: 'operating',
|
||||||
opening_date: '2000-01-01'
|
opening_date: '2000-01-01'
|
||||||
@@ -192,7 +209,10 @@ Deno.serve(async (req) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await createSubmission(user.id, 'park', parkData, options);
|
await createSubmission(user.id, 'park', parkData, options);
|
||||||
createdParks.push(`test-park-${i + 1}`);
|
createdParks.push(slug);
|
||||||
|
if (!shouldConflict && !shouldVersionChain) {
|
||||||
|
createdParkSlugs.push(slug);
|
||||||
|
}
|
||||||
summary.parks++;
|
summary.parks++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +242,19 @@ Deno.serve(async (req) => {
|
|||||||
// Create rides (with dependencies if enabled)
|
// Create rides (with dependencies if enabled)
|
||||||
if (entityTypes.includes('rides') && includeDependencies && createdParks.length > 0) {
|
if (entityTypes.includes('rides') && includeDependencies && createdParks.length > 0) {
|
||||||
for (let i = 0; i < plan.rides; i++) {
|
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
|
// Get random park ID from database
|
||||||
const parkSlug = createdParks[Math.floor(Math.random() * createdParks.length)];
|
const parkSlug = createdParks[Math.floor(Math.random() * createdParks.length)];
|
||||||
const { data: parkData } = await supabase
|
const { data: parkData } = await supabase
|
||||||
@@ -231,9 +264,9 @@ Deno.serve(async (req) => {
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
const rideData = {
|
const rideData = {
|
||||||
name: `Test Ride ${i + 1}`,
|
name: shouldVersionChain ? `Test Ride ${slug} (Updated)` : `Test Ride ${i + 1}`,
|
||||||
slug: `test-ride-${i + 1}`,
|
slug: slug,
|
||||||
description: 'Test ride description',
|
description: shouldVersionChain ? 'Updated test ride description' : 'Test ride description',
|
||||||
category: ['roller_coaster', 'flat_ride', 'water_ride'][Math.floor(Math.random() * 3)],
|
category: ['roller_coaster', 'flat_ride', 'water_ride'][Math.floor(Math.random() * 3)],
|
||||||
status: 'operating',
|
status: 'operating',
|
||||||
park_id: parkData?.id || null,
|
park_id: parkData?.id || null,
|
||||||
@@ -241,6 +274,9 @@ Deno.serve(async (req) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await createSubmission(user.id, 'ride', rideData);
|
await createSubmission(user.id, 'ride', rideData);
|
||||||
|
if (!shouldConflict && !shouldVersionChain) {
|
||||||
|
createdRideSlugs.push(slug);
|
||||||
|
}
|
||||||
summary.rides++;
|
summary.rides++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
let deleteResponse;
|
||||||
try {
|
try {
|
||||||
deleteResponse = await fetch(
|
deleteResponse = await fetch(
|
||||||
|
|||||||
Reference in New Issue
Block a user