mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +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.
124 lines
3.6 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
});
|
|
}
|