Files
thrilltrack-explorer/src/hooks/moderation/useModerationActions.ts
2025-11-05 19:09:18 +00:00

776 lines
26 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';
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>;
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();
/**
* 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') {
// Fetch full item data for validation with relational joins
const { data: fullItems, error: itemError } = await supabase
.from('submission_items')
.select(`
id,
item_type,
park_submission:park_submissions!park_submission_id(*),
ride_submission:ride_submissions!ride_submission_id(*),
company_submission:company_submissions!company_submission_id(*),
ride_model_submission:ride_model_submissions!ride_model_submission_id(*),
timeline_event_submission:timeline_event_submissions!timeline_event_submission_id(*),
photo_submission:photo_submissions!photo_submission_id(*)
`)
.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) {
// Transform to include item_data
const itemsWithData = fullItems.map(item => {
let itemData = {};
switch (item.item_type) {
case 'park':
itemData = item.park_submission || {};
break;
case 'ride':
itemData = item.ride_submission || {};
break;
case 'operator':
case 'manufacturer':
case 'designer':
case 'property_owner':
itemData = item.company_submission || {};
break;
case 'ride_model':
itemData = item.ride_model_submission || {};
break;
case 'milestone':
case 'timeline_event':
itemData = item.timeline_event_submission || {};
break;
case 'photo':
case 'photo_edit':
case 'photo_delete':
itemData = item.photo_submission || {};
break;
default:
logger.warn(`Unknown item_type in validation: ${item.item_type}`);
itemData = {};
}
return {
id: item.id,
item_type: item.item_type,
item_data: itemData
};
});
// Run validation on all items
try {
const validationResults = await validateMultipleItems(itemsWithData);
// Check for blocking errors
const itemsWithBlockingErrors = itemsWithData.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) {
// Log detailed blocking errors
itemsWithBlockingErrors.forEach(item => {
const result = validationResults.get(item.id);
logger.error('Validation blocking approval', {
submissionId: item.id,
itemId: item.id,
itemType: item.item_type,
blockingErrors: result?.blockingErrors
});
});
const errorDetails = itemsWithBlockingErrors.map(item => {
const result = validationResults.get(item.id);
const itemName = (item.item_data as any)?.name || item.item_type;
const errors = result?.blockingErrors.map(e => `${e.field}: ${e.message}`).join(', ');
return `${itemName} - ${errors}`;
}).join('; ');
throw new Error(`Validation failed: ${errorDetails}`);
}
// Check for warnings (optional - can proceed but inform user)
const itemsWithWarnings = itemsWithData.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
});
}
} catch (error) {
// Check if this is a validation error or system error
if (getErrorMessage(error).includes('Validation failed:')) {
// This is expected - validation rules preventing approval
handleError(error, {
action: 'Validation Blocked Approval',
userId: user?.id,
metadata: {
submissionId: item.id,
submissionType: item.submission_type,
selectedItemCount: itemsWithData.length
}
});
toast({
title: 'Cannot Approve - Validation Errors',
description: getErrorMessage(error),
variant: 'destructive',
});
// Return early - do NOT proceed with approval
return;
} else {
// Unexpected validation system error
const errorId = handleError(error, {
action: 'Validation System Failure',
userId: user?.id,
metadata: {
submissionId: item.id,
submissionType: item.submission_type,
phase: 'validation'
}
});
toast({
title: 'Validation System Error',
description: `Unable to validate submission (ref: ${errorId.slice(0, 8)})`,
variant: 'destructive',
});
// Return early - do NOT proceed with approval
return;
}
}
}
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) {
// 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 reference ID and network detection
const isNetworkError = isSupabaseConnectionError(error);
const errorMessage = getErrorMessage(error) || `Failed to ${variables.action} content`;
toast({
title: isNetworkError ? 'Connection Error' : 'Action Failed',
description: errorMessage,
variant: 'destructive',
});
logger.error('Moderation action failed', {
itemId: variables.item.id,
action: variables.action,
error: errorMessage,
errorId: error.errorId,
isNetworkError,
});
},
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 } = 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) {
// Silent - audit logging is non-critical
}
}
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) {
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
const { error: edgeFunctionError, requestId } = await invokeWithTracking(
'send-escalation-notification',
{
submissionId: item.id,
escalationReason: reason,
escalatedBy: user.id,
},
user.id
);
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,
};
}