mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add release blocklist feature
Introduce a per-request release blocklist to auto-block permanently failing releases and provide admin management. Changes include: - Database: add BlockedRelease model (blocked_releases) to Prisma schema with unique (requestId, releaseKey) and indexes; documented in backend database docs. - Service & utils: new blocklist.service, release-key and filter helpers for normalization and matching; processors updated to emit auto-blocks (monitor-download, organize-files, search processors, RSS). - HTTP API: add admin endpoints GET/DELETE /api/admin/blocklist, DELETE /api/admin/blocklist/[id], and GET /api/admin/blocklist/by-request/[requestId]. - Admin UI: new /admin/blocklist page and numerous React components (toolbar, filters, table, rows, pagination, skeleton, chips, date picker) with URL-driven state hook and per-row unblock UX. - Tests: add unit/integration tests for service, routes, utils, and updated processor tests. The blocklist is idempotent (upsert), filters search results before ranking (interactive search shows badges only), and admin-only APIs require auth. This commit wires docs, API, DB, frontend and tests for the new feature.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Component: Admin Blocklist — Single Unblock
|
||||
* Documentation: documentation/admin-features/release-blocklist.md
|
||||
*
|
||||
* DELETE /api/admin/blocklist/[id] → removes a single blocklist entry.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { Prisma } from '@/generated/prisma';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { removeBlock } from '@/lib/services/blocklist.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Blocklist.Unblock');
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
const { id } = await params;
|
||||
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await removeBlock(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2025'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Blocklist entry not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
logger.error('Failed to remove blocklist entry', {
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove blocklist entry' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Component: Admin Blocklist — Per-Request Lookup
|
||||
* Documentation: documentation/admin-features/release-blocklist.md
|
||||
*
|
||||
* GET /api/admin/blocklist/by-request/[requestId]
|
||||
* → { entries: BlockedRelease[], count: number }
|
||||
*
|
||||
* Lightweight, unpaginated lookup used by:
|
||||
* - The "N releases blocked" chip on the admin recent-requests table.
|
||||
* - The InteractiveTorrentSearchModal "already blocked" badge.
|
||||
*
|
||||
* Per-request blocklists are bounded by indexer candidate count (~tens),
|
||||
* so no pagination is needed.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { getBlocklistForRequest } from '@/lib/services/blocklist.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Blocklist.ByRequest');
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ requestId: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
const { requestId } = await params;
|
||||
if (!requestId || typeof requestId !== 'string' || requestId.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'Invalid requestId' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await getBlocklistForRequest(requestId);
|
||||
return NextResponse.json({ entries, count: entries.length });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch blocklist for request', {
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch blocklist for request' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Component: Admin Blocklist API (list + filter-scoped bulk clear)
|
||||
* Documentation: documentation/admin-features/release-blocklist.md
|
||||
*
|
||||
* GET /api/admin/blocklist → paginated, filtered, sorted list
|
||||
* DELETE /api/admin/blocklist?…filters → filter-scoped bulk clear ("Clear filtered (N)")
|
||||
*
|
||||
* `buildBlocklistWhere` is exported as a pure function for the route tests AND
|
||||
* for the DELETE handler to share with GET — the bulk clear MUST scope to the
|
||||
* exact same rows the user is currently viewing.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Prisma } from '@/generated/prisma';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { clearBlocklist } from '@/lib/services/blocklist.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Blocklist');
|
||||
|
||||
const VALID_LIMITS = [25, 50, 100] as const;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const VALID_SOURCES = ['organize_fail', 'download_fail', 'manual'] as const;
|
||||
const VALID_SORT_FIELDS = ['createdAt', 'releaseName', 'reason'] as const;
|
||||
const VALID_SORT_ORDERS = ['asc', 'desc'] as const;
|
||||
|
||||
export interface BlocklistWhereParams {
|
||||
requestId?: string | null;
|
||||
source?: string | null;
|
||||
search?: string | null;
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
}
|
||||
|
||||
function parseLimit(raw: string | null): number {
|
||||
const n = Number(raw);
|
||||
return (VALID_LIMITS as readonly number[]).includes(n) ? n : DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
function parsePage(raw: string | null): number {
|
||||
const n = parseInt(raw ?? '1', 10);
|
||||
return Number.isFinite(n) && n >= 1 ? n : 1;
|
||||
}
|
||||
|
||||
function parseDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function trim(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const t = raw.trim();
|
||||
return t.length > 0 ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Prisma where clause for blocklist queries.
|
||||
* Pure function — same input always yields same output. Exported for tests AND
|
||||
* for the DELETE handler so bulk-clear filter scope matches GET exactly.
|
||||
*/
|
||||
export function buildBlocklistWhere(
|
||||
params: BlocklistWhereParams
|
||||
): Prisma.BlockedReleaseWhereInput {
|
||||
const where: Prisma.BlockedReleaseWhereInput = {};
|
||||
|
||||
const requestId = trim(params.requestId);
|
||||
if (requestId) {
|
||||
where.requestId = requestId;
|
||||
}
|
||||
|
||||
const source = trim(params.source);
|
||||
if (source && source !== 'all' && (VALID_SOURCES as readonly string[]).includes(source)) {
|
||||
where.source = source;
|
||||
}
|
||||
|
||||
const from = parseDate(params.dateFrom);
|
||||
const to = parseDate(params.dateTo);
|
||||
if (from || to) {
|
||||
where.createdAt = {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lte: to } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const search = trim(params.search);
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ releaseName: { contains: search, mode: 'insensitive' } },
|
||||
{ reason: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
function whereFromSearchParams(searchParams: URLSearchParams): Prisma.BlockedReleaseWhereInput {
|
||||
return buildBlocklistWhere({
|
||||
requestId: searchParams.get('requestId'),
|
||||
source: searchParams.get('source'),
|
||||
search: searchParams.get('search'),
|
||||
dateFrom: searchParams.get('dateFrom'),
|
||||
dateTo: searchParams.get('dateTo'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parsePage(searchParams.get('page'));
|
||||
const limit = parseLimit(searchParams.get('limit'));
|
||||
|
||||
const sortByRaw = searchParams.get('sortBy') ?? 'createdAt';
|
||||
const sortBy = (VALID_SORT_FIELDS as readonly string[]).includes(sortByRaw)
|
||||
? (sortByRaw as (typeof VALID_SORT_FIELDS)[number])
|
||||
: 'createdAt';
|
||||
const sortOrderRaw = searchParams.get('sortOrder') ?? 'desc';
|
||||
const sortOrder = (VALID_SORT_ORDERS as readonly string[]).includes(sortOrderRaw)
|
||||
? (sortOrderRaw as (typeof VALID_SORT_ORDERS)[number])
|
||||
: 'desc';
|
||||
|
||||
const where = whereFromSearchParams(searchParams);
|
||||
|
||||
const orderBy: Prisma.BlockedReleaseOrderByWithRelationInput = { [sortBy]: sortOrder };
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [entries, totalCount] = await Promise.all([
|
||||
prisma.blockedRelease.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
requestId: true,
|
||||
releaseName: true,
|
||||
releaseHash: true,
|
||||
indexerName: true,
|
||||
indexerId: true,
|
||||
source: true,
|
||||
reason: true,
|
||||
reasonDetail: true,
|
||||
downloadHistoryId: true,
|
||||
jobId: true,
|
||||
createdAt: true,
|
||||
request: {
|
||||
select: {
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
audiobook: { select: { title: true, author: true } },
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.blockedRelease.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
entries,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalCount,
|
||||
totalPages: Math.max(1, Math.ceil(totalCount / limit)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch blocklist', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch blocklist' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/blocklist?<same filter params as GET>
|
||||
*
|
||||
* Filter-scoped bulk clear. The "Clear filtered (N)" admin UI hits this with
|
||||
* the exact same query string used for the current GET. Returns the count of
|
||||
* rows actually deleted. Empty filters intentionally allowed — the UI gates
|
||||
* with a typed-token confirmation modal; the server's job is enforcing the
|
||||
* auth + admin boundary.
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const where = whereFromSearchParams(searchParams);
|
||||
const result = await clearBlocklist(where);
|
||||
return NextResponse.json({ count: result.count });
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear blocklist', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to clear blocklist' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -119,6 +119,11 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
blockedReleases: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
@@ -141,6 +146,7 @@ export async function GET(request: NextRequest) {
|
||||
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
||||
downloadAttempts: request.downloadAttempts,
|
||||
customSearchTerms: request.customSearchTerms || null,
|
||||
blockedCount: request._count?.blockedReleases ?? 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
Reference in New Issue
Block a user