mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 07:11:13 -05:00
feat: Add idempotency to useModerationActions
Implement idempotency integration in the useModerationActions hook as per the detailed plan.
This commit is contained in:
@@ -6,6 +6,12 @@ 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
|
||||
} from '@/lib/idempotencyHelpers';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import type { ModerationItem } from '@/types/moderation';
|
||||
|
||||
@@ -42,6 +48,105 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/**
|
||||
* Invoke edge function with idempotency key and 409 retry logic
|
||||
*
|
||||
* Wraps invokeWithTracking with:
|
||||
* - Automatic idempotency key generation
|
||||
* - Special handling for 409 Conflict responses
|
||||
* - Exponential backoff retry for conflicts
|
||||
*
|
||||
* @param functionName - Edge function to invoke
|
||||
* @param payload - Request payload
|
||||
* @param idempotencyKey - Pre-generated idempotency key
|
||||
* @param userId - User ID for tracking
|
||||
* @param maxConflictRetries - Max retries for 409 responses (default: 3)
|
||||
* @returns Result with data, error, requestId, etc.
|
||||
*/
|
||||
async function invokeWithIdempotency<T = any>(
|
||||
functionName: string,
|
||||
payload: any,
|
||||
idempotencyKey: string,
|
||||
userId?: string,
|
||||
maxConflictRetries: number = 3,
|
||||
timeout: number = 30000
|
||||
): Promise<{
|
||||
data: T | null;
|
||||
error: any;
|
||||
requestId: string;
|
||||
duration: number;
|
||||
attempts?: number;
|
||||
cached?: boolean;
|
||||
conflictRetries?: number;
|
||||
}> {
|
||||
let conflictRetries = 0;
|
||||
let lastError: any = null;
|
||||
|
||||
while (conflictRetries <= maxConflictRetries) {
|
||||
const result = await invokeWithTracking<T>(
|
||||
functionName,
|
||||
payload,
|
||||
userId,
|
||||
undefined,
|
||||
undefined,
|
||||
timeout,
|
||||
{ maxAttempts: 3, baseDelay: 1500 }, // Standard retry for transient errors
|
||||
{ 'X-Idempotency-Key': idempotencyKey } // NEW: Custom header
|
||||
);
|
||||
|
||||
// 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;
|
||||
|
||||
return {
|
||||
...result,
|
||||
cached: isCached,
|
||||
conflictRetries,
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
|
||||
retryAfterSeconds,
|
||||
});
|
||||
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
return {
|
||||
data: null,
|
||||
error: lastError || { message: 'Unknown conflict retry error' },
|
||||
requestId: 'conflict-retry-failed',
|
||||
duration: 0,
|
||||
attempts: 0,
|
||||
conflictRetries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform moderation action (approve/reject) with optimistic updates
|
||||
*/
|
||||
@@ -138,29 +243,70 @@ 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.
|
||||
|
||||
const { data, error, requestId, attempts } = await invokeWithTracking(
|
||||
// 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,
|
||||
requestId,
|
||||
attempts,
|
||||
cached,
|
||||
conflictRetries
|
||||
} = await invokeWithIdempotency(
|
||||
'process-selective-approval',
|
||||
{
|
||||
itemIds: submissionItems.map((i) => i.id),
|
||||
submissionId: item.id,
|
||||
},
|
||||
idempotencyKey,
|
||||
config.user?.id,
|
||||
undefined,
|
||||
undefined,
|
||||
30000, // 30s timeout
|
||||
{ maxAttempts: 3, baseDelay: 1500 } // Critical operation - retry config
|
||||
3, // Max 3 conflict retries
|
||||
30000 // 30s timeout
|
||||
);
|
||||
|
||||
// Log if retries were needed
|
||||
// Log retry attempts
|
||||
if (attempts && attempts > 1) {
|
||||
logger.log(`Approval succeeded after ${attempts} attempts for ${item.id}`);
|
||||
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) throw error;
|
||||
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: 'Submission Approved',
|
||||
description: `Successfully processed ${submissionItems.length} item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
|
||||
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') {
|
||||
@@ -267,6 +413,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
|
||||
// Enhanced error handling with reference ID and network detection
|
||||
const isNetworkError = isSupabaseConnectionError(error);
|
||||
const isConflict = is409Conflict(error); // NEW: Detect 409 conflicts
|
||||
const errorMessage = getErrorMessage(error) || `Failed to ${variables.action} content`;
|
||||
|
||||
// Check if this is a validation error from edge function
|
||||
@@ -276,8 +423,12 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
|
||||
toast({
|
||||
title: isNetworkError ? 'Connection Error' :
|
||||
isValidationError ? 'Validation Failed' : 'Action Failed',
|
||||
description: errorMessage,
|
||||
isValidationError ? 'Validation Failed' :
|
||||
isConflict ? 'Duplicate Request' : // NEW: Conflict title
|
||||
'Action Failed',
|
||||
description: isConflict
|
||||
? 'This action is already being processed. Please wait for it to complete.' // NEW: Conflict message
|
||||
: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -288,6 +439,7 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
errorId: error.errorId,
|
||||
isNetworkError,
|
||||
isValidationError,
|
||||
isConflict, // NEW: Log conflict status
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
@@ -490,24 +642,62 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
|
||||
failedItemsCount = failedItems.length;
|
||||
|
||||
const { data, error, requestId, attempts } = await invokeWithTracking(
|
||||
// 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,
|
||||
requestId,
|
||||
attempts,
|
||||
cached,
|
||||
conflictRetries
|
||||
} = await invokeWithIdempotency(
|
||||
'process-selective-approval',
|
||||
{
|
||||
itemIds: failedItems.map((i) => i.id),
|
||||
submissionId: item.id,
|
||||
},
|
||||
idempotencyKey,
|
||||
config.user?.id,
|
||||
undefined,
|
||||
undefined,
|
||||
30000,
|
||||
{ maxAttempts: 3, baseDelay: 1500 } // Retry for failed items
|
||||
3, // Max 3 conflict retries
|
||||
30000 // 30s timeout
|
||||
);
|
||||
|
||||
if (attempts && attempts > 1) {
|
||||
logger.log(`Retry succeeded after ${attempts} attempts for ${item.id}`);
|
||||
logger.log(`Retry succeeded after ${attempts} network retries`, {
|
||||
submissionId: item.id,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
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) {
|
||||
@@ -529,8 +719,10 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Items Retried',
|
||||
description: `Successfully retried ${failedItems.length} failed item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user