mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add admin request deletion with soft delete and cleanup
Implements admin ability to delete requests with soft delete, media file cleanup, and seeding-aware torrent management. Adds new API endpoint, frontend confirmation dialog, and request actions dropdown. Updates database schema with deletedAt and deletedBy fields, and ensures all queries filter out deleted requests. Documentation added for feature and user flow.
This commit is contained in:
@@ -15,6 +15,7 @@ export async function GET(request: NextRequest) {
|
||||
const activeDownloads = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'downloading',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
audiobook: {
|
||||
|
||||
@@ -22,13 +22,18 @@ export async function GET(request: NextRequest) {
|
||||
failedLast30Days,
|
||||
totalUsers,
|
||||
] = await Promise.all([
|
||||
// Total requests (all time)
|
||||
prisma.request.count(),
|
||||
// Total requests (all time, only active)
|
||||
prisma.request.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
|
||||
// Active downloads (downloading status)
|
||||
prisma.request.count({
|
||||
where: {
|
||||
status: 'downloading',
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -41,6 +46,7 @@ export async function GET(request: NextRequest) {
|
||||
completedAt: {
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -51,6 +57,7 @@ export async function GET(request: NextRequest) {
|
||||
updatedAt: {
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -103,6 +110,7 @@ async function checkSystemHealth(): Promise<{
|
||||
updatedAt: {
|
||||
lt: oneDayAgo,
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Component: Admin Request Management API
|
||||
* Documentation: documentation/admin-features/request-deletion.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { deleteRequest } from '@/lib/services/request-delete.service';
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/requests/[id]
|
||||
* Soft delete a request with intelligent cleanup (admin only)
|
||||
*
|
||||
* This endpoint:
|
||||
* 1. Validates admin authorization
|
||||
* 2. Soft deletes the request (sets deletedAt timestamp)
|
||||
* 3. Deletes media files from the title folder
|
||||
* 4. Handles torrents based on seeding configuration:
|
||||
* - Unlimited seeding (0): Keeps torrent, stops monitoring
|
||||
* - Seeding complete: Deletes torrent + files
|
||||
* - Still seeding: Keeps torrent for cleanup job
|
||||
* 5. Allows re-requesting the same audiobook after deletion
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Perform soft delete with cleanup
|
||||
const result = await deleteRequest(id, req.user.id);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error || 'DeleteFailed',
|
||||
message: result.message,
|
||||
},
|
||||
{ status: result.error === 'NotFound' ? 404 : 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Return detailed result
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
details: {
|
||||
filesDeleted: result.filesDeleted,
|
||||
torrentsRemoved: result.torrentsRemoved,
|
||||
torrentsKeptSeeding: result.torrentsKeptSeeding,
|
||||
torrentsKeptUnlimited: result.torrentsKeptUnlimited,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to delete request:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'DeleteError',
|
||||
message: 'Failed to delete request',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -11,8 +11,11 @@ export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Get recent requests
|
||||
// Get recent requests (only active, non-deleted)
|
||||
const recentRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
audiobook: {
|
||||
select: {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Component: Request with Specific Torrent API
|
||||
* Documentation: documentation/phase3/prowlarr.md
|
||||
*
|
||||
* Create a request and immediately download a specific torrent
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { z } from 'zod';
|
||||
|
||||
const RequestWithTorrentSchema = 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().optional(),
|
||||
}),
|
||||
torrent: z.object({
|
||||
guid: z.string(),
|
||||
title: z.string(),
|
||||
size: z.number(),
|
||||
seeders: z.number(),
|
||||
leechers: z.number(),
|
||||
indexer: z.string(),
|
||||
downloadUrl: z.string(),
|
||||
publishDate: z.string().transform((str) => new Date(str)),
|
||||
infoHash: z.string().optional(),
|
||||
format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
|
||||
bitrate: z.string().optional(),
|
||||
hasChapters: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/audiobooks/request-with-torrent
|
||||
* Create a request and download a specific torrent in one operation
|
||||
*/
|
||||
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, torrent } = RequestWithTorrentSchema.parse(body);
|
||||
|
||||
// Check if audiobook is already available in Plex library
|
||||
const plexMatch = await findPlexMatch({
|
||||
asin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
});
|
||||
|
||||
if (plexMatch) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'AlreadyAvailable',
|
||||
message: 'This audiobook is already available in your Plex library',
|
||||
plexGuid: plexMatch.plexGuid,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Try to find existing audiobook record by ASIN
|
||||
let audiobookRecord = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: audiobook.asin },
|
||||
});
|
||||
|
||||
// If not found, create new audiobook record
|
||||
if (!audiobookRecord) {
|
||||
audiobookRecord = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: audiobook.asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator,
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
status: 'requested',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user already has an active request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRequest) {
|
||||
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
|
||||
|
||||
if (!canReRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'DuplicateRequest',
|
||||
message: 'You have already requested this audiobook',
|
||||
request: existingRequest,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the existing failed/warn/cancelled request
|
||||
console.log(`[RequestWithTorrent] Deleting existing ${existingRequest.status} request ${existingRequest.id}`);
|
||||
await prisma.request.delete({
|
||||
where: { id: existingRequest.id },
|
||||
});
|
||||
}
|
||||
|
||||
// Create request with downloading status
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Queue download job with the selected torrent
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addDownloadJob(
|
||||
newRequest.id,
|
||||
{
|
||||
id: audiobookRecord.id,
|
||||
title: audiobookRecord.title,
|
||||
author: audiobookRecord.author,
|
||||
},
|
||||
torrent
|
||||
);
|
||||
|
||||
console.log(`[RequestWithTorrent] Queued download monitor job for request ${newRequest.id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: newRequest,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Failed to create request with torrent:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'RequestError',
|
||||
message: error instanceof Error ? error.message : 'Failed to create request and download torrent',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Component: Audiobook Torrent Search API
|
||||
* Documentation: documentation/phase3/prowlarr.md
|
||||
*
|
||||
* Search for torrents without creating a request first
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const SearchSchema = z.object({
|
||||
title: z.string(),
|
||||
author: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/audiobooks/search-torrents
|
||||
* Search for torrents for an audiobook (no request required)
|
||||
*/
|
||||
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 { title, author } = SearchSchema.parse(body);
|
||||
|
||||
// Get enabled indexers from configuration
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
||||
|
||||
if (!indexersConfigStr) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ConfigError', message: 'No indexers configured. Please configure indexers in settings.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const indexersConfig = JSON.parse(indexersConfigStr);
|
||||
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
|
||||
|
||||
if (enabledIndexerIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Search Prowlarr for torrents - ONLY enabled indexers
|
||||
const prowlarr = await getProwlarrService();
|
||||
const searchQuery = `${title} ${author}`;
|
||||
|
||||
console.log(`[AudiobookSearch] Searching ${enabledIndexerIds.length} enabled indexers for: ${searchQuery}`);
|
||||
|
||||
const results = await prowlarr.search(searchQuery, {
|
||||
indexerIds: enabledIndexerIds,
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results: [],
|
||||
message: 'No torrents found',
|
||||
});
|
||||
}
|
||||
|
||||
// Rank torrents using the ranking algorithm
|
||||
const rankedResults = rankTorrents(results, { title, author });
|
||||
|
||||
// Add rank position to each result
|
||||
const resultsWithRank = rankedResults.map((result, index) => ({
|
||||
...result,
|
||||
rank: index + 1,
|
||||
}));
|
||||
|
||||
console.log(`[AudiobookSearch] Found ${resultsWithRank.length} results for "${title}" by ${author}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results: resultsWithRank,
|
||||
message: `Found ${resultsWithRank.length} torrents`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to search for torrents:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'SearchError',
|
||||
message: error instanceof Error ? error.message : 'Failed to search for torrents',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -26,8 +26,11 @@ export async function GET(
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const requestRecord = await prisma.request.findUnique({
|
||||
where: { id },
|
||||
const requestRecord = await prisma.request.findFirst({
|
||||
where: {
|
||||
id,
|
||||
deletedAt: null, // Only show active requests
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
@@ -100,13 +103,16 @@ export async function PATCH(
|
||||
const body = await req.json();
|
||||
const { action } = body;
|
||||
|
||||
const requestRecord = await prisma.request.findUnique({
|
||||
where: { id },
|
||||
const requestRecord = await prisma.request.findFirst({
|
||||
where: {
|
||||
id,
|
||||
deletedAt: null, // Only allow updates to active requests
|
||||
},
|
||||
});
|
||||
|
||||
if (!requestRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Request not found' },
|
||||
{ error: 'NotFound', message: 'Request not found or already deleted' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
@@ -161,8 +167,11 @@ export async function PATCH(
|
||||
|
||||
if (requestRecord.status === 'warn' || requestRecord.status === 'awaiting_import') {
|
||||
// Retry import
|
||||
const requestWithData = await prisma.request.findUnique({
|
||||
where: { id },
|
||||
const requestWithData = await prisma.request.findFirst({
|
||||
where: {
|
||||
id,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
downloadHistory: {
|
||||
@@ -213,8 +222,11 @@ export async function PATCH(
|
||||
jobType = 'import';
|
||||
} else {
|
||||
// Retry search
|
||||
const requestWithData = await prisma.request.findUnique({
|
||||
where: { id },
|
||||
const requestWithData = await prisma.request.findFirst({
|
||||
where: {
|
||||
id,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
|
||||
@@ -80,13 +80,12 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user already has a request for this audiobook
|
||||
const existingRequest = await prisma.request.findUnique({
|
||||
// Check if user already has an active (non-deleted) request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId_audiobookId: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
},
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
deletedAt: null, // Only check active requests
|
||||
},
|
||||
});
|
||||
|
||||
@@ -112,12 +111,15 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Create request
|
||||
// Check if we should skip auto-search (for interactive search)
|
||||
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
|
||||
|
||||
// Create request with appropriate status
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: 'pending',
|
||||
status: skipAutoSearch ? 'awaiting_search' : 'pending',
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
@@ -131,13 +133,15 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger search job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(newRequest.id, {
|
||||
id: audiobookRecord.id,
|
||||
title: audiobookRecord.title,
|
||||
author: audiobookRecord.author,
|
||||
});
|
||||
// Trigger search job only if not skipped
|
||||
if (!skipAutoSearch) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(newRequest.id, {
|
||||
id: audiobookRecord.id,
|
||||
title: audiobookRecord.title,
|
||||
author: audiobookRecord.author,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -194,6 +198,8 @@ export async function GET(request: NextRequest) {
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
// Only show active (non-deleted) requests
|
||||
where.deletedAt = null;
|
||||
|
||||
const requests = await prisma.request.findMany({
|
||||
where,
|
||||
|
||||
Reference in New Issue
Block a user