import { useCallback } from 'react'; 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, handleError, isSupabaseConnectionError } from '@/lib/errorHandler'; // Validation removed from client - edge function is single source of truth import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { generateIdempotencyKey, is409Conflict, getRetryAfter, sleep, generateAndRegisterKey, validateAndStartProcessing, markKeyCompleted, markKeyFailed, } from '@/lib/idempotencyHelpers'; import { withTimeout, isTimeoutError, getTimeoutErrorMessage, type TimeoutError, } from '@/lib/timeoutDetection'; import { autoReleaseLockOnError, } from '@/lib/moderation/lockAutoRelease'; import type { User } from '@supabase/supabase-js'; import type { ModerationItem } from '@/types/moderation'; /** * Configuration for moderation actions */ export interface ModerationActionsConfig { user: User | null; onActionStart: (itemId: string) => void; onActionComplete: () => void; currentLockSubmissionId?: string | null; } /** * Return type for useModerationActions */ export interface ModerationActions { performAction: (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => Promise; deleteSubmission: (item: ModerationItem) => Promise; resetToPending: (item: ModerationItem) => Promise; retryFailedItems: (item: ModerationItem) => Promise; escalateSubmission: (item: ModerationItem, reason: string) => Promise; } /** * Hook for moderation action handlers * Extracted from useModerationQueueManager for better separation of concerns * * @param config - Configuration object with user, callbacks, and dependencies * @returns Object with action handler functions */ export function useModerationActions(config: ModerationActionsConfig): ModerationActions { const { user, onActionStart, onActionComplete } = config; const { toast } = useToast(); const queryClient = useQueryClient(); /** * Invoke edge function with full transaction resilience * * Provides: * - Timeout detection with automatic recovery * - Lock auto-release on error/timeout * - Idempotency key lifecycle management * - 409 Conflict handling with exponential backoff * * @param functionName - Edge function to invoke * @param payload - Request payload with submissionId * @param action - Action type for idempotency key generation * @param itemIds - Item IDs being processed * @param userId - User ID for tracking * @param maxConflictRetries - Max retries for 409 responses (default: 3) * @param timeoutMs - Timeout in milliseconds (default: 30000) * @returns Result with data, error, requestId, etc. */ async function invokeWithResilience( functionName: string, payload: any, action: 'approval' | 'rejection' | 'retry', itemIds: string[], userId?: string, maxConflictRetries: number = 3, timeoutMs: number = 30000 ): Promise<{ data: T | null; error: any; requestId: string; duration: number; attempts?: number; cached?: boolean; conflictRetries?: number; }> { if (!userId) { return { data: null, error: { message: 'User not authenticated' }, requestId: 'auth-error', duration: 0, }; } const submissionId = payload.submissionId; if (!submissionId) { return { data: null, error: { message: 'Missing submissionId in payload' }, requestId: 'validation-error', duration: 0, }; } // Generate and register idempotency key const { key: idempotencyKey } = await generateAndRegisterKey( action, submissionId, itemIds, userId ); logger.info('[ModerationResilience] Starting transaction', { action, submissionId, itemIds, idempotencyKey: idempotencyKey.substring(0, 32) + '...', }); let conflictRetries = 0; let lastError: any = null; try { // Validate key and mark as processing const isValid = await validateAndStartProcessing(idempotencyKey); if (!isValid) { const error = new Error('Idempotency key validation failed - possible duplicate request'); await markKeyFailed(idempotencyKey, error.message); return { data: null, error, requestId: 'idempotency-validation-failed', duration: 0, }; } // Retry loop for 409 conflicts while (conflictRetries <= maxConflictRetries) { try { // Execute with timeout detection const result = await withTimeout( async () => { return await invokeWithTracking( functionName, payload, userId, undefined, undefined, timeoutMs, { maxAttempts: 3, baseDelay: 1500 }, { 'X-Idempotency-Key': idempotencyKey } ); }, timeoutMs, 'edge-function' ); // Success or non-409 error if (!result.error || !is409Conflict(result.error)) { const isCached = result.data && typeof result.data === 'object' && 'cached' in result.data ? (result.data as any).cached : false; // Mark key as completed on success if (!result.error) { await markKeyCompleted(idempotencyKey); } else { await markKeyFailed(idempotencyKey, getErrorMessage(result.error)); } logger.info('[ModerationResilience] Transaction completed', { action, submissionId, idempotencyKey: idempotencyKey.substring(0, 32) + '...', success: !result.error, cached: isCached, conflictRetries, }); return { ...result, cached: isCached, conflictRetries, }; } // 409 Conflict detected lastError = result.error; conflictRetries++; if (conflictRetries > maxConflictRetries) { logger.error('Max 409 conflict retries exceeded', { functionName, idempotencyKey: idempotencyKey.substring(0, 32) + '...', conflictRetries, submissionId, }); break; } // Wait before retry const retryAfterSeconds = getRetryAfter(result.error); const retryDelayMs = retryAfterSeconds * 1000; logger.log(`409 Conflict detected, retrying after ${retryAfterSeconds}s (attempt ${conflictRetries}/${maxConflictRetries})`, { functionName, idempotencyKey: idempotencyKey.substring(0, 32) + '...', retryAfterSeconds, }); await sleep(retryDelayMs); } catch (innerError) { // Handle timeout errors specifically if (isTimeoutError(innerError)) { const timeoutError = innerError as TimeoutError; const message = getTimeoutErrorMessage(timeoutError); logger.error('[ModerationResilience] Transaction timed out', { action, submissionId, idempotencyKey: idempotencyKey.substring(0, 32) + '...', duration: timeoutError.duration, }); // Auto-release lock on timeout await autoReleaseLockOnError(submissionId, userId, timeoutError); // Mark key as failed await markKeyFailed(idempotencyKey, message); return { data: null, error: timeoutError, requestId: 'timeout-error', duration: timeoutError.duration || 0, conflictRetries, }; } // Re-throw non-timeout errors to outer catch throw innerError; } } // All conflict retries exhausted await markKeyFailed(idempotencyKey, 'Max 409 conflict retries exceeded'); return { data: null, error: lastError || { message: 'Unknown conflict retry error' }, requestId: 'conflict-retry-failed', duration: 0, attempts: 0, conflictRetries, }; } catch (error) { // Generic error handling const errorMessage = getErrorMessage(error); logger.error('[ModerationResilience] Transaction failed', { action, submissionId, idempotencyKey: idempotencyKey.substring(0, 32) + '...', error: errorMessage, }); // Auto-release lock on error await autoReleaseLockOnError(submissionId, userId, error); // Mark key as failed await markKeyFailed(idempotencyKey, errorMessage); return { data: null, error, requestId: 'error', duration: 0, conflictRetries, }; } } /** * Perform moderation action (approve/reject) with optimistic updates */ const performActionMutation = useMutation({ mutationFn: async ({ item, action, moderatorNotes }: { item: ModerationItem; action: 'approved' | 'rejected'; moderatorNotes?: string; }) => { // Handle photo submissions if (action === 'approved' && item.submission_type === 'photo') { const { data: photoSubmission, error: fetchError } = await supabase .from('photo_submissions') .select(` *, items:photo_submission_items(*), submission:content_submissions!inner(user_id) `) .eq('submission_id', item.id) .single(); // Add explicit error handling if (fetchError) { throw new Error(`Failed to fetch photo submission: ${fetchError.message}`); } if (!photoSubmission) { throw new Error('Photo submission not found'); } // Type assertion with validation const typedPhotoSubmission = photoSubmission as { id: string; entity_id: string; entity_type: string; items: Array<{ id: string; cloudflare_image_id: string; cloudflare_image_url: string; caption?: string; title?: string; date_taken?: string; date_taken_precision?: string; order_index: number; }>; submission: { user_id: string }; }; // Validate required fields if (!typedPhotoSubmission.items || typedPhotoSubmission.items.length === 0) { throw new Error('No photo items found in submission'); } const { data: existingPhotos } = await supabase .from('photos') .select('id') .eq('submission_id', item.id); if (!existingPhotos || existingPhotos.length === 0) { const photoRecords = typedPhotoSubmission.items.map((photoItem) => ({ entity_id: typedPhotoSubmission.entity_id, entity_type: typedPhotoSubmission.entity_type, cloudflare_image_id: photoItem.cloudflare_image_id, cloudflare_image_url: photoItem.cloudflare_image_url, title: photoItem.title || null, caption: photoItem.caption || null, date_taken: photoItem.date_taken || null, order_index: photoItem.order_index, submission_id: item.id, submitted_by: typedPhotoSubmission.submission?.user_id, approved_by: user?.id, approved_at: new Date().toISOString(), })); await supabase.from('photos').insert(photoRecords); } } // Check for submission items const { data: submissionItems } = await supabase .from('submission_items') .select('id, status') .eq('submission_id', item.id) .in('status', ['pending', 'rejected']); if (submissionItems && submissionItems.length > 0) { if (action === 'approved') { // ⚠️ VALIDATION CENTRALIZED IN EDGE FUNCTION // All business logic validation happens in process-selective-approval edge function. // Client-side only performs basic UX validation (non-empty, format) in forms. // If server-side validation fails, the edge function returns detailed 400/500 errors. const { data, error, requestId, attempts, cached, conflictRetries } = await invokeWithResilience( 'process-selective-approval', { itemIds: submissionItems.map((i) => i.id), submissionId: item.id, }, 'approval', submissionItems.map((i) => i.id), config.user?.id, 3, // Max 3 conflict retries 30000 // 30s timeout ); // Log retry attempts if (attempts && attempts > 1) { logger.log(`Approval succeeded after ${attempts} network retries`, { submissionId: item.id, requestId, }); } if (conflictRetries && conflictRetries > 0) { logger.log(`Resolved 409 conflict after ${conflictRetries} retries`, { submissionId: item.id, requestId, cached: !!cached, }); } if (error) { // Enhance error with context for better UI feedback if (is409Conflict(error)) { throw new Error( 'This approval is being processed by another request. Please wait and try again if it does not complete.' ); } throw error; } toast({ title: cached ? 'Cached Result' : 'Submission Approved', description: cached ? `Returned cached result for ${submissionItems.length} item(s)` : `Successfully processed ${submissionItems.length} item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`, }); return; } else if (action === 'rejected') { await supabase .from('submission_items') .update({ status: 'rejected', rejection_reason: moderatorNotes || 'Parent submission rejected', updated_at: new Date().toISOString(), }) .eq('submission_id', item.id) .eq('status', 'pending'); } } // Standard update const table = item.type === 'review' ? 'reviews' : 'content_submissions'; const statusField = item.type === 'review' ? 'moderation_status' : 'status'; const timestampField = item.type === 'review' ? 'moderated_at' : 'reviewed_at'; const reviewerField = item.type === 'review' ? 'moderated_by' : 'reviewer_id'; const updateData: any = { [statusField]: action, [timestampField]: new Date().toISOString(), }; if (user) { updateData[reviewerField] = user.id; } if (moderatorNotes) { updateData.reviewer_notes = moderatorNotes; } const { error } = await supabase.from(table).update(updateData).eq('id', item.id); if (error) throw error; // Log audit trail for review moderation if (table === 'reviews' && user) { try { // Extract entity information from item content const entityType = item.content?.ride_id ? 'ride' : item.content?.park_id ? 'park' : 'unknown'; const entityId = item.content?.ride_id || item.content?.park_id || null; await supabase.rpc('log_admin_action', { _admin_user_id: user.id, _target_user_id: item.user_id, _action: `review_${action}`, _details: { review_id: item.id, entity_type: entityType, entity_id: entityId, moderator_notes: moderatorNotes } }); } catch (auditError) { // Silent - audit logging is non-critical } } toast({ title: `Content ${action}`, description: `The ${item.type} has been ${action}`, }); logger.log(`✅ Action ${action} completed for ${item.id}`); return { item, action }; }, onMutate: async ({ item, action }) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['moderation-queue'] }); // Snapshot previous value const previousData = queryClient.getQueryData(['moderation-queue']); // Optimistically update cache queryClient.setQueriesData({ queryKey: ['moderation-queue'] }, (old: any) => { if (!old?.submissions) return old; return { ...old, submissions: old.submissions.map((i: ModerationItem) => i.id === item.id ? { ...i, status: action, _optimistic: true, reviewed_at: new Date().toISOString(), reviewer_id: user?.id, } : i ), }; }); return { previousData }; }, onError: (error: any, variables, context) => { // Rollback optimistic update if (context?.previousData) { queryClient.setQueryData(['moderation-queue'], context.previousData); } // Enhanced error handling with timeout, conflict, and network detection const isNetworkError = isSupabaseConnectionError(error); const isConflict = is409Conflict(error); const isTimeout = isTimeoutError(error); const errorMessage = getErrorMessage(error) || `Failed to ${variables.action} content`; // Check if this is a validation error from edge function const isValidationError = errorMessage.includes('Validation failed') || errorMessage.includes('blocking errors') || errorMessage.includes('blockingErrors'); toast({ title: isNetworkError ? 'Connection Error' : isValidationError ? 'Validation Failed' : isConflict ? 'Duplicate Request' : isTimeout ? 'Transaction Timeout' : 'Action Failed', description: isTimeout ? getTimeoutErrorMessage(error as TimeoutError) : isConflict ? 'This action is already being processed. Please wait for it to complete.' : errorMessage, variant: 'destructive', }); logger.error('Moderation action failed', { itemId: variables.item.id, action: variables.action, error: errorMessage, errorId: error.errorId, isNetworkError, isValidationError, isConflict, isTimeout, }); }, onSuccess: (data) => { if (data) { toast({ title: `Content ${data.action}`, description: `The ${data.item.type} has been ${data.action}`, }); } }, onSettled: () => { // Always refetch to ensure consistency queryClient.invalidateQueries({ queryKey: ['moderation-queue'] }); onActionComplete(); }, }); /** * Wrapper function that handles loading states and error tracking */ const performAction = useCallback( async (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => { onActionStart(item.id); 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, user] ); /** * Delete a submission permanently */ const deleteSubmission = useCallback( async (item: ModerationItem) => { if (item.type !== 'content_submission') return; onActionStart(item.id); try { // Fetch submission details for audit log const { data: submission } = await supabase .from('content_submissions') .select('user_id, submission_type, status') .eq('id', item.id) .single(); const { error } = await supabase.from('content_submissions').delete().eq('id', item.id); if (error) throw error; // Log audit trail for deletion if (user && submission) { try { await supabase.rpc('log_admin_action', { _admin_user_id: user.id, _target_user_id: submission.user_id, _action: 'submission_deleted', _details: { submission_id: item.id, submission_type: submission.submission_type, status_when_deleted: submission.status } }); } catch (auditError) { // Silent - audit logging is non-critical } } toast({ title: 'Submission deleted', description: 'The submission has been permanently deleted', }); logger.log(`✅ Submission ${item.id} deleted`); } catch (error: unknown) { const errorId = handleError(error, { action: 'Delete Submission', userId: user?.id, metadata: { submissionId: item.id, submissionType: item.submission_type, }, }); 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(); } }, [toast, onActionStart, onActionComplete] ); /** * Reset submission to pending status */ const resetToPending = useCallback( async (item: ModerationItem) => { onActionStart(item.id); try { const { resetRejectedItemsToPending } = await import('@/lib/submissionItemsService'); await resetRejectedItemsToPending(item.id); // Log audit trail for reset if (user) { try { await supabase.rpc('log_admin_action', { _admin_user_id: user.id, _target_user_id: item.user_id, _action: 'submission_reset', _details: { submission_id: item.id, submission_type: item.submission_type } }); } catch (auditError) { // Silent - audit logging is non-critical } } toast({ title: 'Reset Complete', description: 'Submission and all items have been reset to pending status', }); logger.log(`✅ Submission ${item.id} reset to pending`); } catch (error: unknown) { 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(); } }, [toast, onActionStart, onActionComplete] ); /** * Retry failed items in a submission */ const retryFailedItems = useCallback( async (item: ModerationItem) => { onActionStart(item.id); let failedItemsCount = 0; try { const { data: failedItems } = await supabase .from('submission_items') .select('id') .eq('submission_id', item.id) .eq('status', 'rejected'); if (!failedItems || failedItems.length === 0) { toast({ title: 'No Failed Items', description: 'All items have been processed successfully', }); return; } failedItemsCount = failedItems.length; const { data, error, requestId, attempts, cached, conflictRetries } = await invokeWithResilience( 'process-selective-approval', { itemIds: failedItems.map((i) => i.id), submissionId: item.id, }, 'retry', failedItems.map((i) => i.id), config.user?.id, 3, // Max 3 conflict retries 30000 // 30s timeout ); if (attempts && attempts > 1) { logger.log(`Retry succeeded after ${attempts} network retries`, { submissionId: item.id, requestId, }); } if (conflictRetries && conflictRetries > 0) { logger.log(`Retry resolved 409 conflict after ${conflictRetries} retries`, { submissionId: item.id, requestId, cached: !!cached, }); } if (error) { if (is409Conflict(error)) { throw new Error( 'This retry is being processed by another request. Please wait and try again if it does not complete.' ); } throw error; } // Log audit trail for retry if (user) { try { await supabase.rpc('log_admin_action', { _admin_user_id: user.id, _target_user_id: item.user_id, _action: 'submission_retried', _details: { submission_id: item.id, submission_type: item.submission_type, items_retried: failedItems.length, request_id: requestId } }); } catch (auditError) { // Silent - audit logging is non-critical } } toast({ title: cached ? 'Cached Retry Result' : 'Items Retried', description: cached ? `Returned cached result for ${failedItems.length} item(s)` : `Successfully retried ${failedItems.length} failed item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`, }); logger.log(`✅ Retried ${failedItems.length} failed items for ${item.id}`); } catch (error: unknown) { 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, 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 with retry const { error: edgeFunctionError, requestId, attempts } = await invokeWithTracking( 'send-escalation-notification', { submissionId: item.id, escalationReason: reason, escalatedBy: user.id, }, user.id, undefined, undefined, 45000, // Longer timeout for email sending { maxAttempts: 3, baseDelay: 2000 } // Retry for email delivery ); if (attempts && attempts > 1) { logger.log(`Escalation email sent after ${attempts} attempts`); } 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 { performAction, deleteSubmission, resetToPending, retryFailedItems, escalateSubmission, }; }