Files
thrilltrack-explorer/src/hooks/moderation/useModerationQueueManager.ts
gpt-engineer-app[bot] b22546e7f2 Add audit trail and filters
Implements audit trail view for item approvals, adds approval date range filtering to moderation queue, and wires up UI and backend components (Approval History page, ItemApprovalHistory component, materialized view-based history, and query/filters integration) to support compliant reporting and time-based moderation filtering.
2025-11-12 14:06:34 +00:00

578 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,
approvalDateRange: filters.debouncedApprovalDateRange,
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;
// Log manual submission deletion
const { logAdminAction } = await import('@/lib/adminActionAuditHelpers');
await logAdminAction(
'submission_force_deleted',
{
submission_id: item.id,
submission_type: item.content?.action || 'unknown',
entity_type: item.content?.entity_type,
reason: 'Manual deletion by moderator',
},
item.user_id
);
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, queue],
);
/**
* 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,
};
}