mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 01:47:03 -05:00
Compare commits
2 Commits
fc8631ff0b
...
e4bcad9680
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4bcad9680 | ||
|
|
b917232220 |
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
109
src/components/moderation/TransactionStatusIndicator.tsx
Normal file
109
src/components/moderation/TransactionStatusIndicator.tsx
Normal 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';
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
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<T>(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user