Files
thrilltrack-explorer/src/hooks/moderation/useModerationQueueManager.ts
2025-10-17 15:06:35 +00:00

674 lines
21 KiB
TypeScript

import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { logger } from "@/lib/logger";
import { getErrorMessage } from "@/lib/errorHandler";
import { MODERATION_CONSTANTS } from "@/lib/moderation/constants";
import type { User } from "@supabase/supabase-js";
import {
useEntityCache,
useProfileCache,
useModerationFilters,
usePagination,
useRealtimeSubscriptions,
useQueueQuery,
} from "./index";
import { useModerationQueue } from "@/hooks/useModerationQueue";
import type { ModerationItem, EntityFilter, StatusFilter, LoadingState } 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;
};
}
/**
* 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, settings } = config;
// 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");
},
});
const queue = useModerationQueue({
onLockStateChange: () => {
logger.log('🔄 Lock state changed, invalidating queue cache');
queueQuery.invalidate();
// Force immediate re-render by triggering a loading cycle
setLoadingState(prev => prev === "loading" ? "ready" : prev);
}
});
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);
// Store settings, filters, and pagination in refs to stabilize fetchItems
const settingsRef = useRef(settings);
const filtersRef = useRef(filters);
const sortRef = useRef(filters.debouncedSortConfig);
const paginationRef = useRef(pagination);
// Sync refs with state
useEffect(() => {
settingsRef.current = settings;
}, [settings]);
useEffect(() => {
filtersRef.current = filters;
}, [filters]);
useEffect(() => {
sortRef.current = filters.debouncedSortConfig;
}, [filters.debouncedSortConfig]);
useEffect(() => {
paginationRef.current = pagination;
}, [pagination]);
/**
* Replace manual fetching with TanStack Query
*/
const queueQuery = useQueueQuery({
userId: user?.id,
isAdmin,
isSuperuser,
entityFilter: filtersRef.current.debouncedEntityFilter,
statusFilter: filtersRef.current.debouncedStatusFilter,
tab: filtersRef.current.activeTab,
currentPage: paginationRef.current.currentPage,
pageSize: paginationRef.current.pageSize,
sortConfig: sortRef.current,
enabled: !!user,
});
// 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) {
logger.error('❌ Queue query error:', queueQuery.error);
toast({
variant: 'destructive',
title: 'Failed to Load Queue',
description: queueQuery.error.message || 'An error occurred while fetching the moderation queue.',
});
}
}, [queueQuery.error, toast]);
// Update total count for pagination
useEffect(() => {
paginationRef.current.setTotalCount(queueQuery.totalCount);
}, [queueQuery.totalCount]);
// 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;
});
}, []);
/**
* 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, true); // Silent release
}
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}. Version history updated.`,
});
// Refresh stats to update counts
queue.refreshStats();
} catch (error) {
const errorMsg = getErrorMessage(error);
console.error("Error moderating content:", errorMsg);
// 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: errorMsg || `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",
});
// Refresh stats to update counts
queue.refreshStats();
} catch (error) {
const errorMsg = getErrorMessage(error);
console.error("Error deleting submission:", errorMsg);
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) {
const errorMsg = getErrorMessage(error);
console.error("Error resetting submission:", errorMsg);
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;
}
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)`,
});
// Refresh stats to update counts
queue.refreshStats();
} catch (error) {
const errorMsg = getErrorMessage(error);
console.error("Error retrying failed items:", errorMsg);
toast({
title: "Retry Failed",
description: errorMsg,
variant: "destructive",
});
} finally {
setActionLoading(null);
}
},
[filters.statusFilter, toast],
);
// Extract stable callbacks for dependencies
const invalidateQuery = useCallback(() => {
queueQuery.invalidate();
}, [queueQuery.invalidate]);
const resetPagination = useCallback(() => {
pagination.reset();
}, [pagination.reset]);
// 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,
};
}