diff --git a/src/hooks/moderation/useModerationQueueManager.ts b/src/hooks/moderation/useModerationQueueManager.ts index 8b9db81e..675c4195 100644 --- a/src/hooks/moderation/useModerationQueueManager.ts +++ b/src/hooks/moderation/useModerationQueueManager.ts @@ -16,6 +16,7 @@ import { useQueueQuery, } from "./index"; import { useModerationQueue } from "@/hooks/useModerationQueue"; +import { useModerationActions } from "./useModerationActions"; import type { ModerationItem, EntityFilter, StatusFilter, LoadingState } from "@/types/moderation"; @@ -275,224 +276,36 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig): }, []); /** - * Perform moderation action (approve/reject) + * Use validated action handler from useModerationActions + */ + const moderationActions = useModerationActions({ + user, + onActionStart: setActionLoading, + onActionComplete: () => { + setActionLoading(null); + refresh(); + queue.refreshStats(); + }, + currentLockSubmissionId: queue.currentLock?.submissionId, + }); + + /** + * Perform moderation action (approve/reject) - delegates to validated handler */ const performAction = useCallback( async (item: ModerationItem, action: "approved" | "rejected", moderatorNotes?: string) => { - if (actionLoading === item.id) return; - - setActionLoading(item.id); - - // Check MFA (AAL2) requirement before moderation action - if (aal === null) { - logger.log('⏳ [QUEUE MANAGER] AAL is null, waiting for authentication status...'); - toast({ - title: "Loading Authentication Status", - description: "Please wait while we verify your authentication level...", - }); - setActionLoading(null); - - // Retry after 1 second - setTimeout(() => { - logger.log('🔄 [QUEUE MANAGER] Retrying action after AAL load'); - performAction(item, action, moderatorNotes); - }, 1000); - - return; - } - - if (aal !== 'aal2') { - logger.warn('🚫 [QUEUE MANAGER] MFA check failed', { - aal, - expected: 'aal2', - userId: user?.id - }); - - toast({ - title: "MFA Verification Required", - description: "You must complete multi-factor authentication to perform moderation actions.", - variant: "destructive", - }); - setActionLoading(null); - return; - } - - logger.log('✅ [QUEUE MANAGER] MFA check passed', { aal }); - - // Calculate stat delta for optimistic update - const statDelta: Partial = {}; - - if (action === 'approved' || action === 'rejected') { - statDelta.pendingSubmissions = -1; - } - - // Optimistically update stats IMMEDIATELY - if (optimisticallyUpdateStats) { - optimisticallyUpdateStats(statDelta); - } - - // Optimistic update - const shouldRemove = - (filters.statusFilter === "pending" || filters.statusFilter === "flagged") && - (action === "approved" || action === "rejected"); - - if (shouldRemove) { - setItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, _removing: true } : i))); - - setTimeout(() => { - setItems((prev) => prev.filter((i) => i.id !== item.id)); - recentlyRemovedRef.current.add(item.id); - setTimeout(() => recentlyRemovedRef.current.delete(item.id), 10000); - }, 300); - } - - // Release lock if claimed + // Release lock if held if (queue.currentLock?.submissionId === item.id) { - await queue.releaseLock(item.id, true); // Silent release + await queue.releaseLock(item.id, true); } - - try { - // Handle photo submissions - if (action === "approved" && item.submission_type === "photo") { - const { data: photoSubmission } = await supabase - .from("photo_submissions") - .select(`*, items:photo_submission_items(*), submission:content_submissions!inner(user_id)`) - .eq("submission_id", item.id) - .single(); - - if (photoSubmission && photoSubmission.items) { - const { data: existingPhotos } = await supabase.from("photos").select("id").eq("submission_id", item.id); - - if (!existingPhotos || existingPhotos.length === 0) { - const photoRecords = photoSubmission.items.map((photoItem: any) => ({ - entity_id: photoSubmission.entity_id, - entity_type: photoSubmission.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: photoSubmission.submission_id, - submitted_by: photoSubmission.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}. Version history updated.`, - }); - - // Refresh stats to update counts - queue.refreshStats(); - // Force cache invalidation before refetching to ensure fresh data - await queryClient.invalidateQueries({ - queryKey: ['moderation-queue'], - exact: false // Invalidate all query variants - }); - await queueQuery.refetch(); - } catch (error) { - const errorMsg = getErrorMessage(error); - console.error("Error moderating content:", errorMsg, error); - - // Revert optimistic update - setItems((prev) => { - const exists = prev.find((i) => i.id === item.id); - if (exists) { - return prev.map((i) => (i.id === item.id ? item : i)); - } else { - return [...prev, item]; - } - }); - - // Check for RLS/permission errors - if (errorMsg.includes('row-level security') || - errorMsg.includes('permission denied') || - errorMsg.includes('policy') || - errorMsg.includes('violates row-level security')) { - toast({ - title: "Permission Denied", - description: "You don't have permission to perform this action. MFA verification may be required.", - variant: "destructive", - }); - } else { - toast({ - title: "Error", - description: errorMsg || `Failed to ${action} content`, - variant: "destructive", - }); - } - } finally { - setActionLoading(null); - } + // Use validated action handler + await moderationActions.performAction(item, action, moderatorNotes); }, - [actionLoading, filters.statusFilter, queue, user, toast], + [moderationActions, queue] ); + /** * Delete a submission permanently */ diff --git a/src/lib/entityValidationSchemas.ts b/src/lib/entityValidationSchemas.ts index 9cff8abc..be6cd056 100644 --- a/src/lib/entityValidationSchemas.ts +++ b/src/lib/entityValidationSchemas.ts @@ -225,6 +225,27 @@ export const milestoneValidationSchema = z.object({ path: ['from_value'], }); +// ============================================ +// PHOTO OPERATION SCHEMAS +// ============================================ + +export const photoEditValidationSchema = z.object({ + photo_id: z.string().uuid('Invalid photo ID'), + cloudflare_image_url: z.string().url('Invalid image URL'), + caption: z.string().trim().max(500, 'Caption must be less than 500 characters').optional().or(z.literal('')), + title: z.string().trim().max(200, 'Title must be less than 200 characters').optional().or(z.literal('')), + entity_type: z.string().min(1, 'Entity type is required'), + entity_id: z.string().uuid('Invalid entity ID'), +}); + +export const photoDeleteValidationSchema = z.object({ + photo_id: z.string().uuid('Invalid photo ID'), + cloudflare_image_id: z.string().min(1, 'Image ID is required'), + cloudflare_image_url: z.string().url('Invalid image URL').optional(), + entity_type: z.string().min(1, 'Entity type is required'), + entity_id: z.string().uuid('Invalid entity ID'), +}); + // ============================================ // SCHEMA REGISTRY // ============================================ @@ -238,6 +259,8 @@ export const entitySchemas = { property_owner: companyValidationSchema, ride_model: rideModelValidationSchema, photo: photoValidationSchema, + photo_edit: photoEditValidationSchema, + photo_delete: photoDeleteValidationSchema, milestone: milestoneValidationSchema, timeline_event: milestoneValidationSchema, // Alias for milestone }; diff --git a/supabase/functions/process-selective-approval/validation.ts b/supabase/functions/process-selective-approval/validation.ts index 2d627a09..f8e6e3da 100644 --- a/supabase/functions/process-selective-approval/validation.ts +++ b/supabase/functions/process-selective-approval/validation.ts @@ -156,6 +156,39 @@ export function validateEntityDataStrict( } break; + case 'photo_edit': + if (!data.photo_id) { + result.blockingErrors.push('Photo ID is required'); + } + if (!data.entity_type) { + result.blockingErrors.push('Entity type is required'); + } + if (!data.entity_id) { + result.blockingErrors.push('Entity ID is required'); + } + if (data.caption && data.caption.length > 500) { + result.blockingErrors.push('Caption must be less than 500 characters'); + } + if (data.title && data.title.length > 200) { + result.blockingErrors.push('Title must be less than 200 characters'); + } + break; + + case 'photo_delete': + if (!data.photo_id) { + result.blockingErrors.push('Photo ID is required'); + } + if (!data.cloudflare_image_id && !data.photo_id) { + result.blockingErrors.push('Photo identifier is required'); + } + if (!data.entity_type) { + result.blockingErrors.push('Entity type is required'); + } + if (!data.entity_id) { + result.blockingErrors.push('Entity ID is required'); + } + break; + case 'milestone': case 'timeline_event': if (!data.title?.trim()) {