import { useState, useEffect, useCallback, useRef } from 'react'; import { supabase } from '@/lib/supabaseClient'; import { useAuth } from './useAuth'; import { useToast } from './use-toast'; import { getErrorMessage, handleNonCriticalError, handleError } from '@/lib/errorHandler'; import { getSubmissionTypeLabel } from '@/lib/moderation/entities'; import { logger } from '@/lib/logger'; interface QueuedSubmission { submission_id: string; submission_type: string; waiting_time: string; // PostgreSQL interval format } interface LockState { submissionId: string; expiresAt: Date; autoReleaseTimer?: NodeJS.Timeout; } interface QueueStats { pendingCount: number; assignedToMe: number; avgWaitHours: number; } interface UseModerationQueueConfig { onLockStateChange?: () => void; } export const useModerationQueue = (config?: UseModerationQueueConfig) => { const { onLockStateChange } = config || {}; const [currentLock, setCurrentLock] = useState(null); const [queueStats, setQueueStats] = useState(null); const [isLoading, setIsLoading] = useState(false); const lockTimerRef = useRef(null); const { user } = useAuth(); const { toast } = useToast(); // Auto-release expired locks periodically useEffect(() => { if (!user) return; // Call release_expired_locks every 2 minutes const releaseInterval = setInterval(async () => { try { await supabase.rpc('release_expired_locks'); } catch (error: unknown) { // Log expected periodic failure for debugging without user toast logger.debug('Periodic lock release failed', { operation: 'release_expired_locks', error: getErrorMessage(error) }); } }, 120000); // 2 minutes return () => clearInterval(releaseInterval); }, [user]); // Fetch queue statistics const fetchStats = useCallback(async () => { if (!user) return; try { const { data: slaData } = await supabase .from('moderation_sla_metrics') .select('pending_count, avg_wait_hours'); const { count: assignedCount } = await supabase .from('content_submissions') .select('id', { count: 'exact', head: true }) .eq('assigned_to', user.id) .gt('locked_until', new Date().toISOString()); if (slaData) { const totals = slaData.reduce( (acc, row) => ({ pendingCount: acc.pendingCount + (row.pending_count || 0), avgWaitHours: acc.avgWaitHours + (row.avg_wait_hours || 0), }), { pendingCount: 0, avgWaitHours: 0 } ); setQueueStats({ pendingCount: totals.pendingCount, assignedToMe: assignedCount || 0, avgWaitHours: slaData.length > 0 ? totals.avgWaitHours / slaData.length : 0, }); } } catch (error: unknown) { // Log stats fetch failure for debugging without user toast logger.debug('Queue stats fetch failed', { operation: 'fetchStats', error: getErrorMessage(error) }); } }, [user]); // Start countdown timer for lock expiry with improved memory leak prevention const startLockTimer = useCallback((expiresAt: Date) => { // Track if component is still mounted let isMounted = true; // Clear any existing timer first to prevent leaks if (lockTimerRef.current) { clearInterval(lockTimerRef.current); lockTimerRef.current = null; } lockTimerRef.current = setInterval(() => { // Prevent timer execution if component unmounted if (!isMounted) { if (lockTimerRef.current) { clearInterval(lockTimerRef.current); lockTimerRef.current = null; } return; } const now = new Date(); const timeLeft = expiresAt.getTime() - now.getTime(); if (timeLeft <= 0) { // Clear timer before showing toast to prevent double-firing if (lockTimerRef.current) { clearInterval(lockTimerRef.current); lockTimerRef.current = null; } setCurrentLock(null); toast({ title: 'Lock Expired', description: 'Your review lock has expired. Claim another submission to continue.', variant: 'destructive', }); if (onLockStateChange) { onLockStateChange(); } } }, 1000); // Return cleanup function return () => { isMounted = false; if (lockTimerRef.current) { clearInterval(lockTimerRef.current); lockTimerRef.current = null; } }; }, [toast, onLockStateChange]); // Clean up timer on unmount useEffect(() => { return () => { // Comprehensive cleanup on unmount if (lockTimerRef.current) { clearInterval(lockTimerRef.current); lockTimerRef.current = null; } }; }, []); // Restore active lock from database on mount const restoreActiveLock = useCallback(async () => { if (!user?.id) return; try { // Query for any active lock assigned to current user const { data, error } = await supabase .from('content_submissions') .select('id, locked_until') .eq('assigned_to', user.id) .gt('locked_until', new Date().toISOString()) .in('status', ['pending', 'partially_approved']) .order('locked_until', { ascending: false }) .limit(1) .maybeSingle(); if (error) { throw error; } if (data) { const expiresAt = new Date(data.locked_until || ''); // Only restore if lock hasn't expired (race condition check) if (data.locked_until && expiresAt > new Date()) { const timeRemaining = expiresAt.getTime() - new Date().getTime(); const minTimeMs = 60 * 1000; // 60 seconds minimum if (timeRemaining < minTimeMs) { // Lock expires too soon - auto-release it logger.info('Lock expired or expiring soon, auto-releasing', { submissionId: data.id, timeRemainingSeconds: Math.floor(timeRemaining / 1000), }); // Release the stale lock await supabase.rpc('release_submission_lock', { submission_id: data.id, moderator_id: user.id, }); return; // Don't restore } // Lock has sufficient time - restore it setCurrentLock({ submissionId: data.id, expiresAt, }); // Start countdown timer for restored lock startLockTimer(expiresAt); logger.info('Lock state restored from database', { submissionId: data.id, expiresAt: expiresAt.toISOString(), timeRemainingSeconds: Math.floor(timeRemaining / 1000), }); } } } catch (error: unknown) { // Log but don't show user toast (they haven't taken any action yet) logger.debug('Failed to restore lock state', { error: getErrorMessage(error), userId: user.id, }); } }, [user, startLockTimer]); // Initialize lock state from database on mount useEffect(() => { if (!user) return; restoreActiveLock(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); // Sync lock state across tabs when user returns to the page useEffect(() => { if (!user) return; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { // User returned to tab - check if lock state is still valid if (!currentLock) { restoreActiveLock(); } } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [user, currentLock, restoreActiveLock]); // Claim a specific submission (CRM-style claim any) const extendLock = useCallback(async (submissionId: string): Promise => { if (!user?.id) return false; setIsLoading(true); try { const { data, error } = await supabase.rpc('extend_submission_lock', { submission_id: submissionId, moderator_id: user.id, extension_duration: 'PT15M', // ISO 8601 format: 15 minutes }); if (error) throw error; if (data) { const newExpiresAt = new Date(data); setCurrentLock((prev) => prev?.submissionId === submissionId ? { ...prev, expiresAt: newExpiresAt } : prev ); startLockTimer(newExpiresAt); toast({ title: 'Lock Extended', description: 'Lock extended for 15 more minutes', }); return true; } return false; } catch (error: unknown) { toast({ title: 'Error', description: getErrorMessage(error), variant: 'destructive', }); return false; } finally { setIsLoading(false); } }, [user, toast, startLockTimer]); // Release lock (manual or on completion) const releaseLock = useCallback(async ( submissionId: string, silent: boolean = false ): Promise => { if (!user?.id) return false; setIsLoading(true); try { const { data, error } = await supabase.rpc('release_submission_lock', { submission_id: submissionId, moderator_id: user.id, }); if (error) throw error; // Always clear local state and refresh stats if no error setCurrentLock((prev) => prev?.submissionId === submissionId ? null : prev ); if (lockTimerRef.current) { clearInterval(lockTimerRef.current); lockTimerRef.current = null; // Explicitly null it out } fetchStats(); // Show appropriate toast based on result (unless silent) if (!silent) { if (data === true) { toast({ title: 'Lock Released', description: 'You can now claim another submission', }); } else { toast({ title: 'Lock Already Released', description: 'This submission was already unlocked', }); } } // Trigger refresh callback if (onLockStateChange) { onLockStateChange(); } return data; } catch (error: unknown) { // Always show error toasts even in silent mode toast({ title: 'Failed to Release Lock', description: getErrorMessage(error), variant: 'destructive', }); return false; } finally { setIsLoading(false); } }, [user, fetchStats, toast, onLockStateChange]); // Get time remaining on current lock const getTimeRemaining = useCallback((): number | null => { if (!currentLock) return null; return Math.max(0, currentLock.expiresAt.getTime() - Date.now()); }, [currentLock]); /** * @deprecated Use escalateSubmission from useModerationActions instead * This method only updates the database and doesn't send email notifications */ const escalateSubmission = useCallback(async (submissionId: string, reason: string): Promise => { if (!user?.id) return false; setIsLoading(true); try { const { error } = await supabase .from('content_submissions') .update({ escalated: true, escalated_at: new Date().toISOString(), escalated_by: user.id, escalation_reason: reason, }) .eq('id', submissionId); if (error) throw error; toast({ title: 'Submission Escalated', description: 'This submission has been marked as high priority', }); fetchStats(); return true; } catch (error: unknown) { toast({ title: 'Error', description: getErrorMessage(error), variant: 'destructive', }); return false; } finally { setIsLoading(false); } }, [user, toast, fetchStats]); // Claim a specific submission (CRM-style claim any) const claimSubmission = useCallback(async (submissionId: string): Promise => { if (!user?.id) { toast({ title: 'Authentication Required', description: 'You must be logged in to claim submissions', variant: 'destructive', }); return false; } // Check if trying to claim same submission user already has locked if (currentLock && currentLock.submissionId === submissionId) { toast({ title: 'Already Claimed', description: 'You already have this submission claimed. Review it below.', }); return true; // Return success, don't re-claim } // Check if user already has an active lock on a different submission if (currentLock && currentLock.submissionId !== submissionId) { toast({ title: 'Already Have Active Lock', description: 'Release your current lock before claiming another submission', variant: 'destructive', }); return false; } setIsLoading(true); try { // Get submission details FIRST for better toast message const { data: submission } = await supabase .from('content_submissions') .select('id, submission_type') .eq('id', submissionId) .single(); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // Use direct fetch to force read-write transaction const supabaseUrl = 'https://api.thrillwiki.com'; const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4'; const { data: sessionData } = await supabase.auth.getSession(); const token = sessionData.session?.access_token; const response = await fetch(`${supabaseUrl}/rest/v1/rpc/claim_specific_submission`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': supabaseKey, 'Authorization': `Bearer ${token}`, 'Prefer': 'tx=commit', // Force read-write transaction }, body: JSON.stringify({ p_submission_id: submissionId, p_moderator_id: user.id, p_lock_duration: 'PT15M', // ISO 8601 format: 15 minutes }), }); if (!response.ok) { const errorData = await response.json().catch((parseError) => { handleNonCriticalError(parseError, { action: 'Parse claim error response', userId: user.id, metadata: { submissionId, httpStatus: response.status, context: 'claim_submission_error_parsing' } }); return { message: 'Failed to claim submission' }; }); throw new Error(errorData.message || 'Failed to claim submission'); } const data = await response.json(); if (!data) { throw new Error('Submission is already claimed or no longer available'); } setCurrentLock({ submissionId, expiresAt, }); startLockTimer(expiresAt); // Enhanced toast with submission type const submissionType = submission?.submission_type || 'submission'; const formattedType = getSubmissionTypeLabel(submissionType); toast({ title: '✅ Submission Claimed', description: `${formattedType} locked for 15 minutes. Start reviewing now.`, duration: 4000, }); // Force UI refresh to update queue fetchStats(); if (onLockStateChange) { onLockStateChange(); } return true; } catch (error: unknown) { toast({ title: 'Failed to Claim Submission', description: getErrorMessage(error), variant: 'destructive', }); return false; } finally { setIsLoading(false); // Always clear loading state } }, [user, toast, startLockTimer, fetchStats, onLockStateChange]); // Reassign submission const reassignSubmission = useCallback(async (submissionId: string, newModeratorId: string): Promise => { if (!user?.id) return false; setIsLoading(true); try { const { error } = await supabase .from('content_submissions') .update({ assigned_to: newModeratorId, assigned_at: new Date().toISOString(), locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(), }) .eq('id', submissionId); if (error) throw error; // If this was our lock, clear it if (currentLock?.submissionId === submissionId) { setCurrentLock(null); if (lockTimerRef.current) { clearInterval(lockTimerRef.current); } } toast({ title: 'Submission Reassigned', description: 'The submission has been assigned to another moderator', }); fetchStats(); return true; } catch (error: unknown) { toast({ title: 'Error', description: getErrorMessage(error), variant: 'destructive', }); return false; } finally { setIsLoading(false); } }, [user, currentLock, toast, fetchStats]); // Check if submission is locked by current user const isLockedByMe = useCallback((submissionId: string, assignedTo?: string | null, lockedUntil?: string | null): boolean => { // Check local state first (optimistic UI - immediate feedback) if (currentLock?.submissionId === submissionId) return true; // Also check database state (source of truth) if (assignedTo && lockedUntil && user?.id) { const isAssignedToMe = assignedTo === user.id; const isLockActive = new Date(lockedUntil) > new Date(); return isAssignedToMe && isLockActive; } return false; }, [currentLock, user]); // Check if submission is locked by another moderator const isLockedByOther = useCallback((submissionId: string, assignedTo: string | null, lockedUntil: string | null): boolean => { if (!assignedTo || !lockedUntil) return false; if (user?.id === assignedTo) return false; // It's our lock return new Date(lockedUntil) > new Date(); // Lock is still active }, [user]); // Get lock progress percentage (0-100) const getLockProgress = useCallback((): number => { const timeLeft = getTimeRemaining(); if (timeLeft === null) return 0; const totalTime = 15 * 60 * 1000; // 15 minutes in ms return Math.max(0, Math.min(100, (timeLeft / totalTime) * 100)); }, [getTimeRemaining]); // Auto-release lock after moderation action const releaseAfterAction = useCallback(async (submissionId: string, action: 'approved' | 'rejected'): Promise => { if (currentLock?.submissionId === submissionId) { await releaseLock(submissionId, true); // Silent release } }, [currentLock, releaseLock]); // Superuser: Force release a specific lock const superuserReleaseLock = useCallback(async ( submissionId: string ): Promise => { if (!user?.id) return false; setIsLoading(true); try { const { data, error } = await supabase.rpc('superuser_release_lock', { p_submission_id: submissionId, p_superuser_id: user.id, }); if (error) throw error; toast({ title: 'Lock Forcibly Released', description: 'The submission has been unlocked and is now available', }); fetchStats(); if (onLockStateChange) { onLockStateChange(); } return data; } catch (error: unknown) { handleError(error, { action: 'Superuser Release Lock', userId: user.id, metadata: { submissionId } }); return false; } finally { setIsLoading(false); } }, [user, fetchStats, toast, onLockStateChange]); // Superuser: Clear all locks const superuserReleaseAllLocks = useCallback(async (): Promise => { if (!user?.id) return 0; setIsLoading(true); try { const { data, error } = await supabase.rpc('superuser_release_all_locks', { p_superuser_id: user.id, }); if (error) throw error; const count = data || 0; toast({ title: 'All Locks Cleared', description: `${count} submission${count !== 1 ? 's' : ''} unlocked`, }); fetchStats(); if (onLockStateChange) { onLockStateChange(); } return count; } catch (error: unknown) { handleError(error, { action: 'Superuser Clear All Locks', userId: user.id, metadata: { attemptedAction: 'bulk_release' } }); return 0; } finally { setIsLoading(false); } }, [user, fetchStats, toast, onLockStateChange]); return { currentLock, // Exposed for reactive UI updates queueStats, isLoading, claimSubmission, extendLock, releaseLock, getTimeRemaining, escalateSubmission, reassignSubmission, refreshStats: fetchStats, // New helpers isLockedByMe, isLockedByOther, getLockProgress, releaseAfterAction, // Superuser lock management superuserReleaseLock, superuserReleaseAllLocks, }; }; // Helper to format PostgreSQL interval function formatInterval(interval: string): string { const match = interval.match(/(\d+):(\d+):(\d+)/); if (!match) return interval; const hours = parseInt(match[1]); const minutes = parseInt(match[2]); if (hours > 24) { const days = Math.floor(hours / 24); return `${days}d ${hours % 24}h`; } else if (hours > 0) { return `${hours}h ${minutes}m`; } else { return `${minutes}m`; } }