mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 15:31:12 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
455
src-old/lib/moderation/queries.ts
Normal file
455
src-old/lib/moderation/queries.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* 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<FetchSubmissionsResult> {
|
||||
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<FetchSubmissionsResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user