Add admin request deletion with soft delete and cleanup

Implements admin ability to delete requests with soft delete, media file cleanup, and seeding-aware torrent management. Adds new API endpoint, frontend confirmation dialog, and request actions dropdown. Updates database schema with deletedAt and deletedBy fields, and ensures all queries filter out deleted requests. Documentation added for feature and user flow.
This commit is contained in:
kikootwo
2025-12-22 20:24:43 -05:00
parent bba4af7398
commit 174e9f05b6
26 changed files with 1936 additions and 200 deletions
@@ -13,6 +13,7 @@ import { StatusBadge } from '@/components/requests/StatusBadge';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
interface AudiobookDetailsModalProps {
asin: string;
@@ -41,6 +42,7 @@ export function AudiobookDetailsModal({
const [showToast, setShowToast] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
useEffect(() => {
setMounted(true);
@@ -77,6 +79,29 @@ export function AudiobookDetailsModal({
}
};
const handleInteractiveSearch = () => {
if (!user || !audiobook) {
setRequestError('Please log in to request audiobooks');
return;
}
// Just show the interactive search modal - no request created yet
setShowInteractiveSearch(true);
};
const handleInteractiveSearchClose = () => {
// Clean up state
setShowInteractiveSearch(false);
// Close the details modal too
onClose();
};
const handleInteractiveSearchSuccess = () => {
// Request was created and torrent was selected successfully
onRequestSuccess?.();
};
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
@@ -381,6 +406,35 @@ export function AudiobookDetailsModal({
);
})()}
{/* Interactive Search Button - only show if not already available */}
{!isAvailable && requestStatus !== 'completed' && (
<button
onClick={handleInteractiveSearch}
disabled={!user}
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 border-purple-600 bg-purple-50 dark:bg-purple-900/20 hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Interactive Search"
aria-label="Interactive Search"
>
<svg
className="w-6 h-6 text-purple-600 dark:text-purple-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Interactive Search
</span>
</button>
)}
<Button onClick={onClose} variant="outline" size="lg">
Close
</Button>
@@ -407,5 +461,27 @@ export function AudiobookDetailsModal({
</div>
);
return createPortal(modalContent, document.body);
return (
<>
{createPortal(modalContent, document.body)}
{/* Interactive Search Modal - render with higher z-index to appear above details modal */}
{showInteractiveSearch && audiobook && createPortal(
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
<div style={{ pointerEvents: 'auto' }}>
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearch}
onClose={handleInteractiveSearchClose}
onSuccess={handleInteractiveSearchSuccess}
audiobook={{
title: audiobook.title,
author: audiobook.author,
}}
fullAudiobook={audiobook}
/>
</div>
</div>,
document.body
)}
</>
);
}
@@ -10,16 +10,19 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { useInteractiveSearch, useSelectTorrent } from '@/lib/hooks/useRequests';
import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
interface InteractiveTorrentSearchModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
requestId?: string; // Optional - only provided when called from existing request
audiobook: {
title: string;
author: string;
};
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
onSuccess?: () => void;
}
export function InteractiveTorrentSearchModal({
@@ -27,13 +30,27 @@ export function InteractiveTorrentSearchModal({
onClose,
requestId,
audiobook,
fullAudiobook,
onSuccess,
}: InteractiveTorrentSearchModalProps) {
const { searchTorrents, isLoading: isSearching, error: searchError } = useInteractiveSearch();
const { selectTorrent, isLoading: isDownloading, error: downloadError } = useSelectTorrent();
// Hooks for existing request flow
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
// Hooks for new audiobook flow
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
const [results, setResults] = useState<(TorrentResult & { rank: number; qualityScore?: number })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const error = searchError || downloadError;
// Determine which mode we're in
const hasRequestId = !!requestId;
const isSearching = hasRequestId ? isSearchingByRequest : isSearchingByAudiobook;
const isDownloading = hasRequestId ? isSelectingTorrent : isRequestingWithTorrent;
const error = hasRequestId
? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError);
// Perform search when modal opens
React.useEffect(() => {
@@ -44,7 +61,14 @@ export function InteractiveTorrentSearchModal({
const performSearch = async () => {
try {
const data = await searchTorrents(requestId);
let data;
if (hasRequestId) {
// Existing flow: search by requestId
data = await searchByRequestId(requestId);
} else {
// New flow: search by audiobook title/author
data = await searchByAudiobook(audiobook.title, audiobook.author);
}
setResults(data || []);
} catch (err) {
// Error already handled by hook
@@ -60,7 +84,18 @@ export function InteractiveTorrentSearchModal({
if (!confirmTorrent) return;
try {
await selectTorrent(requestId, confirmTorrent);
if (hasRequestId) {
// Existing flow: select torrent for existing request
await selectTorrent(requestId, confirmTorrent);
} else {
// New flow: create request with torrent
if (!fullAudiobook) {
throw new Error('Audiobook data required to create request');
}
await requestWithTorrent(fullAudiobook, confirmTorrent);
}
// Notify parent of successful selection
onSuccess?.();
// Close modals on success
setConfirmTorrent(null);
onClose();