diff --git a/src/components/rides/RideModelCard.tsx b/src/components/rides/RideModelCard.tsx index 7f41853f..fe4eca9f 100644 --- a/src/components/rides/RideModelCard.tsx +++ b/src/components/rides/RideModelCard.tsx @@ -25,16 +25,25 @@ export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) { ).join(' '); }; - const rideCount = (model as any).rides?.[0]?.count || 0; + // Safely extract ride count and image data + const extendedModel = model as RideModel & { + ride_count?: number; + card_image_url?: string; + card_image_id?: string; + }; + + const rideCount = extendedModel.ride_count || 0; + const cardImageUrl = extendedModel.card_image_url; + const cardImageId = extendedModel.card_image_id; return (
- {((model as any).card_image_url || (model as any).card_image_id) ? ( + {(cardImageUrl || cardImageId) ? ( {model.name} diff --git a/src/hooks/useEntityVersions.ts b/src/hooks/useEntityVersions.ts index 17c8a4c1..ce262fe9 100644 --- a/src/hooks/useEntityVersions.ts +++ b/src/hooks/useEntityVersions.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; @@ -42,10 +42,15 @@ export function useEntityVersions(entityType: string, entityId: string) { // Track the current channel to prevent duplicate subscriptions const channelRef = useRef | null>(null); + + // Track if a fetch is in progress to prevent race conditions + const fetchInProgressRef = useRef(false); - const fetchVersions = async () => { + const fetchVersions = useCallback(async () => { try { - if (!isMountedRef.current) return; + if (!isMountedRef.current || fetchInProgressRef.current) return; + + fetchInProgressRef.current = true; setLoading(true); @@ -87,11 +92,12 @@ export function useEntityVersions(entityType: string, entityId: string) { toast.error('Failed to load version history'); } } finally { + fetchInProgressRef.current = false; if (isMountedRef.current) { setLoading(false); } } - }; + }, [entityType, entityId]); const fetchFieldHistory = async (versionId: string) => { try { @@ -195,7 +201,7 @@ export function useEntityVersions(entityType: string, entityId: string) { if (entityType && entityId) { fetchVersions(); } - }, [entityType, entityId]); + }, [entityType, entityId, fetchVersions]); // Set up realtime subscription for version changes useEffect(() => { diff --git a/src/hooks/useSearch.tsx b/src/hooks/useSearch.tsx index 10ead168..29235f45 100644 --- a/src/hooks/useSearch.tsx +++ b/src/hooks/useSearch.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { Park, Ride, Company } from '@/types/database'; @@ -20,13 +20,23 @@ interface UseSearchOptions { debounceMs?: number; } +// Hoist default values to prevent recreating on every render +const DEFAULT_TYPES: ('park' | 'ride' | 'company')[] = ['park', 'ride', 'company']; +const DEFAULT_LIMIT = 10; +const DEFAULT_MIN_QUERY = 2; +const DEFAULT_DEBOUNCE_MS = 300; + export function useSearch(options: UseSearchOptions = {}) { - const { - types = ['park', 'ride', 'company'], - limit = 10, - minQuery = 2, - debounceMs = 300 - } = options; + // Stabilize options using JSON stringify to prevent infinite loops from array recreation + const optionsKey = JSON.stringify({ + types: options.types || DEFAULT_TYPES, + limit: options.limit || DEFAULT_LIMIT, + minQuery: options.minQuery || DEFAULT_MIN_QUERY, + debounceMs: options.debounceMs || DEFAULT_DEBOUNCE_MS + }); + + const stableOptions = useMemo(() => JSON.parse(optionsKey), [optionsKey]); + const { types, limit, minQuery, debounceMs } = stableOptions; const [query, setQuery] = useState(''); const [results, setResults] = useState([]); @@ -61,7 +71,7 @@ export function useSearch(options: UseSearchOptions = {}) { }, []); // Search function - const search = async (searchQuery: string) => { + const search = useCallback(async (searchQuery: string) => { if (searchQuery.length < minQuery) { setResults([]); return; @@ -162,7 +172,7 @@ export function useSearch(options: UseSearchOptions = {}) { } finally { setLoading(false); } - }; + }, [types, limit, minQuery]); // Effect for debounced search useEffect(() => { @@ -171,7 +181,7 @@ export function useSearch(options: UseSearchOptions = {}) { } else { setResults([]); } - }, [debouncedQuery]); + }, [debouncedQuery, search]); // Save search to recent searches const saveSearch = (searchQuery: string) => { diff --git a/src/hooks/useUsernameValidation.ts b/src/hooks/useUsernameValidation.ts index 5a1f55f4..76d54041 100644 --- a/src/hooks/useUsernameValidation.ts +++ b/src/hooks/useUsernameValidation.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { usernameSchema } from '@/lib/validation'; import { useDebounce } from './useDebounce'; @@ -20,6 +20,33 @@ export function useUsernameValidation(username: string, currentUsername?: string const debouncedUsername = useDebounce(username, 500); + const checkUsernameAvailability = useCallback(async (normalizedUsername: string) => { + try { + const { data, error } = await supabase + .from('profiles') + .select('username') + .eq('username', normalizedUsername) + .maybeSingle(); + + if (error) throw error; + + const isAvailable = !data; + setState({ + isValid: isAvailable, + isAvailable, + isChecking: false, + error: isAvailable ? null : 'Username is already taken', + }); + } catch (error) { + setState({ + isValid: false, + isAvailable: null, + isChecking: false, + error: 'Error checking username availability', + }); + } + }, []); + useEffect(() => { if (!debouncedUsername || debouncedUsername === currentUsername) { setState({ @@ -47,34 +74,7 @@ export function useUsernameValidation(username: string, currentUsername?: string setState(prev => ({ ...prev, isChecking: true, error: null })); checkUsernameAvailability(validation.data); - }, [debouncedUsername, currentUsername]); - - const checkUsernameAvailability = async (normalizedUsername: string) => { - try { - const { data, error } = await supabase - .from('profiles') - .select('username') - .eq('username', normalizedUsername) - .maybeSingle(); - - if (error) throw error; - - const isAvailable = !data; - setState({ - isValid: isAvailable, - isAvailable, - isChecking: false, - error: isAvailable ? null : 'Username is already taken', - }); - } catch (error) { - setState({ - isValid: false, - isAvailable: null, - isChecking: false, - error: 'Error checking username availability', - }); - } - }; + }, [debouncedUsername, currentUsername, checkUsernameAvailability]); return state; } \ No newline at end of file diff --git a/src/lib/imageUploadHelper.ts b/src/lib/imageUploadHelper.ts index 21ad5342..e4577831 100644 --- a/src/lib/imageUploadHelper.ts +++ b/src/lib/imageUploadHelper.ts @@ -16,18 +16,19 @@ export interface CloudflareUploadResponse { */ export async function uploadPendingImages(images: UploadedImage[]): Promise { const uploadedImages: UploadedImage[] = []; + const newlyUploadedIds: string[] = []; // Track newly uploaded IDs for cleanup on error + let currentImageIndex = 0; - for (const image of images) { - if (image.isLocal && image.file) { - try { + 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) { - console.error('Error getting upload URL:', urlError); - throw new Error('Failed to get upload URL from Cloudflare'); + throw new Error(`Failed to get upload URL: ${urlError?.message || 'Unknown error'}`); } // Step 2: Upload file directly to Cloudflare @@ -40,7 +41,8 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise 0) { + console.error(`Upload failed at image ${currentImageIndex + 1}. Cleaning up ${newlyUploadedIds.length} uploaded images...`); + + // Attempt cleanup but don't throw if it fails + for (const imageId of newlyUploadedIds) { + try { + await supabase.functions.invoke('upload-image', { + body: { action: 'delete', imageId } + }); + } 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}`); + } } diff --git a/src/pages/ManufacturerModels.tsx b/src/pages/ManufacturerModels.tsx index 84047a6f..9960f45a 100644 --- a/src/pages/ManufacturerModels.tsx +++ b/src/pages/ManufacturerModels.tsx @@ -57,7 +57,14 @@ export default function ManufacturerModels() { const { data: modelsData, error: modelsError } = await query; if (modelsError) throw modelsError; - setModels((modelsData || []) as any); + + // Transform data to include ride count + const modelsWithCounts = (modelsData || []).map(model => ({ + ...model, + ride_count: Array.isArray(model.rides) ? model.rides[0]?.count || 0 : 0 + })); + + setModels(modelsWithCounts as RideModel[]); } } catch (error) { console.error('Error fetching data:', error); diff --git a/supabase/functions/detect-location/index.ts b/supabase/functions/detect-location/index.ts index b75141c2..4c8894cc 100644 --- a/supabase/functions/detect-location/index.ts +++ b/supabase/functions/detect-location/index.ts @@ -15,12 +15,39 @@ interface IPLocationResponse { const rateLimitMap = new Map(); const RATE_LIMIT_WINDOW = 60000; // 1 minute in milliseconds const MAX_REQUESTS = 10; // 10 requests per minute per IP +const MAX_MAP_SIZE = 10000; // Maximum number of IPs to track + +function cleanupExpiredEntries() { + const now = Date.now(); + for (const [ip, data] of rateLimitMap.entries()) { + if (now > data.resetAt) { + rateLimitMap.delete(ip); + } + } +} function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { const now = Date.now(); const existing = rateLimitMap.get(ip); if (!existing || now > existing.resetAt) { + // If map is too large, clean up expired entries first + if (rateLimitMap.size >= MAX_MAP_SIZE) { + cleanupExpiredEntries(); + + // If still too large after cleanup, clear oldest entries + if (rateLimitMap.size >= MAX_MAP_SIZE) { + const toDelete = Math.floor(MAX_MAP_SIZE * 0.2); // Remove 20% of entries + let deleted = 0; + for (const key of rateLimitMap.keys()) { + if (deleted >= toDelete) break; + rateLimitMap.delete(key); + deleted++; + } + console.warn(`Rate limit map reached size limit. Cleared ${deleted} entries.`); + } + } + // Create new entry or reset expired entry rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }); return { allowed: true }; @@ -36,14 +63,7 @@ function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { } // Clean up old entries periodically to prevent memory leak -setInterval(() => { - const now = Date.now(); - for (const [ip, data] of rateLimitMap.entries()) { - if (now > data.resetAt) { - rateLimitMap.delete(ip); - } - } -}, RATE_LIMIT_WINDOW); +setInterval(cleanupExpiredEntries, RATE_LIMIT_WINDOW); serve(async (req) => { // Handle CORS preflight requests diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index 01a7258b..e1a113ae 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -170,7 +170,22 @@ serve(async (req) => { const submitterId = submission.user_id; // Topologically sort items by dependencies - const sortedItems = topologicalSort(items); + let sortedItems; + try { + sortedItems = topologicalSort(items); + } catch (sortError) { + const errorMessage = sortError instanceof Error ? sortError.message : 'Failed to sort items'; + console.error('Topological sort failed:', errorMessage); + return new Response( + JSON.stringify({ + error: 'Invalid submission structure', + message: errorMessage, + details: 'The submission contains circular dependencies or missing required items' + }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + const dependencyMap = new Map(); const approvalResults: Array<{ itemId: string;