import { useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; import { logger } from '@/lib/logger'; import { getErrorMessage } from '@/lib/errorHandler'; import { validateMultipleItems } from '@/lib/entityValidationSchemas'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; 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; deleteSubmission: (item: ModerationItem) => Promise; resetToPending: (item: ModerationItem) => Promise; retryFailedItems: (item: ModerationItem) => Promise; } /** * 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(); /** * Perform moderation action (approve/reject) */ const performAction = useCallback( async (item: ModerationItem, action: 'approved' | 'rejected', moderatorNotes?: string) => { onActionStart(item.id); try { // 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') { // Fetch full item data for validation const { data: fullItems, error: itemError } = await supabase .from('submission_items') .select('id, item_type, item_data') .eq('submission_id', item.id) .in('status', ['pending', 'rejected']); if (itemError) { throw new Error(`Failed to fetch submission items: ${itemError.message}`); } if (fullItems && fullItems.length > 0) { // Run validation on all items const validationResults = await validateMultipleItems( fullItems.map(item => ({ item_type: item.item_type, item_data: item.item_data, id: item.id })) ); // Check for blocking errors const itemsWithBlockingErrors = fullItems.filter(item => { const result = validationResults.get(item.id); return result && result.blockingErrors.length > 0; }); // CRITICAL: Block approval if any item has blocking errors if (itemsWithBlockingErrors.length > 0) { const errorDetails = itemsWithBlockingErrors.map(item => { const result = validationResults.get(item.id); return `${item.item_type}: ${result?.blockingErrors[0]?.message || 'Unknown error'}`; }).join(', '); toast({ title: 'Cannot Approve - Validation Errors', description: `${itemsWithBlockingErrors.length} item(s) have blocking errors that must be fixed first. ${errorDetails}`, variant: 'destructive', }); // Return early - do NOT proceed with approval return; } // Check for warnings (optional - can proceed but inform user) const itemsWithWarnings = fullItems.filter(item => { const result = validationResults.get(item.id); return result && result.warnings.length > 0; }); if (itemsWithWarnings.length > 0) { logger.info('Approval proceeding with warnings', { submissionId: item.id, warningCount: itemsWithWarnings.length }); } } const { data, error, requestId } = await invokeWithTracking( 'process-selective-approval', { itemIds: submissionItems.map((i) => i.id), submissionId: item.id, }, config.user?.id ); if (error) throw error; toast({ title: 'Submission Approved', description: `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) { logger.error('Failed to log review moderation audit', { error: auditError }); } } toast({ title: `Content ${action}`, description: `The ${item.type} has been ${action}`, }); logger.log(`✅ Action ${action} completed for ${item.id}`); } catch (error: unknown) { logger.error('❌ Error performing action:', { error: getErrorMessage(error) }); toast({ title: 'Error', description: getErrorMessage(error) || `Failed to ${action} content`, variant: 'destructive', }); throw error; } finally { onActionComplete(); } }, [user, toast, onActionStart, onActionComplete] ); /** * 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) { logger.error('Failed to log submission deletion audit', { error: auditError }); } } toast({ title: 'Submission deleted', description: 'The submission has been permanently deleted', }); logger.log(`✅ Submission ${item.id} deleted`); } catch (error: unknown) { logger.error('❌ Error deleting submission:', { error: getErrorMessage(error) }); toast({ title: 'Error', description: getErrorMessage(error), variant: 'destructive', }); throw error; } 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) { logger.error('Failed to log submission reset audit', { error: auditError }); } } 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) { logger.error('❌ Error resetting submission:', { error: getErrorMessage(error) }); toast({ title: 'Reset Failed', description: getErrorMessage(error), variant: 'destructive', }); } finally { onActionComplete(); } }, [toast, onActionStart, onActionComplete] ); /** * Retry failed items in a submission */ const retryFailedItems = useCallback( async (item: ModerationItem) => { onActionStart(item.id); 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; } const { data, error, requestId } = await invokeWithTracking( 'process-selective-approval', { itemIds: failedItems.map((i) => i.id), submissionId: item.id, }, config.user?.id ); if (error) 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) { logger.error('Failed to log submission retry audit', { error: auditError }); } } toast({ title: 'Items Retried', description: `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) { logger.error('❌ Error retrying items:', { error: getErrorMessage(error) }); toast({ title: 'Retry Failed', description: getErrorMessage(error) || 'Failed to retry items', variant: 'destructive', }); } finally { onActionComplete(); } }, [toast, onActionStart, onActionComplete] ); return { performAction, deleteSubmission, resetToPending, retryFailedItems, }; }