diff --git a/src/hooks/moderation/useModerationActions.ts b/src/hooks/moderation/useModerationActions.ts index a2a47033..cd8fab2b 100644 --- a/src/hooks/moderation/useModerationActions.ts +++ b/src/hooks/moderation/useModerationActions.ts @@ -10,8 +10,21 @@ import { generateIdempotencyKey, is409Conflict, getRetryAfter, - sleep + 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'; @@ -49,27 +62,31 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio const queryClient = useQueryClient(); /** - * Invoke edge function with idempotency key and 409 retry logic + * Invoke edge function with full transaction resilience * - * Wraps invokeWithTracking with: - * - Automatic idempotency key generation - * - Special handling for 409 Conflict responses - * - Exponential backoff retry for conflicts + * 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 - * @param idempotencyKey - Pre-generated idempotency key + * @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 invokeWithIdempotency( + async function invokeWithResilience( functionName: string, payload: any, - idempotencyKey: string, + action: 'approval' | 'rejection' | 'retry', + itemIds: string[], userId?: string, maxConflictRetries: number = 3, - timeout: number = 30000 + timeoutMs: number = 30000 ): Promise<{ data: T | null; error: any; @@ -79,72 +96,201 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio 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; - - while (conflictRetries <= maxConflictRetries) { - const result = await invokeWithTracking( - functionName, - payload, - userId, - undefined, - undefined, - timeout, - { maxAttempts: 3, baseDelay: 1500 }, // Standard retry for transient errors - { 'X-Idempotency-Key': idempotencyKey } // NEW: Custom header - ); + + try { + // Validate key and mark as processing + const isValid = await validateAndStartProcessing(idempotencyKey); - // Success or non-409 error - return immediately - if (!result.error || !is409Conflict(result.error)) { - // Check if response indicates cached result - const isCached = result.data && typeof result.data === 'object' && 'cached' in result.data - ? (result.data as any).cached - : false; - + if (!isValid) { + const error = new Error('Idempotency key validation failed - possible duplicate request'); + await markKeyFailed(idempotencyKey, error.message); return { - ...result, - cached: isCached, - conflictRetries, + data: null, + error, + requestId: 'idempotency-validation-failed', + duration: 0, }; } - - // 409 Conflict detected - lastError = result.error; - conflictRetries++; - - if (conflictRetries > maxConflictRetries) { - // Max retries exceeded - logger.error('Max 409 conflict retries exceeded', { - functionName, - idempotencyKey: idempotencyKey.substring(0, 32) + '...', - conflictRetries, - submissionId: payload.submissionId, - }); - break; + + // 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; + } } - - // Extract retry-after from error and wait - const retryAfterSeconds = getRetryAfter(result.error); - const retryDelayMs = retryAfterSeconds * 1000; - - logger.log(`409 Conflict detected, retrying after ${retryAfterSeconds}s (attempt ${conflictRetries}/${maxConflictRetries})`, { - functionName, + + // 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) + '...', - retryAfterSeconds, + error: errorMessage, }); - - await sleep(retryDelayMs); + + // 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, + }; } - - // All retries exhausted - return { - data: null, - error: lastError || { message: 'Unknown conflict retry error' }, - requestId: 'conflict-retry-failed', - duration: 0, - attempts: 0, - conflictRetries, - }; } /** @@ -243,20 +389,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio // 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. - // Generate idempotency key BEFORE calling edge function - const idempotencyKey = generateIdempotencyKey( - 'approval', - item.id, - submissionItems.map((i) => i.id), - config.user?.id || 'unknown' - ); - - logger.log('Generated idempotency key for approval', { - submissionId: item.id, - itemCount: submissionItems.length, - idempotencyKey: idempotencyKey.substring(0, 32) + '...', // Log partial key - }); - const { data, error, @@ -264,13 +396,14 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio attempts, cached, conflictRetries - } = await invokeWithIdempotency( + } = await invokeWithResilience( 'process-selective-approval', { itemIds: submissionItems.map((i) => i.id), submissionId: item.id, }, - idempotencyKey, + 'approval', + submissionItems.map((i) => i.id), config.user?.id, 3, // Max 3 conflict retries 30000 // 30s timeout @@ -411,9 +544,10 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio queryClient.setQueryData(['moderation-queue'], context.previousData); } - // Enhanced error handling with reference ID and network detection + // Enhanced error handling with timeout, conflict, and network detection const isNetworkError = isSupabaseConnectionError(error); - const isConflict = is409Conflict(error); // NEW: Detect 409 conflicts + 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 @@ -424,11 +558,14 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio toast({ title: isNetworkError ? 'Connection Error' : isValidationError ? 'Validation Failed' : - isConflict ? 'Duplicate Request' : // NEW: Conflict title + isConflict ? 'Duplicate Request' : + isTimeout ? 'Transaction Timeout' : 'Action Failed', - description: isConflict - ? 'This action is already being processed. Please wait for it to complete.' // NEW: Conflict message - : errorMessage, + description: isTimeout + ? getTimeoutErrorMessage(error as TimeoutError) + : isConflict + ? 'This action is already being processed. Please wait for it to complete.' + : errorMessage, variant: 'destructive', }); @@ -439,7 +576,8 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio errorId: error.errorId, isNetworkError, isValidationError, - isConflict, // NEW: Log conflict status + isConflict, + isTimeout, }); }, onSuccess: (data) => { @@ -642,20 +780,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio failedItemsCount = failedItems.length; - // Generate idempotency key for retry operation - const idempotencyKey = generateIdempotencyKey( - 'retry', - item.id, - failedItems.map((i) => i.id), - config.user?.id || 'unknown' - ); - - logger.log('Generated idempotency key for retry', { - submissionId: item.id, - itemCount: failedItems.length, - idempotencyKey: idempotencyKey.substring(0, 32) + '...', - }); - const { data, error, @@ -663,13 +787,14 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio attempts, cached, conflictRetries - } = await invokeWithIdempotency( + } = await invokeWithResilience( 'process-selective-approval', { itemIds: failedItems.map((i) => i.id), submissionId: item.id, }, - idempotencyKey, + 'retry', + failedItems.map((i) => i.id), config.user?.id, 3, // Max 3 conflict retries 30000 // 30s timeout