Refactor: Complete error handling overhaul

This commit is contained in:
gpt-engineer-app[bot]
2025-11-02 23:19:46 +00:00
parent d057ddc8cc
commit 35c7c3e957
7 changed files with 303 additions and 26 deletions

13
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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<ModerationQueueRef, ModerationQueuePro
// Keyboard shortcuts help dialog
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
// Offline detection state
const [isOffline, setIsOffline] = useState(!navigator.onLine);
// Offline detection effect
useEffect(() => {
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<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
@@ -200,6 +229,17 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
return (
<div className="space-y-4">
{/* Offline Banner */}
{isOffline && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>No Internet Connection</AlertTitle>
<AlertDescription>
You're offline. The moderation queue will automatically sync when your connection is restored.
</AlertDescription>
</Alert>
)}
{/* Queue Statistics & Lock Status */}
{queueManager.queue.queueStats && (
<Card className="bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">

View File

@@ -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"}
>
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Approve
{actionLoading === item.id ? (
<>
<RefreshCw className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} mr-2 animate-spin`} />
{item.submission_items && item.submission_items.length > 5
? `Processing ${item.submission_items.length} items...`
: 'Processing...'
}
</>
) : (
<>
<CheckCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Approve
</>
)}
</Button>
<Button
variant="destructive"
@@ -259,8 +306,17 @@ export const QueueItemActions = memo(({
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
size={isMobile ? "default" : "default"}
>
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Reject
{actionLoading === item.id ? (
<>
<RefreshCw className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} mr-2 animate-spin`} />
Processing...
</>
) : (
<>
<XCircle className={isMobile ? "w-5 h-5 mr-2" : "w-4 h-4 mr-2"} />
Reject
</>
)}
</Button>
</div>
</div>

View File

@@ -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

View File

@@ -24,8 +24,13 @@ export async function invokeWithTracking<T = any>(
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<T = any>(
// Include client request ID in payload for correlation
const { data, error } = await supabase.functions.invoke<T>(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<T = any>(
}
);
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 },

View File

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