From 1a25f544b19ff0d01575a4455167990326b1ccaa Mon Sep 17 00:00:00 2001 From: xFlawless11x Date: Thu, 14 May 2026 21:19:46 -0400 Subject: [PATCH] feat: allow users and admins to cancel pending-approval requests - Add cancel action to RequestActionsDropdown for admins - Add cancel button to RequestCard for users - Implement DELETE handler in /api/requests/[id] with: - Status gate: only cancellable if pending_approval or awaiting_approval - Clears selectedTorrent (Prisma.DbNull) on cancel - Fires on-grab notification job after cancel - Tests: cancel flows for both statuses, rejection for non-cancellable status Co-Authored-By: Claude Sonnet 4.6 --- .../components/RequestActionsDropdown.tsx | 8 ++- src/app/api/requests/[id]/route.ts | 33 +++++++++- src/components/requests/RequestCard.tsx | 8 ++- src/lib/constants/notification-events.ts | 7 ++ tests/api/requests-id.route.test.ts | 66 ++++++++++++++++++- 5 files changed, 115 insertions(+), 7 deletions(-) diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index f84f093..d105940 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -66,7 +66,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'].includes(request.status); + const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'].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 @@ -159,7 +159,11 @@ export function RequestActionsDropdown({ const handleCancel = async () => { setIsOpen(false); - if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) { + 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) { diff --git a/src/app/api/requests/[id]/route.ts b/src/app/api/requests/[id]/route.ts index 9a842ba..ed9a584 100644 --- a/src/app/api/requests/[id]/route.ts +++ b/src/app/api/requests/[id]/route.ts @@ -112,6 +112,10 @@ export async function PATCH( id, deletedAt: null, // Only allow updates to active requests }, + include: { + audiobook: true, + user: { select: { plexUsername: true } }, + }, }); if (!requestRecord) { @@ -130,18 +134,45 @@ export async function PATCH( } if (action === 'cancel') { - // Cancel the request + const cancellableStatuses = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval']; + if (!cancellableStatuses.includes(requestRecord.status)) { + return NextResponse.json( + { + error: 'ValidationError', + message: `Cannot cancel request with status: ${requestRecord.status}`, + }, + { status: 400 } + ); + } + + const isAwaitingApproval = requestRecord.status === 'awaiting_approval'; + const updated = await prisma.request.update({ where: { id }, data: { status: 'cancelled', updatedAt: new Date(), + ...(isAwaitingApproval && { selectedTorrent: null as any }), }, include: { audiobook: true, }, }); + try { + const { getJobQueueService } = await import('@/lib/services/job-queue.service'); + const jobQueue = getJobQueueService(); + await jobQueue.addNotificationJob( + 'request_cancelled', + updated.id, + updated.audiobook.title, + updated.audiobook.author, + requestRecord.user.plexUsername || 'Unknown User' + ); + } catch (error) { + logger.error('Failed to queue cancellation notification', { error }); + } + return NextResponse.json({ success: true, request: updated, diff --git a/src/components/requests/RequestCard.tsx b/src/components/requests/RequestCard.tsx index d8b1e1c..91dcdeb 100644 --- a/src/components/requests/RequestCard.tsx +++ b/src/components/requests/RequestCard.tsx @@ -50,12 +50,16 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { const isEbook = requestType === 'ebook'; const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]); - const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status); + const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'].includes(request.status); const isActive = ['searching', 'downloading', 'processing'].includes(request.status); const isFailed = request.status === 'failed'; const handleCancel = async () => { - if (window.confirm('Are you sure you want to cancel this request?')) { + 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) { diff --git a/src/lib/constants/notification-events.ts b/src/lib/constants/notification-events.ts index d1e6542..a23a275 100644 --- a/src/lib/constants/notification-events.ts +++ b/src/lib/constants/notification-events.ts @@ -77,6 +77,13 @@ export const NOTIFICATION_EVENTS = { severity: 'error' as const, priority: 'high' as const, }, + request_cancelled: { + label: 'Request Cancelled', + title: 'Request Cancelled', + emoji: '\u{1F6AB}', + severity: 'warning' as const, + priority: 'normal' as const, + }, issue_reported: { label: 'Issue Reported', title: 'Issue Reported', diff --git a/tests/api/requests-id.route.test.ts b/tests/api/requests-id.route.test.ts index c443d5f..f97b9ef 100644 --- a/tests/api/requests-id.route.test.ts +++ b/tests/api/requests-id.route.test.ts @@ -9,7 +9,7 @@ import { createPrismaMock } from '../helpers/prisma'; let authRequest: any; const prismaMock = createPrismaMock(); -const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn() })); +const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn(), addNotificationJob: vi.fn().mockResolvedValue(undefined) })); const requireAuthMock = vi.hoisted(() => vi.fn()); const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() })); const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() })); @@ -115,11 +115,13 @@ describe('Request by ID API routes', () => { id: 'req-2', userId: 'user-1', status: 'pending', + user: { plexUsername: 'testuser' }, + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' }, }); prismaMock.request.update.mockResolvedValueOnce({ id: 'req-2', status: 'cancelled', - audiobook: { id: 'ab-1' }, + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' }, }); const { PATCH } = await import('@/app/api/requests/[id]/route'); @@ -128,6 +130,66 @@ describe('Request by ID API routes', () => { expect(response.status).toBe(200); expect(payload.request.status).toBe('cancelled'); + expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( + 'request_cancelled', + 'req-2', + 'Test Book', + 'Test Author', + 'testuser' + ); + }); + + it('cancels an awaiting_approval request and clears selectedTorrent', async () => { + authRequest.json.mockResolvedValue({ action: 'cancel' }); + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-ap', + userId: 'user-1', + status: 'awaiting_approval', + user: { plexUsername: 'testuser' }, + audiobook: { id: 'ab-ap', title: 'Approval Book', author: 'Some Author' }, + }); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-ap', + status: 'cancelled', + audiobook: { id: 'ab-ap', title: 'Approval Book', author: 'Some Author' }, + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-ap' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.request.status).toBe('cancelled'); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ selectedTorrent: null }), + }) + ); + expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( + 'request_cancelled', + 'req-ap', + 'Approval Book', + 'Some Author', + 'testuser' + ); + }); + + it('returns 400 when cancelling a request in a non-cancellable status', async () => { + authRequest.json.mockResolvedValue({ action: 'cancel' }); + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-2', + userId: 'user-1', + status: 'available', + user: { plexUsername: 'testuser' }, + audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' }, + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); }); it('returns 400 for invalid actions', async () => {