mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:11:17 -05:00
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:
@@ -44,6 +44,7 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [results, setResults] = useState<LocationResult[]>([]);
|
const [results, setResults] = useState<LocationResult[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null);
|
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null);
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
|
||||||
@@ -81,10 +82,12 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className
|
|||||||
const searchLocations = useCallback(async (query: string) => {
|
const searchLocations = useCallback(async (query: string) => {
|
||||||
if (!query || query.length < 3) {
|
if (!query || query.length < 3) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setSearchError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&addressdetails=1&limit=5`,
|
`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
|
// Check if response is OK and content-type is JSON
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const errorMsg = `Location search failed (${response.status}). Please try again.`;
|
||||||
console.error('OpenStreetMap API error:', response.status);
|
console.error('OpenStreetMap API error:', response.status);
|
||||||
|
setSearchError(errorMsg);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
return;
|
return;
|
||||||
@@ -105,7 +110,9 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className
|
|||||||
|
|
||||||
const contentType = response.headers.get('content-type');
|
const contentType = response.headers.get('content-type');
|
||||||
if (!contentType || !contentType.includes('application/json')) {
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
const errorMsg = 'Invalid response from location service. Please try again.';
|
||||||
console.error('Invalid response format from OpenStreetMap');
|
console.error('Invalid response format from OpenStreetMap');
|
||||||
|
setSearchError(errorMsg);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
return;
|
return;
|
||||||
@@ -114,8 +121,11 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setResults(data);
|
setResults(data);
|
||||||
setShowResults(true);
|
setShowResults(true);
|
||||||
|
setSearchError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Failed to search locations. Please check your connection.';
|
||||||
console.error('Error searching locations:', error);
|
console.error('Error searching locations:', error);
|
||||||
|
setSearchError(errorMsg);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -186,6 +196,12 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{searchError && (
|
||||||
|
<div className="text-sm text-destructive mt-1">
|
||||||
|
{searchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showResults && results.length > 0 && (
|
{showResults && results.length > 0 && (
|
||||||
<Card className="absolute z-50 w-full max-h-64 overflow-y-auto">
|
<Card className="absolute z-50 w-full max-h-64 overflow-y-auto">
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
@@ -210,6 +226,12 @@ export function LocationSearch({ onLocationSelect, initialLocationId, className
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -22,15 +22,19 @@ export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayP
|
|||||||
.from('photo_submission_items')
|
.from('photo_submission_items')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
photo_submission:photo_submissions!inner(submission_id)
|
photo_submission:photo_submissions(submission_id)
|
||||||
`)
|
`)
|
||||||
.eq('photo_submission.submission_id', submissionId)
|
.eq('photo_submission.submission_id', submissionId)
|
||||||
.order('order_index');
|
.order('order_index');
|
||||||
|
|
||||||
if (error) throw error;
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching photo submission items:', error);
|
console.error('Error fetching photo submission items:', error);
|
||||||
|
setPhotos([]); // Ensure photos is empty on error
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function useSearch(options: UseSearchOptions = {}) {
|
|||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
|
|
||||||
@@ -83,10 +84,12 @@ export function useSearch(options: UseSearchOptions = {}) {
|
|||||||
const search = useCallback(async (searchQuery: string) => {
|
const search = useCallback(async (searchQuery: string) => {
|
||||||
if (searchQuery.length < minQuery) {
|
if (searchQuery.length < minQuery) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const searchResults: SearchResult[] = [];
|
const searchResults: SearchResult[] = [];
|
||||||
|
|
||||||
@@ -177,6 +180,8 @@ export function useSearch(options: UseSearchOptions = {}) {
|
|||||||
setResults(searchResults.slice(0, limit));
|
setResults(searchResults.slice(0, limit));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error);
|
console.error('Search error:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to search. Please try again.';
|
||||||
|
setError(errorMessage);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -225,6 +230,7 @@ export function useSearch(options: UseSearchOptions = {}) {
|
|||||||
results,
|
results,
|
||||||
suggestions,
|
suggestions,
|
||||||
loading,
|
loading,
|
||||||
|
error,
|
||||||
recentSearches,
|
recentSearches,
|
||||||
saveSearch,
|
saveSearch,
|
||||||
clearRecentSearches,
|
clearRecentSearches,
|
||||||
|
|||||||
@@ -106,16 +106,26 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
|
|||||||
if (newlyUploadedImageIds.length > 0) {
|
if (newlyUploadedImageIds.length > 0) {
|
||||||
console.error(`imageUploadHelper.uploadPendingImages: Some uploads failed. Cleaning up ${newlyUploadedImageIds.length} newly uploaded images...`);
|
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
|
// Attempt cleanup in parallel with detailed error tracking
|
||||||
await Promise.allSettled(
|
const cleanupResults = await Promise.allSettled(
|
||||||
newlyUploadedImageIds.map(imageId =>
|
newlyUploadedImageIds.map(imageId =>
|
||||||
supabase.functions.invoke('upload-image', {
|
supabase.functions.invoke('upload-image', {
|
||||||
body: { action: 'delete', imageId }
|
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('; ')}`);
|
throw new Error(`Failed to upload ${errors.length} of ${images.length} images: ${errors.join('; ')}`);
|
||||||
|
|||||||
@@ -41,36 +41,38 @@ function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const existing = rateLimitMap.get(ip);
|
const existing = rateLimitMap.get(ip);
|
||||||
|
|
||||||
if (!existing || now > existing.resetAt) {
|
// Handle existing entries (most common case - early return for performance)
|
||||||
// If map is too large, clean up expired entries first
|
if (existing && now <= existing.resetAt) {
|
||||||
if (rateLimitMap.size >= MAX_MAP_SIZE) {
|
if (existing.count >= MAX_REQUESTS) {
|
||||||
cleanupExpiredEntries();
|
const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
|
||||||
|
return { allowed: false, retryAfter };
|
||||||
// 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.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
existing.count++;
|
||||||
// Create new entry or reset expired entry
|
|
||||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
|
||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.count >= MAX_REQUESTS) {
|
// Need to add new entry or reset expired one
|
||||||
const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
|
// Only perform cleanup if we're at capacity AND adding a new IP
|
||||||
return { allowed: false, retryAfter };
|
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 };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
if (!isMod) {
|
||||||
return new Response(JSON.stringify({ error: 'Must be moderator' }), {
|
return new Response(JSON.stringify({ error: 'Insufficient permissions. Moderator role required.' }), {
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
@@ -94,6 +102,11 @@ Deno.serve(async (req) => {
|
|||||||
|
|
||||||
// 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 } = {}) {
|
||||||
|
// 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 submissionId = crypto.randomUUID();
|
||||||
const itemId = crypto.randomUUID();
|
const itemId = crypto.randomUUID();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user