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()
});
const [submissionMemo, setSubmissionMemo] = useState<Map<string, ModerationItem>>(new Map());
const [pendingNewItems, setPendingNewItems] = useState<ModerationItem[]>([]);
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
const { user } = useAuth();
const queue = useModerationQueue();
const fetchInProgressRef = useRef(false);
const itemsRef = useRef<ModerationItem[]>([]);
const loadedIdsRef = useRef<Set<string>>(new Set());
// Get admin settings for polling configuration
const {
@@ -111,9 +113,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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 && (
<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">
<Label htmlFor={`notes-${item.id}`}>Moderation Notes (optional)</Label>
<Textarea
@@ -1643,6 +1706,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return next;
})}
rows={2}
disabled={lockedSubmissions.has(item.id) || queue.currentLock?.submissionId !== item.id}
/>
</div>
@@ -1654,7 +1718,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
setSelectedSubmissionId(item.id);
setReviewManagerOpen(true);
}}
disabled={actionLoading === item.id}
disabled={actionLoading === item.id || lockedSubmissions.has(item.id) || queue.currentLock?.submissionId !== item.id}
variant="outline"
className={`flex-1 ${isMobile ? 'h-11' : ''}`}
size={isMobile ? "default" : "default"}
@@ -1666,7 +1730,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Button
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' : ''}`}
size={isMobile ? "default" : "default"}
>
@@ -1676,7 +1740,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<Button
variant="destructive"
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' : ''}`}
size={isMobile ? "default" : "default"}
>
@@ -2090,8 +2154,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
variant="default"
size="sm"
onClick={() => {
// Merge pending new items into the main queue at the top
if (pendingNewItems.length > 0) {
setItems(prev => [...pendingNewItems, ...prev]);
setPendingNewItems([]);
}
setNewItemsCount(0);
fetchItems(activeEntityFilter, activeStatusFilter, false);
console.log('✅ New items merged into queue');
}}
className="ml-4"
>