Implement claim and freeze system for moderation queue

Refactors the moderation queue to implement a "claim and freeze" model, preventing automatic updates during background polling and enforcing claim/lock isolation. Adds a `claimSubmission` function to `useModerationQueue` and modifies `ModerationQueue.tsx` to filter submissions based on claim status and update handling.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e14c2292-b0e5-43fe-b301-a4ad668949e9
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
This commit is contained in:
pac7
2025-10-08 14:48:15 +00:00
parent fb10642fed
commit 0050032681
2 changed files with 143 additions and 19 deletions

View File

@@ -92,12 +92,14 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
companies: new Map() companies: new Map()
}); });
const [submissionMemo, setSubmissionMemo] = useState<Map<string, ModerationItem>>(new Map()); const [submissionMemo, setSubmissionMemo] = useState<Map<string, ModerationItem>>(new Map());
const [pendingNewItems, setPendingNewItems] = useState<ModerationItem[]>([]);
const { toast } = useToast(); const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole(); const { isAdmin, isSuperuser } = useUserRole();
const { user } = useAuth(); const { user } = useAuth();
const queue = useModerationQueue(); const queue = useModerationQueue();
const fetchInProgressRef = useRef(false); const fetchInProgressRef = useRef(false);
const itemsRef = useRef<ModerationItem[]>([]); const itemsRef = useRef<ModerationItem[]>([]);
const loadedIdsRef = useRef<Set<string>>(new Set());
// Get admin settings for polling configuration // Get admin settings for polling configuration
const { const {
@@ -111,9 +113,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const refreshStrategy = getAutoRefreshStrategy(); const refreshStrategy = getAutoRefreshStrategy();
const preserveInteraction = getPreserveInteractionState(); const preserveInteraction = getPreserveInteractionState();
// Sync itemsRef with items state // Sync itemsRef and loadedIdsRef with items state
useEffect(() => { useEffect(() => {
itemsRef.current = items; itemsRef.current = items;
loadedIdsRef.current = new Set(items.map(item => item.id));
}, [items]); }, [items]);
const fetchItems = useCallback(async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false) => { const fetchItems = useCallback(async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false) => {
@@ -181,6 +184,19 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
submissionsQuery = submissionsQuery.neq('submission_type', 'photo'); submissionsQuery = submissionsQuery.neq('submission_type', 'photo');
} }
// CRM-style claim filtering: moderators only see unclaimed OR self-assigned submissions
// Admins see all submissions
if (!isAdmin && !isSuperuser) {
const now = new Date().toISOString();
// Show submissions that are:
// 1. Unclaimed (assigned_to is null)
// 2. Have expired locks (locked_until < now)
// 3. Are assigned to current user
submissionsQuery = submissionsQuery.or(
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}`
);
}
const { data: submissions, error: submissionsError } = await submissionsQuery; const { data: submissions, error: submissionsError } = await submissionsQuery;
if (submissionsError) throw submissionsError; if (submissionsError) throw submissionsError;
@@ -384,22 +400,39 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
}); });
setSubmissionMemo(newMemoMap); setSubmissionMemo(newMemoMap);
// Apply smart merge for state updates using ref for current items // CRM-style frozen queue logic
const mergeResult = smartMergeArray(itemsRef.current, moderationItems, { if (silent) {
compareFields: ['status', 'reviewed_at', 'reviewer_notes'], // Background polling: ONLY detect NEW submissions, never update existing ones
preserveOrder: silent && preserveInteraction, const currentLoadedIds = loadedIdsRef.current;
addToTop: false, const newSubmissions = moderationItems.filter(item => !currentLoadedIds.has(item.id));
});
if (!silent || mergeResult.hasChanges) {
setItems(mergeResult.items);
// Track new items for toast notification if (newSubmissions.length > 0) {
if (silent && mergeResult.changes.added.length > 0) { console.log('🆕 Detected new submissions:', newSubmissions.length);
setNewItemsCount(prev => prev + mergeResult.changes.added.length);
} else if (!silent) { // Check against existing pendingNewItems to avoid double-counting
setNewItemsCount(0); setPendingNewItems(prev => {
const existingIds = new Set(prev.map(p => p.id));
const uniqueNew = newSubmissions.filter(item => !existingIds.has(item.id));
// Track these IDs as loaded to prevent re-counting on next poll
if (uniqueNew.length > 0) {
const newIds = uniqueNew.map(item => item.id);
loadedIdsRef.current = new Set([...currentLoadedIds, ...newIds]);
setNewItemsCount(prev => prev + uniqueNew.length);
}
return [...prev, ...uniqueNew];
});
} }
// DON'T update items array during background polling - queue stays frozen
console.log('✅ Queue frozen - existing submissions unchanged');
} else {
// Normal fetch: Load all items and reset pending
setItems(moderationItems);
setPendingNewItems([]);
setNewItemsCount(0);
console.log('📋 Queue loaded with', moderationItems.length, 'submissions');
} }
} catch (error: any) { } catch (error: any) {
@@ -1629,6 +1662,36 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
{/* Action buttons based on status */} {/* Action buttons based on status */}
{(item.status === 'pending' || item.status === 'flagged') && ( {(item.status === 'pending' || item.status === 'flagged') && (
<> <>
{/* Claim button for unclaimed submissions */}
{!lockedSubmissions.has(item.id) && queue.currentLock?.submissionId !== item.id && (
<div className="mb-4">
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950/20">
<AlertCircle className="h-4 w-4 text-blue-600" />
<AlertTitle className="text-blue-900 dark:text-blue-100">Unclaimed Submission</AlertTitle>
<AlertDescription className="text-blue-800 dark:text-blue-200">
<div className="flex items-center justify-between mt-2">
<span className="text-sm">Claim this submission to lock it for 15 minutes while you review</span>
<Button
onClick={async () => {
const success = await queue.claimSubmission(item.id);
if (success) {
// Refresh to update UI
fetchItems(activeEntityFilter, activeStatusFilter, false);
}
}}
disabled={queue.isLoading}
size="sm"
className="ml-4"
>
<Lock className="w-4 h-4 mr-2" />
Claim Submission
</Button>
</div>
</AlertDescription>
</Alert>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`notes-${item.id}`}>Moderation Notes (optional)</Label> <Label htmlFor={`notes-${item.id}`}>Moderation Notes (optional)</Label>
<Textarea <Textarea
@@ -1643,6 +1706,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return next; return next;
})} })}
rows={2} rows={2}
disabled={lockedSubmissions.has(item.id) || queue.currentLock?.submissionId !== item.id}
/> />
</div> </div>
@@ -1654,7 +1718,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
setSelectedSubmissionId(item.id); setSelectedSubmissionId(item.id);
setReviewManagerOpen(true); setReviewManagerOpen(true);
}} }}
disabled={actionLoading === item.id} disabled={actionLoading === item.id || lockedSubmissions.has(item.id) || queue.currentLock?.submissionId !== item.id}
variant="outline" variant="outline"
className={`flex-1 ${isMobile ? 'h-11' : ''}`} className={`flex-1 ${isMobile ? 'h-11' : ''}`}
size={isMobile ? "default" : "default"} size={isMobile ? "default" : "default"}
@@ -1666,7 +1730,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Button <Button
onClick={() => handleModerationAction(item, 'approved', notes[item.id])} onClick={() => handleModerationAction(item, 'approved', notes[item.id])}
disabled={actionLoading === item.id} disabled={actionLoading === item.id || lockedSubmissions.has(item.id) || queue.currentLock?.submissionId !== item.id}
className={`flex-1 ${isMobile ? 'h-11' : ''}`} className={`flex-1 ${isMobile ? 'h-11' : ''}`}
size={isMobile ? "default" : "default"} size={isMobile ? "default" : "default"}
> >
@@ -1676,7 +1740,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Button <Button
variant="destructive" variant="destructive"
onClick={() => handleModerationAction(item, 'rejected', notes[item.id])} onClick={() => handleModerationAction(item, 'rejected', notes[item.id])}
disabled={actionLoading === item.id} disabled={actionLoading === item.id || lockedSubmissions.has(item.id) || queue.currentLock?.submissionId !== item.id}
className={`flex-1 ${isMobile ? 'h-11' : ''}`} className={`flex-1 ${isMobile ? 'h-11' : ''}`}
size={isMobile ? "default" : "default"} size={isMobile ? "default" : "default"}
> >
@@ -2090,8 +2154,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
variant="default" variant="default"
size="sm" size="sm"
onClick={() => { onClick={() => {
// Merge pending new items into the main queue at the top
if (pendingNewItems.length > 0) {
setItems(prev => [...pendingNewItems, ...prev]);
setPendingNewItems([]);
}
setNewItemsCount(0); setNewItemsCount(0);
fetchItems(activeEntityFilter, activeStatusFilter, false); console.log('✅ New items merged into queue');
}} }}
className="ml-4" className="ml-4"
> >

View File

@@ -305,6 +305,60 @@ export const useModerationQueue = () => {
} }
}, [user, toast, fetchStats]); }, [user, toast, fetchStats]);
// Claim a specific submission (CRM-style claim any)
const claimSubmission = useCallback(async (submissionId: string): Promise<boolean> => {
if (!user?.id) {
toast({
title: 'Authentication Required',
description: 'You must be logged in to claim submissions',
variant: 'destructive',
});
return false;
}
setIsLoading(true);
try {
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const { error } = await supabase
.from('content_submissions')
.update({
assigned_to: user.id,
assigned_at: new Date().toISOString(),
locked_until: expiresAt.toISOString(),
})
.eq('id', submissionId)
.or(`assigned_to.is.null,locked_until.lt.${new Date().toISOString()}`); // Only if unclaimed or lock expired
if (error) throw error;
setCurrentLock({
submissionId,
expiresAt,
});
startLockTimer(expiresAt);
fetchStats();
toast({
title: 'Submission Claimed',
description: 'You now have 15 minutes to review this submission',
});
return true;
} catch (error: any) {
console.error('Error claiming submission:', error);
toast({
title: 'Error',
description: error.message || 'Failed to claim submission',
variant: 'destructive',
});
return false;
} finally {
setIsLoading(false);
}
}, [user, toast, startLockTimer, fetchStats]);
// Reassign submission // Reassign submission
const reassignSubmission = useCallback(async (submissionId: string, newModeratorId: string): Promise<boolean> => { const reassignSubmission = useCallback(async (submissionId: string, newModeratorId: string): Promise<boolean> => {
if (!user?.id) return false; if (!user?.id) return false;
@@ -355,6 +409,7 @@ export const useModerationQueue = () => {
queueStats, queueStats,
isLoading, isLoading,
claimNext, claimNext,
claimSubmission,
extendLock, extendLock,
releaseLock, releaseLock,
getTimeRemaining, getTimeRemaining,