mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:11:11 -05:00
Implement superuser lock management
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
84
src/components/moderation/SuperuserQueueControls.tsx
Normal file
84
src/components/moderation/SuperuserQueueControls.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user