Improve error handling and display for searches and uploads

Enhance user feedback by displaying search errors, refine photo submission fetching, add rate limiting cleanup logic, improve image upload cleanup, and strengthen moderator permission checks.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 2741d09b-80fb-4f0a-bfd6-ababb2ac4bfc
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
This commit is contained in:
pac7
2025-10-08 19:55:55 +00:00
parent 7101632977
commit 13a4d8f64c
6 changed files with 90 additions and 33 deletions

View File

@@ -44,6 +44,7 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<LocationResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(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
)}
</div>
{searchError && (
<div className="text-sm text-destructive mt-1">
{searchError}
</div>
)}
{showResults && results.length > 0 && (
<Card className="absolute z-50 w-full max-h-64 overflow-y-auto">
<div className="divide-y">
@@ -210,6 +226,12 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className
</div>
</Card>
)}
{showResults && results.length === 0 && !isSearching && !searchError && (
<div className="text-sm text-muted-foreground mt-1">
No locations found. Try a different search term.
</div>
)}
</div>
) : (
<div className="space-y-4">

View File

@@ -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);
}

View File

@@ -31,6 +31,7 @@ export function useSearch(options: UseSearchOptions = {}) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
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,

View File

@@ -106,16 +106,26 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
if (newlyUploadedImageIds.length > 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('; ')}`);

View File

@@ -41,12 +41,23 @@ 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) {
// 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 };
}
existing.count++;
return { allowed: true };
}
// 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 too large after cleanup, remove entries based on LRU (oldest resetAt)
// 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())
@@ -65,15 +76,6 @@ function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {
return { allowed: true };
}
if (existing.count >= MAX_REQUESTS) {
const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
return { allowed: false, retryAfter };
}
existing.count++;
return { allowed: true };
}
// Clean up old entries periodically to prevent memory leak
// Run cleanup more frequently to catch expired entries sooner
setInterval(cleanupExpiredEntries, Math.min(RATE_LIMIT_WINDOW / 2, 30000)); // Every 30 seconds or half the window

View File

@@ -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();