diff --git a/src/components/admin/LocationSearch.tsx b/src/components/admin/LocationSearch.tsx index 66c397e0..b150a714 100644 --- a/src/components/admin/LocationSearch.tsx +++ b/src/components/admin/LocationSearch.tsx @@ -44,6 +44,7 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className const [searchQuery, setSearchQuery] = useState(''); const [results, setResults] = useState([]); const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState(null); const [selectedLocation, setSelectedLocation] = useState(null); const [showResults, setShowResults] = useState(false); @@ -81,10 +82,12 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className const searchLocations = useCallback(async (query: string) => { if (!query || query.length < 3) { setResults([]); + setSearchError(null); return; } setIsSearching(true); + setSearchError(null); try { const response = await fetch( `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&addressdetails=1&limit=5`, @@ -97,7 +100,9 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className // Check if response is OK and content-type is JSON if (!response.ok) { + const errorMsg = `Location search failed (${response.status}). Please try again.`; console.error('OpenStreetMap API error:', response.status); + setSearchError(errorMsg); setResults([]); setShowResults(false); return; @@ -105,7 +110,9 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { + const errorMsg = 'Invalid response from location service. Please try again.'; console.error('Invalid response format from OpenStreetMap'); + setSearchError(errorMsg); setResults([]); setShowResults(false); return; @@ -114,8 +121,11 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className const data = await response.json(); setResults(data); setShowResults(true); + setSearchError(null); } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to search locations. Please check your connection.'; console.error('Error searching locations:', error); + setSearchError(errorMsg); setResults([]); setShowResults(false); } finally { @@ -186,6 +196,12 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className )} + {searchError && ( +
+ {searchError} +
+ )} + {showResults && results.length > 0 && (
@@ -210,6 +226,12 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className
)} + + {showResults && results.length === 0 && !isSearching && !searchError && ( +
+ No locations found. Try a different search term. +
+ )} ) : (
diff --git a/src/components/moderation/PhotoSubmissionDisplay.tsx b/src/components/moderation/PhotoSubmissionDisplay.tsx index 6dc37d49..f616a48a 100644 --- a/src/components/moderation/PhotoSubmissionDisplay.tsx +++ b/src/components/moderation/PhotoSubmissionDisplay.tsx @@ -22,15 +22,19 @@ export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayP .from('photo_submission_items') .select(` *, - photo_submission:photo_submissions!inner(submission_id) + photo_submission:photo_submissions(submission_id) `) .eq('photo_submission.submission_id', submissionId) .order('order_index'); if (error) throw error; - setPhotos(data || []); + + // Filter out any items where photo_submission is null (shouldn't happen but be safe) + const validPhotos = (data || []).filter(item => item.photo_submission); + setPhotos(validPhotos); } catch (error) { console.error('Error fetching photo submission items:', error); + setPhotos([]); // Ensure photos is empty on error } finally { setLoading(false); } diff --git a/src/hooks/useSearch.tsx b/src/hooks/useSearch.tsx index 215eb227..438ff83e 100644 --- a/src/hooks/useSearch.tsx +++ b/src/hooks/useSearch.tsx @@ -31,6 +31,7 @@ export function useSearch(options: UseSearchOptions = {}) { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const [recentSearches, setRecentSearches] = useState([]); const [debouncedQuery, setDebouncedQuery] = useState(''); @@ -83,10 +84,12 @@ export function useSearch(options: UseSearchOptions = {}) { const search = useCallback(async (searchQuery: string) => { if (searchQuery.length < minQuery) { setResults([]); + setError(null); return; } setLoading(true); + setError(null); try { const searchResults: SearchResult[] = []; @@ -177,6 +180,8 @@ export function useSearch(options: UseSearchOptions = {}) { setResults(searchResults.slice(0, limit)); } catch (error) { console.error('Search error:', error); + const errorMessage = error instanceof Error ? error.message : 'Failed to search. Please try again.'; + setError(errorMessage); setResults([]); } finally { setLoading(false); @@ -225,6 +230,7 @@ export function useSearch(options: UseSearchOptions = {}) { results, suggestions, loading, + error, recentSearches, saveSearch, clearRecentSearches, diff --git a/src/lib/imageUploadHelper.ts b/src/lib/imageUploadHelper.ts index 9f58abba..fa97775d 100644 --- a/src/lib/imageUploadHelper.ts +++ b/src/lib/imageUploadHelper.ts @@ -106,16 +106,26 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise 0) { console.error(`imageUploadHelper.uploadPendingImages: Some uploads failed. Cleaning up ${newlyUploadedImageIds.length} newly uploaded images...`); - // Attempt cleanup in parallel but don't throw if it fails - await Promise.allSettled( + // Attempt cleanup in parallel with detailed error tracking + const cleanupResults = await Promise.allSettled( newlyUploadedImageIds.map(imageId => supabase.functions.invoke('upload-image', { body: { action: 'delete', imageId } - }).catch(cleanupError => { - console.error(`imageUploadHelper.uploadPendingImages: Failed to cleanup image ${imageId}:`, cleanupError); }) ) ); + + // Track cleanup failures for better debugging + const cleanupFailures = cleanupResults.filter(r => r.status === 'rejected'); + if (cleanupFailures.length > 0) { + console.error( + `imageUploadHelper.uploadPendingImages: Failed to cleanup ${cleanupFailures.length} of ${newlyUploadedImageIds.length} images.`, + 'These images may remain orphaned in Cloudflare:', + newlyUploadedImageIds.filter((_, i) => cleanupResults[i].status === 'rejected') + ); + } else { + console.log(`imageUploadHelper.uploadPendingImages: Successfully cleaned up ${newlyUploadedImageIds.length} images.`); + } } throw new Error(`Failed to upload ${errors.length} of ${images.length} images: ${errors.join('; ')}`); diff --git a/supabase/functions/detect-location/index.ts b/supabase/functions/detect-location/index.ts index c67dc02e..68c08f8f 100644 --- a/supabase/functions/detect-location/index.ts +++ b/supabase/functions/detect-location/index.ts @@ -41,36 +41,38 @@ 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, remove entries based on LRU (oldest resetAt) - if (rateLimitMap.size >= MAX_MAP_SIZE) { - const toDelete = Math.floor(MAX_MAP_SIZE * 0.3); // Remove 30% of entries - const sortedEntries = Array.from(rateLimitMap.entries()) - .sort((a, b) => a[1].resetAt - b[1].resetAt); - - for (let i = 0; i < toDelete && i < sortedEntries.length; i++) { - rateLimitMap.delete(sortedEntries[i][0]); - } - - console.warn(`Rate limit map reached ${MAX_MAP_SIZE} entries. Cleared ${toDelete} oldest entries.`); - } + // Handle existing entries (most common case - early return for performance) + if (existing && now <= existing.resetAt) { + if (existing.count >= MAX_REQUESTS) { + const retryAfter = Math.ceil((existing.resetAt - now) / 1000); + return { allowed: false, retryAfter }; } - - // Create new entry or reset expired entry - rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }); + existing.count++; return { allowed: true }; } - if (existing.count >= MAX_REQUESTS) { - const retryAfter = Math.ceil((existing.resetAt - now) / 1000); - return { allowed: false, retryAfter }; + // Need to add new entry or reset expired one + // Only perform cleanup if we're at capacity AND adding a new IP + if (!existing && rateLimitMap.size >= MAX_MAP_SIZE) { + // First try cleaning expired entries + cleanupExpiredEntries(); + + // If still at capacity after cleanup, remove oldest entries (LRU eviction) + if (rateLimitMap.size >= MAX_MAP_SIZE) { + const toDelete = Math.floor(MAX_MAP_SIZE * 0.3); // Remove 30% of entries + const sortedEntries = Array.from(rateLimitMap.entries()) + .sort((a, b) => a[1].resetAt - b[1].resetAt); + + for (let i = 0; i < toDelete && i < sortedEntries.length; i++) { + rateLimitMap.delete(sortedEntries[i][0]); + } + + console.warn(`Rate limit map reached ${MAX_MAP_SIZE} entries. Cleared ${toDelete} oldest entries.`); + } } - - existing.count++; + + // Create new entry or reset expired entry + rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }); return { allowed: true }; } diff --git a/supabase/functions/seed-test-data/index.ts b/supabase/functions/seed-test-data/index.ts index 8864e549..77db4572 100644 --- a/supabase/functions/seed-test-data/index.ts +++ b/supabase/functions/seed-test-data/index.ts @@ -66,9 +66,17 @@ Deno.serve(async (req) => { }); } - const { data: isMod } = await supabase.rpc('is_moderator', { _user_id: user.id }); + const { data: isMod, error: modError } = await supabase.rpc('is_moderator', { _user_id: user.id }); + if (modError) { + console.error('Failed to check moderator status:', modError); + return new Response(JSON.stringify({ error: 'Failed to verify permissions' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + }); + } + if (!isMod) { - return new Response(JSON.stringify({ error: 'Must be moderator' }), { + return new Response(JSON.stringify({ error: 'Insufficient permissions. Moderator role required.' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); @@ -94,6 +102,11 @@ Deno.serve(async (req) => { // Helper to create submission async function createSubmission(userId: string, type: string, itemData: any, options: { escalated?: boolean; expiredLock?: boolean } = {}) { + // Ensure crypto.randomUUID is available + if (typeof crypto === 'undefined' || typeof crypto.randomUUID !== 'function') { + throw new Error('crypto.randomUUID is not available in this environment'); + } + const submissionId = crypto.randomUUID(); const itemId = crypto.randomUUID();