mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add cancel confirmation and cancellable statuses
Introduce a unified CANCELLABLE_STATUSES constant and add confirmation UI for cancelling requests. RequestActionsDropdown and RequestCard now show a ConfirmModal before cancelling and use the shared CANCELLABLE_STATUSES to gate cancel actions. The API route imports the constant to enforce server-side validation and uses Prisma.DbNull for selectedTorrent when withdrawing an awaiting-approval request. Tests updated to expect Prisma.DbNull. Improves UX and centralizes cancel logic to avoid duplicated status lists.
This commit is contained in:
@@ -12,6 +12,8 @@ import { createPortal } from 'react-dom';
|
|||||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
|
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
|
||||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||||
|
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||||
|
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
|
||||||
|
|
||||||
export interface RequestActionsDropdownProps {
|
export interface RequestActionsDropdownProps {
|
||||||
request: {
|
request: {
|
||||||
@@ -54,8 +56,12 @@ export function RequestActionsDropdown({
|
|||||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||||
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = 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 { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||||
|
|
||||||
|
const isAwaitingApproval = request.status === 'awaiting_approval';
|
||||||
|
|
||||||
// Determine request type
|
// Determine request type
|
||||||
const isEbook = request.type === 'ebook';
|
const isEbook = request.type === 'ebook';
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ export function RequestActionsDropdown({
|
|||||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
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
|
const canDelete = true; // Admins can always delete
|
||||||
|
|
||||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
// 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);
|
setIsOpen(false);
|
||||||
const statusNote = request.status === 'awaiting_approval'
|
setConfirmCancelOpen(true);
|
||||||
? ' 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}`;
|
const handleConfirmCancel = async () => {
|
||||||
if (window.confirm(message)) {
|
setIsCancelling(true);
|
||||||
try {
|
try {
|
||||||
await onCancel(request.requestId);
|
await onCancel(request.requestId);
|
||||||
} catch (error) {
|
setConfirmCancelOpen(false);
|
||||||
console.error('Failed to cancel request:', error);
|
} catch (error) {
|
||||||
}
|
console.error('Failed to cancel request:', error);
|
||||||
|
setConfirmCancelOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsCancelling(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -533,6 +542,22 @@ export function RequestActionsDropdown({
|
|||||||
currentSearchTerms={request.customSearchTerms}
|
currentSearchTerms={request.customSearchTerms}
|
||||||
onSuccess={onSearchTermsUpdated}
|
onSuccess={onSearchTermsUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmCancelOpen}
|
||||||
|
onClose={() => !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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
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');
|
const logger = RMABLogger.create('API.RequestById');
|
||||||
|
|
||||||
@@ -134,8 +136,7 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'cancel') {
|
if (action === 'cancel') {
|
||||||
const cancellableStatuses = ['pending', 'searching', 'downloading', 'awaiting_search', 'awaiting_approval'];
|
if (!(CANCELLABLE_STATUSES as readonly string[]).includes(requestRecord.status)) {
|
||||||
if (!cancellableStatuses.includes(requestRecord.status)) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'ValidationError',
|
error: 'ValidationError',
|
||||||
@@ -152,7 +153,7 @@ export async function PATCH(
|
|||||||
data: {
|
data: {
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
...(isAwaitingApproval && { selectedTorrent: null as any }),
|
...(isAwaitingApproval && { selectedTorrent: Prisma.DbNull }),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
audiobook: true,
|
audiobook: true,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { useCancelRequest } from '@/lib/hooks/useRequests';
|
|||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
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 {
|
interface RequestCardProps {
|
||||||
request: {
|
request: {
|
||||||
@@ -45,26 +46,25 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
const [showError, setShowError] = React.useState(false);
|
const [showError, setShowError] = React.useState(false);
|
||||||
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
||||||
const [coverError, setCoverError] = 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 requestType = request.type || 'audiobook';
|
||||||
const isEbook = requestType === 'ebook';
|
const isEbook = requestType === 'ebook';
|
||||||
|
|
||||||
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
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 isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||||
const isFailed = request.status === 'failed';
|
const isFailed = request.status === 'failed';
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleConfirmCancel = async () => {
|
||||||
const statusNote = request.status === 'awaiting_approval'
|
try {
|
||||||
? ' It is pending admin approval and will be withdrawn.'
|
await cancelRequest(request.id);
|
||||||
: ' It has already been approved and is actively being processed/monitored.';
|
setConfirmCancelOpen(false);
|
||||||
const message = `Are you sure you want to cancel this request?${statusNote}`;
|
} catch (error) {
|
||||||
if (window.confirm(message)) {
|
console.error('Failed to cancel request:', error);
|
||||||
try {
|
setConfirmCancelOpen(false);
|
||||||
await cancelRequest(request.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to cancel request:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -232,13 +232,13 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCancel}
|
onClick={() => setConfirmCancelOpen(true)}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
>
|
>
|
||||||
Cancel
|
{isAwaitingApproval ? 'Withdraw' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -258,6 +258,22 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
hideRequestActions
|
hideRequestActions
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmCancelOpen}
|
||||||
|
onClose={() => !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}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,12 @@
|
|||||||
|
|
||||||
/** Terminal statuses indicating a request has been fulfilled and files are ready */
|
/** Terminal statuses indicating a request has been fulfilled and files are ready */
|
||||||
export const COMPLETED_STATUSES = ['available', 'downloaded'] as const;
|
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;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { createPrismaMock } from '../helpers/prisma';
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
let authRequest: any;
|
let authRequest: any;
|
||||||
@@ -162,7 +163,7 @@ describe('Request by ID API routes', () => {
|
|||||||
expect(payload.request.status).toBe('cancelled');
|
expect(payload.request.status).toBe('cancelled');
|
||||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: expect.objectContaining({ selectedTorrent: null }),
|
data: expect.objectContaining({ selectedTorrent: Prisma.DbNull }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
|
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
|
||||||
|
|||||||
Reference in New Issue
Block a user