Files
thrilltrack-explorer/src-old/hooks/moderation/useModerationQueueManager.ts

564 lines
17 KiB
TypeScript

import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { supabase } from "@/lib/supabaseClient";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/useAuth";
import { logger } from "@/lib/logger";
import { getErrorMessage } from "@/lib/errorHandler";
import { invokeWithTracking } from "@/lib/edgeFunctionTracking";
import { MODERATION_CONSTANTS } from "@/lib/moderation/constants";
import { useQueryClient } from '@tanstack/react-query';
import type { User } from "@supabase/supabase-js";
import {
useEntityCache,
useProfileCache,
useModerationFilters,
usePagination,
useRealtimeSubscriptions,
useQueueQuery,
} from "./index";
import { useModerationQueue } from "@/hooks/useModerationQueue";
import { useModerationActions } from "./useModerationActions";
import type { ModerationItem, EntityFilter, StatusFilter, LoadingState } from "@/types/moderation";
interface ModerationStats {
pendingSubmissions: number;
openReports: number;
flaggedContent: number;
}
/**
* Configuration for useModerationQueueManager
*/
export interface ModerationQueueManagerConfig {
user: User | null;
isAdmin: boolean;
isSuperuser: boolean;
toast: ReturnType<typeof useToast>["toast"];
optimisticallyUpdateStats?: (delta: Partial<ModerationStats>) => void;
settings: {
refreshMode: "auto" | "manual";
pollInterval: number;
refreshStrategy: "notify" | "merge" | "replace";
preserveInteraction: boolean;
useRealtimeQueue: 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>;
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 {
logger.log('🚀 [QUEUE MANAGER] Hook mounting/rendering', {
hasUser: !!config.user,
isAdmin: config.isAdmin,
timestamp: new Date().toISOString()
});
const { user, isAdmin, isSuperuser, toast, optimisticallyUpdateStats, settings } = config;
const queryClient = useQueryClient();
const { aal } = useAuth();
// Debug AAL status
useEffect(() => {
logger.log('🔐 [QUEUE MANAGER] AAL Status:', {
aal,
isNull: aal === null,
isAal1: aal === 'aal1',
isAal2: aal === 'aal2',
timestamp: new Date().toISOString()
});
}, [aal]);
// Initialize sub-hooks
const filters = useModerationFilters({
initialEntityFilter: "all",
initialStatusFilter: "pending",
initialTab: "mainQueue",
debounceDelay: 300,
persist: true,
storageKey: "moderationQueue_filters",
});
// Memoize filters object for realtime subscriptions to prevent reconnections
const realtimeFilters = useMemo(() => ({
entityFilter: filters.debouncedEntityFilter,
statusFilter: filters.debouncedStatusFilter,
}), [filters.debouncedEntityFilter, filters.debouncedStatusFilter]);
const pagination = usePagination({
initialPage: 1,
initialPageSize: 25,
persist: false,
onPageChange: (page) => {
if (page > 1) {
setLoadingState("loading");
}
},
onPageSizeChange: () => {
setLoadingState("loading");
},
});
// Use a stable callback via ref to prevent excessive re-renders
const lockStateChangeHandlerRef = useRef<() => void>();
const queue = useModerationQueue({
onLockStateChange: useCallback(() => {
lockStateChangeHandlerRef.current?.();
}, [])
});
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 initialFetchCompleteRef = useRef(false);
const isMountingRef = useRef(true);
/**
* Replace manual fetching with TanStack Query
* Use direct state values for stable query keys
*/
const queueQuery = useQueueQuery({
userId: user?.id,
isAdmin,
isSuperuser,
entityFilter: filters.debouncedEntityFilter,
statusFilter: filters.debouncedStatusFilter,
tab: filters.activeTab,
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
sortConfig: filters.debouncedSortConfig,
enabled: !!user,
});
// Update the lock state change handler ref whenever queueQuery changes
lockStateChangeHandlerRef.current = () => {
logger.log('🔄 Lock state changed, invalidating queue cache');
queueQuery.invalidate();
setLoadingState(prev => prev === "loading" ? "ready" : prev);
};
// Update items when query data changes
useEffect(() => {
if (queueQuery.items) {
setItems(queueQuery.items);
logger.log('✅ Queue items updated from TanStack Query:', queueQuery.items.length);
}
}, [queueQuery.items]);
// Update loading state based on query status
useEffect(() => {
if (queueQuery.isLoading) {
setLoadingState('loading');
} else if (queueQuery.isRefreshing) {
setLoadingState('refreshing');
} else {
setLoadingState('ready');
}
}, [queueQuery.isLoading, queueQuery.isRefreshing]);
// Show error toast when query fails
useEffect(() => {
if (queueQuery.error) {
// Error already captured by TanStack Query
toast({
variant: 'destructive',
title: 'Failed to Load Queue',
description: queueQuery.error.message || 'An error occurred while fetching the moderation queue.',
});
}
}, [queueQuery.error, toast]);
// Extract stable callback to prevent infinite loop
const { setTotalCount } = pagination;
// Update total count for pagination
useEffect(() => {
setTotalCount(queueQuery.totalCount);
}, [queueQuery.totalCount, setTotalCount]);
// Mark initial fetch as complete
useEffect(() => {
if (!queueQuery.isLoading && !initialFetchCompleteRef.current) {
initialFetchCompleteRef.current = true;
logger.log('✅ Initial queue fetch complete');
}
}, [queueQuery.isLoading]);
/**
* Manual refresh function
*/
const refresh = useCallback(async () => {
logger.log('🔄 Manual refresh triggered');
await queueQuery.refetch();
}, [queueQuery]);
/**
* Show pending new items by invalidating query
*/
const showNewItems = useCallback(async () => {
logger.log('✅ Showing new items via query invalidation');
await queueQuery.invalidate();
setPendingNewItems([]);
setNewItemsCount(0);
}, [queueQuery]);
/**
* 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;
});
}, []);
/**
* Use validated action handler from useModerationActions
*/
const moderationActions = useModerationActions({
user,
onActionStart: setActionLoading,
onActionComplete: () => {
setActionLoading(null);
refresh();
queue.refreshStats();
},
currentLockSubmissionId: queue.currentLock?.submissionId,
});
/**
* Perform moderation action (approve/reject) - delegates to validated handler
*/
const performAction = useCallback(
async (item: ModerationItem, action: "approved" | "rejected", moderatorNotes?: string) => {
// Release lock if held
if (queue.currentLock?.submissionId === item.id) {
await queue.releaseLock(item.id, true);
}
// Use validated action handler
await moderationActions.performAction(item, action, moderatorNotes);
},
[moderationActions, queue]
);
/**
* 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",
});
// Refresh stats to update counts
queue.refreshStats();
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
// Silent - operation handled optimistically
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",
});
// Refresh stats to update counts
queue.refreshStats();
setItems((prev) => prev.filter((i) => i.id !== item.id));
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
// Silent - operation handled optimistically
toast({
title: "Reset Failed",
description: errorMsg,
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;
}
const { data, error, requestId } = await invokeWithTracking(
"process-selective-approval",
{
itemIds: failedItems.map((i) => i.id),
submissionId: item.id,
},
user?.id
);
if (error) throw error;
toast({
title: "Retry Complete",
description: `Processed ${failedItems.length} failed item(s)${requestId ? ` (Request: ${requestId.substring(0, 8)})` : ""}`,
});
// Refresh stats to update counts
queue.refreshStats();
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
// Silent - operation handled optimistically
toast({
title: "Retry Failed",
description: errorMsg,
variant: "destructive",
});
} finally {
setActionLoading(null);
}
},
[filters.statusFilter, toast],
);
// Extract stable callbacks to prevent infinite loop in effects
const { invalidate: invalidateQuery } = queueQuery;
const { reset: resetPagination } = pagination;
// Mark initial fetch as complete when query loads
useEffect(() => {
if (!queueQuery.isLoading && !initialFetchCompleteRef.current) {
initialFetchCompleteRef.current = true;
isMountingRef.current = false;
logger.log('✅ Initial queue fetch complete');
}
}, [queueQuery.isLoading]);
// Invalidate query when filters or sort changes (OPTIMIZED)
useEffect(() => {
if (
!user ||
!initialFetchCompleteRef.current ||
isMountingRef.current
) return;
logger.log('🔄 Filters/sort changed, invalidating query');
resetPagination();
invalidateQuery();
}, [
filters.debouncedEntityFilter,
filters.debouncedStatusFilter,
filters.debouncedSortConfig.field,
filters.debouncedSortConfig.direction,
user,
invalidateQuery,
resetPagination
]);
// Polling effect (when realtime disabled) - MUTUALLY EXCLUSIVE
useEffect(() => {
const shouldPoll = settings.refreshMode === 'auto'
&& !settings.useRealtimeQueue
&& loadingState !== 'initial'
&& !!user;
if (!shouldPoll) {
return;
}
logger.log("⚠️ Polling ENABLED - interval:", settings.pollInterval);
const interval = setInterval(() => {
logger.log("🔄 Polling refresh triggered");
queueQuery.refetch();
}, settings.pollInterval);
return () => {
clearInterval(interval);
logger.log("🛑 Polling stopped");
};
}, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, queueQuery.refetch]);
// Initialize realtime subscriptions
useRealtimeSubscriptions({
enabled: settings.useRealtimeQueue && !!user,
filters: realtimeFilters,
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;
// Only track removals for optimistic update protection
if (shouldRemove && !recentlyRemovedRef.current.has(item.id)) {
recentlyRemovedRef.current.add(item.id);
setTimeout(() => recentlyRemovedRef.current.delete(item.id), MODERATION_CONSTANTS.REALTIME_OPTIMISTIC_REMOVAL_TIMEOUT);
}
// TanStack Query handles actual state updates via invalidation
},
onItemRemoved: (itemId: string) => {
// Track for optimistic update protection
recentlyRemovedRef.current.add(itemId);
setTimeout(() => recentlyRemovedRef.current.delete(itemId), MODERATION_CONSTANTS.REALTIME_OPTIMISTIC_REMOVAL_TIMEOUT);
// TanStack Query handles removal via invalidation
},
entityCache,
profileCache,
recentlyRemovedIds: recentlyRemovedRef.current,
interactingWithIds: interactingWith,
});
return {
items,
loadingState,
actionLoading,
filters,
pagination,
queue,
newItemsCount,
pendingNewItems,
showNewItems,
interactingWith,
markInteracting,
refresh,
performAction,
deleteSubmission,
resetToPending,
retryFailedItems,
entityCache,
profileCache,
};
}