mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 15:11:12 -05:00
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:
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user