/** * 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 * @param skipModeratorFilter - Skip the moderator access control filter * @returns Configured Supabase query builder */ export function buildSubmissionQuery( supabase: SupabaseClient, config: QueryConfig, skipModeratorFilter = false ) { const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config; // Use optimized view with pre-joined profiles and entity data let query = supabase .from('moderation_queue_with_entities') .select('*'); // 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 // Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions if (!isAdmin && !isSuperuser && !skipModeratorFilter) { const now = new Date().toISOString(); // Single filter approach (used by getQueueStats) 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'); } // Note: Count query not used for non-admin users (multi-query approach handles count) 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 { const { userId, isAdmin, isSuperuser, currentPage, pageSize } = config; // For non-admin users, use multi-query approach to avoid complex OR filters if (!isAdmin && !isSuperuser) { return await fetchSubmissionsMultiQuery(supabase, config); } // Admin path: use single query with count 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 = (currentPage - 1) * pageSize; const endIndex = startIndex + 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) { return { submissions: [], totalCount: 0, error: error as Error, }; } } /** * Fetch submissions using multi-query approach for non-admin users * * Executes three separate queries to avoid complex OR filters: * 1. Unclaimed items (assigned_to is null) * 2. Expired locks (locked_until < now, not assigned to current user) * 3. Items assigned to current user * * Results are merged, deduplicated, sorted, and paginated. */ async function fetchSubmissionsMultiQuery( supabase: SupabaseClient, config: QueryConfig ): Promise { const { userId, currentPage, pageSize } = config; const now = new Date().toISOString(); try { // Build three separate queries // Query 1: Unclaimed items const query1 = buildSubmissionQuery(supabase, config, true).is('assigned_to', null); // Query 2: Expired locks (not mine) const query2 = buildSubmissionQuery(supabase, config, true) .not('assigned_to', 'is', null) .neq('assigned_to', userId) .lt('locked_until', now); // Query 3: My claimed items const query3 = buildSubmissionQuery(supabase, config, true).eq('assigned_to', userId); // Execute all queries in parallel const [result1, result2, result3] = await Promise.all([ query1, query2, query3, ]); // Check for errors if (result1.error) throw result1.error; if (result2.error) throw result2.error; if (result3.error) throw result3.error; // Merge all submissions const allSubmissions = [ ...(result1.data || []), ...(result2.data || []), ...(result3.data || []), ]; // Deduplicate by ID const uniqueMap = new Map(); allSubmissions.forEach(sub => { if (!uniqueMap.has(sub.id)) { uniqueMap.set(sub.id, sub); } }); const uniqueSubmissions = Array.from(uniqueMap.values()); // Apply sorting (same logic as buildSubmissionQuery) uniqueSubmissions.sort((a, b) => { // Level 1: Escalated first if (a.escalated !== b.escalated) { return b.escalated ? 1 : -1; } // Level 2: Custom sort (if provided) if (config.sortConfig) { const field = config.sortConfig.field; const ascending = config.sortConfig.direction === 'asc'; const aVal = a[field]; const bVal = b[field]; if (aVal !== bVal) { if (aVal == null) return 1; if (bVal == null) return -1; const comparison = aVal < bVal ? -1 : 1; return ascending ? comparison : -comparison; } } // Level 3: Tiebreaker by created_at const aTime = new Date(a.created_at).getTime(); const bTime = new Date(b.created_at).getTime(); return aTime - bTime; }); // Apply pagination const totalCount = uniqueSubmissions.length; const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedSubmissions = uniqueSubmissions.slice(startIndex, endIndex); // Enrich with type field const enrichedSubmissions = paginatedSubmissions.map(sub => ({ ...sub, type: 'content_submission' as const, })); return { submissions: enrichedSubmissions, totalCount, }; } catch (error: unknown) { 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 using simple OR filter if (!isAdmin && !isSuperuser) { const now = new Date().toISOString(); // Show: unclaimed items OR items with expired locks OR my items 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, }; } }