diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 670c6656..2e6ed521 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -8,6 +8,7 @@ import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; import { getErrorMessage } from '@/lib/errorHandler'; +import { supabase } from '@/lib/supabaseClient'; import { PhotoModal } from './PhotoModal'; import { SubmissionReviewManager } from './SubmissionReviewManager'; import { ItemEditDialog } from './ItemEditDialog'; @@ -29,6 +30,7 @@ import { EnhancedEmptyState } from './EnhancedEmptyState'; import { QueuePagination } from './QueuePagination'; import { ConfirmationDialog } from './ConfirmationDialog'; import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp'; +import { SuperuserQueueControls } from './SuperuserQueueControls'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { fetchSubmissionItems, type SubmissionItemWithDeps } from '@/lib/submissionItemsService'; import type { ModerationQueueRef, ModerationItem } from '@/types/moderation'; @@ -85,6 +87,7 @@ export const ModerationQueue = forwardRef([]); const [bulkEditMode, setBulkEditMode] = useState(false); const [bulkEditItems, setBulkEditItems] = useState([]); + const [activeLocksCount, setActiveLocksCount] = useState(0); // Confirmation dialog state const [confirmDialog, setConfirmDialog] = useState<{ @@ -129,6 +132,27 @@ export const ModerationQueue = forwardRef { + if (!isSuperuser()) return; + + const fetchActiveLocksCount = async () => { + const { count } = await supabase + .from('content_submissions') + .select('id', { count: 'exact', head: true }) + .not('assigned_to', 'is', null) + .gt('locked_until', new Date().toISOString()); + + setActiveLocksCount(count || 0); + }; + + fetchActiveLocksCount(); + + // Refresh count periodically + const interval = setInterval(fetchActiveLocksCount, 30000); // Every 30s + return () => clearInterval(interval); + }, [isSuperuser, queueManager.queue.queueStats]); + // Virtual scrolling setup const parentRef = useRef(null); const virtualizer = useVirtualizer({ @@ -154,6 +178,22 @@ export const ModerationQueue = forwardRef { + await queueManager.queue.superuserReleaseLock(submissionId); + // Refresh locks count and queue + setActiveLocksCount(prev => Math.max(0, prev - 1)); + queueManager.refresh(); + }, [queueManager]); + + // Superuser clear all locks + const handleClearAllLocks = useCallback(async () => { + const count = await queueManager.queue.superuserReleaseAllLocks(); + setActiveLocksCount(0); + // Force queue refresh + queueManager.refresh(); + }, [queueManager]); + // Clear filters handler const handleClearFilters = useCallback(() => { queueManager.filters.clearFilters(); @@ -310,6 +350,15 @@ export const ModerationQueue = forwardRef )} + {/* Superuser Queue Controls */} + {isSuperuser() && ( + + )} + {/* Filter Bar */} queueManager.markInteracting(id, true)} onInteractionBlur={(id) => queueManager.markInteracting(id, false)} + onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined} /> ))} @@ -451,6 +501,7 @@ export const ModerationQueue = forwardRef queueManager.markInteracting(id, true)} onInteractionBlur={(id) => queueManager.markInteracting(id, false)} + onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined} /> diff --git a/src/components/moderation/QueueItem.tsx b/src/components/moderation/QueueItem.tsx index 233e146b..db8c4e00 100644 --- a/src/components/moderation/QueueItem.tsx +++ b/src/components/moderation/QueueItem.tsx @@ -48,6 +48,7 @@ interface QueueItemProps { onDeleteSubmission: (item: ModerationItem) => void; onInteractionFocus: (id: string) => void; onInteractionBlur: (id: string) => void; + onSuperuserReleaseLock?: (submissionId: string) => Promise; } @@ -74,7 +75,8 @@ export const QueueItem = memo(({ onClaimSubmission, onDeleteSubmission, onInteractionFocus, - onInteractionBlur + onInteractionBlur, + onSuperuserReleaseLock, }: QueueItemProps) => { const [validationResult, setValidationResult] = useState(null); const [isClaiming, setIsClaiming] = useState(false); @@ -323,29 +325,30 @@ export const QueueItem = memo(({ )} - + {/* Raw Data Modal */} diff --git a/src/components/moderation/SuperuserQueueControls.tsx b/src/components/moderation/SuperuserQueueControls.tsx new file mode 100644 index 00000000..154612d1 --- /dev/null +++ b/src/components/moderation/SuperuserQueueControls.tsx @@ -0,0 +1,84 @@ +import { Shield, Unlock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; + +interface SuperuserQueueControlsProps { + activeLocksCount: number; + onClearAllLocks: () => Promise; + isLoading: boolean; +} + +export const SuperuserQueueControls = ({ + activeLocksCount, + onClearAllLocks, + isLoading +}: SuperuserQueueControlsProps) => { + if (activeLocksCount === 0) return null; + + return ( + + + + Superuser Queue Management + + +
+ + {activeLocksCount} active lock{activeLocksCount !== 1 ? 's' : ''} in queue + + + + + + + + + Clear All Active Locks? + + This will release {activeLocksCount} active lock{activeLocksCount !== 1 ? 's' : ''}, + making all submissions available for claiming again. + This action will be logged in the audit trail. +

+ Use this for: +
    +
  • Clearing stale locks after system issues
  • +
  • Resetting queue after team changes
  • +
  • Emergency queue management
  • +
+
+
+ + Cancel + + Clear All Locks + + +
+
+
+
+
+ ); +}; diff --git a/src/components/moderation/renderers/QueueItemActions.tsx b/src/components/moderation/renderers/QueueItemActions.tsx index bd46f3ac..1145865e 100644 --- a/src/components/moderation/renderers/QueueItemActions.tsx +++ b/src/components/moderation/renderers/QueueItemActions.tsx @@ -1,7 +1,7 @@ import { memo, useCallback } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { - AlertCircle, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar + AlertCircle, Edit, Info, ExternalLink, ChevronDown, ListTree, Calendar, Crown, Unlock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ActionButton } from '@/components/ui/action-button'; @@ -37,6 +37,7 @@ interface QueueItemActionsProps { onInteractionFocus: (id: string) => void; onInteractionBlur: (id: string) => void; onClaim: () => void; + onSuperuserReleaseLock?: (submissionId: string) => Promise; } export const QueueItemActions = memo(({ @@ -60,7 +61,8 @@ export const QueueItemActions = memo(({ onDeleteSubmission, onInteractionFocus, onInteractionBlur, - onClaim + onClaim, + onSuperuserReleaseLock }: QueueItemActionsProps) => { // Memoize all handlers to prevent re-renders const handleNoteChange = useCallback((e: React.ChangeEvent) => { @@ -172,6 +174,34 @@ export const QueueItemActions = memo(({ )} + + {/* Superuser Lock Override - Show for locked items */} + {isSuperuser && isLockedByOther && onSuperuserReleaseLock && ( + + + + Superuser Override + + +
+

+ This submission is locked by another moderator. + You can force-release this lock. +

+ +
+
+
+ )}
{/* Submitter Context - shown before moderator can add their notes */} diff --git a/src/hooks/useModerationQueue.ts b/src/hooks/useModerationQueue.ts index 24d24358..93523a71 100644 --- a/src/hooks/useModerationQueue.ts +++ b/src/hooks/useModerationQueue.ts @@ -494,6 +494,85 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => { } }, [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) { + toast({ + title: 'Failed to Release Lock', + description: getErrorMessage(error), + variant: 'destructive', + }); + 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) { + toast({ + title: 'Failed to Clear Locks', + description: getErrorMessage(error), + variant: 'destructive', + }); + return 0; + } finally { + setIsLoading(false); + } + }, [user, fetchStats, toast, onLockStateChange]); + return { currentLock, // Exposed for reactive UI updates queueStats, @@ -510,6 +589,9 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => { isLockedByOther, getLockProgress, releaseAfterAction, + // Superuser lock management + superuserReleaseLock, + superuserReleaseAllLocks, }; }; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 961f0351..f6c2106b 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -5760,6 +5760,14 @@ export type Database = { } Returns: undefined } + superuser_release_all_locks: { + Args: { p_superuser_id: string } + Returns: number + } + superuser_release_lock: { + Args: { p_submission_id: string; p_superuser_id: string } + Returns: boolean + } update_company_ratings: { Args: { target_company_id: string } Returns: undefined diff --git a/supabase/migrations/20251104230458_df4067cc-0275-4b91-a783-b04168afeafd.sql b/supabase/migrations/20251104230458_df4067cc-0275-4b91-a783-b04168afeafd.sql new file mode 100644 index 00000000..e3343803 --- /dev/null +++ b/supabase/migrations/20251104230458_df4067cc-0275-4b91-a783-b04168afeafd.sql @@ -0,0 +1,128 @@ +-- Create superuser lock management functions + +-- Function to allow superusers to force-release any lock +CREATE OR REPLACE FUNCTION public.superuser_release_lock( + p_submission_id UUID, + p_superuser_id UUID +) RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_is_superuser BOOLEAN; + v_original_moderator UUID; + v_submission_type TEXT; + v_user_id UUID; +BEGIN + -- Verify caller is actually a superuser + SELECT EXISTS ( + SELECT 1 FROM user_roles + WHERE user_id = p_superuser_id + AND role = 'superuser' + ) INTO v_is_superuser; + + IF NOT v_is_superuser THEN + RAISE EXCEPTION 'Unauthorized: Only superusers can force-release locks'; + END IF; + + -- Capture original moderator and submission details for audit logging + SELECT assigned_to, submission_type, user_id + INTO v_original_moderator, v_submission_type, v_user_id + FROM content_submissions + WHERE id = p_submission_id; + + -- Release the lock + UPDATE content_submissions + SET + assigned_to = NULL, + assigned_at = NULL, + locked_until = NULL + WHERE id = p_submission_id + AND assigned_to IS NOT NULL; + + -- Log the forced release if a lock was actually released + IF FOUND THEN + PERFORM log_admin_action( + p_superuser_id, + v_user_id, + 'submission_lock_force_released', + jsonb_build_object( + 'submission_id', p_submission_id, + 'submission_type', v_submission_type, + 'original_moderator', v_original_moderator, + 'forced_release', true + ) + ); + END IF; + + RETURN FOUND; +END; +$$; + +-- Function to allow superusers to clear all active locks +CREATE OR REPLACE FUNCTION public.superuser_release_all_locks( + p_superuser_id UUID +) RETURNS INTEGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_is_superuser BOOLEAN; + v_released_count INTEGER; + v_released_locks JSONB; +BEGIN + -- Verify caller is actually a superuser + SELECT EXISTS ( + SELECT 1 FROM user_roles + WHERE user_id = p_superuser_id + AND role = 'superuser' + ) INTO v_is_superuser; + + IF NOT v_is_superuser THEN + RAISE EXCEPTION 'Unauthorized: Only superusers can release all locks'; + END IF; + + -- Capture all locked submissions for audit + SELECT jsonb_agg( + jsonb_build_object( + 'submission_id', id, + 'assigned_to', assigned_to, + 'locked_until', locked_until, + 'submission_type', submission_type + ) + ) INTO v_released_locks + FROM content_submissions + WHERE assigned_to IS NOT NULL + AND locked_until > NOW(); + + -- Release all active locks + UPDATE content_submissions + SET + assigned_to = NULL, + assigned_at = NULL, + locked_until = NULL + WHERE assigned_to IS NOT NULL + AND locked_until > NOW() + AND status IN ('pending', 'partially_approved'); + + GET DIAGNOSTICS v_released_count = ROW_COUNT; + + -- Log the bulk release + IF v_released_count > 0 THEN + PERFORM log_admin_action( + p_superuser_id, + NULL, -- No specific target user + 'submission_locks_bulk_released', + jsonb_build_object( + 'released_count', v_released_count, + 'released_locks', v_released_locks, + 'bulk_operation', true + ) + ); + END IF; + + RETURN v_released_count; +END; +$$; \ No newline at end of file