diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index d105940..6f9563f 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -12,6 +12,8 @@ import { createPortal } from 'react-dom'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { AdjustSearchTermsModal } from './AdjustSearchTermsModal'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses'; export interface RequestActionsDropdownProps { request: { @@ -54,8 +56,12 @@ export function RequestActionsDropdown({ const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false); + const [confirmCancelOpen, setConfirmCancelOpen] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen); + const isAwaitingApproval = request.status === 'awaiting_approval'; + // Determine request type const isEbook = request.type === 'ebook'; @@ -66,7 +72,7 @@ export function RequestActionsDropdown({ const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status); const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload; - const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'].includes(request.status); + const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status); const canDelete = true; // Admins can always delete // View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive @@ -157,18 +163,21 @@ export function RequestActionsDropdown({ } }; - const handleCancel = async () => { + const handleCancel = () => { setIsOpen(false); - const statusNote = request.status === 'awaiting_approval' - ? ' It is pending admin approval and will be withdrawn.' - : ' It has already been approved and is actively being processed/monitored.'; - const message = `Are you sure you want to cancel this request?${statusNote}`; - if (window.confirm(message)) { - try { - await onCancel(request.requestId); - } catch (error) { - console.error('Failed to cancel request:', error); - } + setConfirmCancelOpen(true); + }; + + const handleConfirmCancel = async () => { + setIsCancelling(true); + try { + await onCancel(request.requestId); + setConfirmCancelOpen(false); + } catch (error) { + console.error('Failed to cancel request:', error); + setConfirmCancelOpen(false); + } finally { + setIsCancelling(false); } }; @@ -533,6 +542,22 @@ export function RequestActionsDropdown({ currentSearchTerms={request.customSearchTerms} onSuccess={onSearchTermsUpdated} /> + + !isCancelling && setConfirmCancelOpen(false)} + onConfirm={handleConfirmCancel} + title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'} + message={ + isAwaitingApproval + ? `"${request.title}" is pending admin approval and will be withdrawn. The user can request it again later.` + : `"${request.title}" has already been approved and is actively being processed. Cancelling will stop the download.` + } + confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'} + cancelText="Keep request" + variant="danger" + isLoading={isCancelling} + /> ); } diff --git a/src/app/api/requests/[id]/route.ts b/src/app/api/requests/[id]/route.ts index ed9a584..e407acd 100644 --- a/src/app/api/requests/[id]/route.ts +++ b/src/app/api/requests/[id]/route.ts @@ -4,10 +4,12 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import { Prisma } from '@/generated/prisma/client'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface'; +import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses'; const logger = RMABLogger.create('API.RequestById'); @@ -134,8 +136,7 @@ export async function PATCH( } if (action === 'cancel') { - const cancellableStatuses = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval']; - if (!cancellableStatuses.includes(requestRecord.status)) { + if (!(CANCELLABLE_STATUSES as readonly string[]).includes(requestRecord.status)) { return NextResponse.json( { error: 'ValidationError', @@ -152,7 +153,7 @@ export async function PATCH( data: { status: 'cancelled', updatedAt: new Date(), - ...(isAwaitingApproval && { selectedTorrent: null as any }), + ...(isAwaitingApproval && { selectedTorrent: Prisma.DbNull }), }, include: { audiobook: true, diff --git a/src/components/requests/RequestCard.tsx b/src/components/requests/RequestCard.tsx index 91dcdeb..6a636fc 100644 --- a/src/components/requests/RequestCard.tsx +++ b/src/components/requests/RequestCard.tsx @@ -13,7 +13,8 @@ import { useCancelRequest } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; import { usePreferences } from '@/contexts/PreferencesContext'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; -import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses'; +import { ConfirmModal } from '@/components/ui/ConfirmModal'; +import { COMPLETED_STATUSES, CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses'; interface RequestCardProps { request: { @@ -45,26 +46,25 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { const [showError, setShowError] = React.useState(false); const [showDetailsModal, setShowDetailsModal] = React.useState(false); const [coverError, setCoverError] = React.useState(false); + const [confirmCancelOpen, setConfirmCancelOpen] = React.useState(false); + + const isAwaitingApproval = request.status === 'awaiting_approval'; const requestType = request.type || 'audiobook'; const isEbook = requestType === 'ebook'; const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]); - const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'].includes(request.status); + const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status); const isActive = ['searching', 'downloading', 'processing'].includes(request.status); const isFailed = request.status === 'failed'; - const handleCancel = async () => { - const statusNote = request.status === 'awaiting_approval' - ? ' It is pending admin approval and will be withdrawn.' - : ' It has already been approved and is actively being processed/monitored.'; - const message = `Are you sure you want to cancel this request?${statusNote}`; - if (window.confirm(message)) { - try { - await cancelRequest(request.id); - } catch (error) { - console.error('Failed to cancel request:', error); - } + const handleConfirmCancel = async () => { + try { + await cancelRequest(request.id); + setConfirmCancelOpen(false); + } catch (error) { + console.error('Failed to cancel request:', error); + setConfirmCancelOpen(false); } }; @@ -232,13 +232,13 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
{canCancel && ( )}
@@ -258,6 +258,22 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { hideRequestActions /> )} + + !isLoading && setConfirmCancelOpen(false)} + onConfirm={handleConfirmCancel} + title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'} + message={ + isAwaitingApproval + ? 'This request is pending admin approval and will be withdrawn. You can request it again later.' + : 'This request has already been approved and is actively being processed. Cancelling will stop the download.' + } + confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'} + cancelText="Keep request" + variant="danger" + isLoading={isLoading} + /> ); } diff --git a/src/lib/constants/request-statuses.ts b/src/lib/constants/request-statuses.ts index 2e7c99f..e4770f6 100644 --- a/src/lib/constants/request-statuses.ts +++ b/src/lib/constants/request-statuses.ts @@ -5,3 +5,12 @@ /** Terminal statuses indicating a request has been fulfilled and files are ready */ export const COMPLETED_STATUSES = ['available', 'downloaded'] as const; + +/** Statuses from which a request can be cancelled (server-enforced and UI-gated) */ +export const CANCELLABLE_STATUSES = [ + 'pending', + 'searching', + 'downloading', + 'awaiting_search', + 'awaiting_approval', +] as const; diff --git a/tests/api/requests-id.route.test.ts b/tests/api/requests-id.route.test.ts index f97b9ef..d597af0 100644 --- a/tests/api/requests-id.route.test.ts +++ b/tests/api/requests-id.route.test.ts @@ -4,6 +4,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Prisma } from '@/generated/prisma/client'; import { createPrismaMock } from '../helpers/prisma'; let authRequest: any; @@ -162,7 +163,7 @@ describe('Request by ID API routes', () => { expect(payload.request.status).toBe('cancelled'); expect(prismaMock.request.update).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ selectedTorrent: null }), + data: expect.objectContaining({ selectedTorrent: Prisma.DbNull }), }) ); expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(