Files
thrilltrack-explorer/src/lib/moderation/queries.ts
gpt-engineer-app[bot] 81fccdc4d0 Fix remaining catch blocks
2025-10-21 17:08:24 +00:00

398 lines
11 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
let query = supabase
.from('content_submissions')
.select(`
id,
submission_type,
status,
content,
created_at,
user_id,
reviewed_at,
reviewer_id,
reviewer_notes,
escalated,
assigned_to,
locked_until,
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) {
console.error('Error fetching submissions:', error instanceof Error ? error.message : String(error));
return {
submissions: [],
totalCount: 0,
error: error as Error,
};
}
}
/**
* Fetch user profiles for submitters and reviewers
*
* @param supabase - Supabase client instance
* @param userIds - Array of user IDs to fetch profiles for
* @returns Map of userId -> profile data
*/
export async function fetchUserProfiles(
supabase: SupabaseClient,
userIds: string[]
): Promise<Map<string, any>> {
if (userIds.length === 0) {
return new Map();
}
try {
const { data: profiles, error } = await supabase
.from('profiles')
.select('user_id, username, display_name, avatar_url')
.in('user_id', userIds);
if (error) {
console.error('Error fetching user profiles:', error);
return new Map();
}
return new Map(profiles?.map(p => [p.user_id, p]) || []);
} catch (error: unknown) {
console.error('Failed to fetch user profiles:', error instanceof Error ? error.message : String(error));
return new Map();
}
}
/**
* Extract user IDs from submissions for profile fetching
*
* Collects all unique user IDs (submitters and reviewers) from a list of submissions.
*
* @param submissions - Array of submission objects
* @returns Array of unique user IDs
*/
export function extractUserIds(submissions: any[]): string[] {
const userIds = submissions.map(s => s.user_id).filter(Boolean);
const reviewerIds = submissions
.map(s => s.reviewer_id)
.filter((id): id is string => !!id);
return [...new Set([...userIds, ...reviewerIds])];
}
/**
* 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
*
* Fetches counts for different submission states to display in the queue dashboard.
*
* @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 {
// Build base query
let baseQuery = supabase
.from('content_submissions')
.select('status, escalated', { count: 'exact', head: false });
// Apply access control
if (!isAdmin && !isSuperuser) {
const now = new Date().toISOString();
baseQuery = baseQuery.or(
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${userId}`
);
}
const { data: submissions, error } = await baseQuery;
if (error) {
throw error;
}
// Calculate statistics
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) {
console.error('Error fetching queue stats:', error instanceof Error ? error.message : String(error));
return {
pending: 0,
flagged: 0,
escalated: 0,
total: 0,
};
}
}