From 35c7c3e9571df8f89d94e8c18e9d5effb34fed82 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 23:19:46 +0000 Subject: [PATCH] Refactor: Complete error handling overhaul --- package-lock.json | 13 +++ package.json | 1 + src/components/moderation/ModerationQueue.tsx | 42 ++++++++- .../moderation/renderers/QueueItemActions.tsx | 88 +++++++++++++++---- src/hooks/moderation/useQueueQuery.ts | 81 +++++++++++++++-- src/lib/edgeFunctionTracking.ts | 25 +++++- src/lib/moderation/validation.ts | 79 +++++++++++++++++ 7 files changed, 303 insertions(+), 26 deletions(-) create mode 100644 src/lib/moderation/validation.ts diff --git a/package-lock.json b/package-lock.json index c4ed16fc..11192f21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "use-debounce": "^10.0.6", "vaul": "^0.9.9", "zod": "^4.1.11" }, @@ -12172,6 +12173,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz", + "integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", diff --git a/package.json b/package.json index f395bba1..b2329cd3 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "use-debounce": "^10.0.6", "vaul": "^0.9.9", "zod": "^4.1.11" }, diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index fa9102e1..b4a773eb 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -1,6 +1,8 @@ -import { useState, useImperativeHandle, forwardRef, useMemo, useCallback, useRef } from 'react'; +import { useState, useImperativeHandle, forwardRef, useMemo, useCallback, useRef, useEffect } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; +import { AlertCircle } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { TooltipProvider } from '@/components/ui/tooltip'; import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; @@ -95,6 +97,33 @@ export const ModerationQueue = forwardRef { + const handleOnline = () => { + setIsOffline(false); + toast({ + title: 'Connection Restored', + description: 'You are back online. Refreshing queue...', + }); + queueManager.refresh(); + }; + + const handleOffline = () => { + setIsOffline(true); + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, [queueManager, toast]); + // Virtual scrolling setup const parentRef = useRef(null); const virtualizer = useVirtualizer({ @@ -200,6 +229,17 @@ export const ModerationQueue = forwardRef + {/* Offline Banner */} + {isOffline && ( + + + No Internet Connection + + You're offline. The moderation queue will automatically sync when your connection is restored. + + + )} + {/* Queue Statistics & Lock Status */} {queueManager.queue.queueStats && ( diff --git a/src/components/moderation/renderers/QueueItemActions.tsx b/src/components/moderation/renderers/QueueItemActions.tsx index f2bfb88e..0c8a8a35 100644 --- a/src/components/moderation/renderers/QueueItemActions.tsx +++ b/src/components/moderation/renderers/QueueItemActions.tsx @@ -1,4 +1,5 @@ import { memo, useCallback } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; import { CheckCircle, XCircle, RefreshCw, AlertCircle, Lock, Trash2, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar @@ -66,13 +67,31 @@ export const QueueItemActions = memo(({ onNoteChange(item.id, e.target.value); }, [onNoteChange, item.id]); - const handleApprove = useCallback(() => { - onApprove(item, 'approved', notes[item.id]); - }, [onApprove, item, notes]); + // Debounced handlers to prevent duplicate submissions + const handleApprove = useDebouncedCallback( + () => { + // Extra guard against race conditions + if (actionLoading === item.id) { + console.warn('⚠️ Action already in progress, ignoring duplicate request'); + return; + } + onApprove(item, 'approved', notes[item.id]); + }, + 300, // 300ms debounce + { leading: true, trailing: false } // Only fire on first click + ); - const handleReject = useCallback(() => { - onApprove(item, 'rejected', notes[item.id]); - }, [onApprove, item, notes]); + const handleReject = useDebouncedCallback( + () => { + if (actionLoading === item.id) { + console.warn('⚠️ Action already in progress, ignoring duplicate request'); + return; + } + onApprove(item, 'rejected', notes[item.id]); + }, + 300, + { leading: true, trailing: false } + ); const handleResetToPending = useCallback(() => { onResetToPending(item); @@ -106,13 +125,29 @@ export const QueueItemActions = memo(({ onNoteChange(`reverse-${item.id}`, e.target.value); }, [onNoteChange, item.id]); - const handleReverseApprove = useCallback(() => { - onApprove(item, 'approved', notes[`reverse-${item.id}`]); - }, [onApprove, item, notes]); + const handleReverseApprove = useDebouncedCallback( + () => { + if (actionLoading === item.id) { + console.warn('⚠️ Action already in progress, ignoring duplicate request'); + return; + } + onApprove(item, 'approved', notes[`reverse-${item.id}`]); + }, + 300, + { leading: true, trailing: false } + ); - const handleReverseReject = useCallback(() => { - onApprove(item, 'rejected', notes[`reverse-${item.id}`]); - }, [onApprove, item, notes]); + const handleReverseReject = useDebouncedCallback( + () => { + if (actionLoading === item.id) { + console.warn('⚠️ Action already in progress, ignoring duplicate request'); + return; + } + onApprove(item, 'rejected', notes[`reverse-${item.id}`]); + }, + 300, + { leading: true, trailing: false } + ); return ( <> @@ -249,8 +284,20 @@ export const QueueItemActions = memo(({ className={`flex-1 ${isMobile ? 'h-11' : ''}`} size={isMobile ? "default" : "default"} > - - Approve + {actionLoading === item.id ? ( + <> + + {item.submission_items && item.submission_items.length > 5 + ? `Processing ${item.submission_items.length} items...` + : 'Processing...' + } + + ) : ( + <> + + Approve + + )} diff --git a/src/hooks/moderation/useQueueQuery.ts b/src/hooks/moderation/useQueueQuery.ts index 05a3b2fa..de70d386 100644 --- a/src/hooks/moderation/useQueueQuery.ts +++ b/src/hooks/moderation/useQueueQuery.ts @@ -11,6 +11,7 @@ import { supabase } from '@/integrations/supabase/client'; import { logger } from '@/lib/logger'; import { getErrorMessage } from '@/lib/errorHandler'; import { MODERATION_CONSTANTS } from '@/lib/moderation/constants'; +import { validateModerationItems } from '@/lib/moderation/validation'; import type { ModerationItem, EntityFilter, @@ -20,6 +21,49 @@ import type { SortDirection } from '@/types/moderation'; +/** + * Get specific, actionable error message based on error type + */ +function getSpecificErrorMessage(error: unknown): string { + // Offline detection + if (!navigator.onLine) { + return 'You appear to be offline. Check your internet connection and try again.'; + } + + // Timeout + if (error instanceof Error && error.name === 'AbortError') { + return 'Request timed out. The server is taking too long to respond. Please try again.'; + } + + // Check for Supabase-specific errors + if (typeof error === 'object' && error !== null) { + const err = error as any; + + // 500 errors + if (err.status === 500 || err.code === '500') { + return 'Server error occurred. Our team has been notified. Please try again in a few minutes.'; + } + + // 429 Rate limiting + if (err.status === 429 || err.message?.includes('rate limit')) { + return 'Too many requests. Please wait a moment before trying again.'; + } + + // Authentication errors + if (err.status === 401 || err.message?.includes('JWT')) { + return 'Your session has expired. Please refresh the page and sign in again.'; + } + + // Permission errors + if (err.status === 403 || err.message?.includes('permission')) { + return 'You do not have permission to access the moderation queue.'; + } + } + + // Fallback + return getErrorMessage(error) || 'Failed to load moderation queue. Please try again.'; +} + /** * Configuration for queue query */ @@ -124,21 +168,44 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn queryKey, queryFn: async () => { logger.log('🔍 [TanStack Query] Fetching queue data:', queryKey); - const result = await fetchSubmissions(supabase, queryConfig); - if (result.error) { - logger.error('❌ [TanStack Query] Error:', { error: getErrorMessage(result.error) }); - throw result.error; + // Create timeout controller (30s timeout) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const result = await fetchSubmissions(supabase, queryConfig); + clearTimeout(timeoutId); + + if (result.error) { + const specificMessage = getSpecificErrorMessage(result.error); + logger.error('❌ [TanStack Query] Error:', { error: specificMessage }); + throw new Error(specificMessage); + } + + // Validate data shape before returning + const validation = validateModerationItems(result.submissions); + if (!validation.success) { + logger.error('❌ Invalid data shape', { error: validation.error }); + throw new Error(validation.error || 'Invalid data format'); + } + + logger.log('✅ [TanStack Query] Fetched', validation.data!.length, 'items'); + return { ...result, submissions: validation.data! }; + } catch (error) { + clearTimeout(timeoutId); + throw error; } - - logger.log('✅ [TanStack Query] Fetched', result.submissions.length, 'items'); - return result; }, enabled: config.enabled !== false && !!config.userId, staleTime: MODERATION_CONSTANTS.QUERY_STALE_TIME, gcTime: MODERATION_CONSTANTS.QUERY_GC_TIME, retry: MODERATION_CONSTANTS.QUERY_RETRY_COUNT, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + networkMode: 'offlineFirst', // Handle offline gracefully + meta: { + errorMessage: 'Failed to load moderation queue', + }, }); // Invalidate helper diff --git a/src/lib/edgeFunctionTracking.ts b/src/lib/edgeFunctionTracking.ts index 22f6eda3..8779c283 100644 --- a/src/lib/edgeFunctionTracking.ts +++ b/src/lib/edgeFunctionTracking.ts @@ -24,8 +24,13 @@ export async function invokeWithTracking( payload: any = {}, userId?: string, parentRequestId?: string, - traceId?: string + traceId?: string, + timeout: number = 30000 // Default 30s timeout ): Promise<{ data: T | null; error: any; requestId: string; duration: number }> { + // Create AbortController for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { const { result, requestId, duration } = await trackRequest( { @@ -39,6 +44,7 @@ export async function invokeWithTracking( // Include client request ID in payload for correlation const { data, error } = await supabase.functions.invoke(functionName, { body: { ...payload, clientRequestId: context.requestId }, + signal: controller.signal, // Add abort signal for timeout }); if (error) throw error; @@ -46,10 +52,25 @@ export async function invokeWithTracking( } ); + clearTimeout(timeoutId); return { data: result, error: null, requestId, duration }; } catch (error: unknown) { + clearTimeout(timeoutId); + + // Handle AbortError specifically + if (error instanceof Error && error.name === 'AbortError') { + return { + data: null, + error: { + message: `Request timeout: ${functionName} took longer than ${timeout}ms to respond`, + code: 'TIMEOUT', + }, + requestId: 'timeout', + duration: timeout, + }; + } + const errorMessage = getErrorMessage(error); - // On error, we don't have tracking info, so create basic response return { data: null, error: { message: errorMessage }, diff --git a/src/lib/moderation/validation.ts b/src/lib/moderation/validation.ts new file mode 100644 index 00000000..85904018 --- /dev/null +++ b/src/lib/moderation/validation.ts @@ -0,0 +1,79 @@ +/** + * Runtime Data Validation for Moderation Queue + * + * Uses Zod to validate data shapes from the database at runtime. + * Prevents runtime errors if database schema changes unexpectedly. + */ + +import { z } from 'zod'; +import { logger } from '@/lib/logger'; + +// Profile schema +const ProfileSchema = z.object({ + username: z.string(), + display_name: z.string().optional().nullable(), + avatar_url: z.string().optional().nullable(), +}); + +// Submission item schema +const SubmissionItemSchema = z.object({ + id: z.string().uuid(), + status: z.string(), + item_type: z.string().optional(), + item_data: z.record(z.string(), z.any()).optional().nullable(), + original_data: z.record(z.string(), z.any()).optional().nullable(), + error_message: z.string().optional().nullable(), +}); + +// Main moderation item schema +export const ModerationItemSchema = z.object({ + id: z.string().uuid(), + status: z.enum(['pending', 'approved', 'rejected', 'partially_approved', 'flagged']), + type: z.string(), + submission_type: z.string(), + created_at: z.string(), + updated_at: z.string().optional().nullable(), + content: z.record(z.string(), z.any()), + submitter_id: z.string().uuid(), + assigned_to: z.string().uuid().optional().nullable(), + locked_until: z.string().optional().nullable(), + reviewed_at: z.string().optional().nullable(), + reviewed_by: z.string().uuid().optional().nullable(), + reviewer_notes: z.string().optional().nullable(), + submission_items: z.array(SubmissionItemSchema).optional(), + submitter_profile: ProfileSchema.optional().nullable(), + assigned_profile: ProfileSchema.optional().nullable(), + reviewer_profile: ProfileSchema.optional().nullable(), +}); + +export const ModerationItemArraySchema = z.array(ModerationItemSchema); + +/** + * Validate moderation items array + * + * @param data - Data to validate + * @returns Validation result with typed data or error + */ +export function validateModerationItems(data: unknown): { + success: boolean; + data?: any[]; + error?: string +} { + const result = ModerationItemArraySchema.safeParse(data); + + if (!result.success) { + logger.error('❌ Data validation failed', { + errors: result.error.issues.slice(0, 5) // Log first 5 issues + }); + + return { + success: false, + error: 'Received invalid data format from server. Please refresh the page.', + }; + } + + return { + success: true, + data: result.data, + }; +}