/** * Lock Auto-Release Mechanism * * Automatically releases submission locks when operations fail, timeout, * or are abandoned by moderators. Prevents deadlocks and improves queue flow. * * Part of Sacred Pipeline Phase 4: Transaction Resilience */ import { supabase } from '@/lib/supabaseClient'; import { logger } from '@/lib/logger'; import { isTimeoutError } from '@/lib/timeoutDetection'; import { toast } from '@/hooks/use-toast'; export interface LockReleaseOptions { submissionId: string; moderatorId: string; reason: 'timeout' | 'error' | 'abandoned' | 'manual'; error?: unknown; silent?: boolean; // Don't show toast notification } /** * Release a lock on a submission */ export async function releaseLock(options: LockReleaseOptions): Promise { const { submissionId, moderatorId, reason, error, silent = false } = options; try { // Call Supabase RPC to release lock const { error: releaseError } = await supabase.rpc('release_submission_lock', { submission_id: submissionId, moderator_id: moderatorId, }); if (releaseError) { logger.error('Failed to release lock', { submissionId, moderatorId, reason, error: releaseError, }); if (!silent) { toast({ title: 'Lock Release Failed', description: 'Failed to release submission lock. It will expire automatically.', variant: 'destructive', }); } return false; } logger.info('Lock released', { submissionId, moderatorId, reason, hasError: !!error, }); if (!silent) { const message = getLockReleaseMessage(reason); toast({ title: 'Lock Released', description: message, }); } return true; } catch (err) { logger.error('Exception while releasing lock', { submissionId, moderatorId, reason, error: err, }); return false; } } /** * Auto-release lock when an operation fails * * @param submissionId - Submission ID * @param moderatorId - Moderator ID * @param error - Error that triggered the release */ export async function autoReleaseLockOnError( submissionId: string, moderatorId: string, error: unknown ): Promise { const isTimeout = isTimeoutError(error); logger.warn('Auto-releasing lock due to error', { submissionId, moderatorId, isTimeout, error: error instanceof Error ? error.message : String(error), }); await releaseLock({ submissionId, moderatorId, reason: isTimeout ? 'timeout' : 'error', error, silent: false, // Show notification for transparency }); } /** * Auto-release lock when moderator abandons review * Triggered by navigation away, tab close, or inactivity */ export async function autoReleaseLockOnAbandon( submissionId: string, moderatorId: string ): Promise { logger.info('Auto-releasing lock due to abandonment', { submissionId, moderatorId, }); await releaseLock({ submissionId, moderatorId, reason: 'abandoned', silent: true, // Silent for better UX }); } /** * Setup auto-release on page unload (user navigates away or closes tab) */ export function setupAutoReleaseOnUnload( submissionId: string, moderatorId: string ): () => void { const handleUnload = () => { // Use sendBeacon for reliable unload requests const payload = JSON.stringify({ submission_id: submissionId, moderator_id: moderatorId, }); // Try to call RPC via sendBeacon (more reliable on unload) const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/rpc/release_submission_lock`; const blob = new Blob([payload], { type: 'application/json' }); navigator.sendBeacon(url, blob); logger.info('Scheduled lock release on unload', { submissionId, moderatorId, }); }; // Add listeners window.addEventListener('beforeunload', handleUnload); window.addEventListener('pagehide', handleUnload); // Return cleanup function return () => { window.removeEventListener('beforeunload', handleUnload); window.removeEventListener('pagehide', handleUnload); }; } /** * Monitor inactivity and auto-release after timeout * * @param submissionId - Submission ID * @param moderatorId - Moderator ID * @param inactivityMinutes - Minutes of inactivity before release (default: 10) * @returns Cleanup function */ export function setupInactivityAutoRelease( submissionId: string, moderatorId: string, inactivityMinutes: number = 10 ): () => void { let inactivityTimer: NodeJS.Timeout | null = null; const resetTimer = () => { if (inactivityTimer) { clearTimeout(inactivityTimer); } inactivityTimer = setTimeout(() => { logger.warn('Inactivity timeout - auto-releasing lock', { submissionId, moderatorId, inactivityMinutes, }); autoReleaseLockOnAbandon(submissionId, moderatorId); }, inactivityMinutes * 60 * 1000); }; // Track user activity const activityEvents = ['mousedown', 'keydown', 'scroll', 'touchstart']; activityEvents.forEach(event => { window.addEventListener(event, resetTimer, { passive: true }); }); // Start timer resetTimer(); // Return cleanup function return () => { if (inactivityTimer) { clearTimeout(inactivityTimer); } activityEvents.forEach(event => { window.removeEventListener(event, resetTimer); }); }; } /** * Get user-friendly lock release message */ function getLockReleaseMessage(reason: LockReleaseOptions['reason']): string { switch (reason) { case 'timeout': return 'Lock released due to timeout. The submission is available for other moderators.'; case 'error': return 'Lock released due to an error. You can reclaim it to continue reviewing.'; case 'abandoned': return 'Lock released. The submission is back in the queue.'; case 'manual': return 'Lock released successfully.'; } }