From 3e0c4db0a17797da9eeabfffe2f8eb51816468a5 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:09:45 +0000 Subject: [PATCH] Refactor moderation queue query --- src/lib/moderation/queries.ts | 164 +++++++++++++++++++++++++++++----- 1 file changed, 143 insertions(+), 21 deletions(-) diff --git a/src/lib/moderation/queries.ts b/src/lib/moderation/queries.ts index 7276476d..ce22d73b 100644 --- a/src/lib/moderation/queries.ts +++ b/src/lib/moderation/queries.ts @@ -45,11 +45,13 @@ export interface FetchSubmissionsResult { * * @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 + config: QueryConfig, + skipModeratorFilter = false ) { const { entityFilter, statusFilter, tab, userId, isAdmin, isSuperuser } = config; @@ -135,11 +137,12 @@ export function buildSubmissionQuery( // CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions // Admins see all submissions - if (!isAdmin && !isSuperuser) { + // Note: For non-admin users, moderator filtering is handled by multi-query approach in fetchSubmissions + if (!isAdmin && !isSuperuser && !skipModeratorFilter) { const now = new Date().toISOString(); - // Fixed: Add null check for locked_until to prevent 400 error + // Single filter approach (used by getQueueStats) query = query.or( - `assigned_to.is.null,and(locked_until.not.is.null,locked_until.lt.${now}),assigned_to.eq.${userId}` + `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}` ); } @@ -188,11 +191,11 @@ export function buildCountQuery( 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(); - // Fixed: Add null check for locked_until to prevent 400 error countQuery = countQuery.or( - `assigned_to.is.null,and(locked_until.not.is.null,locked_until.lt.${now}),assigned_to.eq.${userId}` + `assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}` ); } @@ -228,7 +231,14 @@ export async function fetchSubmissions( config: QueryConfig ): Promise { try { - // Get total count first + 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; @@ -238,8 +248,8 @@ export async function fetchSubmissions( // Build main query with pagination const query = buildSubmissionQuery(supabase, config); - const startIndex = (config.currentPage - 1) * config.pageSize; - const endIndex = startIndex + config.pageSize - 1; + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize - 1; const paginatedQuery = query.range(startIndex, endIndex); // Execute query @@ -249,19 +259,130 @@ export async function fetchSubmissions( throw submissionsError; } - // Enrich submissions with type field for UI conditional logic - const enrichedSubmissions = (submissions || []).map(sub => ({ - ...sub, - type: 'content_submission' as const, - })); + // 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, - }; + 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) { - const errorMessage = error instanceof Error ? error.message : String(error); - // Use logger instead of console.error for consistent error tracking return { submissions: [], totalCount: 0, @@ -327,9 +448,10 @@ export async function getQueueStats( .from('content_submissions') .select('status, escalated'); - // Apply access control + // 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}` );