diff --git a/src/components/moderation/renderers/QueueItemActions.tsx b/src/components/moderation/renderers/QueueItemActions.tsx index 1145865e..4de22fad 100644 --- a/src/components/moderation/renderers/QueueItemActions.tsx +++ b/src/components/moderation/renderers/QueueItemActions.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback } from 'react'; +import { memo, useCallback, useState } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { AlertCircle, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar, Crown, Unlock @@ -14,6 +14,7 @@ import { UserAvatar } from '@/components/ui/user-avatar'; import { format } from 'date-fns'; import type { ModerationItem } from '@/types/moderation'; import { sanitizeURL, sanitizePlainText } from '@/lib/sanitize'; +import { getErrorMessage } from '@/lib/errorHandler'; interface QueueItemActionsProps { item: ModerationItem; @@ -64,30 +65,50 @@ export const QueueItemActions = memo(({ onClaim, onSuperuserReleaseLock }: QueueItemActionsProps) => { + // Error state for retry functionality + const [actionError, setActionError] = useState<{ + message: string; + errorId?: string; + action: 'approve' | 'reject'; + } | null>(null); + // Memoize all handlers to prevent re-renders const handleNoteChange = useCallback((e: React.ChangeEvent) => { onNoteChange(item.id, e.target.value); }, [onNoteChange, item.id]); - // Debounced handlers to prevent duplicate submissions + // Debounced handlers with error tracking const handleApprove = useDebouncedCallback( - () => { - // Extra guard against race conditions - if (actionLoading === item.id) { - return; + async () => { + if (actionLoading === item.id) return; + try { + setActionError(null); + await onApprove(item, 'approved', notes[item.id]); + } catch (error: any) { + setActionError({ + message: getErrorMessage(error), + errorId: error.errorId, + action: 'approve', + }); } - onApprove(item, 'approved', notes[item.id]); }, - 300, // 300ms debounce - { leading: true, trailing: false } // Only fire on first click + 300, + { leading: true, trailing: false } ); const handleReject = useDebouncedCallback( - () => { - if (actionLoading === item.id) { - return; + async () => { + if (actionLoading === item.id) return; + try { + setActionError(null); + await onApprove(item, 'rejected', notes[item.id]); + } catch (error: any) { + setActionError({ + message: getErrorMessage(error), + errorId: error.errorId, + action: 'reject', + }); } - onApprove(item, 'rejected', notes[item.id]); }, 300, { leading: true, trailing: false } @@ -149,6 +170,40 @@ export const QueueItemActions = memo(({ return ( <> + {/* Error Display with Retry */} + {actionError && ( + + + Action Failed: {actionError.action} + +
+

{actionError.message}

+ {actionError.errorId && ( +

+ Reference ID: {actionError.errorId.slice(0, 8)} +

+ )} +
+ + +
+
+
+
+ )} + {/* Action buttons based on status */} {(item.status === 'pending' || item.status === 'flagged') && ( <> diff --git a/src/hooks/moderation/useModerationActions.ts b/src/hooks/moderation/useModerationActions.ts index d3852566..728b2dd9 100644 --- a/src/hooks/moderation/useModerationActions.ts +++ b/src/hooks/moderation/useModerationActions.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { supabase } from '@/lib/supabaseClient'; import { useToast } from '@/hooks/use-toast'; import { logger } from '@/lib/logger'; -import { getErrorMessage } from '@/lib/errorHandler'; +import { getErrorMessage, handleError, isSupabaseConnectionError } from '@/lib/errorHandler'; import { validateMultipleItems } from '@/lib/entityValidationSchemas'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import type { User } from '@supabase/supabase-js'; @@ -27,6 +27,7 @@ export interface ModerationActions { deleteSubmission: (item: ModerationItem) => Promise; resetToPending: (item: ModerationItem) => Promise; retryFailedItems: (item: ModerationItem) => Promise; + escalateSubmission: (item: ModerationItem, reason: string) => Promise; } /** @@ -321,18 +322,29 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio return { previousData }; }, - onError: (error, variables, context) => { - // Rollback on error + onError: (error: any, variables, context) => { + // Rollback optimistic update if (context?.previousData) { queryClient.setQueryData(['moderation-queue'], context.previousData); } + + // Enhanced error handling with reference ID and network detection + const isNetworkError = isSupabaseConnectionError(error); + const errorMessage = getErrorMessage(error) || `Failed to ${variables.action} content`; - // Error already logged by mutation, just show toast toast({ - title: 'Action Failed', - description: getErrorMessage(error) || `Failed to ${variables.action} content`, + title: isNetworkError ? 'Connection Error' : 'Action Failed', + description: errorMessage, variant: 'destructive', }); + + logger.error('Moderation action failed', { + itemId: variables.item.id, + action: variables.action, + error: errorMessage, + errorId: error.errorId, + isNetworkError, + }); }, onSuccess: (data) => { if (data) { @@ -350,14 +362,34 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio }); /** - * Wrapper for performAction mutation to maintain API compatibility + * Wrapper function that handles loading states and error tracking */ const performAction = useCallback( async (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => { onActionStart(item.id); - await performActionMutation.mutateAsync({ item, action, moderatorNotes }); + try { + await performActionMutation.mutateAsync({ item, action, moderatorNotes }); + } catch (error) { + const errorId = handleError(error, { + action: `Moderation ${action}`, + userId: user?.id, + metadata: { + submissionId: item.id, + submissionType: item.submission_type, + itemType: item.type, + hasSubmissionItems: item.submission_items?.length ?? 0, + moderatorNotes: moderatorNotes?.substring(0, 100), + }, + }); + + // Attach error ID for UI display + const enhancedError = error instanceof Error + ? Object.assign(error, { errorId }) + : { message: getErrorMessage(error), errorId }; + throw enhancedError; + } }, - [onActionStart, performActionMutation] + [onActionStart, performActionMutation, user] ); /** @@ -406,13 +438,23 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio logger.log(`✅ Submission ${item.id} deleted`); } catch (error: unknown) { - // Error already handled, just show toast - toast({ - title: 'Error', - description: getErrorMessage(error), - variant: 'destructive', + const errorId = handleError(error, { + action: 'Delete Submission', + userId: user?.id, + metadata: { + submissionId: item.id, + submissionType: item.submission_type, + }, }); - throw error; + + logger.error('Failed to delete submission', { + submissionId: item.id, + errorId, + }); + const enhancedError = error instanceof Error + ? Object.assign(error, { errorId }) + : { message: getErrorMessage(error), errorId }; + throw enhancedError; } finally { onActionComplete(); } @@ -455,12 +497,23 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio logger.log(`✅ Submission ${item.id} reset to pending`); } catch (error: unknown) { - // Error already handled, just show toast - toast({ - title: 'Reset Failed', - description: getErrorMessage(error), - variant: 'destructive', + const errorId = handleError(error, { + action: 'Reset to Pending', + userId: user?.id, + metadata: { + submissionId: item.id, + submissionType: item.submission_type, + }, }); + + logger.error('Failed to reset status', { + submissionId: item.id, + errorId, + }); + const enhancedError = error instanceof Error + ? Object.assign(error, { errorId }) + : { message: getErrorMessage(error), errorId }; + throw enhancedError; } finally { onActionComplete(); } @@ -474,6 +527,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio const retryFailedItems = useCallback( async (item: ModerationItem) => { onActionStart(item.id); + let failedItemsCount = 0; try { const { data: failedItems } = await supabase @@ -490,6 +544,8 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio return; } + failedItemsCount = failedItems.length; + const { data, error, requestId } = await invokeWithTracking( 'process-selective-approval', { @@ -527,17 +583,112 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio logger.log(`✅ Retried ${failedItems.length} failed items for ${item.id}`); } catch (error: unknown) { - // Error already handled, just show toast - toast({ - title: 'Retry Failed', - description: getErrorMessage(error) || 'Failed to retry items', - variant: 'destructive', + const errorId = handleError(error, { + action: 'Retry Failed Items', + userId: user?.id, + metadata: { + submissionId: item.id, + failedItemsCount, + }, }); + + logger.error('Failed to retry items', { + submissionId: item.id, + errorId, + }); + const enhancedError = error instanceof Error + ? Object.assign(error, { errorId }) + : { message: getErrorMessage(error), errorId }; + throw enhancedError; } finally { onActionComplete(); } }, - [toast, onActionStart, onActionComplete] + [toast, onActionStart, onActionComplete, user] + ); + + /** + * Escalate submission for admin review + * Consolidates escalation logic with comprehensive error handling + */ + const escalateSubmission = useCallback( + async (item: ModerationItem, reason: string) => { + if (!user?.id) { + toast({ + title: 'Authentication Required', + description: 'You must be logged in to escalate submissions', + variant: 'destructive', + }); + return; + } + + onActionStart(item.id); + + try { + // Call edge function for email notification + const { error: edgeFunctionError, requestId } = await invokeWithTracking( + 'send-escalation-notification', + { + submissionId: item.id, + escalationReason: reason, + escalatedBy: user.id, + }, + user.id + ); + + if (edgeFunctionError) { + // Edge function failed - log and show fallback toast + handleError(edgeFunctionError, { + action: 'Send escalation notification', + userId: user.id, + metadata: { + submissionId: item.id, + reason: reason.substring(0, 100), + fallbackUsed: true, + }, + }); + + toast({ + title: 'Escalated (Email Failed)', + description: 'Submission escalated but notification email could not be sent', + }); + } else { + toast({ + title: 'Escalated Successfully', + description: `Submission escalated and admin notified${requestId ? ` (${requestId.substring(0, 8)})` : ''}`, + }); + } + + // Invalidate cache + queryClient.invalidateQueries({ queryKey: ['moderation-queue'] }); + + logger.log(`✅ Submission ${item.id} escalated`); + } catch (error: unknown) { + const errorId = handleError(error, { + action: 'Escalate Submission', + userId: user.id, + metadata: { + submissionId: item.id, + submissionType: item.submission_type, + reason: reason.substring(0, 100), + }, + }); + + logger.error('Escalation failed', { + submissionId: item.id, + errorId, + }); + + // Re-throw to allow UI to show retry option + const enhancedError = error instanceof Error + ? Object.assign(error, { errorId }) + : { message: getErrorMessage(error), errorId }; + throw enhancedError; + } finally { + onActionComplete(); + } + }, + [user, toast, onActionStart, onActionComplete, queryClient] ); return { @@ -545,5 +696,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio deleteSubmission, resetToPending, retryFailedItems, + escalateSubmission, }; } diff --git a/src/lib/errorHandler.ts b/src/lib/errorHandler.ts index 4fe6f801..e1b9b563 100644 --- a/src/lib/errorHandler.ts +++ b/src/lib/errorHandler.ts @@ -25,7 +25,7 @@ export class AppError extends Error { /** * Check if error is a Supabase connection/API error */ -function isSupabaseConnectionError(error: unknown): boolean { +export function isSupabaseConnectionError(error: unknown): boolean { if (error && typeof error === 'object') { const supabaseError = error as { code?: string; status?: number; message?: string };