Implement superuser lock management

This commit is contained in:
gpt-engineer-app[bot]
2025-11-04 23:08:00 +00:00
parent ae22a48ce2
commit 16386f9894
7 changed files with 412 additions and 26 deletions

View File

@@ -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<ModerationQueueRef, ModerationQueuePro
const [availableItems, setAvailableItems] = useState<SubmissionItemWithDeps[]>([]);
const [bulkEditMode, setBulkEditMode] = useState(false);
const [bulkEditItems, setBulkEditItems] = useState<SubmissionItemWithDeps[]>([]);
const [activeLocksCount, setActiveLocksCount] = useState(0);
// Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState<{
@@ -129,6 +132,27 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
};
}, [queueManager, toast]);
// Fetch active locks count for superusers
useEffect(() => {
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<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
@@ -154,6 +178,22 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
});
}, [queueManager]);
// Superuser force release lock
const handleSuperuserReleaseLock = useCallback(async (submissionId: string) => {
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<ModerationQueueRef, ModerationQueuePro
</Card>
)}
{/* Superuser Queue Controls */}
{isSuperuser() && (
<SuperuserQueueControls
activeLocksCount={activeLocksCount}
onClearAllLocks={handleClearAllLocks}
isLoading={queueManager.queue.isLoading}
/>
)}
{/* Filter Bar */}
<QueueFilters
activeEntityFilter={queueManager.filters.entityFilter}
@@ -390,6 +439,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
onDeleteSubmission={handleDeleteSubmission}
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined}
/>
</ModerationErrorBoundary>
))}
@@ -451,6 +501,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef, ModerationQueuePro
onDeleteSubmission={handleDeleteSubmission}
onInteractionFocus={(id) => queueManager.markInteracting(id, true)}
onInteractionBlur={(id) => queueManager.markInteracting(id, false)}
onSuperuserReleaseLock={isSuperuser() ? handleSuperuserReleaseLock : undefined}
/>
</ModerationErrorBoundary>
</div>

View File

@@ -48,6 +48,7 @@ interface QueueItemProps {
onDeleteSubmission: (item: ModerationItem) => void;
onInteractionFocus: (id: string) => void;
onInteractionBlur: (id: string) => void;
onSuperuserReleaseLock?: (submissionId: string) => Promise<void>;
}
@@ -74,7 +75,8 @@ export const QueueItem = memo(({
onClaimSubmission,
onDeleteSubmission,
onInteractionFocus,
onInteractionBlur
onInteractionBlur,
onSuperuserReleaseLock,
}: QueueItemProps) => {
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isClaiming, setIsClaiming] = useState(false);
@@ -345,6 +347,7 @@ export const QueueItem = memo(({
onInteractionFocus={onInteractionFocus}
onInteractionBlur={onInteractionBlur}
onClaim={handleClaim}
onSuperuserReleaseLock={onSuperuserReleaseLock}
/>
</CardContent>

View File

@@ -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<void>;
isLoading: boolean;
}
export const SuperuserQueueControls = ({
activeLocksCount,
onClearAllLocks,
isLoading
}: SuperuserQueueControlsProps) => {
if (activeLocksCount === 0) return null;
return (
<Alert className="border-purple-500/50 bg-purple-500/5">
<Shield className="h-4 w-4 text-purple-600" />
<AlertTitle className="text-purple-900 dark:text-purple-100">
Superuser Queue Management
</AlertTitle>
<AlertDescription className="text-purple-800 dark:text-purple-200">
<div className="flex items-center justify-between mt-2">
<span className="text-sm">
{activeLocksCount} active lock{activeLocksCount !== 1 ? 's' : ''} in queue
</span>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-purple-500 text-purple-700 hover:bg-purple-50 dark:hover:bg-purple-950"
disabled={isLoading}
>
<Unlock className="w-4 h-4 mr-2" />
Clear All Locks
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear All Active Locks?</AlertDialogTitle>
<AlertDialogDescription>
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.
<br /><br />
<strong>Use this for:</strong>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Clearing stale locks after system issues</li>
<li>Resetting queue after team changes</li>
<li>Emergency queue management</li>
</ul>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onClearAllLocks}
className="bg-purple-600 hover:bg-purple-700"
>
Clear All Locks
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</AlertDescription>
</Alert>
);
};

View File

@@ -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<void>;
}
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<HTMLTextAreaElement>) => {
@@ -173,6 +175,34 @@ export const QueueItemActions = memo(({
</div>
)}
{/* Superuser Lock Override - Show for locked items */}
{isSuperuser && isLockedByOther && onSuperuserReleaseLock && (
<Alert className="border-purple-500/50 bg-purple-500/10">
<Crown className="h-4 w-4 text-purple-600" />
<AlertTitle className="text-purple-900 dark:text-purple-100">
Superuser Override
</AlertTitle>
<AlertDescription className="text-purple-800 dark:text-purple-200">
<div className="flex flex-col gap-2 mt-2">
<p className="text-sm">
This submission is locked by another moderator.
You can force-release this lock.
</p>
<Button
size="sm"
variant="outline"
className="border-purple-500 text-purple-700 hover:bg-purple-50 dark:hover:bg-purple-950"
onClick={() => onSuperuserReleaseLock(item.id)}
disabled={actionLoading === item.id}
>
<Unlock className="w-4 h-4 mr-2" />
Force Release Lock
</Button>
</div>
</AlertDescription>
</Alert>
)}
<div className={isMobile ? 'space-y-4 mt-4' : 'grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start mt-4'}>
{/* Submitter Context - shown before moderator can add their notes */}
{(item.submission_items?.[0]?.item_data?.source_url || item.submission_items?.[0]?.item_data?.submission_notes) && (

View File

@@ -494,6 +494,85 @@ export const useModerationQueue = (config?: UseModerationQueueConfig) => {
}
}, [currentLock, releaseLock]);
// Superuser: Force release a specific lock
const superuserReleaseLock = useCallback(async (
submissionId: string
): Promise<boolean> => {
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<number> => {
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,
};
};

View File

@@ -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

View File

@@ -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;
$$;