diff --git a/src/lib/moderation/actions.ts b/src/lib/moderation/actions.ts new file mode 100644 index 00000000..79751d77 --- /dev/null +++ b/src/lib/moderation/actions.ts @@ -0,0 +1,466 @@ +/** + * Moderation Actions + * + * Business logic for performing moderation actions on submissions. + * Handles approval, rejection, and deletion workflows with proper + * error handling and database updates. + */ + +import { SupabaseClient } from '@supabase/supabase-js'; +import type { ModerationItem } from '@/types/moderation'; + +/** + * Result of a moderation action + */ +export interface ModerationActionResult { + success: boolean; + message: string; + error?: Error; + shouldRemoveFromQueue: boolean; +} + +/** + * Configuration for photo approval + */ +interface PhotoApprovalConfig { + submissionId: string; + moderatorId: string; + moderatorNotes?: string; +} + +/** + * Approve a photo submission + * + * Creates photo records in the database and updates submission status. + * Handles both new approvals and re-approvals (where photos already exist). + * + * @param supabase - Supabase client + * @param config - Photo approval configuration + * @returns Action result with success status and message + */ +export async function approvePhotoSubmission( + supabase: SupabaseClient, + config: PhotoApprovalConfig +): Promise { + try { + // Fetch photo submission from relational tables + const { data: photoSubmission, error: fetchError } = await supabase + .from('photo_submissions') + .select(` + *, + items:photo_submission_items(*), + submission:content_submissions!inner(user_id, status) + `) + .eq('submission_id', config.submissionId) + .single(); + + if (fetchError || !photoSubmission) { + throw new Error('Failed to fetch photo submission data'); + } + + if (!photoSubmission.items || photoSubmission.items.length === 0) { + throw new Error('No photos found in submission'); + } + + // Check if photos already exist for this submission (re-approval case) + const { data: existingPhotos } = await supabase + .from('photos') + .select('id') + .eq('submission_id', config.submissionId); + + if (!existingPhotos || existingPhotos.length === 0) { + // Create new photo records from photo_submission_items + const photoRecords = photoSubmission.items.map((item: any) => ({ + entity_id: photoSubmission.entity_id, + entity_type: photoSubmission.entity_type, + cloudflare_image_id: item.cloudflare_image_id, + cloudflare_image_url: item.cloudflare_image_url, + title: item.title || null, + caption: item.caption || null, + date_taken: item.date_taken || null, + order_index: item.order_index, + submission_id: photoSubmission.submission_id, + submitted_by: photoSubmission.submission?.user_id, + approved_by: config.moderatorId, + approved_at: new Date().toISOString(), + })); + + const { error: insertError } = await supabase + .from('photos') + .insert(photoRecords); + + if (insertError) { + throw insertError; + } + } + + // Update submission status + const { error: updateError } = await supabase + .from('content_submissions') + .update({ + status: 'approved', + reviewer_id: config.moderatorId, + reviewed_at: new Date().toISOString(), + reviewer_notes: config.moderatorNotes, + }) + .eq('id', config.submissionId); + + if (updateError) { + throw updateError; + } + + return { + success: true, + message: `Successfully approved and published ${photoSubmission.items.length} photo(s)`, + shouldRemoveFromQueue: true, + }; + } catch (error) { + console.error('Photo approval error:', error); + return { + success: false, + message: 'Failed to approve photo submission', + error: error as Error, + shouldRemoveFromQueue: false, + }; + } +} + +/** + * Approve a submission with submission_items + * + * Uses the edge function to process all pending submission items. + * + * @param supabase - Supabase client + * @param submissionId - Submission ID + * @param itemIds - Array of item IDs to approve + * @returns Action result + */ +export async function approveSubmissionItems( + supabase: SupabaseClient, + submissionId: string, + itemIds: string[] +): Promise { + try { + const { error: approvalError } = await supabase.functions.invoke( + 'process-selective-approval', + { + body: { + itemIds, + submissionId, + }, + } + ); + + if (approvalError) { + throw new Error(`Failed to process submission items: ${approvalError.message}`); + } + + return { + success: true, + message: `Successfully processed ${itemIds.length} item(s)`, + shouldRemoveFromQueue: true, + }; + } catch (error) { + console.error('Submission items approval error:', error); + return { + success: false, + message: 'Failed to approve submission items', + error: error as Error, + shouldRemoveFromQueue: false, + }; + } +} + +/** + * Reject a submission with submission_items + * + * Cascades rejection to all pending items. + * + * @param supabase - Supabase client + * @param submissionId - Submission ID + * @param rejectionReason - Reason for rejection + * @returns Action result + */ +export async function rejectSubmissionItems( + supabase: SupabaseClient, + submissionId: string, + rejectionReason?: string +): Promise { + try { + const { error: rejectError } = await supabase + .from('submission_items') + .update({ + status: 'rejected', + rejection_reason: rejectionReason || 'Parent submission rejected', + updated_at: new Date().toISOString(), + }) + .eq('submission_id', submissionId) + .eq('status', 'pending'); + + if (rejectError) { + console.error('Failed to cascade rejection:', rejectError); + // Don't fail the whole operation, just log it + } + + return { + success: true, + message: 'Submission items rejected', + shouldRemoveFromQueue: false, // Parent rejection will handle removal + }; + } catch (error) { + console.error('Submission items rejection error:', error); + return { + success: false, + message: 'Failed to reject submission items', + error: error as Error, + shouldRemoveFromQueue: false, + }; + } +} + +/** + * Configuration for standard moderation actions + */ +export interface ModerationConfig { + item: ModerationItem; + action: 'approved' | 'rejected'; + moderatorId: string; + moderatorNotes?: string; +} + +/** + * Perform a standard moderation action (approve/reject) + * + * Updates the submission or review status in the database. + * Handles both content_submissions and reviews. + * + * @param supabase - Supabase client + * @param config - Moderation configuration + * @returns Action result + */ +export async function performModerationAction( + supabase: SupabaseClient, + config: ModerationConfig +): Promise { + const { item, action, moderatorId, moderatorNotes } = config; + + try { + // Handle photo submissions specially + if ( + action === 'approved' && + item.type === 'content_submission' && + item.submission_type === 'photo' + ) { + return await approvePhotoSubmission(supabase, { + submissionId: item.id, + moderatorId, + moderatorNotes, + }); + } + + // Check if this submission has submission_items + if (item.type === 'content_submission') { + const { data: submissionItems, error: itemsError } = await supabase + .from('submission_items') + .select('id, status') + .eq('submission_id', item.id) + .in('status', ['pending', 'rejected']); + + if (!itemsError && submissionItems && submissionItems.length > 0) { + if (action === 'approved') { + return await approveSubmissionItems( + supabase, + item.id, + submissionItems.map(i => i.id) + ); + } else if (action === 'rejected') { + await rejectSubmissionItems(supabase, item.id, moderatorNotes); + } + } + } + + // Standard moderation flow + const table = item.type === 'review' ? 'reviews' : 'content_submissions'; + const statusField = item.type === 'review' ? 'moderation_status' : 'status'; + const timestampField = item.type === 'review' ? 'moderated_at' : 'reviewed_at'; + const reviewerField = item.type === 'review' ? 'moderated_by' : 'reviewer_id'; + + const updateData: any = { + [statusField]: action, + [timestampField]: new Date().toISOString(), + [reviewerField]: moderatorId, + }; + + if (moderatorNotes) { + updateData.reviewer_notes = moderatorNotes; + } + + const { error, data } = await supabase + .from(table as any) + .update(updateData) + .eq('id', item.id) + .select(); + + if (error) { + throw error; + } + + // Check if the update actually affected any rows + if (!data || data.length === 0) { + throw new Error( + 'Failed to update item - no rows affected. You might not have permission to moderate this content.' + ); + } + + return { + success: true, + message: `Content ${action}`, + shouldRemoveFromQueue: action === 'approved' || action === 'rejected', + }; + } catch (error) { + console.error('Moderation action error:', error); + return { + success: false, + message: `Failed to ${action} content`, + error: error as Error, + shouldRemoveFromQueue: false, + }; + } +} + +/** + * Configuration for submission deletion + */ +export interface DeleteSubmissionConfig { + item: ModerationItem; + deletePhotos?: boolean; +} + +/** + * Delete a submission and its associated photos + * + * Extracts photo IDs, deletes them from Cloudflare, then deletes the submission. + * + * @param supabase - Supabase client + * @param config - Deletion configuration + * @returns Action result + */ +export async function deleteSubmission( + supabase: SupabaseClient, + config: DeleteSubmissionConfig +): Promise { + const { item, deletePhotos = true } = config; + + if (item.type !== 'content_submission') { + return { + success: false, + message: 'Can only delete content submissions', + shouldRemoveFromQueue: false, + }; + } + + try { + let deletedPhotoCount = 0; + let skippedPhotoCount = 0; + + // Extract and delete photos if requested + if (deletePhotos) { + const photosArray = item.content?.content?.photos || item.content?.photos; + + if (photosArray && Array.isArray(photosArray)) { + const validImageIds: string[] = []; + + for (const photo of photosArray) { + let imageId = ''; + + if (photo.imageId) { + imageId = photo.imageId; + } else if (photo.url && !photo.url.startsWith('blob:')) { + // Try to extract from URL + const uuidRegex = + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; + + if (uuidRegex.test(photo.url)) { + imageId = photo.url; + } else { + const cloudflareMatch = photo.url.match( + /imagedelivery\.net\/[^\/]+\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i + ); + if (cloudflareMatch) { + imageId = cloudflareMatch[1]; + } + } + } + + if (imageId) { + validImageIds.push(imageId); + } else { + skippedPhotoCount++; + } + } + + // Delete photos from Cloudflare + if (validImageIds.length > 0) { + const deletePromises = validImageIds.map(async imageId => { + try { + await supabase.functions.invoke('upload-image', { + method: 'DELETE', + body: { imageId }, + }); + } catch (error) { + console.error(`Failed to delete photo ${imageId}:`, error); + } + }); + + await Promise.allSettled(deletePromises); + deletedPhotoCount = validImageIds.length; + } + } + } + + // Delete the submission from the database + const { error } = await supabase + .from('content_submissions') + .delete() + .eq('id', item.id); + + if (error) { + throw error; + } + + // Verify deletion + const { data: checkData, error: checkError } = await supabase + .from('content_submissions') + .select('id') + .eq('id', item.id) + .single(); + + if (checkData && !checkError) { + throw new Error('Deletion failed - item still exists in database'); + } + + // Build result message + let message = 'The submission has been permanently deleted'; + if (deletedPhotoCount > 0 && skippedPhotoCount > 0) { + message = `The submission and ${deletedPhotoCount} photo(s) have been deleted. ${skippedPhotoCount} photo(s) could not be deleted from storage`; + } else if (deletedPhotoCount > 0) { + message = `The submission and ${deletedPhotoCount} associated photo(s) have been permanently deleted`; + } else if (skippedPhotoCount > 0) { + message = `The submission has been deleted. ${skippedPhotoCount} photo(s) could not be deleted from storage`; + } + + return { + success: true, + message, + shouldRemoveFromQueue: true, + }; + } catch (error) { + console.error('Error deleting submission:', error); + return { + success: false, + message: 'Failed to delete submission', + error: error as Error, + shouldRemoveFromQueue: false, + }; + } +} diff --git a/src/lib/moderation/entities.ts b/src/lib/moderation/entities.ts new file mode 100644 index 00000000..9b086e0e --- /dev/null +++ b/src/lib/moderation/entities.ts @@ -0,0 +1,202 @@ +/** + * Entity Resolution Utilities + * + * Functions for resolving entity names and display information + * from cached entity data used in moderation workflows. + */ + +/** + * Entity cache structure (matching useEntityCache hook) + */ +interface EntityCache { + rides: Map; + parks: Map; + companies: Map; +} + +/** + * Result of entity name resolution + */ +export interface ResolvedEntityNames { + entityName: string; + parkName?: string; +} + +/** + * Resolve entity name and related park name from submission content + * + * This function determines what entity is being modified based on the + * submission type and content, then looks up cached entity data to + * resolve display names. + * + * @param submissionType - Type of submission (e.g., 'ride', 'park', 'manufacturer') + * @param content - Submission content containing entity IDs + * @param entityCache - Cache of entity data + * @returns Resolved entity and park names + * + * @example + * ```tsx + * const { entityName, parkName } = resolveEntityName( + * 'ride', + * { entity_id: 'ride-123' }, + * entityCacheRef.current + * ); + * // Returns: { entityName: "Steel Vengeance", parkName: "Cedar Point" } + * ``` + */ +export function resolveEntityName( + submissionType: string, + content: any, + entityCache: EntityCache +): ResolvedEntityNames { + let entityName = content?.name || 'Unknown'; + let parkName: string | undefined; + + // Handle ride submissions - look up ride name and park + if (submissionType === 'ride' && content?.entity_id) { + const ride = entityCache.rides.get(content.entity_id); + if (ride) { + entityName = ride.name; + if (ride.park_id) { + const park = entityCache.parks.get(ride.park_id); + if (park) parkName = park.name; + } + } + } + // Handle park submissions + else if (submissionType === 'park' && content?.entity_id) { + const park = entityCache.parks.get(content.entity_id); + if (park) entityName = park.name; + } + // Handle company submissions (manufacturer, operator, designer, property_owner) + else if ( + ['manufacturer', 'operator', 'designer', 'property_owner'].includes(submissionType) && + content?.entity_id + ) { + const company = entityCache.companies.get(content.entity_id); + if (company) entityName = company.name; + } + // Handle content with ride_id reference + else if (content?.ride_id) { + const ride = entityCache.rides.get(content.ride_id); + if (ride) { + entityName = ride.name; + if (ride.park_id) { + const park = entityCache.parks.get(ride.park_id); + if (park) parkName = park.name; + } + } + } + // Handle content with park_id reference + else if (content?.park_id) { + const park = entityCache.parks.get(content.park_id); + if (park) parkName = park.name; + } + + return { entityName, parkName }; +} + +/** + * Get a display-ready entity identifier string + * + * Combines entity name and park name (if available) into a single + * human-readable string for display in the moderation interface. + * + * @param entityName - Primary entity name + * @param parkName - Optional related park name + * @returns Formatted display string + * + * @example + * ```tsx + * getEntityDisplayName("Steel Vengeance", "Cedar Point") + * // Returns: "Steel Vengeance at Cedar Point" + * + * getEntityDisplayName("Cedar Point") + * // Returns: "Cedar Point" + * ``` + */ +export function getEntityDisplayName( + entityName: string, + parkName?: string +): string { + if (parkName) { + return `${entityName} at ${parkName}`; + } + return entityName; +} + +/** + * Extract all entity IDs from a list of submissions + * + * Scans through submission content to find all referenced entity IDs, + * grouped by entity type (rides, parks, companies). + * + * @param submissions - Array of submission objects + * @returns Object containing Sets of IDs for each entity type + */ +export function extractEntityIds(submissions: any[]): { + rideIds: Set; + parkIds: Set; + companyIds: Set; +} { + const rideIds = new Set(); + const parkIds = new Set(); + const companyIds = new Set(); + + submissions.forEach(submission => { + const content = submission.content as any; + if (content && typeof content === 'object') { + // Direct entity references + if (content.ride_id) rideIds.add(content.ride_id); + if (content.park_id) parkIds.add(content.park_id); + if (content.company_id) companyIds.add(content.company_id); + + // Entity ID based on submission type + if (content.entity_id) { + if (submission.submission_type === 'ride') { + rideIds.add(content.entity_id); + } else if (submission.submission_type === 'park') { + parkIds.add(content.entity_id); + } else if ( + ['manufacturer', 'operator', 'designer', 'property_owner'].includes( + submission.submission_type + ) + ) { + companyIds.add(content.entity_id); + } + } + + // Company role references + if (content.manufacturer_id) companyIds.add(content.manufacturer_id); + if (content.designer_id) companyIds.add(content.designer_id); + if (content.operator_id) companyIds.add(content.operator_id); + if (content.property_owner_id) companyIds.add(content.property_owner_id); + } + }); + + return { rideIds, parkIds, companyIds }; +} + +/** + * Determine submission type display label + * + * Converts internal submission type identifiers to human-readable labels. + * + * @param submissionType - Internal submission type + * @returns Human-readable label + */ +export function getSubmissionTypeLabel(submissionType: string): string { + const labels: Record = { + park: 'Park', + ride: 'Ride', + manufacturer: 'Manufacturer', + operator: 'Operator', + designer: 'Designer', + property_owner: 'Property Owner', + ride_model: 'Ride Model', + photo: 'Photo', + review: 'Review', + }; + + return labels[submissionType] || submissionType; +} diff --git a/src/lib/moderation/index.ts b/src/lib/moderation/index.ts new file mode 100644 index 00000000..21e6163f --- /dev/null +++ b/src/lib/moderation/index.ts @@ -0,0 +1,44 @@ +/** + * Moderation Library + * + * Centralized exports for all moderation-related utilities. + * Provides business logic for moderation workflows, queries, and entity resolution. + */ + +// Query builders and data fetching +export { + buildSubmissionQuery, + buildCountQuery, + fetchSubmissions, + fetchUserProfiles, + extractUserIds, + isLockedByOther, + getQueueStats, +} from './queries'; + +export type { QueryConfig, FetchSubmissionsResult } from './queries'; + +// Entity resolution +export { + resolveEntityName, + getEntityDisplayName, + extractEntityIds, + getSubmissionTypeLabel, +} from './entities'; + +export type { ResolvedEntityNames } from './entities'; + +// Moderation actions +export { + approvePhotoSubmission, + approveSubmissionItems, + rejectSubmissionItems, + performModerationAction, + deleteSubmission, +} from './actions'; + +export type { + ModerationActionResult, + ModerationConfig, + DeleteSubmissionConfig, +} from './actions'; diff --git a/src/lib/moderation/queries.ts b/src/lib/moderation/queries.ts new file mode 100644 index 00000000..737b8723 --- /dev/null +++ b/src/lib/moderation/queries.ts @@ -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 { + 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> { + 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, + }; + } +}