mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 10:51:13 -05:00
feat: Implement moderation queue manager hook
This commit is contained in:
@@ -23,3 +23,9 @@ export type {
|
|||||||
RealtimeSubscriptionConfig,
|
RealtimeSubscriptionConfig,
|
||||||
UseRealtimeSubscriptionsReturn
|
UseRealtimeSubscriptionsReturn
|
||||||
} from './useRealtimeSubscriptions';
|
} from './useRealtimeSubscriptions';
|
||||||
|
|
||||||
|
export { useModerationQueueManager } from './useModerationQueueManager';
|
||||||
|
export type {
|
||||||
|
ModerationQueueManager,
|
||||||
|
ModerationQueueManagerConfig
|
||||||
|
} from './useModerationQueueManager';
|
||||||
|
|||||||
879
src/hooks/moderation/useModerationQueueManager.ts
Normal file
879
src/hooks/moderation/useModerationQueueManager.ts
Normal file
@@ -0,0 +1,879 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import type { User } from '@supabase/supabase-js';
|
||||||
|
import {
|
||||||
|
useEntityCache,
|
||||||
|
useProfileCache,
|
||||||
|
useModerationFilters,
|
||||||
|
useModerationSort,
|
||||||
|
usePagination,
|
||||||
|
useRealtimeSubscriptions
|
||||||
|
} from './index';
|
||||||
|
import { useModerationQueue } from '@/hooks/useModerationQueue';
|
||||||
|
import { smartMergeArray } from '@/lib/smartStateUpdate';
|
||||||
|
import type {
|
||||||
|
ModerationItem,
|
||||||
|
EntityFilter,
|
||||||
|
StatusFilter,
|
||||||
|
LoadingState,
|
||||||
|
SortConfig
|
||||||
|
} from '@/types/moderation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for useModerationQueueManager
|
||||||
|
*/
|
||||||
|
export interface ModerationQueueManagerConfig {
|
||||||
|
user: User | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isSuperuser: boolean;
|
||||||
|
toast: ReturnType<typeof useToast>['toast'];
|
||||||
|
settings: {
|
||||||
|
refreshMode: 'auto' | 'manual';
|
||||||
|
pollInterval: number;
|
||||||
|
refreshStrategy: 'notify' | 'merge' | 'replace';
|
||||||
|
preserveInteraction: boolean;
|
||||||
|
useRealtimeQueue: boolean;
|
||||||
|
refreshOnTabVisible: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for useModerationQueueManager
|
||||||
|
*/
|
||||||
|
export interface ModerationQueueManager {
|
||||||
|
// State
|
||||||
|
items: ModerationItem[];
|
||||||
|
loadingState: LoadingState;
|
||||||
|
actionLoading: string | null;
|
||||||
|
|
||||||
|
// Sub-hooks (exposed for granular control)
|
||||||
|
filters: ReturnType<typeof useModerationFilters>;
|
||||||
|
pagination: ReturnType<typeof usePagination>;
|
||||||
|
sort: ReturnType<typeof useModerationSort>;
|
||||||
|
queue: ReturnType<typeof useModerationQueue>;
|
||||||
|
|
||||||
|
// Realtime
|
||||||
|
newItemsCount: number;
|
||||||
|
pendingNewItems: ModerationItem[];
|
||||||
|
showNewItems: () => void;
|
||||||
|
|
||||||
|
// Interaction tracking
|
||||||
|
interactingWith: Set<string>;
|
||||||
|
markInteracting: (id: string, interacting: boolean) => void;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
refresh: () => void;
|
||||||
|
performAction: (
|
||||||
|
item: ModerationItem,
|
||||||
|
action: 'approved' | 'rejected',
|
||||||
|
moderatorNotes?: string
|
||||||
|
) => Promise<void>;
|
||||||
|
deleteSubmission: (item: ModerationItem) => Promise<void>;
|
||||||
|
resetToPending: (item: ModerationItem) => Promise<void>;
|
||||||
|
retryFailedItems: (item: ModerationItem) => Promise<void>;
|
||||||
|
|
||||||
|
// Caches (for QueueItem enrichment)
|
||||||
|
entityCache: ReturnType<typeof useEntityCache>;
|
||||||
|
profileCache: ReturnType<typeof useProfileCache>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator hook for moderation queue management
|
||||||
|
* Consolidates all queue-related logic into a single hook
|
||||||
|
*/
|
||||||
|
export function useModerationQueueManager(
|
||||||
|
config: ModerationQueueManagerConfig
|
||||||
|
): ModerationQueueManager {
|
||||||
|
const { user, isAdmin, isSuperuser, toast, settings } = config;
|
||||||
|
|
||||||
|
// Initialize sub-hooks
|
||||||
|
const filters = useModerationFilters({
|
||||||
|
initialEntityFilter: 'all',
|
||||||
|
initialStatusFilter: 'pending',
|
||||||
|
initialTab: 'mainQueue',
|
||||||
|
debounceDelay: 300,
|
||||||
|
persist: true,
|
||||||
|
storageKey: 'moderationQueue_filters'
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagination = usePagination({
|
||||||
|
initialPage: 1,
|
||||||
|
initialPageSize: 25,
|
||||||
|
persist: false,
|
||||||
|
onPageChange: (page) => {
|
||||||
|
if (page > 1) {
|
||||||
|
setLoadingState('loading');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageSizeChange: () => {
|
||||||
|
setLoadingState('loading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sort = useModerationSort({
|
||||||
|
initialConfig: { field: 'created_at', direction: 'asc' },
|
||||||
|
persist: true,
|
||||||
|
storageKey: 'moderationQueue_sortConfig'
|
||||||
|
});
|
||||||
|
|
||||||
|
const queue = useModerationQueue();
|
||||||
|
const entityCache = useEntityCache();
|
||||||
|
const profileCache = useProfileCache();
|
||||||
|
|
||||||
|
// Core state
|
||||||
|
const [items, setItems] = useState<ModerationItem[]>([]);
|
||||||
|
const [loadingState, setLoadingState] = useState<LoadingState>('initial');
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [interactingWith, setInteractingWith] = useState<Set<string>>(new Set());
|
||||||
|
const [pendingNewItems, setPendingNewItems] = useState<ModerationItem[]>([]);
|
||||||
|
const [newItemsCount, setNewItemsCount] = useState(0);
|
||||||
|
|
||||||
|
// Refs for tracking
|
||||||
|
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
||||||
|
const fetchInProgressRef = useRef(false);
|
||||||
|
const itemsRef = useRef<ModerationItem[]>([]);
|
||||||
|
const lastFetchTimeRef = useRef<number>(0);
|
||||||
|
const pauseFetchingRef = useRef(false);
|
||||||
|
const initialFetchCompleteRef = useRef(false);
|
||||||
|
const isMountingRef = useRef(true);
|
||||||
|
|
||||||
|
const FETCH_COOLDOWN_MS = 1000;
|
||||||
|
|
||||||
|
// Store settings in refs to avoid re-creating fetchItems
|
||||||
|
const settingsRef = useRef(settings);
|
||||||
|
useEffect(() => {
|
||||||
|
settingsRef.current = settings;
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
// Sync items with ref
|
||||||
|
useEffect(() => {
|
||||||
|
itemsRef.current = items;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch queue items from database
|
||||||
|
*/
|
||||||
|
const fetchItems = useCallback(async (silent = false) => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// Check if tab is hidden
|
||||||
|
if (pauseFetchingRef.current || document.hidden) {
|
||||||
|
console.log('⏸️ Fetch paused (tab hidden)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent concurrent calls
|
||||||
|
if (fetchInProgressRef.current) {
|
||||||
|
console.log('⚠️ Fetch already in progress, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown check
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||||
|
if (timeSinceLastFetch < FETCH_COOLDOWN_MS && lastFetchTimeRef.current > 0) {
|
||||||
|
console.log(`⏸️ Fetch cooldown active (${timeSinceLastFetch}ms)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchInProgressRef.current = true;
|
||||||
|
lastFetchTimeRef.current = now;
|
||||||
|
|
||||||
|
console.log('🔍 fetchItems called:', {
|
||||||
|
entityFilter: filters.debouncedEntityFilter,
|
||||||
|
statusFilter: filters.debouncedStatusFilter,
|
||||||
|
silent,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set loading states
|
||||||
|
if (!silent) {
|
||||||
|
setLoadingState('loading');
|
||||||
|
} else {
|
||||||
|
setLoadingState('refreshing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build base query
|
||||||
|
let submissionsQuery = supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
submission_type,
|
||||||
|
status,
|
||||||
|
content,
|
||||||
|
created_at,
|
||||||
|
user_id,
|
||||||
|
reviewed_at,
|
||||||
|
reviewer_id,
|
||||||
|
reviewer_notes,
|
||||||
|
escalated,
|
||||||
|
assigned_to,
|
||||||
|
locked_until,
|
||||||
|
submission_items (
|
||||||
|
id,
|
||||||
|
item_type,
|
||||||
|
item_data,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order('escalated', { ascending: false })
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
|
// Apply tab-based status filtering
|
||||||
|
const tab = filters.activeTab;
|
||||||
|
const statusFilter = filters.debouncedStatusFilter;
|
||||||
|
const entityFilter = filters.debouncedEntityFilter;
|
||||||
|
|
||||||
|
if (tab === 'mainQueue') {
|
||||||
|
if (statusFilter === 'all') {
|
||||||
|
submissionsQuery = submissionsQuery.in('status', ['pending', 'flagged', 'partially_approved']);
|
||||||
|
} else if (statusFilter === 'pending') {
|
||||||
|
submissionsQuery = submissionsQuery.in('status', ['pending', 'partially_approved']);
|
||||||
|
} else {
|
||||||
|
submissionsQuery = submissionsQuery.eq('status', statusFilter);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (statusFilter === 'all') {
|
||||||
|
submissionsQuery = submissionsQuery.in('status', ['approved', 'rejected']);
|
||||||
|
} else {
|
||||||
|
submissionsQuery = submissionsQuery.eq('status', statusFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply entity type filter
|
||||||
|
if (entityFilter === 'photos') {
|
||||||
|
submissionsQuery = submissionsQuery.eq('submission_type', 'photo');
|
||||||
|
} else if (entityFilter === 'submissions') {
|
||||||
|
submissionsQuery = submissionsQuery.neq('submission_type', 'photo');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply access control
|
||||||
|
if (!isAdmin && !isSuperuser) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
submissionsQuery = submissionsQuery.or(
|
||||||
|
`assigned_to.is.null,locked_until.lt.${now},assigned_to.eq.${user.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.match(submissionsQuery as any);
|
||||||
|
|
||||||
|
pagination.setTotalCount(count || 0);
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
const startIndex = pagination.startIndex;
|
||||||
|
const endIndex = pagination.endIndex;
|
||||||
|
submissionsQuery = submissionsQuery.range(startIndex, endIndex);
|
||||||
|
|
||||||
|
const { data: submissions, error: submissionsError } = await submissionsQuery;
|
||||||
|
|
||||||
|
if (submissionsError) throw submissionsError;
|
||||||
|
|
||||||
|
// Fetch related profiles and entities
|
||||||
|
const userIds = [...new Set([
|
||||||
|
...(submissions?.map(s => s.user_id) || []),
|
||||||
|
...(submissions?.map(s => s.reviewer_id).filter(Boolean) || [])
|
||||||
|
])];
|
||||||
|
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await profileCache.bulkFetch(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect entity IDs
|
||||||
|
if (submissions) {
|
||||||
|
await entityCache.fetchRelatedEntities(submissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to ModerationItems
|
||||||
|
const moderationItems: ModerationItem[] = submissions?.map(submission => {
|
||||||
|
const content = submission.content as any;
|
||||||
|
let entityName = content?.name || 'Unknown';
|
||||||
|
let parkName: string | undefined;
|
||||||
|
|
||||||
|
// Resolve entity names from cache
|
||||||
|
if (submission.submission_type === 'ride' && content?.entity_id) {
|
||||||
|
const ride = entityCache.getCached('rides', content.entity_id);
|
||||||
|
if (ride) {
|
||||||
|
entityName = ride.name;
|
||||||
|
if (ride.park_id) {
|
||||||
|
const park = entityCache.getCached('parks', ride.park_id);
|
||||||
|
if (park) parkName = park.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (submission.submission_type === 'park' && content?.entity_id) {
|
||||||
|
const park = entityCache.getCached('parks', content.entity_id);
|
||||||
|
if (park) entityName = park.name;
|
||||||
|
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type) && content?.entity_id) {
|
||||||
|
const company = entityCache.getCached('companies', content.entity_id);
|
||||||
|
if (company) entityName = company.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProfile = profileCache.getCached(submission.user_id);
|
||||||
|
const reviewerProfile = submission.reviewer_id ? profileCache.getCached(submission.reviewer_id) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: submission.id,
|
||||||
|
type: 'content_submission',
|
||||||
|
content: submission.content,
|
||||||
|
created_at: submission.created_at,
|
||||||
|
user_id: submission.user_id,
|
||||||
|
status: submission.status,
|
||||||
|
submission_type: submission.submission_type,
|
||||||
|
user_profile: userProfile,
|
||||||
|
entity_name: entityName,
|
||||||
|
park_name: parkName,
|
||||||
|
reviewed_at: submission.reviewed_at || undefined,
|
||||||
|
reviewed_by: submission.reviewer_id || undefined,
|
||||||
|
reviewer_notes: submission.reviewer_notes || undefined,
|
||||||
|
reviewer_profile: reviewerProfile,
|
||||||
|
escalated: submission.escalated,
|
||||||
|
assigned_to: submission.assigned_to || undefined,
|
||||||
|
locked_until: submission.locked_until || undefined,
|
||||||
|
submission_items: submission.submission_items || undefined,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
// Apply refresh strategy
|
||||||
|
const currentRefreshStrategy = settingsRef.current.refreshStrategy;
|
||||||
|
const currentPreserveInteraction = settingsRef.current.preserveInteraction;
|
||||||
|
|
||||||
|
if (silent) {
|
||||||
|
// Background polling: detect new submissions
|
||||||
|
const currentDisplayedIds = new Set(itemsRef.current.map(item => item.id));
|
||||||
|
const newSubmissions = moderationItems.filter(item => !currentDisplayedIds.has(item.id));
|
||||||
|
|
||||||
|
if (newSubmissions.length > 0) {
|
||||||
|
console.log('🆕 Detected new submissions:', newSubmissions.length);
|
||||||
|
|
||||||
|
setPendingNewItems(prev => {
|
||||||
|
const existingIds = new Set(prev.map(p => p.id));
|
||||||
|
const uniqueNew = newSubmissions.filter(item => !existingIds.has(item.id));
|
||||||
|
|
||||||
|
if (uniqueNew.length > 0) {
|
||||||
|
setNewItemsCount(prev => prev + uniqueNew.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, ...uniqueNew];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply refresh strategy
|
||||||
|
switch (currentRefreshStrategy) {
|
||||||
|
case 'notify':
|
||||||
|
console.log('✅ Queue frozen (notify mode)');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'merge':
|
||||||
|
if (newSubmissions.length > 0) {
|
||||||
|
const currentIds = new Set(itemsRef.current.map(item => item.id));
|
||||||
|
const trulyNewSubmissions = newSubmissions.filter(item => !currentIds.has(item.id));
|
||||||
|
|
||||||
|
if (trulyNewSubmissions.length > 0) {
|
||||||
|
setItems(prev => [...prev, ...trulyNewSubmissions]);
|
||||||
|
console.log('🔀 Queue merged - added', trulyNewSubmissions.length, 'items');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'replace':
|
||||||
|
const mergeResult = smartMergeArray(itemsRef.current, moderationItems, {
|
||||||
|
compareFields: ['status', 'content', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'assigned_to', 'locked_until'],
|
||||||
|
preserveOrder: false,
|
||||||
|
addToTop: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mergeResult.hasChanges) {
|
||||||
|
setItems(mergeResult.items);
|
||||||
|
console.log('🔄 Queue updated (replace mode)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentPreserveInteraction) {
|
||||||
|
setPendingNewItems([]);
|
||||||
|
setNewItemsCount(0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Manual refresh
|
||||||
|
const mergeResult = smartMergeArray(itemsRef.current, moderationItems, {
|
||||||
|
compareFields: ['status', 'content', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'assigned_to', 'locked_until'],
|
||||||
|
preserveOrder: false,
|
||||||
|
addToTop: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mergeResult.hasChanges) {
|
||||||
|
setItems(mergeResult.items);
|
||||||
|
console.log('🔄 Queue updated (manual refresh)');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingNewItems([]);
|
||||||
|
setNewItemsCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching moderation items:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.message || 'Failed to fetch moderation queue',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fetchInProgressRef.current = false;
|
||||||
|
setLoadingState('ready');
|
||||||
|
}
|
||||||
|
}, [user, isAdmin, isSuperuser, filters, pagination, profileCache, entityCache, toast]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show pending new items by merging them into the queue
|
||||||
|
*/
|
||||||
|
const showNewItems = useCallback(() => {
|
||||||
|
if (pendingNewItems.length > 0) {
|
||||||
|
setItems(prev => [...pendingNewItems, ...prev]);
|
||||||
|
setPendingNewItems([]);
|
||||||
|
setNewItemsCount(0);
|
||||||
|
console.log('✅ New items merged into queue:', pendingNewItems.length);
|
||||||
|
}
|
||||||
|
}, [pendingNewItems]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an item as being interacted with (prevents realtime updates)
|
||||||
|
*/
|
||||||
|
const markInteracting = useCallback((id: string, interacting: boolean) => {
|
||||||
|
setInteractingWith(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (interacting) {
|
||||||
|
next.add(id);
|
||||||
|
} else {
|
||||||
|
next.delete(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform moderation action (approve/reject)
|
||||||
|
*/
|
||||||
|
const performAction = useCallback(async (
|
||||||
|
item: ModerationItem,
|
||||||
|
action: 'approved' | 'rejected',
|
||||||
|
moderatorNotes?: string
|
||||||
|
) => {
|
||||||
|
if (actionLoading === item.id) return;
|
||||||
|
|
||||||
|
setActionLoading(item.id);
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const shouldRemove = (filters.statusFilter === 'pending' || filters.statusFilter === 'flagged') &&
|
||||||
|
(action === 'approved' || action === 'rejected');
|
||||||
|
|
||||||
|
if (shouldRemove) {
|
||||||
|
setItems(prev => prev.map(i =>
|
||||||
|
i.id === item.id ? { ...i, _removing: true } : i
|
||||||
|
));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||||
|
recentlyRemovedRef.current.add(item.id);
|
||||||
|
setTimeout(() => recentlyRemovedRef.current.delete(item.id), 10000);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release lock if claimed
|
||||||
|
if (queue.currentLock?.submissionId === item.id) {
|
||||||
|
await queue.releaseLock(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle photo submissions
|
||||||
|
if (action === 'approved' && item.submission_type === 'photo') {
|
||||||
|
const { data: photoSubmission } = await supabase
|
||||||
|
.from('photo_submissions')
|
||||||
|
.select(`*, items:photo_submission_items(*), submission:content_submissions!inner(user_id)`)
|
||||||
|
.eq('submission_id', item.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (photoSubmission && photoSubmission.items) {
|
||||||
|
const { data: existingPhotos } = await supabase
|
||||||
|
.from('photos')
|
||||||
|
.select('id')
|
||||||
|
.eq('submission_id', item.id);
|
||||||
|
|
||||||
|
if (!existingPhotos || existingPhotos.length === 0) {
|
||||||
|
const photoRecords = photoSubmission.items.map((photoItem: any) => ({
|
||||||
|
entity_id: photoSubmission.entity_id,
|
||||||
|
entity_type: photoSubmission.entity_type,
|
||||||
|
cloudflare_image_id: photoItem.cloudflare_image_id,
|
||||||
|
cloudflare_image_url: photoItem.cloudflare_image_url,
|
||||||
|
title: photoItem.title || null,
|
||||||
|
caption: photoItem.caption || null,
|
||||||
|
date_taken: photoItem.date_taken || null,
|
||||||
|
order_index: photoItem.order_index,
|
||||||
|
submission_id: photoSubmission.submission_id,
|
||||||
|
submitted_by: photoSubmission.submission?.user_id,
|
||||||
|
approved_by: user?.id,
|
||||||
|
approved_at: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await supabase.from('photos').insert(photoRecords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for submission items
|
||||||
|
const { data: submissionItems } = await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.select('id, status')
|
||||||
|
.eq('submission_id', item.id)
|
||||||
|
.in('status', ['pending', 'rejected']);
|
||||||
|
|
||||||
|
if (submissionItems && submissionItems.length > 0) {
|
||||||
|
if (action === 'approved') {
|
||||||
|
await supabase.functions.invoke('process-selective-approval', {
|
||||||
|
body: {
|
||||||
|
itemIds: submissionItems.map(i => i.id),
|
||||||
|
submissionId: item.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Submission Approved",
|
||||||
|
description: `Successfully processed ${submissionItems.length} item(s)`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if (action === 'rejected') {
|
||||||
|
await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.update({
|
||||||
|
status: 'rejected',
|
||||||
|
rejection_reason: moderatorNotes || 'Parent submission rejected',
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('submission_id', item.id)
|
||||||
|
.eq('status', 'pending');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard update
|
||||||
|
const table = item.type === 'review' ? 'reviews' : 'content_submissions';
|
||||||
|
const statusField = item.type === 'review' ? 'moderation_status' : 'status';
|
||||||
|
const timestampField = item.type === 'review' ? 'moderated_at' : 'reviewed_at';
|
||||||
|
const reviewerField = item.type === 'review' ? 'moderated_by' : 'reviewer_id';
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
[statusField]: action,
|
||||||
|
[timestampField]: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
updateData[reviewerField] = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moderatorNotes) {
|
||||||
|
updateData.reviewer_notes = moderatorNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from(table)
|
||||||
|
.update(updateData)
|
||||||
|
.eq('id', item.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: `Content ${action}`,
|
||||||
|
description: `The ${item.type} has been ${action}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error moderating content:', error);
|
||||||
|
|
||||||
|
// Revert optimistic update
|
||||||
|
setItems(prev => {
|
||||||
|
const exists = prev.find(i => i.id === item.id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.map(i => i.id === item.id ? item : i);
|
||||||
|
} else {
|
||||||
|
return [...prev, item];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message || `Failed to ${action} content`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}, [actionLoading, filters.statusFilter, queue, user, toast]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a submission permanently
|
||||||
|
*/
|
||||||
|
const deleteSubmission = useCallback(async (item: ModerationItem) => {
|
||||||
|
if (item.type !== 'content_submission') return;
|
||||||
|
if (actionLoading === item.id) return;
|
||||||
|
|
||||||
|
setActionLoading(item.id);
|
||||||
|
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.delete()
|
||||||
|
.eq('id', item.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Submission deleted",
|
||||||
|
description: "The submission has been permanently deleted",
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting submission:', error);
|
||||||
|
|
||||||
|
setItems(prev => {
|
||||||
|
if (prev.some(i => i.id === item.id)) return prev;
|
||||||
|
return [...prev, item];
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to delete submission",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}, [actionLoading, toast]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset submission to pending status
|
||||||
|
*/
|
||||||
|
const resetToPending = useCallback(async (item: ModerationItem) => {
|
||||||
|
setActionLoading(item.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { resetRejectedItemsToPending } = await import('@/lib/submissionItemsService');
|
||||||
|
await resetRejectedItemsToPending(item.id);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Reset Complete",
|
||||||
|
description: "Submission and all items have been reset to pending status",
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error resetting submission:', error);
|
||||||
|
toast({
|
||||||
|
title: "Reset Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry failed items in a submission
|
||||||
|
*/
|
||||||
|
const retryFailedItems = useCallback(async (item: ModerationItem) => {
|
||||||
|
setActionLoading(item.id);
|
||||||
|
|
||||||
|
const shouldRemove = (
|
||||||
|
filters.statusFilter === 'pending' ||
|
||||||
|
filters.statusFilter === 'flagged' ||
|
||||||
|
filters.statusFilter === 'partially_approved'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldRemove) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||||
|
recentlyRemovedRef.current.add(item.id);
|
||||||
|
setTimeout(() => recentlyRemovedRef.current.delete(item.id), 10000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: failedItems } = await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.select('id')
|
||||||
|
.eq('submission_id', item.id)
|
||||||
|
.eq('status', 'rejected');
|
||||||
|
|
||||||
|
if (!failedItems || failedItems.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "No Failed Items",
|
||||||
|
description: "All items have been processed successfully",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase.functions.invoke('process-selective-approval', {
|
||||||
|
body: {
|
||||||
|
itemIds: failedItems.map(i => i.id),
|
||||||
|
submissionId: item.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Retry Complete",
|
||||||
|
description: `Processed ${failedItems.length} failed item(s)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error retrying failed items:', error);
|
||||||
|
toast({
|
||||||
|
title: "Retry Failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}, [filters.statusFilter, toast]);
|
||||||
|
|
||||||
|
// Initial fetch on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
isMountingRef.current = true;
|
||||||
|
fetchItems(false).then(() => {
|
||||||
|
initialFetchCompleteRef.current = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isMountingRef.current = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Filter changes trigger refetch
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !initialFetchCompleteRef.current || isMountingRef.current) return;
|
||||||
|
|
||||||
|
pagination.reset();
|
||||||
|
fetchItems(true);
|
||||||
|
}, [filters.debouncedEntityFilter, filters.debouncedStatusFilter]);
|
||||||
|
|
||||||
|
// Pagination changes trigger refetch
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !initialFetchCompleteRef.current || pagination.currentPage === 1) return;
|
||||||
|
|
||||||
|
fetchItems(true);
|
||||||
|
}, [pagination.currentPage, pagination.pageSize]);
|
||||||
|
|
||||||
|
// Polling effect (when realtime disabled)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || settings.refreshMode !== 'auto' || loadingState === 'initial' || settings.useRealtimeQueue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('⚠️ Polling ENABLED - interval:', settings.pollInterval);
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
console.log('🔄 Polling refresh triggered');
|
||||||
|
fetchItems(true);
|
||||||
|
}, settings.pollInterval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
console.log('🛑 Polling stopped');
|
||||||
|
};
|
||||||
|
}, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, fetchItems]);
|
||||||
|
|
||||||
|
// Visibility change handler
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
console.log('📴 Tab hidden - pausing queue updates');
|
||||||
|
pauseFetchingRef.current = true;
|
||||||
|
} else {
|
||||||
|
console.log('📱 Tab visible - resuming queue updates');
|
||||||
|
pauseFetchingRef.current = false;
|
||||||
|
|
||||||
|
if (settings.refreshOnTabVisible && initialFetchCompleteRef.current && !isMountingRef.current) {
|
||||||
|
console.log('🔄 Tab became visible - triggering refresh');
|
||||||
|
fetchItems(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
}, [settings.refreshOnTabVisible, fetchItems]);
|
||||||
|
|
||||||
|
// Initialize realtime subscriptions
|
||||||
|
useRealtimeSubscriptions({
|
||||||
|
enabled: settings.useRealtimeQueue && !!user,
|
||||||
|
filters: {
|
||||||
|
entityFilter: filters.debouncedEntityFilter,
|
||||||
|
statusFilter: filters.debouncedStatusFilter,
|
||||||
|
},
|
||||||
|
onNewItem: (item: ModerationItem) => {
|
||||||
|
if (recentlyRemovedRef.current.has(item.id)) return;
|
||||||
|
|
||||||
|
setPendingNewItems(prev => {
|
||||||
|
if (prev.some(p => p.id === item.id)) return prev;
|
||||||
|
return [...prev, item];
|
||||||
|
});
|
||||||
|
setNewItemsCount(prev => prev + 1);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: '🆕 New Submission',
|
||||||
|
description: `${item.submission_type} - ${item.entity_name}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUpdateItem: (item: ModerationItem, shouldRemove: boolean) => {
|
||||||
|
if (recentlyRemovedRef.current.has(item.id)) return;
|
||||||
|
if (interactingWith.has(item.id)) return;
|
||||||
|
|
||||||
|
if (shouldRemove) {
|
||||||
|
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||||
|
} else {
|
||||||
|
setItems(prev => {
|
||||||
|
const exists = prev.some(i => i.id === item.id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.map(i => i.id === item.id ? item : i);
|
||||||
|
} else {
|
||||||
|
return [item, ...prev];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onItemRemoved: (itemId: string) => {
|
||||||
|
setItems(prev => prev.filter(i => i.id !== itemId));
|
||||||
|
},
|
||||||
|
entityCache,
|
||||||
|
profileCache,
|
||||||
|
recentlyRemovedIds: recentlyRemovedRef.current,
|
||||||
|
interactingWithIds: interactingWith,
|
||||||
|
currentItems: items,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loadingState,
|
||||||
|
actionLoading,
|
||||||
|
filters,
|
||||||
|
pagination,
|
||||||
|
sort,
|
||||||
|
queue,
|
||||||
|
newItemsCount,
|
||||||
|
pendingNewItems,
|
||||||
|
showNewItems,
|
||||||
|
interactingWith,
|
||||||
|
markInteracting,
|
||||||
|
refresh: () => fetchItems(false),
|
||||||
|
performAction,
|
||||||
|
deleteSubmission,
|
||||||
|
resetToPending,
|
||||||
|
retryFailedItems,
|
||||||
|
entityCache,
|
||||||
|
profileCache,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user