diff --git a/src/lib/moderation/actions.ts b/src/lib/moderation/actions.ts index a3d291a0..66469ef6 100644 --- a/src/lib/moderation/actions.ts +++ b/src/lib/moderation/actions.ts @@ -57,126 +57,6 @@ export interface ModerationActionResult { shouldRemoveFromQueue: boolean; } -/** - * Configuration for photo approval - */ -interface PhotoApprovalConfig { - submissionId: string; - moderatorId: string; - moderatorNotes?: string; -} - -/** - * Approve a photo submission - * - * Creates photo records in the database and updates submission status. - * Handles both new approvals and re-approvals (where photos already exist). - * - * @param supabase - Supabase client - * @param config - Photo approval configuration - * @returns Action result with success status and message - */ -export async function approvePhotoSubmission( - supabase: SupabaseClient, - config: PhotoApprovalConfig -): Promise { - try { - // Fetch photo submission from relational tables - const { data: photoSubmission, error: fetchError } = await supabase - .from('photo_submissions') - .select(` - *, - items:photo_submission_items(*), - submission:content_submissions!inner(user_id, status) - `) - .eq('submission_id', config.submissionId) - .single(); - - if (fetchError || !photoSubmission) { - throw new Error('Failed to fetch photo submission data'); - } - - if (!photoSubmission.items || photoSubmission.items.length === 0) { - throw new Error('No photos found in submission'); - } - - // Check if photos already exist for this submission (re-approval case) - const { data: existingPhotos } = await supabase - .from('photos') - .select('id') - .eq('submission_id', config.submissionId); - - if (!existingPhotos || existingPhotos.length === 0) { - // Create new photo records from photo_submission_items - const photoRecords = photoSubmission.items.map((item: any) => ({ - entity_id: photoSubmission.entity_id, - entity_type: photoSubmission.entity_type, - cloudflare_image_id: item.cloudflare_image_id, - cloudflare_image_url: item.cloudflare_image_url, - title: item.title || null, - caption: item.caption || null, - date_taken: item.date_taken || null, - order_index: item.order_index, - submission_id: photoSubmission.submission_id, - submitted_by: photoSubmission.submission?.user_id, - approved_by: config.moderatorId, - approved_at: new Date().toISOString(), - })); - - const { error: insertError } = await supabase - .from('photos') - .insert(photoRecords); - - if (insertError) { - throw insertError; - } - } - - // Update submission status - const { error: updateError } = await supabase - .from('content_submissions') - .update({ - status: 'approved' as const, - reviewer_id: config.moderatorId, - reviewed_at: new Date().toISOString(), - reviewer_notes: config.moderatorNotes, - }) - .eq('id', config.submissionId); - - if (updateError) { - throw updateError; - } - - return { - success: true, - message: `Successfully approved and published ${photoSubmission.items.length} photo(s)`, - shouldRemoveFromQueue: true, - }; - } catch (error: unknown) { - handleError(error, { - action: 'Approve Photo Submission', - userId: config.moderatorId, - metadata: { submissionId: config.submissionId } - }); - return { - success: false, - message: 'Failed to approve photo submission', - error: error instanceof Error ? error : new Error(getErrorMessage(error)), - shouldRemoveFromQueue: false, - }; - } -} - -/** - * Approve a submission with submission_items - * - * Uses the edge function to process all pending submission items. - * - * @param supabase - Supabase client - * @param submissionId - Submission ID - * @param itemIds - Array of item IDs to approve - * @returns Action result - */ /** * Approve submission items using atomic transaction RPC. * @@ -238,194 +118,6 @@ export async function approveSubmissionItems( } } -/** - * Reject a submission with submission_items - * - * Cascades rejection to all pending items. - * - * @param supabase - Supabase client - * @param submissionId - Submission ID - * @param rejectionReason - Reason for rejection - * @returns Action result - */ -export async function rejectSubmissionItems( - supabase: SupabaseClient, - submissionId: string, - rejectionReason?: string -): Promise { - try { - const { error: rejectError } = await supabase - .from('submission_items') - .update({ - status: 'rejected' as const, - rejection_reason: rejectionReason || 'Parent submission rejected', - updated_at: new Date().toISOString(), - }) - .eq('submission_id', submissionId) - .eq('status', 'pending'); - - if (rejectError) { - handleError(rejectError, { - action: 'Reject Submission Items (Cascade)', - metadata: { submissionId } - }); - } - - return { - success: true, - message: 'Submission items rejected', - shouldRemoveFromQueue: false, // Parent rejection will handle removal - }; - } catch (error: unknown) { - handleError(error, { - action: 'Reject Submission Items', - metadata: { submissionId } - }); - return { - success: false, - message: 'Failed to reject submission items', - error: error instanceof Error ? error : new Error(getErrorMessage(error)), - shouldRemoveFromQueue: false, - }; - } -} - -/** - * Configuration for standard moderation actions - */ -export interface ModerationConfig { - item: ModerationItem; - action: 'approved' | 'rejected'; - moderatorId: string; - moderatorNotes?: string; -} - -/** - * Perform a standard moderation action (approve/reject) - * - * Updates the submission or review status in the database. - * Handles both content_submissions and reviews. - * - * @param supabase - Supabase client - * @param config - Moderation configuration - * @returns Action result - */ -export async function performModerationAction( - supabase: SupabaseClient, - config: ModerationConfig -): Promise { - const { item, action, moderatorId, moderatorNotes } = config; - - try { - // Handle photo submissions specially - if ( - action === 'approved' && - item.type === 'content_submission' && - item.submission_type === 'photo' - ) { - return await approvePhotoSubmission(supabase, { - submissionId: item.id, - moderatorId, - moderatorNotes, - }); - } - - // Check if this submission has submission_items - if (item.type === 'content_submission') { - const { data: submissionItems, error: itemsError } = await supabase - .from('submission_items') - .select('id, status') - .eq('submission_id', item.id) - .in('status', ['pending', 'rejected']); - - if (!itemsError && submissionItems && submissionItems.length > 0) { - if (action === 'approved') { - return await approveSubmissionItems( - supabase, - item.id, - submissionItems.map(i => i.id) - ); - } else if (action === 'rejected') { - await rejectSubmissionItems(supabase, item.id, moderatorNotes); - } - } - } - - // Standard moderation flow - Build update object with type-appropriate fields - let error: any = null; - let data: any = null; - - // Use type-safe table queries based on item type - if (item.type === 'review') { - const reviewUpdate: { - moderation_status: 'approved' | 'rejected' | 'pending'; - moderated_at: string; - moderated_by: string; - reviewer_notes?: string; - } = { - moderation_status: action, - moderated_at: new Date().toISOString(), - moderated_by: moderatorId, - ...(moderatorNotes && { reviewer_notes: moderatorNotes }), - }; - - const result = await createTableQuery('reviews') - .update(reviewUpdate) - .eq('id', item.id) - .select(); - error = result.error; - data = result.data; - } else { - const submissionUpdate: { - status: 'approved' | 'rejected' | 'pending'; - reviewed_at: string; - reviewer_id: string; - reviewer_notes?: string; - } = { - status: action, - reviewed_at: new Date().toISOString(), - reviewer_id: moderatorId, - ...(moderatorNotes && { reviewer_notes: moderatorNotes }), - }; - - const result = await createTableQuery('content_submissions') - .update(submissionUpdate) - .eq('id', item.id) - .select(); - error = result.error; - data = result.data; - } - - if (error) { - throw error; - } - - // Check if the update actually affected any rows - if (!data || data.length === 0) { - throw new Error( - 'Failed to update item - no rows affected. You might not have permission to moderate this content.' - ); - } - - return { - success: true, - message: `Content ${action}`, - shouldRemoveFromQueue: action === 'approved' || action === 'rejected', - }; - } catch (error: unknown) { - handleError(error, { - action: `${config.action === 'approved' ? 'Approve' : 'Reject'} Content`, - userId: config.moderatorId, - metadata: { itemType: item.type, itemId: item.id } - }); - return { - success: false, - message: `Failed to ${config.action} content`, - error: error instanceof Error ? error : new Error(getErrorMessage(error)), - shouldRemoveFromQueue: false, - }; - } -} /** * Configuration for submission deletion diff --git a/src/lib/moderation/index.ts b/src/lib/moderation/index.ts index 3f5ab30b..35d1fa3a 100644 --- a/src/lib/moderation/index.ts +++ b/src/lib/moderation/index.ts @@ -28,16 +28,12 @@ export type { ResolvedEntityNames } from './entities'; // Moderation actions export { - approvePhotoSubmission, approveSubmissionItems, - rejectSubmissionItems, - performModerationAction, deleteSubmission, } from './actions'; export type { ModerationActionResult, - ModerationConfig, DeleteSubmissionConfig, } from './actions'; diff --git a/src/lib/submissionItemsService.ts b/src/lib/submissionItemsService.ts index cae2d991..9c5c2ab3 100644 --- a/src/lib/submissionItemsService.ts +++ b/src/lib/submissionItemsService.ts @@ -1,6 +1,7 @@ import { supabase } from '@/lib/supabaseClient'; import { handleError, handleNonCriticalError, getErrorMessage } from './errorHandler'; import { extractCloudflareImageId } from './cloudflareImageUtils'; +import { invokeWithTracking } from './edgeFunctionTracking'; // Core submission item interface with dependencies // NOTE: item_data and original_data use `unknown` because they contain dynamic structures @@ -1367,32 +1368,24 @@ export async function rejectSubmissionItems( } } - // Update all items to rejected status - const updates = Array.from(itemsToReject).map(async (itemId) => { - const { error } = await supabase - .from('submission_items') - .update({ - status: 'rejected' as const, - rejection_reason: reason, - updated_at: new Date().toISOString(), - }) - .eq('id', itemId); - - if (error) { - handleNonCriticalError(error, { - action: 'Reject Submission Item', - metadata: { itemId } - }); - throw error; - } - }); - - await Promise.all(updates); - - // Update parent submission status const submissionId = items[0]?.submission_id; - if (submissionId) { - await updateSubmissionStatusAfterRejection(submissionId); + if (!submissionId) { + throw new Error('Cannot reject items: missing submission ID'); + } + + // Use atomic edge function for rejection + const { data, error } = await invokeWithTracking( + 'process-selective-rejection', + { + itemIds: Array.from(itemsToReject), + submissionId, + rejectionReason: reason, + }, + userId + ); + + if (error) { + throw new Error(`Failed to reject items: ${error.message}`); } }