mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:31:13 -05:00
364 lines
9.8 KiB
TypeScript
364 lines
9.8 KiB
TypeScript
/**
|
|
* 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<FetchSubmissionsResult> {
|
|
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,
|
|
};
|
|
}
|
|
}
|