/** * Moderation Queue Query Builder * * Constructs Supabase queries for fetching and filtering moderation queue items. * Handles complex filtering logic, pagination, and entity resolution. */ import { SupabaseClient } from '@supabase/supabase-js'; import type { EntityFilter, StatusFilter, QueueTab, SortConfig, } from '@/types/moderation'; /** * Query configuration for building submission queries */ export interface QueryConfig { entityFilter: EntityFilter; statusFilter: StatusFilter; tab: QueueTab; userId: string; isAdmin: boolean; isSuperuser: boolean; currentPage: number; pageSize: number; sortConfig?: SortConfig; } /** * Result from fetching submissions */ export interface FetchSubmissionsResult { submissions: any[]; totalCount: number; error?: Error; } /** * Build a Supabase query for content submissions based on filters * * Applies tab-based filtering (main queue vs archive), entity type filtering, * status filtering, and access control (admin vs moderator view). * * @param supabase - Supabase client instance * @param config - Query configuration * @returns Configured Supabase query builder */ export function buildSubmissionQuery( supabase: SupabaseClient, config: QueryConfig ) { const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config; // Build base query with all needed data + user profiles (eliminate N+1 query) let query = supabase .from('content_submissions') .select(` id, submission_type, status, content, created_at, submitted_at, user_id, reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until, submitter:profiles!content_submissions_user_id_fkey ( user_id, username, display_name, avatar_url ), reviewer:profiles!content_submissions_reviewer_id_fkey ( user_id, username, display_name, avatar_url ), submission_items ( id, item_type, item_data, status ) `); // CRITICAL: Multi-level ordering // Level 1: Always sort by escalated first (descending) - escalated items always appear at top query = query.order('escalated', { ascending: false }); // Level 2: Apply user-selected sort (if provided) if (config.sortConfig) { query = query.order(config.sortConfig.field, { ascending: config.sortConfig.direction === 'asc' }); } // Level 3: Tertiary sort by created_at as tiebreaker (if not already primary sort) if (!config.sortConfig || config.sortConfig.field !== 'created_at') { query = query.order('created_at', { ascending: true }); } // Apply tab-based status filtering if (tab === 'mainQueue') { // Main queue: pending, flagged, partially_approved submissions if (statusFilter === 'all') { query = query.in('status', ['pending', 'flagged', 'partially_approved']); } else if (statusFilter === 'pending') { query = query.in('status', ['pending', 'partially_approved']); } else { query = query.eq('status', statusFilter); } } else { // Archive: approved or rejected submissions if (statusFilter === 'all') { query = query.in('status', ['approved', 'rejected']); } else { query = query.eq('status', statusFilter); } } // Apply entity type filter if (entityFilter === 'photos') { query = query.eq('submission_type', 'photo'); } else if (entityFilter === 'submissions') { query = query.neq('submission_type', 'photo'); } // 'all' and 'reviews' filters don't add any conditions // CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions // Admins see all submissions if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); query = query.or( `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}` ); } return query; } /** * Build a count query with the same filters as the main query * * Used for pagination to get total number of items matching the filter criteria. * * @param supabase - Supabase client instance * @param config - Query configuration * @returns Configured count query */ export function buildCountQuery( supabase: SupabaseClient, config: QueryConfig ) { const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config; let countQuery = supabase .from('content_submissions') .select('*', { count: 'exact', head: true }); // Apply same filters as main query if (tab === 'mainQueue') { if (statusFilter === 'all') { countQuery = countQuery.in('status', ['pending', 'flagged', 'partially_approved']); } else if (statusFilter === 'pending') { countQuery = countQuery.in('status', ['pending', 'partially_approved']); } else { countQuery = countQuery.eq('status', statusFilter); } } else { if (statusFilter === 'all') { countQuery = countQuery.in('status', ['approved', 'rejected']); } else { countQuery = countQuery.eq('status', statusFilter); } } if (entityFilter === 'photos') { countQuery = countQuery.eq('submission_type', 'photo'); } else if (entityFilter === 'submissions') { countQuery = countQuery.neq('submission_type', 'photo'); } if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); countQuery = countQuery.or( `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}` ); } return countQuery; } /** * Fetch submissions with pagination and all required data * * Executes the query and returns both the submissions and total count. * Handles errors gracefully and returns them in the result object. * * @param supabase - Supabase client instance * @param config - Query configuration * @returns Submissions data and total count * * @example * ```tsx * const { submissions, totalCount, error } = await fetchSubmissions(supabase, { * entityFilter: 'all', * statusFilter: 'pending', * tab: 'mainQueue', * userId: user.id, * isAdmin: false, * isSuperuser: false, * currentPage: 1, * pageSize: 25 * }); * ``` */ export async function fetchSubmissions( supabase: SupabaseClient, config: QueryConfig ): Promise { try { // Get total count first const countQuery = buildCountQuery(supabase, config); const { count, error: countError } = await countQuery; if (countError) { throw countError; } // Build main query with pagination const query = buildSubmissionQuery(supabase, config); const startIndex = (config.currentPage - 1) * config.pageSize; const endIndex = startIndex + config.pageSize - 1; const paginatedQuery = query.range(startIndex, endIndex); // Execute query const { data: submissions, error: submissionsError } = await paginatedQuery; if (submissionsError) { throw submissionsError; } // Enrich submissions with type field for UI conditional logic const enrichedSubmissions = (submissions || []).map(sub => ({ ...sub, type: 'content_submission' as const, })); return { submissions: enrichedSubmissions, totalCount: count || 0, }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); // Use logger instead of console.error for consistent error tracking return { submissions: [], totalCount: 0, error: error as Error, }; } } /** * Check if a submission is locked by another moderator * * @param submission - Submission object * @param currentUserId - Current user's ID * @returns True if locked by another user */ export function isLockedByOther( submission: any, currentUserId: string ): boolean { if (!submission.locked_until || !submission.assigned_to) { return false; } const lockExpiry = new Date(submission.locked_until); const now = new Date(); // Lock is expired if (lockExpiry < now) { return false; } // Locked by current user if (submission.assigned_to === currentUserId) { return false; } // Locked by someone else return true; } /** * Get queue statistics (optimized with aggregation query) * * Fetches counts for different submission states to display in the queue dashboard. * Uses a single aggregation query instead of fetching all data and filtering client-side. * * @param supabase - Supabase client instance * @param userId - Current user's ID * @param isAdmin - Whether user is admin * @param isSuperuser - Whether user is superuser * @returns Object with various queue statistics */ export async function getQueueStats( supabase: SupabaseClient, userId: string, isAdmin: boolean, isSuperuser: boolean ) { try { // Optimized: Use aggregation directly in database let statsQuery = supabase .from('content_submissions') .select('status, escalated'); // Apply access control if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); statsQuery = statsQuery.or( `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}` ); } const { data: submissions, error } = await statsQuery; if (error) { throw error; } // Calculate statistics (still done client-side but with minimal data transfer) const pending = submissions?.filter(s => s.status === 'pending' || s.status === 'partially_approved').length || 0; const flagged = submissions?.filter(s => s.status === 'flagged').length || 0; const escalated = submissions?.filter(s => s.escalated).length || 0; const total = submissions?.length || 0; return { pending, flagged, escalated, total, }; } catch (error: unknown) { // Error already logged in caller, just return defaults return { pending: 0, flagged: 0, escalated: 0, total: 0, }; } }