mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 19:11:12 -05:00
469 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|