Files
ReadMeABook/src/app/api/user/ignored-audiobooks/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

124 lines
3.6 KiB
TypeScript

/**
* Component: Ignored Audiobooks API Routes
* Documentation: documentation/features/ignored-audiobooks.md
*
* Per-user ignore list for auto-request suppression.
* GET returns the user's full ignore list; POST adds a new entry.
*/
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';
const logger = RMABLogger.create('API.IgnoredAudiobooks');
const AddIgnoredSchema = z.object({
asin: z.string().min(1).max(20),
title: z.string().min(1).max(500),
author: z.string().min(1).max(500),
coverArtUrl: z.string().optional(),
});
/**
* GET /api/user/ignored-audiobooks
* List the current user's ignored audiobooks
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ignored = await prisma.ignoredAudiobook.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({
success: true,
ignoredAudiobooks: ignored.map((item) => ({
id: item.id,
asin: item.asin,
title: item.title,
author: item.author,
coverArtUrl: item.coverArtUrl,
createdAt: item.createdAt.toISOString(),
})),
});
} catch (error) {
logger.error('Failed to list ignored audiobooks', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch ignored audiobooks' },
{ status: 500 }
);
}
});
}
/**
* POST /api/user/ignored-audiobooks
* Add an audiobook to the user's ignore list
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const data = AddIgnoredSchema.parse(body);
// Upsert to handle duplicate gracefully
const ignored = await prisma.ignoredAudiobook.upsert({
where: {
userId_asin: { userId: req.user.id, asin: data.asin },
},
update: {}, // Already exists — no-op
create: {
userId: req.user.id,
asin: data.asin,
title: data.title,
author: data.author,
coverArtUrl: data.coverArtUrl,
},
});
logger.info(`User ${req.user.id} ignored ASIN ${data.asin} ("${data.title}")`);
return NextResponse.json({
success: true,
ignoredAudiobook: {
id: ignored.id,
asin: ignored.asin,
title: ignored.title,
author: ignored.author,
coverArtUrl: ignored.coverArtUrl,
createdAt: ignored.createdAt.toISOString(),
},
}, { status: 201 });
} catch (error) {
logger.error('Failed to add ignored audiobook', {
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: 'CreateError', message: 'Failed to ignore audiobook' },
{ status: 500 }
);
}
});
}