mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 00:07:16 -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
|
// 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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 { 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={{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user