mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
95e63dfc36
Introduce ROOTLESS_CONTAINER env to opt out of gosu (replace /proc uid_map detection) and update entrypoint messaging; adjust app-start.sh and redis-start.sh to skip gosu when ROOTLESS_CONTAINER=true and warn on UID/GID mismatch only when applicable. Backend: include audiobook audibleAsin in admin requests response (mapped to asin) and pass baseUrl through test-flaresolverr endpoint to the FlareSolverr tester. Frontend: RecentRequestsTable and RequestActionsDropdown now surface asin, accept/passthrough annasArchiveBaseUrl, and add a "View Details" flow using AudiobookDetailsModal; admin page passes ebook baseUrl from settings. InteractiveTorrentSearchModal refactor: improved UX/UI, keyboard handling, portal/modal mounting, skeleton/loading states, formatting helpers, and richer result display. Tests updated to match changes.
160 lines
5.2 KiB
TypeScript
160 lines
5.2 KiB
TypeScript
/**
|
|
* Component: Admin Requests API (Paginated)
|
|
* Documentation: documentation/admin-dashboard.md
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
|
import { prisma } from '@/lib/db';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
import { Prisma } from '@/generated/prisma';
|
|
|
|
const logger = RMABLogger.create('API.Admin.Requests');
|
|
|
|
const VALID_SORT_FIELDS = ['createdAt', 'completedAt', 'title', 'user', 'status'] as const;
|
|
const VALID_SORT_ORDERS = ['asc', 'desc'] as const;
|
|
const VALID_PAGE_SIZES = [10, 25, 50, 100] as const;
|
|
|
|
type SortField = (typeof VALID_SORT_FIELDS)[number];
|
|
type SortOrder = (typeof VALID_SORT_ORDERS)[number];
|
|
|
|
export async function GET(request: NextRequest) {
|
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
|
return requireAdmin(req, async () => {
|
|
try {
|
|
const { searchParams } = new URL(request.url);
|
|
|
|
// Parse query parameters
|
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
|
const pageSizeParam = parseInt(searchParams.get('pageSize') || '25', 10);
|
|
const pageSize = VALID_PAGE_SIZES.includes(pageSizeParam as (typeof VALID_PAGE_SIZES)[number])
|
|
? pageSizeParam
|
|
: 25;
|
|
const search = searchParams.get('search')?.trim() || '';
|
|
const status = searchParams.get('status') || '';
|
|
const userId = searchParams.get('userId') || '';
|
|
const sortByParam = searchParams.get('sortBy') || 'createdAt';
|
|
const sortBy: SortField = VALID_SORT_FIELDS.includes(sortByParam as SortField)
|
|
? (sortByParam as SortField)
|
|
: 'createdAt';
|
|
const sortOrderParam = searchParams.get('sortOrder') || 'desc';
|
|
const sortOrder: SortOrder = VALID_SORT_ORDERS.includes(sortOrderParam as SortOrder)
|
|
? (sortOrderParam as SortOrder)
|
|
: 'desc';
|
|
|
|
// Build where clause
|
|
const where: Prisma.RequestWhereInput = {
|
|
deletedAt: null,
|
|
};
|
|
|
|
// Filter by status
|
|
if (status && status !== 'all') {
|
|
where.status = status;
|
|
}
|
|
|
|
// Filter by user
|
|
if (userId) {
|
|
where.userId = userId;
|
|
}
|
|
|
|
// Search by title or author
|
|
if (search) {
|
|
where.audiobook = {
|
|
OR: [
|
|
{ title: { contains: search, mode: 'insensitive' } },
|
|
{ author: { contains: search, mode: 'insensitive' } },
|
|
],
|
|
};
|
|
}
|
|
|
|
// Build orderBy clause
|
|
let orderBy: Prisma.RequestOrderByWithRelationInput;
|
|
switch (sortBy) {
|
|
case 'title':
|
|
orderBy = { audiobook: { title: sortOrder } };
|
|
break;
|
|
case 'user':
|
|
orderBy = { user: { plexUsername: sortOrder } };
|
|
break;
|
|
case 'completedAt':
|
|
// Sort nulls last for completedAt
|
|
orderBy = { completedAt: { sort: sortOrder, nulls: 'last' } };
|
|
break;
|
|
case 'status':
|
|
orderBy = { status: sortOrder };
|
|
break;
|
|
case 'createdAt':
|
|
default:
|
|
orderBy = { createdAt: sortOrder };
|
|
break;
|
|
}
|
|
|
|
// Get total count for pagination
|
|
const total = await prisma.request.count({ where });
|
|
|
|
// Get paginated requests
|
|
const requests = await prisma.request.findMany({
|
|
where,
|
|
include: {
|
|
audiobook: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
author: true,
|
|
audibleAsin: true,
|
|
},
|
|
},
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
plexUsername: true,
|
|
},
|
|
},
|
|
downloadHistory: {
|
|
where: {
|
|
selected: true,
|
|
},
|
|
select: {
|
|
torrentUrl: true,
|
|
},
|
|
take: 1,
|
|
},
|
|
},
|
|
orderBy,
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
});
|
|
|
|
// Format response
|
|
const formatted = requests.map((request) => ({
|
|
requestId: request.id,
|
|
title: request.audiobook.title,
|
|
author: request.audiobook.author,
|
|
asin: request.audiobook.audibleAsin || null,
|
|
status: request.status,
|
|
type: request.type || 'audiobook', // Include request type for UI display
|
|
userId: request.user.id,
|
|
user: request.user.plexUsername,
|
|
createdAt: request.createdAt,
|
|
completedAt: request.completedAt,
|
|
errorMessage: request.errorMessage,
|
|
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
|
}));
|
|
|
|
return NextResponse.json({
|
|
requests: formatted,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to fetch requests', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return NextResponse.json({ error: 'Failed to fetch requests' }, { status: 500 });
|
|
}
|
|
});
|
|
});
|
|
}
|