From 0050032681b0c077b00d3e5389216a5f7887846f Mon Sep 17 00:00:00 2001 From: pac7 <47831526-pac7@users.noreply.replit.com> Date: Wed, 8 Oct 2025 14:48:15 +0000 Subject: [PATCH] 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 --- src/components/moderation/ModerationQueue.tsx | 107 ++++++++++++++---- src/hooks/useModerationQueue.ts | 55 +++++++++ 2 files changed, 143 insertions(+), 19 deletions(-) diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 44633d0b..7e3248fb 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -92,12 +92,14 @@ export const ModerationQueue = forwardRef((props, ref) => { companies: new Map() }); const [submissionMemo, setSubmissionMemo] = useState>(new Map()); + const [pendingNewItems, setPendingNewItems] = useState([]); const { toast } = useToast(); const { isAdmin, isSuperuser } = useUserRole(); const { user } = useAuth(); const queue = useModerationQueue(); const fetchInProgressRef = useRef(false); const itemsRef = useRef([]); + const loadedIdsRef = useRef>(new Set()); // Get admin settings for polling configuration const { @@ -111,9 +113,10 @@ export const ModerationQueue = forwardRef((props, ref) => { const refreshStrategy = getAutoRefreshStrategy(); const preserveInteraction = getPreserveInteractionState(); - // Sync itemsRef with items state + // Sync itemsRef and loadedIdsRef with items state useEffect(() => { itemsRef.current = items; + loadedIdsRef.current = new Set(items.map(item => item.id)); }, [items]); const fetchItems = useCallback(async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false) => { @@ -181,6 +184,19 @@ export const ModerationQueue = forwardRef((props, ref) => { 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; if (submissionsError) throw submissionsError; @@ -384,22 +400,39 @@ export const ModerationQueue = forwardRef((props, ref) => { }); setSubmissionMemo(newMemoMap); - // Apply smart merge for state updates using ref for current items - const mergeResult = smartMergeArray(itemsRef.current, moderationItems, { - compareFields: ['status', 'reviewed_at', 'reviewer_notes'], - preserveOrder: silent && preserveInteraction, - addToTop: false, - }); - - if (!silent || mergeResult.hasChanges) { - setItems(mergeResult.items); + // CRM-style frozen queue logic + if (silent) { + // Background polling: ONLY detect NEW submissions, never update existing ones + const currentLoadedIds = loadedIdsRef.current; + const newSubmissions = moderationItems.filter(item => !currentLoadedIds.has(item.id)); - // Track new items for toast notification - if (silent && mergeResult.changes.added.length > 0) { - setNewItemsCount(prev => prev + mergeResult.changes.added.length); - } else if (!silent) { - setNewItemsCount(0); + if (newSubmissions.length > 0) { + console.log('🆕 Detected new submissions:', newSubmissions.length); + + // Check against existing pendingNewItems to avoid double-counting + 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) { @@ -1629,6 +1662,36 @@ export const ModerationQueue = forwardRef((props, ref) => { {/* Action buttons based on status */} {(item.status === 'pending' || item.status === 'flagged') && ( <> + {/* Claim button for unclaimed submissions */} + {!lockedSubmissions.has(item.id) && queue.currentLock?.submissionId !== item.id && ( +
+ + + Unclaimed Submission + +
+ Claim this submission to lock it for 15 minutes while you review + +
+
+
+
+ )} +