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

319 lines
9.9 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 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') {
await supabase.functions.invoke('process-selective-approval', {
body: {
itemIds: submissionItems.map((i) => i.id),
submissionId: item.id,
},
});
toast({
title: 'Submission Approved',
description: `Successfully processed ${submissionItems.length} item(s)`,
});
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;
toast({
title: `Content ${action}`,
description: `The ${item.type} has been ${action}`,
});
logger.log(`✅ Action ${action} completed for ${item.id}`);
} catch (error) {
logger.error('❌ Error performing action:', 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 {
const { error } = await supabase.from('content_submissions').delete().eq('id', item.id);
if (error) throw error;
toast({
title: 'Submission deleted',
description: 'The submission has been permanently deleted',
});
logger.log(`✅ Submission ${item.id} deleted`);
} catch (error) {
logger.error('❌ Error deleting submission:', 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);
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) {
logger.error('❌ Error resetting submission:', 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 { error } = await supabase.functions.invoke('process-selective-approval', {
body: {
itemIds: failedItems.map((i) => i.id),
submissionId: item.id,
},
});
if (error) throw error;
toast({
title: 'Items Retried',
description: `Successfully retried ${failedItems.length} failed item(s)`,
});
logger.log(`✅ Retried ${failedItems.length} failed items for ${item.id}`);
} catch (error) {
logger.error('❌ Error retrying items:', error);
toast({
title: 'Retry Failed',
description: getErrorMessage(error) || 'Failed to retry items',
variant: 'destructive',
});
} finally {
onActionComplete();
}
},
[toast, onActionStart, onActionComplete]
);
return {
performAction,
deleteSubmission,
resetToPending,
retryFailedItems,
};
}