mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 15:11:13 -05:00
979 lines
31 KiB
TypeScript
979 lines
31 KiB
TypeScript
import { useCallback } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { supabase } from '@/lib/supabaseClient';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import { logger } from '@/lib/logger';
|
|
import { getErrorMessage, handleError, isSupabaseConnectionError } from '@/lib/errorHandler';
|
|
// Validation removed from client - edge function is single source of truth
|
|
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
|
import {
|
|
generateIdempotencyKey,
|
|
is409Conflict,
|
|
getRetryAfter,
|
|
sleep,
|
|
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';
|
|
|
|
/**
|
|
* Configuration for moderation actions
|
|
*/
|
|
export interface ModerationActionsConfig {
|
|
user: User | null;
|
|
onActionStart: (itemId: string) => void;
|
|
onActionComplete: () => void;
|
|
currentLockSubmissionId?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Return type for useModerationActions
|
|
*/
|
|
export interface ModerationActions {
|
|
performAction: (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => Promise<void>;
|
|
deleteSubmission: (item: ModerationItem) => Promise<void>;
|
|
resetToPending: (item: ModerationItem) => Promise<void>;
|
|
retryFailedItems: (item: ModerationItem) => Promise<void>;
|
|
escalateSubmission: (item: ModerationItem, reason: string) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Hook for moderation action handlers
|
|
* Extracted from useModerationQueueManager for better separation of concerns
|
|
*
|
|
* @param config - Configuration object with user, callbacks, and dependencies
|
|
* @returns Object with action handler functions
|
|
*/
|
|
export function useModerationActions(config: ModerationActionsConfig): ModerationActions {
|
|
const { user, onActionStart, onActionComplete } = config;
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
/**
|
|
* Invoke edge function with full transaction resilience
|
|
*
|
|
* 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 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 invokeWithResilience<T = any>(
|
|
functionName: string,
|
|
payload: any,
|
|
action: 'approval' | 'rejection' | 'retry',
|
|
itemIds: string[],
|
|
userId?: string,
|
|
maxConflictRetries: number = 3,
|
|
timeoutMs: number = 30000
|
|
): Promise<{
|
|
data: T | null;
|
|
error: any;
|
|
requestId: string;
|
|
duration: number;
|
|
attempts?: number;
|
|
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;
|
|
|
|
try {
|
|
// Validate key and mark as processing
|
|
const isValid = await validateAndStartProcessing(idempotencyKey);
|
|
|
|
if (!isValid) {
|
|
const error = new Error('Idempotency key validation failed - possible duplicate request');
|
|
await markKeyFailed(idempotencyKey, error.message);
|
|
return {
|
|
data: null,
|
|
error,
|
|
requestId: 'idempotency-validation-failed',
|
|
duration: 0,
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// 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) + '...',
|
|
error: errorMessage,
|
|
});
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform moderation action (approve/reject) with optimistic updates
|
|
*/
|
|
const performActionMutation = useMutation({
|
|
mutationFn: async ({
|
|
item,
|
|
action,
|
|
moderatorNotes
|
|
}: {
|
|
item: ModerationItem;
|
|
action: 'approved' | 'rejected';
|
|
moderatorNotes?: string;
|
|
}) => {
|
|
// Handle photo submissions
|
|
if (action === 'approved' && item.submission_type === 'photo') {
|
|
const { data: photoSubmission, error: fetchError } = await supabase
|
|
.from('photo_submissions')
|
|
.select(`
|
|
*,
|
|
items:photo_submission_items(*),
|
|
submission:content_submissions!inner(user_id)
|
|
`)
|
|
.eq('submission_id', item.id)
|
|
.single();
|
|
|
|
// Add explicit error handling
|
|
if (fetchError) {
|
|
throw new Error(`Failed to fetch photo submission: ${fetchError.message}`);
|
|
}
|
|
|
|
if (!photoSubmission) {
|
|
throw new Error('Photo submission not found');
|
|
}
|
|
|
|
// Type assertion with validation
|
|
const typedPhotoSubmission = photoSubmission as {
|
|
id: string;
|
|
entity_id: string;
|
|
entity_type: string;
|
|
items: Array<{
|
|
id: string;
|
|
cloudflare_image_id: string;
|
|
cloudflare_image_url: string;
|
|
caption?: string;
|
|
title?: string;
|
|
date_taken?: string;
|
|
date_taken_precision?: string;
|
|
order_index: number;
|
|
}>;
|
|
submission: { user_id: string };
|
|
};
|
|
|
|
// Validate required fields
|
|
if (!typedPhotoSubmission.items || typedPhotoSubmission.items.length === 0) {
|
|
throw new Error('No photo items found in submission');
|
|
}
|
|
|
|
const { data: existingPhotos } = await supabase
|
|
.from('photos')
|
|
.select('id')
|
|
.eq('submission_id', item.id);
|
|
|
|
if (!existingPhotos || existingPhotos.length === 0) {
|
|
const photoRecords = typedPhotoSubmission.items.map((photoItem) => ({
|
|
entity_id: typedPhotoSubmission.entity_id,
|
|
entity_type: typedPhotoSubmission.entity_type,
|
|
cloudflare_image_id: photoItem.cloudflare_image_id,
|
|
cloudflare_image_url: photoItem.cloudflare_image_url,
|
|
title: photoItem.title || null,
|
|
caption: photoItem.caption || null,
|
|
date_taken: photoItem.date_taken || null,
|
|
order_index: photoItem.order_index,
|
|
submission_id: item.id,
|
|
submitted_by: typedPhotoSubmission.submission?.user_id,
|
|
approved_by: user?.id,
|
|
approved_at: new Date().toISOString(),
|
|
}));
|
|
|
|
await supabase.from('photos').insert(photoRecords);
|
|
}
|
|
}
|
|
|
|
// Check for submission items
|
|
const { data: submissionItems } = await supabase
|
|
.from('submission_items')
|
|
.select('id, status')
|
|
.eq('submission_id', item.id)
|
|
.in('status', ['pending', 'rejected']);
|
|
|
|
if (submissionItems && submissionItems.length > 0) {
|
|
if (action === 'approved') {
|
|
// ⚠️ VALIDATION CENTRALIZED IN EDGE FUNCTION
|
|
// All business logic validation happens in process-selective-approval edge function.
|
|
// Client-side only performs basic UX validation (non-empty, format) in forms.
|
|
// If server-side validation fails, the edge function returns detailed 400/500 errors.
|
|
|
|
const {
|
|
data,
|
|
error,
|
|
requestId,
|
|
attempts,
|
|
cached,
|
|
conflictRetries
|
|
} = await invokeWithResilience(
|
|
'process-selective-approval',
|
|
{
|
|
itemIds: submissionItems.map((i) => i.id),
|
|
submissionId: item.id,
|
|
},
|
|
'approval',
|
|
submissionItems.map((i) => i.id),
|
|
config.user?.id,
|
|
3, // Max 3 conflict retries
|
|
30000 // 30s timeout
|
|
);
|
|
|
|
// Log retry attempts
|
|
if (attempts && attempts > 1) {
|
|
logger.log(`Approval succeeded after ${attempts} network retries`, {
|
|
submissionId: item.id,
|
|
requestId,
|
|
});
|
|
}
|
|
|
|
if (conflictRetries && conflictRetries > 0) {
|
|
logger.log(`Resolved 409 conflict after ${conflictRetries} retries`, {
|
|
submissionId: item.id,
|
|
requestId,
|
|
cached: !!cached,
|
|
});
|
|
}
|
|
|
|
if (error) {
|
|
// Enhance error with context for better UI feedback
|
|
if (is409Conflict(error)) {
|
|
throw new Error(
|
|
'This approval is being processed by another request. Please wait and try again if it does not complete.'
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
toast({
|
|
title: cached ? 'Cached Result' : 'Submission Approved',
|
|
description: cached
|
|
? `Returned cached result for ${submissionItems.length} item(s)`
|
|
: `Successfully processed ${submissionItems.length} item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
|
|
});
|
|
return;
|
|
} else if (action === 'rejected') {
|
|
await supabase
|
|
.from('submission_items')
|
|
.update({
|
|
status: 'rejected',
|
|
rejection_reason: moderatorNotes || 'Parent submission rejected',
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('submission_id', item.id)
|
|
.eq('status', 'pending');
|
|
}
|
|
}
|
|
|
|
// Standard update
|
|
const table = item.type === 'review' ? 'reviews' : 'content_submissions';
|
|
const statusField = item.type === 'review' ? 'moderation_status' : 'status';
|
|
const timestampField = item.type === 'review' ? 'moderated_at' : 'reviewed_at';
|
|
const reviewerField = item.type === 'review' ? 'moderated_by' : 'reviewer_id';
|
|
|
|
const updateData: any = {
|
|
[statusField]: action,
|
|
[timestampField]: new Date().toISOString(),
|
|
};
|
|
|
|
if (user) {
|
|
updateData[reviewerField] = user.id;
|
|
}
|
|
|
|
if (moderatorNotes) {
|
|
updateData.reviewer_notes = moderatorNotes;
|
|
}
|
|
|
|
const { error } = await supabase.from(table).update(updateData).eq('id', item.id);
|
|
|
|
if (error) throw error;
|
|
|
|
// Log audit trail for review moderation
|
|
if (table === 'reviews' && user) {
|
|
try {
|
|
// Extract entity information from item content
|
|
const entityType = item.content?.ride_id ? 'ride' : item.content?.park_id ? 'park' : 'unknown';
|
|
const entityId = item.content?.ride_id || item.content?.park_id || null;
|
|
|
|
await supabase.rpc('log_admin_action', {
|
|
_admin_user_id: user.id,
|
|
_target_user_id: item.user_id,
|
|
_action: `review_${action}`,
|
|
_details: {
|
|
review_id: item.id,
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
moderator_notes: moderatorNotes
|
|
}
|
|
});
|
|
} catch (auditError) {
|
|
// Silent - audit logging is non-critical
|
|
}
|
|
}
|
|
|
|
toast({
|
|
title: `Content ${action}`,
|
|
description: `The ${item.type} has been ${action}`,
|
|
});
|
|
|
|
logger.log(`✅ Action ${action} completed for ${item.id}`);
|
|
return { item, action };
|
|
},
|
|
onMutate: async ({ item, action }) => {
|
|
// Cancel outgoing refetches
|
|
await queryClient.cancelQueries({ queryKey: ['moderation-queue'] });
|
|
|
|
// Snapshot previous value
|
|
const previousData = queryClient.getQueryData(['moderation-queue']);
|
|
|
|
// Optimistically update cache
|
|
queryClient.setQueriesData({ queryKey: ['moderation-queue'] }, (old: any) => {
|
|
if (!old?.submissions) return old;
|
|
|
|
return {
|
|
...old,
|
|
submissions: old.submissions.map((i: ModerationItem) =>
|
|
i.id === item.id
|
|
? {
|
|
...i,
|
|
status: action,
|
|
_optimistic: true,
|
|
reviewed_at: new Date().toISOString(),
|
|
reviewer_id: user?.id,
|
|
}
|
|
: i
|
|
),
|
|
};
|
|
});
|
|
|
|
return { previousData };
|
|
},
|
|
onError: (error: any, variables, context) => {
|
|
// Rollback optimistic update
|
|
if (context?.previousData) {
|
|
queryClient.setQueryData(['moderation-queue'], context.previousData);
|
|
}
|
|
|
|
// Enhanced error handling with timeout, conflict, and network detection
|
|
const isNetworkError = isSupabaseConnectionError(error);
|
|
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
|
|
const isValidationError = errorMessage.includes('Validation failed') ||
|
|
errorMessage.includes('blocking errors') ||
|
|
errorMessage.includes('blockingErrors');
|
|
|
|
toast({
|
|
title: isNetworkError ? 'Connection Error' :
|
|
isValidationError ? 'Validation Failed' :
|
|
isConflict ? 'Duplicate Request' :
|
|
isTimeout ? 'Transaction Timeout' :
|
|
'Action Failed',
|
|
description: isTimeout
|
|
? getTimeoutErrorMessage(error as TimeoutError)
|
|
: isConflict
|
|
? 'This action is already being processed. Please wait for it to complete.'
|
|
: errorMessage,
|
|
variant: 'destructive',
|
|
});
|
|
|
|
logger.error('Moderation action failed', {
|
|
itemId: variables.item.id,
|
|
action: variables.action,
|
|
error: errorMessage,
|
|
errorId: error.errorId,
|
|
isNetworkError,
|
|
isValidationError,
|
|
isConflict,
|
|
isTimeout,
|
|
});
|
|
},
|
|
onSuccess: (data) => {
|
|
if (data) {
|
|
toast({
|
|
title: `Content ${data.action}`,
|
|
description: `The ${data.item.type} has been ${data.action}`,
|
|
});
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
// Always refetch to ensure consistency
|
|
queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
|
|
onActionComplete();
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Wrapper function that handles loading states and error tracking
|
|
*/
|
|
const performAction = useCallback(
|
|
async (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => {
|
|
onActionStart(item.id);
|
|
try {
|
|
await performActionMutation.mutateAsync({ item, action, moderatorNotes });
|
|
} catch (error) {
|
|
const errorId = handleError(error, {
|
|
action: `Moderation ${action}`,
|
|
userId: user?.id,
|
|
metadata: {
|
|
submissionId: item.id,
|
|
submissionType: item.submission_type,
|
|
itemType: item.type,
|
|
hasSubmissionItems: item.submission_items?.length ?? 0,
|
|
moderatorNotes: moderatorNotes?.substring(0, 100),
|
|
},
|
|
});
|
|
|
|
// Attach error ID for UI display
|
|
const enhancedError = error instanceof Error
|
|
? Object.assign(error, { errorId })
|
|
: { message: getErrorMessage(error), errorId };
|
|
throw enhancedError;
|
|
}
|
|
},
|
|
[onActionStart, performActionMutation, user]
|
|
);
|
|
|
|
/**
|
|
* Delete a submission permanently
|
|
*/
|
|
const deleteSubmission = useCallback(
|
|
async (item: ModerationItem) => {
|
|
if (item.type !== 'content_submission') return;
|
|
|
|
onActionStart(item.id);
|
|
|
|
try {
|
|
// Fetch submission details for audit log
|
|
const { data: submission } = await supabase
|
|
.from('content_submissions')
|
|
.select('user_id, submission_type, status')
|
|
.eq('id', item.id)
|
|
.single();
|
|
|
|
const { error } = await supabase.from('content_submissions').delete().eq('id', item.id);
|
|
|
|
if (error) throw error;
|
|
|
|
// Log audit trail for deletion
|
|
if (user && submission) {
|
|
try {
|
|
await supabase.rpc('log_admin_action', {
|
|
_admin_user_id: user.id,
|
|
_target_user_id: submission.user_id,
|
|
_action: 'submission_deleted',
|
|
_details: {
|
|
submission_id: item.id,
|
|
submission_type: submission.submission_type,
|
|
status_when_deleted: submission.status
|
|
}
|
|
});
|
|
} catch (auditError) {
|
|
// Silent - audit logging is non-critical
|
|
}
|
|
}
|
|
|
|
toast({
|
|
title: 'Submission deleted',
|
|
description: 'The submission has been permanently deleted',
|
|
});
|
|
|
|
logger.log(`✅ Submission ${item.id} deleted`);
|
|
} catch (error: unknown) {
|
|
const errorId = handleError(error, {
|
|
action: 'Delete Submission',
|
|
userId: user?.id,
|
|
metadata: {
|
|
submissionId: item.id,
|
|
submissionType: item.submission_type,
|
|
},
|
|
});
|
|
|
|
logger.error('Failed to delete submission', {
|
|
submissionId: item.id,
|
|
errorId,
|
|
});
|
|
const enhancedError = error instanceof Error
|
|
? Object.assign(error, { errorId })
|
|
: { message: getErrorMessage(error), errorId };
|
|
throw enhancedError;
|
|
} finally {
|
|
onActionComplete();
|
|
}
|
|
},
|
|
[toast, onActionStart, onActionComplete]
|
|
);
|
|
|
|
/**
|
|
* Reset submission to pending status
|
|
*/
|
|
const resetToPending = useCallback(
|
|
async (item: ModerationItem) => {
|
|
onActionStart(item.id);
|
|
|
|
try {
|
|
const { resetRejectedItemsToPending } = await import('@/lib/submissionItemsService');
|
|
await resetRejectedItemsToPending(item.id);
|
|
|
|
// Log audit trail for reset
|
|
if (user) {
|
|
try {
|
|
await supabase.rpc('log_admin_action', {
|
|
_admin_user_id: user.id,
|
|
_target_user_id: item.user_id,
|
|
_action: 'submission_reset',
|
|
_details: {
|
|
submission_id: item.id,
|
|
submission_type: item.submission_type
|
|
}
|
|
});
|
|
} catch (auditError) {
|
|
// Silent - audit logging is non-critical
|
|
}
|
|
}
|
|
|
|
toast({
|
|
title: 'Reset Complete',
|
|
description: 'Submission and all items have been reset to pending status',
|
|
});
|
|
|
|
logger.log(`✅ Submission ${item.id} reset to pending`);
|
|
} catch (error: unknown) {
|
|
const errorId = handleError(error, {
|
|
action: 'Reset to Pending',
|
|
userId: user?.id,
|
|
metadata: {
|
|
submissionId: item.id,
|
|
submissionType: item.submission_type,
|
|
},
|
|
});
|
|
|
|
logger.error('Failed to reset status', {
|
|
submissionId: item.id,
|
|
errorId,
|
|
});
|
|
const enhancedError = error instanceof Error
|
|
? Object.assign(error, { errorId })
|
|
: { message: getErrorMessage(error), errorId };
|
|
throw enhancedError;
|
|
} finally {
|
|
onActionComplete();
|
|
}
|
|
},
|
|
[toast, onActionStart, onActionComplete]
|
|
);
|
|
|
|
/**
|
|
* Retry failed items in a submission
|
|
*/
|
|
const retryFailedItems = useCallback(
|
|
async (item: ModerationItem) => {
|
|
onActionStart(item.id);
|
|
let failedItemsCount = 0;
|
|
|
|
try {
|
|
const { data: failedItems } = await supabase
|
|
.from('submission_items')
|
|
.select('id')
|
|
.eq('submission_id', item.id)
|
|
.eq('status', 'rejected');
|
|
|
|
if (!failedItems || failedItems.length === 0) {
|
|
toast({
|
|
title: 'No Failed Items',
|
|
description: 'All items have been processed successfully',
|
|
});
|
|
return;
|
|
}
|
|
|
|
failedItemsCount = failedItems.length;
|
|
|
|
const {
|
|
data,
|
|
error,
|
|
requestId,
|
|
attempts,
|
|
cached,
|
|
conflictRetries
|
|
} = await invokeWithResilience(
|
|
'process-selective-approval',
|
|
{
|
|
itemIds: failedItems.map((i) => i.id),
|
|
submissionId: item.id,
|
|
},
|
|
'retry',
|
|
failedItems.map((i) => i.id),
|
|
config.user?.id,
|
|
3, // Max 3 conflict retries
|
|
30000 // 30s timeout
|
|
);
|
|
|
|
if (attempts && attempts > 1) {
|
|
logger.log(`Retry succeeded after ${attempts} network retries`, {
|
|
submissionId: item.id,
|
|
requestId,
|
|
});
|
|
}
|
|
|
|
if (conflictRetries && conflictRetries > 0) {
|
|
logger.log(`Retry resolved 409 conflict after ${conflictRetries} retries`, {
|
|
submissionId: item.id,
|
|
requestId,
|
|
cached: !!cached,
|
|
});
|
|
}
|
|
|
|
if (error) {
|
|
if (is409Conflict(error)) {
|
|
throw new Error(
|
|
'This retry is being processed by another request. Please wait and try again if it does not complete.'
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// Log audit trail for retry
|
|
if (user) {
|
|
try {
|
|
await supabase.rpc('log_admin_action', {
|
|
_admin_user_id: user.id,
|
|
_target_user_id: item.user_id,
|
|
_action: 'submission_retried',
|
|
_details: {
|
|
submission_id: item.id,
|
|
submission_type: item.submission_type,
|
|
items_retried: failedItems.length,
|
|
request_id: requestId
|
|
}
|
|
});
|
|
} catch (auditError) {
|
|
// Silent - audit logging is non-critical
|
|
}
|
|
}
|
|
|
|
toast({
|
|
title: cached ? 'Cached Retry Result' : 'Items Retried',
|
|
description: cached
|
|
? `Returned cached result for ${failedItems.length} item(s)`
|
|
: `Successfully retried ${failedItems.length} failed item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ''}`,
|
|
});
|
|
|
|
logger.log(`✅ Retried ${failedItems.length} failed items for ${item.id}`);
|
|
} catch (error: unknown) {
|
|
const errorId = handleError(error, {
|
|
action: 'Retry Failed Items',
|
|
userId: user?.id,
|
|
metadata: {
|
|
submissionId: item.id,
|
|
failedItemsCount,
|
|
},
|
|
});
|
|
|
|
logger.error('Failed to retry items', {
|
|
submissionId: item.id,
|
|
errorId,
|
|
});
|
|
const enhancedError = error instanceof Error
|
|
? Object.assign(error, { errorId })
|
|
: { message: getErrorMessage(error), errorId };
|
|
throw enhancedError;
|
|
} finally {
|
|
onActionComplete();
|
|
}
|
|
},
|
|
[toast, onActionStart, onActionComplete, user]
|
|
);
|
|
|
|
/**
|
|
* Escalate submission for admin review
|
|
* Consolidates escalation logic with comprehensive error handling
|
|
*/
|
|
const escalateSubmission = useCallback(
|
|
async (item: ModerationItem, reason: string) => {
|
|
if (!user?.id) {
|
|
toast({
|
|
title: 'Authentication Required',
|
|
description: 'You must be logged in to escalate submissions',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
onActionStart(item.id);
|
|
|
|
try {
|
|
// Call edge function for email notification with retry
|
|
const { error: edgeFunctionError, requestId, attempts } = await invokeWithTracking(
|
|
'send-escalation-notification',
|
|
{
|
|
submissionId: item.id,
|
|
escalationReason: reason,
|
|
escalatedBy: user.id,
|
|
},
|
|
user.id,
|
|
undefined,
|
|
undefined,
|
|
45000, // Longer timeout for email sending
|
|
{ maxAttempts: 3, baseDelay: 2000 } // Retry for email delivery
|
|
);
|
|
|
|
if (attempts && attempts > 1) {
|
|
logger.log(`Escalation email sent after ${attempts} attempts`);
|
|
}
|
|
|
|
if (edgeFunctionError) {
|
|
// Edge function failed - log and show fallback toast
|
|
handleError(edgeFunctionError, {
|
|
action: 'Send escalation notification',
|
|
userId: user.id,
|
|
metadata: {
|
|
submissionId: item.id,
|
|
reason: reason.substring(0, 100),
|
|
fallbackUsed: true,
|
|
},
|
|
});
|
|
|
|
toast({
|
|
title: 'Escalated (Email Failed)',
|
|
description: 'Submission escalated but notification email could not be sent',
|
|
});
|
|
} else {
|
|
toast({
|
|
title: 'Escalated Successfully',
|
|
description: `Submission escalated and admin notified${requestId ? ` (${requestId.substring(0, 8)})` : ''}`,
|
|
});
|
|
}
|
|
|
|
// Invalidate cache
|
|
queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
|
|
|
|
logger.log(`✅ Submission ${item.id} escalated`);
|
|
} catch (error: unknown) {
|
|
const errorId = handleError(error, {
|
|
action: 'Escalate Submission',
|
|
userId: user.id,
|
|
metadata: {
|
|
submissionId: item.id,
|
|
submissionType: item.submission_type,
|
|
reason: reason.substring(0, 100),
|
|
},
|
|
});
|
|
|
|
logger.error('Escalation failed', {
|
|
submissionId: item.id,
|
|
errorId,
|
|
});
|
|
|
|
// Re-throw to allow UI to show retry option
|
|
const enhancedError = error instanceof Error
|
|
? Object.assign(error, { errorId })
|
|
: { message: getErrorMessage(error), errorId };
|
|
throw enhancedError;
|
|
} finally {
|
|
onActionComplete();
|
|
}
|
|
},
|
|
[user, toast, onActionStart, onActionComplete, queryClient]
|
|
);
|
|
|
|
return {
|
|
performAction,
|
|
deleteSubmission,
|
|
resetToPending,
|
|
retryFailedItems,
|
|
escalateSubmission,
|
|
};
|
|
}
|