mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
674 lines
21 KiB
TypeScript
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,
|
|
};
|
|
}
|