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 // UI-only state
const [notes, setNotes] = useState<Record<string, string>>({}); 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 [photoModalOpen, setPhotoModalOpen] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<PhotoItem[]>([]); const [selectedPhotos, setSelectedPhotos] = useState<PhotoItem[]>([]);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(0);
@@ -196,6 +197,50 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
setNotes(prev => ({ ...prev, [id]: value })); 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 // Wrapped delete with confirmation
const handleDeleteSubmission = useCallback((item: ModerationItem) => { const handleDeleteSubmission = useCallback((item: ModerationItem) => {
setConfirmDialog({ setConfirmDialog({
@@ -495,8 +540,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
isAdmin={isAdmin()} isAdmin={isAdmin()}
isSuperuser={isSuperuser()} isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading} queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange} onNoteChange={handleNoteChange}
onApprove={queueManager.performAction} onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending} onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems} onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos} onOpenPhotos={handleOpenPhotos}
@@ -557,8 +603,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
isAdmin={isAdmin()} isAdmin={isAdmin()}
isSuperuser={isSuperuser()} isSuperuser={isSuperuser()}
queueIsLoading={queueManager.queue.isLoading} queueIsLoading={queueManager.queue.isLoading}
transactionStatuses={transactionStatuses}
onNoteChange={handleNoteChange} onNoteChange={handleNoteChange}
onApprove={queueManager.performAction} onApprove={handlePerformAction}
onResetToPending={queueManager.resetToPending} onResetToPending={queueManager.resetToPending}
onRetryFailed={queueManager.retryFailedItems} onRetryFailed={queueManager.retryFailedItems}
onOpenPhotos={handleOpenPhotos} onOpenPhotos={handleOpenPhotos}

View File

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

View File

@@ -39,6 +39,7 @@ import { ValidationBlockerDialog } from './ValidationBlockerDialog';
import { WarningConfirmDialog } from './WarningConfirmDialog'; import { WarningConfirmDialog } from './WarningConfirmDialog';
import { ConflictResolutionModal } from './ConflictResolutionModal'; import { ConflictResolutionModal } from './ConflictResolutionModal';
import { EditHistoryAccordion } from './EditHistoryAccordion'; import { EditHistoryAccordion } from './EditHistoryAccordion';
import { TransactionStatusIndicator } from './TransactionStatusIndicator';
import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas'; import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { ModerationErrorBoundary } from '@/components/error'; import { ModerationErrorBoundary } from '@/components/error';
@@ -83,6 +84,8 @@ export function SubmissionReviewManager({
message: string; message: string;
errorId?: string; errorId?: string;
} | null>(null); } | null>(null);
const [transactionStatus, setTransactionStatus] = useState<'idle' | 'processing' | 'timeout' | 'cached' | 'completed' | 'failed'>('idle');
const [transactionMessage, setTransactionMessage] = useState<string | undefined>();
const { toast } = useToast(); const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole(); const { isAdmin, isSuperuser } = useUserRole();
@@ -337,6 +340,7 @@ export function SubmissionReviewManager({
} }
// Proceed with approval - wrapped with transaction resilience // Proceed with approval - wrapped with transaction resilience
setTransactionStatus('processing');
await executeTransaction( await executeTransaction(
'approval', 'approval',
selectedIds, selectedIds,
@@ -400,10 +404,34 @@ export function SubmissionReviewManager({
onComplete(); onComplete();
onOpenChange(false); onOpenChange(false);
setTransactionStatus('completed');
setTimeout(() => setTransactionStatus('idle'), 3000);
return data; return data;
} }
); );
} catch (error: unknown) { } 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) } }); dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, { handleError(error, {
action: 'Approve Submission Items', action: 'Approve Submission Items',
@@ -467,6 +495,7 @@ export function SubmissionReviewManager({
try { try {
// Wrap rejection with transaction resilience // Wrap rejection with transaction resilience
setTransactionStatus('processing');
await executeTransaction( await executeTransaction(
'rejection', 'rejection',
selectedIds, selectedIds,
@@ -484,10 +513,34 @@ export function SubmissionReviewManager({
onComplete(); onComplete();
onOpenChange(false); onOpenChange(false);
setTransactionStatus('completed');
setTimeout(() => setTransactionStatus('idle'), 3000);
return { success: true }; return { success: true };
} }
); );
} catch (error: unknown) { } 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) } }); dispatch({ type: 'ERROR', payload: { error: getErrorMessage(error) } });
handleError(error, { handleError(error, {
action: 'Reject Submission Items', action: 'Reject Submission Items',
@@ -625,7 +678,10 @@ export function SubmissionReviewManager({
{isMobile ? ( {isMobile ? (
<SheetContent side="bottom" className="h-[90vh] overflow-y-auto"> <SheetContent side="bottom" className="h-[90vh] overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle>Review Submission</SheetTitle> <div className="flex items-center justify-between">
<SheetTitle>Review Submission</SheetTitle>
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
</div>
<SheetDescription> <SheetDescription>
{pendingCount} pending item(s) {selectedCount} selected {pendingCount} pending item(s) {selectedCount} selected
</SheetDescription> </SheetDescription>
@@ -635,7 +691,10 @@ export function SubmissionReviewManager({
) : ( ) : (
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Review Submission</DialogTitle> <div className="flex items-center justify-between">
<DialogTitle>Review Submission</DialogTitle>
<TransactionStatusIndicator status={transactionStatus} message={transactionMessage} />
</div>
<DialogDescription> <DialogDescription>
{pendingCount} pending item(s) {selectedCount} selected {pendingCount} pending item(s) {selectedCount} selected
</DialogDescription> </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 { UserAvatar } from '@/components/ui/user-avatar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ValidationSummary } from '../ValidationSummary'; import { ValidationSummary } from '../ValidationSummary';
import { TransactionStatusIndicator, type TransactionStatus } from '../TransactionStatusIndicator';
import { format } from 'date-fns'; import { format } from 'date-fns';
import type { ModerationItem } from '@/types/moderation'; import type { ModerationItem } from '@/types/moderation';
import type { ValidationResult } from '@/lib/entityValidationSchemas'; import type { ValidationResult } from '@/lib/entityValidationSchemas';
@@ -16,6 +17,8 @@ interface QueueItemHeaderProps {
isLockedByOther: boolean; isLockedByOther: boolean;
currentLockSubmissionId?: string; currentLockSubmissionId?: string;
validationResult: ValidationResult | null; validationResult: ValidationResult | null;
transactionStatus?: TransactionStatus;
transactionMessage?: string;
onValidationChange: (result: ValidationResult) => void; onValidationChange: (result: ValidationResult) => void;
onViewRawData?: () => void; onViewRawData?: () => void;
} }
@@ -38,6 +41,8 @@ export const QueueItemHeader = memo(({
isLockedByOther, isLockedByOther,
currentLockSubmissionId, currentLockSubmissionId,
validationResult, validationResult,
transactionStatus = 'idle',
transactionMessage,
onValidationChange, onValidationChange,
onViewRawData onViewRawData
}: QueueItemHeaderProps) => { }: QueueItemHeaderProps) => {
@@ -105,6 +110,11 @@ export const QueueItemHeader = memo(({
Claimed by You Claimed by You
</Badge> </Badge>
)} )}
<TransactionStatusIndicator
status={transactionStatus}
message={transactionMessage}
showLabel={!isMobile}
/>
{item.submission_items && item.submission_items.length > 0 && item.submission_items[0].item_data && ( {item.submission_items && item.submission_items.length > 0 && item.submission_items[0].item_data && (
<ValidationSummary <ValidationSummary
item={{ item={{

View File

@@ -10,8 +10,21 @@ import {
generateIdempotencyKey, generateIdempotencyKey,
is409Conflict, is409Conflict,
getRetryAfter, getRetryAfter,
sleep sleep,
generateAndRegisterKey,
validateAndStartProcessing,
markKeyCompleted,
markKeyFailed,
} from '@/lib/idempotencyHelpers'; } 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 { User } from '@supabase/supabase-js';
import type { ModerationItem } from '@/types/moderation'; import type { ModerationItem } from '@/types/moderation';
@@ -49,27 +62,31 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
const queryClient = useQueryClient(); const queryClient = useQueryClient();
/** /**
* Invoke edge function with idempotency key and 409 retry logic * Invoke edge function with full transaction resilience
* *
* Wraps invokeWithTracking with: * Provides:
* - Automatic idempotency key generation * - Timeout detection with automatic recovery
* - Special handling for 409 Conflict responses * - Lock auto-release on error/timeout
* - Exponential backoff retry for conflicts * - Idempotency key lifecycle management
* - 409 Conflict handling with exponential backoff
* *
* @param functionName - Edge function to invoke * @param functionName - Edge function to invoke
* @param payload - Request payload * @param payload - Request payload with submissionId
* @param idempotencyKey - Pre-generated idempotency key * @param action - Action type for idempotency key generation
* @param itemIds - Item IDs being processed
* @param userId - User ID for tracking * @param userId - User ID for tracking
* @param maxConflictRetries - Max retries for 409 responses (default: 3) * @param maxConflictRetries - Max retries for 409 responses (default: 3)
* @param timeoutMs - Timeout in milliseconds (default: 30000)
* @returns Result with data, error, requestId, etc. * @returns Result with data, error, requestId, etc.
*/ */
async function invokeWithIdempotency<T = any>( async function invokeWithResilience<T = any>(
functionName: string, functionName: string,
payload: any, payload: any,
idempotencyKey: string, action: 'approval' | 'rejection' | 'retry',
itemIds: string[],
userId?: string, userId?: string,
maxConflictRetries: number = 3, maxConflictRetries: number = 3,
timeout: number = 30000 timeoutMs: number = 30000
): Promise<{ ): Promise<{
data: T | null; data: T | null;
error: any; error: any;
@@ -79,72 +96,201 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
cached?: boolean; cached?: boolean;
conflictRetries?: number; 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 conflictRetries = 0;
let lastError: any = null; let lastError: any = null;
while (conflictRetries <= maxConflictRetries) { try {
const result = await invokeWithTracking<T>( // Validate key and mark as processing
functionName, const isValid = await validateAndStartProcessing(idempotencyKey);
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;
if (!isValid) {
const error = new Error('Idempotency key validation failed - possible duplicate request');
await markKeyFailed(idempotencyKey, error.message);
return { return {
...result, data: null,
cached: isCached, error,
conflictRetries, requestId: 'idempotency-validation-failed',
duration: 0,
}; };
} }
// 409 Conflict detected // Retry loop for 409 conflicts
lastError = result.error; while (conflictRetries <= maxConflictRetries) {
conflictRetries++; 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) { // Success or non-409 error
// Max retries exceeded if (!result.error || !is409Conflict(result.error)) {
logger.error('Max 409 conflict retries exceeded', { const isCached = result.data && typeof result.data === 'object' && 'cached' in result.data
functionName, ? (result.data as any).cached
idempotencyKey: idempotencyKey.substring(0, 32) + '...', : false;
conflictRetries,
submissionId: payload.submissionId, // Mark key as completed on success
}); if (!result.error) {
break; 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 // All conflict retries exhausted
const retryAfterSeconds = getRetryAfter(result.error); await markKeyFailed(idempotencyKey, 'Max 409 conflict retries exceeded');
const retryDelayMs = retryAfterSeconds * 1000; 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})`, { logger.error('[ModerationResilience] Transaction failed', {
functionName, action,
submissionId,
idempotencyKey: idempotencyKey.substring(0, 32) + '...', idempotencyKey: idempotencyKey.substring(0, 32) + '...',
retryAfterSeconds, error: errorMessage,
}); });
await sleep(retryDelayMs); // Auto-release lock on error
} await autoReleaseLockOnError(submissionId, userId, error);
// All retries exhausted // Mark key as failed
return { await markKeyFailed(idempotencyKey, errorMessage);
data: null,
error: lastError || { message: 'Unknown conflict retry error' }, return {
requestId: 'conflict-retry-failed', data: null,
duration: 0, error,
attempts: 0, requestId: 'error',
conflictRetries, 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. // 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. // 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 { const {
data, data,
error, error,
@@ -264,13 +396,14 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
attempts, attempts,
cached, cached,
conflictRetries conflictRetries
} = await invokeWithIdempotency( } = await invokeWithResilience(
'process-selective-approval', 'process-selective-approval',
{ {
itemIds: submissionItems.map((i) => i.id), itemIds: submissionItems.map((i) => i.id),
submissionId: item.id, submissionId: item.id,
}, },
idempotencyKey, 'approval',
submissionItems.map((i) => i.id),
config.user?.id, config.user?.id,
3, // Max 3 conflict retries 3, // Max 3 conflict retries
30000 // 30s timeout 30000 // 30s timeout
@@ -411,9 +544,10 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
queryClient.setQueryData(['moderation-queue'], context.previousData); 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 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`; const errorMessage = getErrorMessage(error) || `Failed to ${variables.action} content`;
// Check if this is a validation error from edge function // Check if this is a validation error from edge function
@@ -424,11 +558,14 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
toast({ toast({
title: isNetworkError ? 'Connection Error' : title: isNetworkError ? 'Connection Error' :
isValidationError ? 'Validation Failed' : isValidationError ? 'Validation Failed' :
isConflict ? 'Duplicate Request' : // NEW: Conflict title isConflict ? 'Duplicate Request' :
isTimeout ? 'Transaction Timeout' :
'Action Failed', 'Action Failed',
description: isConflict description: isTimeout
? 'This action is already being processed. Please wait for it to complete.' // NEW: Conflict message ? getTimeoutErrorMessage(error as TimeoutError)
: errorMessage, : isConflict
? 'This action is already being processed. Please wait for it to complete.'
: errorMessage,
variant: 'destructive', variant: 'destructive',
}); });
@@ -439,7 +576,8 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
errorId: error.errorId, errorId: error.errorId,
isNetworkError, isNetworkError,
isValidationError, isValidationError,
isConflict, // NEW: Log conflict status isConflict,
isTimeout,
}); });
}, },
onSuccess: (data) => { onSuccess: (data) => {
@@ -642,20 +780,6 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
failedItemsCount = failedItems.length; 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 { const {
data, data,
error, error,
@@ -663,13 +787,14 @@ export function useModerationActions(config: ModerationActionsConfig): Moderatio
attempts, attempts,
cached, cached,
conflictRetries conflictRetries
} = await invokeWithIdempotency( } = await invokeWithResilience(
'process-selective-approval', 'process-selective-approval',
{ {
itemIds: failedItems.map((i) => i.id), itemIds: failedItems.map((i) => i.id),
submissionId: item.id, submissionId: item.id,
}, },
idempotencyKey, 'retry',
failedItems.map((i) => i.id),
config.user?.id, config.user?.id,
3, // Max 3 conflict retries 3, // Max 3 conflict retries
30000 // 30s timeout 30000 // 30s timeout