Compare commits

...

2 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
e4bcad9680 Add transaction status indicators to moderation UI
Implement visual indicators in the moderation queue and review manager to display the status of ongoing transactions. This includes states for processing, timeout, and cached results, providing users with clearer feedback on the system's activity.
2025-11-07 16:07:48 +00:00
gpt-engineer-app[bot]
b917232220 Refactor useModerationActions for resilience
Integrate transaction resilience features into the `useModerationActions` hook by refactoring the `invokeWithIdempotency` function. This change ensures that all moderation paths, including approvals, rejections, and retries, benefit from timeout detection, automatic lock release, and robust idempotency key management. The `invokeWithIdempotency` function has been replaced with a new `invokeWithResilience` function that incorporates these enhancements.
2025-11-07 15:53:54 +00:00
6 changed files with 469 additions and 110 deletions

View File

@@ -76,6 +76,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
// UI-only state
const [notes, setNotes] = useState<Record<string, string>>({});
const [transactionStatuses, setTransactionStatuses] = useState<Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>>({});
const [photoModalOpen, setPhotoModalOpen] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<PhotoItem[]>([]);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
@@ -196,6 +197,50 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
setNotes(prev => ({ ...prev, [id]: value }));
};
// Transaction status helpers
const setTransactionStatus = useCallback((submissionId: string, status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed', message?: string) => {
setTransactionStatuses(prev => ({
...prev,
[submissionId]: { status, message }
}));
// Auto-clear completed/failed statuses after 5 seconds
if (status === 'completed' || status === 'failed') {
setTimeout(() => {
setTransactionStatuses(prev => {
const updated = { ...prev };
if (updated[submissionId]?.status === status) {
updated[submissionId] = { status: 'idle' };
}
return updated;
});
}, 5000);
}
}, []);
// Wrap performAction to track transaction status
const handlePerformAction = useCallback(async (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => {
setTransactionStatus(item.id, 'processing');
try {
await queueManager.performAction(item, action, notes);
setTransactionStatus(item.id, 'completed');
} catch (error: any) {
// Check for timeout
if (error?.type === 'timeout' || error?.message?.toLowerCase().includes('timeout')) {
setTransactionStatus(item.id, 'timeout', error.message);
}
// Check for cached/409
else if (error?.status === 409 || error?.message?.toLowerCase().includes('duplicate')) {
setTransactionStatus(item.id, 'cached', 'Using cached result from duplicate request');
}
// Generic failure
else {
setTransactionStatus(item.id, 'failed', error.message);
}
throw error; // Re-throw to allow normal error handling
}
}, [queueManager, setTransactionStatus]);
// Wrapped delete with confirmation
const handleDeleteSubmission = useCallback((item: ModerationItem) => {
setConfirmDialog({
@@ -495,8 +540,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
isAdmin={isAdmin()}
isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange}
onApprove={queueManager.performAction}
onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos}
@@ -557,8 +603,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
isAdmin={isAdmin()}
isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange}
onApprove={queueManager.performAction}
onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos}

View File

@@ -37,6 +37,7 @@ interface QueueItemProps {
isSuperuser: boolean;
queueIsLoading: boolean;
isInitialRender?: boolean;
transactionStatuses?: Record<string, { status: 'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'; message?: string }>;
onNoteChange: (id: string, value: string) => void;
onApprove: (item: ModerationItem, action: 'approved' | 'rejected', notes?: string) => void;
onResetToPending: (item: ModerationItem) => void;
@@ -65,6 +66,7 @@ export const QueueItem = memo(({
isSuperuser,
queueIsLoading,
isInitialRender = false,
transactionStatuses,
onNoteChange,
onApprove,
onResetToPending,
@@ -82,6 +84,11 @@ export const QueueItem = memo(({
const [isClaiming, setIsClaiming] = useState(false);
const [showRawData, setShowRawData] = useState(false);
// Get transaction status from props or default to idle
const transactionState = transactionStatuses?.[item.id] || { status: 'idle' as const };
const transactionStatus = transactionState.status;
const transactionMessage = transactionState.message;
// Fetch relational photo data for photo submissions
const { photos: photoItems, loading: photosLoading } = usePhotoSubmissionItems(
item.submission_type === 'photo' ? item.id : undefined
@@ -145,6 +152,8 @@ export const QueueItem = memo(({
isLockedByOther={isLockedByOther}
currentLockSubmissionId={currentLockSubmissionId}
validationResult={validationResult}
transactionStatus={transactionStatus}
transactionMessage={transactionMessage}
onValidationChange={handleValidationChange}
onViewRawData={() => setShowRawData(true)}
/>

View File

@@ -39,6 +39,7 @@ import { ValidationBlockerDialog } from './ValidationBlockerDialog';
import { WarningConfirmDialog } from './WarningConfirmDialog';
import { ConflictResolutionModal } from './ConflictResolutionModal';
import { EditHistoryAccordion } from './EditHistoryAccordion';
import { TransactionStatusIndicator } from './TransactionStatusIndicator';
import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
import { logger } from '@/lib/logger';
import { ModerationErrorBoundary } from '@/components/error';
@@ -83,6 +84,8 @@ export function SubmissionReviewManager({
message: string;
errorId?: string;
} | null>(null);
const [transactionStatus, setTransactionStatus] = useState<'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'>('idle');
const [transactionMessage, setTransactionMessage] = useState<string | undefined>();
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
@@ -337,6 +340,7 @@ export function SubmissionReviewManager({
}
// Proceed with approval - wrapped with transaction resilience
setTransactionStatus('processing');
await executeTransaction(
'approval',
selectedIds,
@@ -400,10 +404,34 @@ export function SubmissionReviewManager({
onComplete();
onOpenChange(false);
setTransactionStatus('completed');
setTimeout(() => setTransactionStatus('idle'), 3000);
return data;
}
);
} catch (error: unknown) {
// Check for timeout
if (error && typeof error === 'object' && 'type' in error && error.type === 'timeout') {
setTransactionStatus('timeout');
setTransactionMessage(getErrorMessage(error));
}
// Check for cached/409
else if (error && typeof error === 'object' && ('status' in error && error.status === 409)) {
setTransactionStatus('cached');
setTransactionMessage('Using cached result from duplicate request');
}
// Generic failure
else {
setTransactionStatus('failed');
setTransactionMessage(getErrorMessage(error));
}
setTimeout(() => {
setTransactionStatus('idle');
setTransactionMessage(undefined);
}, 5000);
dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, {
action: 'Approve Submission Items',
@@ -467,6 +495,7 @@ export function SubmissionReviewManager({
try {
// Wrap rejection with transaction resilience
setTransactionStatus('processing');
await executeTransaction(
'rejection',
selectedIds,
@@ -484,10 +513,34 @@ export function SubmissionReviewManager({
onComplete();
onOpenChange(false);
setTransactionStatus('completed');
setTimeout(() => setTransactionStatus('idle'), 3000);
return { success: true };
}
);
} catch (error: unknown) {
// Check for timeout
if (error && typeof error === 'object' && 'type' in error && error.type === 'timeout') {
setTransactionStatus('timeout');
setTransactionMessage(getErrorMessage(error));
}
// Check for cached/409
else if (error && typeof error === 'object' && ('status' in error && error.status === 409)) {
setTransactionStatus('cached');
setTransactionMessage('Using cached result from duplicate request');
}
// Generic failure
else {
setTransactionStatus('failed');
setTransactionMessage(getErrorMessage(error));
}
setTimeout(() => {
setTransactionStatus('idle');
setTransactionMessage(undefined);
}, 5000);
dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, {
action: 'Reject Submission Items',
@@ -625,7 +678,10 @@ export function SubmissionReviewManager({
{isMobile ? (
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto">
<SheetHeader>
<SheetTitle>Review Submission</SheetTitle>
<div className="flex items-center justify-between">
<SheetTitle>Review Submission</SheetTitle>
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
</div>
<SheetDescription>
{pendingCount} pending item(s) {selectedCount} selected
</SheetDescription>
@@ -635,7 +691,10 @@ export function SubmissionReviewManager({
) : (
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Review Submission</DialogTitle>
<div className="flex items-center justify-between">
<DialogTitle>Review Submission</DialogTitle>
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
</div>
<DialogDescription>
{pendingCount} pending item(s) {selectedCount} selected
</DialogDescription>

View File

@@ -0,0 +1,109 @@
import { memo } from 'react';
import { Loader2, Clock, Database, CheckCircle2, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
export type TransactionStatus =
| 'idle'
| 'processing'
| 'timeout'
| 'cached'
| 'completed'
| 'failed';
interface TransactionStatusIndicatorProps {
status: TransactionStatus;
message?: string;
className?: string;
showLabel?: boolean;
}
export const TransactionStatusIndicator = memo(({
status,
message,
className,
showLabel = true,
}: TransactionStatusIndicatorProps) => {
if (status === 'idle') return null;
const getStatusConfig = () => {
switch (status) {
case 'processing':
return {
icon: Loader2,
label: 'Processing',
description: 'Transaction in progress...',
variant: 'secondary' as const,
className: 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950 dark:text-blue-200 dark:border-blue-800',
iconClassName: 'animate-spin',
};
case 'timeout':
return {
icon: Clock,
label: 'Timeout',
description: message || 'Transaction timed out. Lock may have been auto-released.',
variant: 'destructive' as const,
className: 'bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-950 dark:text-orange-200 dark:border-orange-800',
iconClassName: '',
};
case 'cached':
return {
icon: Database,
label: 'Cached',
description: message || 'Using cached result from duplicate request',
variant: 'outline' as const,
className: 'bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-950 dark:text-purple-200 dark:border-purple-800',
iconClassName: '',
};
case 'completed':
return {
icon: CheckCircle2,
label: 'Completed',
description: 'Transaction completed successfully',
variant: 'default' as const,
className: 'bg-green-100 text-green-800 border-green-200 dark:bg-green-950 dark:text-green-200 dark:border-green-800',
iconClassName: '',
};
case 'failed':
return {
icon: XCircle,
label: 'Failed',
description: message || 'Transaction failed',
variant: 'destructive' as const,
className: '',
iconClassName: '',
};
default:
return null;
}
};
const config = getStatusConfig();
if (!config) return null;
const Icon = config.icon;
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={config.variant}
className={cn(
'flex items-center gap-1.5 px-2 py-1',
config.className,
className
)}
>
<Icon className={cn('h-3.5 w-3.5', config.iconClassName)} />
{showLabel && <span className="text-xs font-medium">{config.label}</span>}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-sm">{config.description}</p>
</TooltipContent>
</Tooltip>
);
});
TransactionStatusIndicator.displayName = 'TransactionStatusIndicator';

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { UserAvatar } from '@/components/ui/user-avatar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ValidationSummary } from '../ValidationSummary';
import { TransactionStatusIndicator, type TransactionStatus } from '../TransactionStatusIndicator';
import { format } from 'date-fns';
import type { ModerationItem } from '@/types/moderation';
import type { ValidationResult } from '@/lib/entityValidationSchemas';
@@ -16,6 +17,8 @@ interface QueueItemHeaderProps {
isLockedByOther: boolean;
currentLockSubmissionId?: string;
validationResult: ValidationResult | null;
transactionStatus?: TransactionStatus;
transactionMessage?: string;
onValidationChange: (result: ValidationResult) => void;
onViewRawData?: () => void;
}
@@ -38,6 +41,8 @@ export const QueueItemHeader = memo(({
isLockedByOther,
currentLockSubmissionId,
validationResult,
transactionStatus = 'idle',
transactionMessage,
onValidationChange,
onViewRawData
}: QueueItemHeaderProps) => {
@@ -105,6 +110,11 @@ export const QueueItemHeader = memo(({
Claimed by You
</Badge>
)}
<TransactionStatusIndicator
status={transactionStatus}
message={transactionMessage}
showLabel={!isMobile}
/>
{item.submission_items && item.submission_items.length > 0 && item.submission_items[0].item_data && (
<ValidationSummary
item={{

View File

@@ -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<T = any>(
async function invokeWithResilience<T = any>(
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<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;
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 {
...result,
cached: isCached,
conflictRetries,
data: null,
error,
requestId: 'idempotency-validation-failed',
duration: 0,
};
}
// 409 Conflict detected
lastError = result.error;
conflictRetries++;
// Retry loop for 409 conflicts
while (conflictRetries <= maxConflictRetries) {
try {
// Execute with timeout detection
const result = await withTimeout(
async () => {
return await invokeWithTracking<T>(
functionName,
payload,
userId,
undefined,
undefined,
timeoutMs,
{ maxAttempts: 3, baseDelay: 1500 },
{ 'X-Idempotency-Key': idempotencyKey }
);
},
timeoutMs,
'edge-function'
);
if (conflictRetries > maxConflictRetries) {
// Max retries exceeded
logger.error('Max 409 conflict retries exceeded', {
functionName,
idempotencyKey: idempotencyKey.substring(0, 32) + '...',
conflictRetries,
submissionId: payload.submissionId,
});
break;
// 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;
// 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.log(`409 Conflict detected, retrying after ${retryAfterSeconds}s (attempt ${conflictRetries}/${maxConflictRetries})`, {
functionName,
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);
// All retries exhausted
return {
data: null,
error: lastError || { message: 'Unknown conflict retry error' },
requestId: 'conflict-retry-failed',
duration: 0,
attempts: 0,
conflictRetries,
};
// Mark key as failed
await markKeyFailed(idempotencyKey, errorMessage);
return {
data: null,
error,
requestId: 'error',
duration: 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