mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 03:11:12 -05:00
feat: Extract business logic for moderation
This commit is contained in:
375
src/lib/moderation/queries.ts
Normal file
375
src/lib/moderation/queries.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* 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,
|
||||
} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
`)
|
||||
.order('escalated', { ascending: false })
|
||||
.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;
|
||||
}
|
||||
|
||||
return {
|
||||
submissions: submissions || [],
|
||||
totalCount: count || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching submissions:', 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) {
|
||||
console.error('Failed to fetch user profiles:', 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) {
|
||||
console.error('Error fetching queue stats:', error);
|
||||
return {
|
||||
pending: 0,
|
||||
flagged: 0,
|
||||
escalated: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user