mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 13:20:11 +00:00
09cff5b68d
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.
235 lines
8.2 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
});
|
|
}
|