Files
thrilltrack-explorer/src/hooks/moderation/useModerationActions.ts
2025-10-27 17:17:13 +00:00

469 lines
16 KiB
TypeScript

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<void>;
deleteSubmission: (item: ModerationItem) => Promise<void>;
resetToPending: (item: ModerationItem) => Promise<void>;
retryFailedItems: (item: ModerationItem) => 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();
/**
* 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,
};
}