Files
ReadMeABook/src/app/api/requests/route.ts
T
kikootwo 09cff5b68d Add per-user ignored audiobooks feature
Introduce a per-user "ignored audiobooks" feature to suppress auto-requests. Changes include:

- Database: add Prisma model IgnoredAudiobook and SQL migration to create ignored_audiobooks table with indexes and FK to users.
- Backend: new API routes to list, add, delete, and check ignored audiobooks (/api/user/ignored-audiobooks, /check/:asin, /:id). Add annotateWithIgnoreStatus utility and integrate it into multiple audiobook list endpoints (popular, new-releases, category, search, authors, series).
- Request creator: add ignore-list check (with sibling-ASIN expansion) and a bypassIgnore option for manual requests; return an 'ignored' reason when blocked.
- Frontend: hooks (useIsIgnored, useToggleIgnore, useIgnoredList) and UI updates — AudiobookCard shows an "Ignored" indicator and AudiobookDetailsModal adds an ignore toggle and propagates local state changes.
- Misc: adjust deduplication duration tolerance (to 5% / min 10 minutes), tweak SWR refresh intervals for shelves/syncing, and small logging/info updates.
- Tests: add unit tests for request-creator ignore logic and update existing tests/mocks to account for ignore annotation; extend prisma test helper with ignoredAudiobook mock.

This commit implements the ignore-list end-to-end (DB, server, client, and tests) so users can ignore specific ASINs and have auto-request flows respect that preference.
2026-03-11 11:56:35 -04:00

235 lines
8.2 KiB
TypeScript

/**
* Component: Requests API Routes
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
import { createRequestForUser } from '@/lib/services/request-creator.service';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
const logger = RMABLogger.create('API.Requests');
const CreateRequestSchema = z.object({
audiobook: z.object({
asin: z.string(),
title: z.string(),
author: z.string(),
narrator: z.string().optional(),
description: z.string().optional(),
coverArtUrl: z.string().optional(),
durationMinutes: z.number().optional(),
releaseDate: z.string().optional(),
rating: z.number().nullable().optional(),
}),
});
/**
* POST /api/requests
* Create a new audiobook request
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const body = await req.json();
const { audiobook } = CreateRequestSchema.parse(body);
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
const result = await createRequestForUser(req.user.id, {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
}, { skipAutoSearch, bypassIgnore: true });
if (!result.success) {
const statusMap: Record<string, { error: string; status: number }> = {
already_available: { error: 'AlreadyAvailable', status: 409 },
being_processed: { error: 'BeingProcessed', status: 409 },
duplicate: { error: 'DuplicateRequest', status: 409 },
user_not_found: { error: 'UserNotFound', status: 404 },
ignored: { error: 'Ignored', status: 409 },
};
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
return NextResponse.json(
{ error: mapped.error, message: result.message },
{ status: mapped.status }
);
}
return NextResponse.json({
success: true,
request: result.request,
}, { status: 201 });
} catch (error) {
logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) });
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'ValidationError',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'RequestError',
message: 'Failed to create audiobook request',
},
{ status: 500 }
);
}
});
}
// Status groups for server-side filtering and count aggregation
const STATUS_GROUPS: Record<string, string[]> = {
active: ['pending', 'searching', 'downloading', 'processing'],
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'],
completed: ['available', 'downloaded'],
failed: ['failed'],
cancelled: ['cancelled', 'denied'],
};
/**
* GET /api/requests
* Get user's audiobook requests with cursor-based pagination and accurate counts.
*
* Query params:
* status - filter group: 'active'|'waiting'|'completed'|'failed'|'cancelled'|specific status
* cursor - request ID for cursor-based pagination (exclusive start)
* take - page size (default 20, max 100)
* myOnly - 'true' to restrict to current user even for admins
* type - 'audiobook'|'ebook'
*
* Response: { requests, nextCursor, counts: { all, active, waiting, completed, failed, cancelled } }
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const searchParams = req.nextUrl.searchParams;
const statusParam = searchParams.get('status');
const cursor = searchParams.get('cursor');
const take = Math.min(parseInt(searchParams.get('take') || '20', 10), 100);
// Legacy support: honour `limit` if `take` not supplied
const limit = searchParams.has('take')
? take
: Math.min(parseInt(searchParams.get('limit') || '20', 10), 100);
const myOnly = searchParams.get('myOnly') === 'true';
const type = searchParams.get('type');
const isAdmin = req.user.role === 'admin';
// Base ownership filter
const baseWhere: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
baseWhere.deletedAt = null;
if (type && ['audiobook', 'ebook'].includes(type)) {
baseWhere.type = type;
}
// Resolve status filter
const statusFilter: any = {};
if (statusParam) {
const group = STATUS_GROUPS[statusParam];
if (group) {
statusFilter.status = { in: group };
} else {
// Treat as a specific status literal
statusFilter.status = statusParam;
}
}
const where = { ...baseWhere, ...statusFilter };
// ── Paginated request fetch ──────────────────────────────────────────
const requests = await prisma.request.findMany({
where,
include: {
audiobook: true,
user: {
select: { id: true, plexUsername: true },
},
},
orderBy: { createdAt: 'desc' },
take: limit + 1, // fetch one extra to determine if there's a next page
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const hasNextPage = requests.length > limit;
const page = hasNextPage ? requests.slice(0, limit) : requests;
const nextCursor = hasNextPage ? page[page.length - 1].id : null;
const enriched = page.map(r => {
const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]);
const downloadAvailable = isCompleted && !!r.audiobook?.filePath;
const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook;
return { ...r, audiobook, downloadAvailable };
});
// ── Accurate counts per group (always scoped to ownership/type filter) ──
const countWhere = { ...baseWhere };
const [
totalAll,
totalActive,
totalWaiting,
totalCompleted,
totalFailed,
totalCancelled,
] = await Promise.all([
prisma.request.count({ where: countWhere }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.active } } }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.waiting } } }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.completed } } }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.failed } } }),
prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.cancelled } } }),
]);
return NextResponse.json({
success: true,
requests: enriched,
nextCursor,
counts: {
all: totalAll,
active: totalActive,
waiting: totalWaiting,
completed: totalCompleted,
failed: totalFailed,
cancelled: totalCancelled,
},
// Legacy field for callers that still read `count`
count: enriched.length,
});
} catch (error) {
logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch requests' },
{ status: 500 }
);
}
});
}