mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Backend Mode API
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { ConfigurationService } from '@/lib/services/config.service';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const configService = new ConfigurationService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
|
||||
return NextResponse.json({
|
||||
backendMode,
|
||||
isAudiobookshelf: backendMode === 'audiobookshelf'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[BackendMode] Failed to get backend mode:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get backend mode' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { mode } = body;
|
||||
|
||||
if (!mode || (mode !== 'plex' && mode !== 'audiobookshelf')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid backend mode. Must be "plex" or "audiobookshelf"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const configService = new ConfigurationService();
|
||||
await configService.setMany([
|
||||
{ key: 'system.backend_mode', value: mode, category: 'system' }
|
||||
]);
|
||||
|
||||
// Clear library service cache to force re-initialization with new mode
|
||||
const { clearLibraryServiceCache } = await import('@/lib/services/library');
|
||||
clearLibraryServiceCache();
|
||||
|
||||
console.log(`[BackendMode] Backend mode changed to: ${mode}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
backendMode: mode,
|
||||
message: `Backend mode set to ${mode}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[BackendMode] Failed to set backend mode:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to set backend mode' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* BookDate: Admin Global Toggle
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
async function handler(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { isEnabled } = body;
|
||||
|
||||
if (typeof isEnabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'isEnabled must be a boolean' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update all BookDate configurations
|
||||
await prisma.bookDateConfig.updateMany({
|
||||
data: { isEnabled },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
isEnabled,
|
||||
message: `BookDate ${isEnabled ? 'enabled' : 'disabled'} for all users`,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[BookDate] Admin toggle error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to toggle BookDate' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
return requireAuth(req, (authReq) => requireAdmin(authReq, handler));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Component: Admin Active Downloads API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Get active downloads with related data
|
||||
const activeDownloads = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'downloading',
|
||||
},
|
||||
include: {
|
||||
audiobook: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
author: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
downloadHistory: {
|
||||
where: {
|
||||
downloadStatus: 'downloading',
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
downloadStatus: true,
|
||||
torrentName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
|
||||
// Format response
|
||||
const formatted = activeDownloads.map((download) => ({
|
||||
requestId: download.id,
|
||||
title: download.audiobook.title,
|
||||
author: download.audiobook.author,
|
||||
status: download.status,
|
||||
progress: download.progress,
|
||||
torrentName: download.downloadHistory[0]?.torrentName || null,
|
||||
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
|
||||
user: download.user.plexUsername,
|
||||
startedAt: download.updatedAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ downloads: formatted });
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to fetch active downloads:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch active downloads' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Component: Admin Job Execution Status API
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAccessToken } from '@/lib/utils/jwt';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
|
||||
/**
|
||||
* GET /api/admin/job-status/:id
|
||||
* Get job execution status by database job ID
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Verify admin auth
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = verifyAccessToken(token);
|
||||
if (!payload || payload.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Await params in Next.js 15+
|
||||
const { id } = await params;
|
||||
|
||||
console.log(`[JobStatus] Fetching status for job ID: ${id}`);
|
||||
|
||||
const jobQueueService = getJobQueueService();
|
||||
const job = await jobQueueService.getJob(id);
|
||||
|
||||
if (!job) {
|
||||
console.log(`[JobStatus] Job not found: ${id}`);
|
||||
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
console.log(`[JobStatus] Job ${id} status: ${job.status}, type: ${job.type}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
job: {
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt,
|
||||
startedAt: job.startedAt,
|
||||
completedAt: job.completedAt,
|
||||
result: job.result,
|
||||
errorMessage: job.errorMessage,
|
||||
attempts: job.attempts,
|
||||
maxAttempts: job.maxAttempts,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get job status:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InternalError',
|
||||
message: error instanceof Error ? error.message : 'Failed to get job status',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Component: Admin Job Update API
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAccessToken } from '@/lib/utils/jwt';
|
||||
import { getSchedulerService } from '@/lib/services/scheduler.service';
|
||||
|
||||
/**
|
||||
* PUT /api/admin/jobs/:id
|
||||
* Update a scheduled job
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Verify admin auth
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = verifyAccessToken(token);
|
||||
if (!payload || payload.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Await params in Next.js 15+
|
||||
const { id } = await params;
|
||||
|
||||
const body = await request.json();
|
||||
const schedulerService = getSchedulerService();
|
||||
|
||||
const job = await schedulerService.updateScheduledJob(id, {
|
||||
name: body.name,
|
||||
schedule: body.schedule,
|
||||
enabled: body.enabled,
|
||||
payload: body.payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
job,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update scheduled job:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InternalError',
|
||||
message: error instanceof Error ? error.message : 'Failed to update scheduled job',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/jobs/:id
|
||||
* Delete a scheduled job
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Verify admin auth
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = verifyAccessToken(token);
|
||||
if (!payload || payload.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Await params in Next.js 15+
|
||||
const { id } = await params;
|
||||
|
||||
const schedulerService = getSchedulerService();
|
||||
await schedulerService.deleteScheduledJob(id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Job deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete scheduled job:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InternalError',
|
||||
message: error instanceof Error ? error.message : 'Failed to delete scheduled job',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Component: Admin Job Trigger API
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAccessToken } from '@/lib/utils/jwt';
|
||||
import { getSchedulerService } from '@/lib/services/scheduler.service';
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs/:id/trigger
|
||||
* Manually trigger a scheduled job
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Verify admin auth
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = verifyAccessToken(token);
|
||||
if (!payload || payload.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Await params in Next.js 15+
|
||||
const { id } = await params;
|
||||
|
||||
console.log(`[JobTrigger] Triggering scheduled job: ${id}`);
|
||||
|
||||
const schedulerService = getSchedulerService();
|
||||
const jobId = await schedulerService.triggerJobNow(id);
|
||||
|
||||
console.log(`[JobTrigger] Job triggered successfully, database job ID: ${jobId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
jobId,
|
||||
message: 'Job triggered successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger job:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InternalError',
|
||||
message: error instanceof Error ? error.message : 'Failed to trigger job',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Component: Admin Jobs Management API
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAccessToken } from '@/lib/utils/jwt';
|
||||
import { getSchedulerService } from '@/lib/services/scheduler.service';
|
||||
|
||||
/**
|
||||
* GET /api/admin/jobs
|
||||
* Get all scheduled jobs
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Verify admin auth
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = verifyAccessToken(token);
|
||||
if (!payload || payload.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const schedulerService = getSchedulerService();
|
||||
const jobs = await schedulerService.getScheduledJobs();
|
||||
|
||||
return NextResponse.json({
|
||||
jobs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get scheduled jobs:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InternalError',
|
||||
message: 'Failed to retrieve scheduled jobs',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs
|
||||
* Create a new scheduled job
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Verify admin auth
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = verifyAccessToken(token);
|
||||
if (!payload || payload.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const schedulerService = getSchedulerService();
|
||||
|
||||
const job = await schedulerService.createScheduledJob({
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
schedule: body.schedule,
|
||||
enabled: body.enabled,
|
||||
payload: body.payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
job,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create scheduled job:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InternalError',
|
||||
message: error instanceof Error ? error.message : 'Failed to create scheduled job',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Component: Admin Logs API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
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 = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '100');
|
||||
const status = searchParams.get('status') || 'all';
|
||||
const type = searchParams.get('type') || 'all';
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Build where clause
|
||||
const where: any = {};
|
||||
if (status !== 'all') {
|
||||
where.status = status;
|
||||
}
|
||||
if (type !== 'all') {
|
||||
where.type = type;
|
||||
}
|
||||
|
||||
const [logs, totalCount] = await Promise.all([
|
||||
prisma.job.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
bullJobId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
priority: true,
|
||||
attempts: true,
|
||||
maxAttempts: true,
|
||||
errorMessage: true,
|
||||
startedAt: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
result: true,
|
||||
events: {
|
||||
select: {
|
||||
id: true,
|
||||
level: true,
|
||||
context: true,
|
||||
message: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
select: {
|
||||
id: true,
|
||||
audiobook: {
|
||||
select: {
|
||||
title: true,
|
||||
author: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.job.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
logs,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to fetch logs:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch logs' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Component: Admin Metrics API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Get system metrics
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [
|
||||
totalRequests,
|
||||
activeDownloads,
|
||||
completedLast30Days,
|
||||
failedLast30Days,
|
||||
totalUsers,
|
||||
] = await Promise.all([
|
||||
// Total requests (all time)
|
||||
prisma.request.count(),
|
||||
|
||||
// Active downloads (downloading status)
|
||||
prisma.request.count({
|
||||
where: {
|
||||
status: 'downloading',
|
||||
},
|
||||
}),
|
||||
|
||||
// Completed requests (last 30 days) - 'downloaded' and 'available' statuses
|
||||
prisma.request.count({
|
||||
where: {
|
||||
status: {
|
||||
in: ['downloaded', 'available'],
|
||||
},
|
||||
completedAt: {
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Failed requests (last 30 days)
|
||||
prisma.request.count({
|
||||
where: {
|
||||
status: 'failed',
|
||||
updatedAt: {
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Total users
|
||||
prisma.user.count(),
|
||||
]);
|
||||
|
||||
// Check system health
|
||||
const systemHealth = await checkSystemHealth();
|
||||
|
||||
return NextResponse.json({
|
||||
totalRequests,
|
||||
activeDownloads,
|
||||
completedLast30Days,
|
||||
failedLast30Days,
|
||||
totalUsers,
|
||||
systemHealth,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to fetch metrics:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch metrics' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkSystemHealth(): Promise<{
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
issues: string[];
|
||||
}> {
|
||||
const issues: string[] = [];
|
||||
|
||||
try {
|
||||
// Check database connection
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch (error) {
|
||||
issues.push('Database connection failed');
|
||||
}
|
||||
|
||||
// Check for stale downloads (downloading for more than 24 hours)
|
||||
const oneDayAgo = new Date();
|
||||
oneDayAgo.setHours(oneDayAgo.getHours() - 24);
|
||||
|
||||
const staleDownloads = await prisma.request.count({
|
||||
where: {
|
||||
status: 'downloading',
|
||||
updatedAt: {
|
||||
lt: oneDayAgo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (staleDownloads > 0) {
|
||||
issues.push(`${staleDownloads} stale downloads (>24h)`);
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||
if (issues.length > 0) {
|
||||
status = issues.some((i) => i.includes('Database')) ? 'unhealthy' : 'degraded';
|
||||
}
|
||||
|
||||
return { status, issues };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Component: Admin Plex Library Scan API
|
||||
* Documentation: documentation/integrations/plex.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin } from '@/lib/middleware/auth';
|
||||
import { processScanPlex } from '@/lib/processors/scan-plex.processor';
|
||||
|
||||
/**
|
||||
* POST /api/admin/plex/scan
|
||||
* Trigger a Plex library scan to update availability status for audiobooks
|
||||
* Admin-only endpoint
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Trigger scan with empty payload (will use configured library ID)
|
||||
const result = await processScanPlex({
|
||||
libraryId: undefined,
|
||||
partial: false,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API] Plex scan failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ScanFailed',
|
||||
message: error instanceof Error ? error.message : 'Failed to scan Plex library',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Component: Admin Recent Requests API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Get recent requests
|
||||
const recentRequests = await prisma.request.findMany({
|
||||
include: {
|
||||
audiobook: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
author: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
|
||||
// Format response
|
||||
const formatted = recentRequests.map((request) => ({
|
||||
requestId: request.id,
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
status: request.status,
|
||||
user: request.user.plexUsername,
|
||||
createdAt: request.createdAt,
|
||||
completedAt: request.completedAt,
|
||||
errorMessage: request.errorMessage,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ requests: formatted });
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to fetch recent requests:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch recent requests' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Audiobookshelf Libraries API
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log('[ABS Libraries] GET request received');
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
console.log('[ABS Libraries] Auth passed, user:', req.user);
|
||||
return requireAdmin(req, async () => {
|
||||
console.log('[ABS Libraries] Admin check passed');
|
||||
try {
|
||||
// Use getConfigService like Plex endpoint does
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
const serverUrl = await configService.get('audiobookshelf.server_url');
|
||||
const apiToken = await configService.get('audiobookshelf.api_token');
|
||||
console.log('[ABS Libraries] Config loaded:', { hasServerUrl: !!serverUrl, hasApiToken: !!apiToken });
|
||||
|
||||
if (!serverUrl || !apiToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobookshelf not configured' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch libraries from Audiobookshelf
|
||||
const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch libraries from Audiobookshelf' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Filter to only audiobook libraries and map to expected format
|
||||
const libraries = (data.libraries || [])
|
||||
.filter((lib: any) => lib.mediaType === 'book')
|
||||
.map((lib: any) => ({
|
||||
id: lib.id,
|
||||
name: lib.name,
|
||||
type: lib.mediaType,
|
||||
itemCount: lib.stats?.totalItems || 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ libraries });
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to fetch ABS libraries:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch libraries' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Audiobookshelf Settings API
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { ConfigUpdate } from '@/lib/services/config.service';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverUrl, apiToken, libraryId } = body;
|
||||
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
|
||||
// Build updates array, skipping masked values
|
||||
const updates: ConfigUpdate[] = [
|
||||
{ key: 'audiobookshelf.server_url', value: serverUrl || '' },
|
||||
{ key: 'audiobookshelf.library_id', value: libraryId || '' },
|
||||
];
|
||||
|
||||
// Only update API token if it's not the masked placeholder
|
||||
if (apiToken && !apiToken.startsWith('••••')) {
|
||||
updates.push({
|
||||
key: 'audiobookshelf.api_token',
|
||||
value: apiToken,
|
||||
encrypted: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await configService.setMany(updates);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Audiobookshelf settings saved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to save Audiobookshelf settings:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Component: Local Admin Password Change API
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireLocalAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
/**
|
||||
* POST /api/admin/settings/change-password
|
||||
* Change password for local admin user
|
||||
*
|
||||
* Security:
|
||||
* - Only available to local admin (isSetupAdmin=true AND plexId starts with 'local-')
|
||||
* - Requires current password verification
|
||||
* - New password must be at least 8 characters
|
||||
* - New password must be different from current password
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireLocalAdmin(req, async (authenticatedReq: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { currentPassword, newPassword, confirmPassword } = await request.json();
|
||||
|
||||
// Validate input
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'All fields are required',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate new password length
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'New password must be at least 8 characters',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if (newPassword !== confirmPassword) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'New passwords do not match',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate new password is different from current
|
||||
if (currentPassword === newPassword) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'New password must be different from current password',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: authenticatedReq.user!.id },
|
||||
select: {
|
||||
id: true,
|
||||
authToken: true,
|
||||
plexId: true,
|
||||
isSetupAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.authToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'User not found or invalid account type',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const currentPasswordValid = await bcrypt.compare(currentPassword, user.authToken);
|
||||
|
||||
if (!currentPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Current password is incorrect',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password in database
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
authToken: hashedPassword,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[Auth] Local admin password changed successfully for user ${user.id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Password changed successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to change password:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to change password',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Component: Admin Download Client Settings API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { type, url, username, password } = await request.json();
|
||||
|
||||
if (!type || !url || !username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Type, URL, username, and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (type !== 'qbittorrent' && type !== 'transmission') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid client type. Must be qbittorrent or transmission' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_type' },
|
||||
update: { value: type },
|
||||
create: { key: 'download_client_type', value: type },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_url' },
|
||||
update: { value: url },
|
||||
create: { key: 'download_client_url', value: url },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_username' },
|
||||
update: { value: username },
|
||||
create: { key: 'download_client_username', value: username },
|
||||
});
|
||||
|
||||
// Only update password if it's not the masked value
|
||||
if (!password.startsWith('••••')) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_password' },
|
||||
update: { value: password },
|
||||
create: { key: 'download_client_password', value: password },
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Admin] Download client settings updated');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Download client settings updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to update download client settings:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update settings',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* OIDC Settings API
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enabled, providerName, issuerUrl, clientId, clientSecret } = body;
|
||||
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
|
||||
// Build config updates
|
||||
const updates: Array<{key: string; value: string; encrypted?: boolean}> = [
|
||||
{ key: 'oidc.enabled', value: enabled ? 'true' : 'false' },
|
||||
{ key: 'oidc.provider_name', value: providerName || '' },
|
||||
{ key: 'oidc.issuer_url', value: issuerUrl || '' },
|
||||
{ key: 'oidc.client_id', value: clientId || '' },
|
||||
];
|
||||
|
||||
// Only update client secret if provided (not masked)
|
||||
if (clientSecret && !clientSecret.includes('••')) {
|
||||
updates.push({
|
||||
key: 'oidc.client_secret',
|
||||
value: clientSecret,
|
||||
encrypted: true
|
||||
});
|
||||
}
|
||||
|
||||
await configService.setMany(updates);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'OIDC settings saved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to save OIDC settings:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Component: Admin Paths Settings API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { downloadDir, mediaDir, metadataTaggingEnabled } = await request.json();
|
||||
|
||||
if (!downloadDir || !mediaDir) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Download directory and media directory are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate paths are not the same
|
||||
if (downloadDir === mediaDir) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Download and media directories must be different' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_dir' },
|
||||
update: { value: downloadDir },
|
||||
create: { key: 'download_dir', value: downloadDir },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'media_dir' },
|
||||
update: { value: mediaDir },
|
||||
create: { key: 'media_dir', value: mediaDir },
|
||||
});
|
||||
|
||||
// Update metadata tagging setting
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'metadata_tagging_enabled' },
|
||||
update: { value: String(metadataTaggingEnabled ?? true) },
|
||||
create: {
|
||||
key: 'metadata_tagging_enabled',
|
||||
value: String(metadataTaggingEnabled ?? true),
|
||||
category: 'automation',
|
||||
description: 'Automatically tag audio files with correct metadata during file organization',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Admin] Paths settings updated');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Paths settings updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to update paths settings:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update settings',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Component: Plex Libraries API Route
|
||||
* Documentation: documentation/backend/api.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings/plex/libraries
|
||||
* Fetch available Plex libraries
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const plexService = await getPlexService();
|
||||
|
||||
// Get Plex configuration
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
const plexUrl = await configService.get('plex_url');
|
||||
const plexToken = await configService.get('plex_token');
|
||||
|
||||
if (!plexUrl || !plexToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Plex not configured',
|
||||
message: 'Please configure Plex URL and token first',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all libraries from Plex
|
||||
const libraries = await plexService.getLibraries(plexUrl, plexToken);
|
||||
|
||||
// Filter for audiobook/music libraries (type 8 or 15)
|
||||
const audioLibraries = libraries.filter((lib: any) =>
|
||||
lib.type === 'artist' || lib.type === 'music' || lib.title.toLowerCase().includes('audio')
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
libraries: audioLibraries.map((lib: any) => ({
|
||||
id: lib.key,
|
||||
title: lib.title,
|
||||
type: lib.type,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Plex] Failed to fetch libraries:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch Plex libraries',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Component: Admin Plex Settings API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { url, token, libraryId } = await request.json();
|
||||
|
||||
if (!url || !token || !libraryId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'URL, token, and library ID are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_url' },
|
||||
update: { value: url },
|
||||
create: { key: 'plex_url', value: url },
|
||||
});
|
||||
|
||||
// Only update token if it's not the masked value
|
||||
if (!token.startsWith('••••')) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_token' },
|
||||
update: { value: token },
|
||||
create: { key: 'plex_token', value: token },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_audiobook_library_id' },
|
||||
update: { value: libraryId },
|
||||
create: { key: 'plex_audiobook_library_id', value: libraryId },
|
||||
});
|
||||
|
||||
// Fetch and save machine identifier (for server-specific access tokens)
|
||||
// This is needed for BookDate per-user rating functionality
|
||||
try {
|
||||
const plexService = getPlexService();
|
||||
const actualToken = token.startsWith('••••') ? null : token;
|
||||
|
||||
// Get token from DB if it was masked
|
||||
const tokenToUse = actualToken || (await prisma.configuration.findUnique({
|
||||
where: { key: 'plex_token' },
|
||||
}))?.value;
|
||||
|
||||
if (tokenToUse) {
|
||||
const serverInfo = await plexService.testConnection(url, tokenToUse);
|
||||
if (serverInfo.success && serverInfo.info?.machineIdentifier) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'plex_machine_identifier' },
|
||||
update: { value: serverInfo.info.machineIdentifier },
|
||||
create: { key: 'plex_machine_identifier', value: serverInfo.info.machineIdentifier },
|
||||
});
|
||||
console.log('[Admin] machineIdentifier updated:', serverInfo.info.machineIdentifier);
|
||||
} else {
|
||||
console.warn('[Admin] Could not fetch machineIdentifier');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Admin] Error fetching machineIdentifier:', error);
|
||||
// Don't fail the request if machineIdentifier fetch fails
|
||||
}
|
||||
|
||||
console.log('[Admin] Plex settings updated');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Plex settings updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to update Plex settings:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update settings',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Component: Prowlarr Indexers API Route
|
||||
* Documentation: documentation/backend/api.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
|
||||
interface SavedIndexerConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings/prowlarr/indexers
|
||||
* Fetch available Prowlarr indexers with configuration
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const prowlarrService = await getProwlarrService();
|
||||
const configService = getConfigService();
|
||||
|
||||
// Fetch indexers from Prowlarr
|
||||
const indexers = await prowlarrService.getIndexers();
|
||||
|
||||
// Get saved indexer configuration (matches wizard format)
|
||||
const savedConfigStr = await configService.get('prowlarr_indexers');
|
||||
const savedIndexers: SavedIndexerConfig[] = savedConfigStr ? JSON.parse(savedConfigStr) : [];
|
||||
|
||||
// Merge with defaults (wizard format: array of {id, name, priority, seedingTimeMinutes})
|
||||
const savedIndexersMap = new Map<number, SavedIndexerConfig>(
|
||||
savedIndexers.map((idx) => [idx.id, idx])
|
||||
);
|
||||
|
||||
const indexersWithConfig = indexers.map((indexer: any) => {
|
||||
const saved = savedIndexersMap.get(indexer.id);
|
||||
|
||||
return {
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
protocol: indexer.protocol,
|
||||
privacy: indexer.privacy,
|
||||
enabled: !!saved, // Enabled if in saved list
|
||||
priority: saved?.priority || 10,
|
||||
seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0,
|
||||
rssEnabled: saved?.rssEnabled ?? false,
|
||||
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
indexers: indexersWithConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Prowlarr] Failed to fetch indexers:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch Prowlarr indexers',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/settings/prowlarr/indexers
|
||||
* Save indexer configuration
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { indexers } = await req.json();
|
||||
|
||||
// Filter to only enabled indexers and convert to wizard format
|
||||
const enabledIndexers = indexers
|
||||
.filter((indexer: any) => indexer.enabled)
|
||||
.map((indexer: any) => ({
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
priority: indexer.priority,
|
||||
seedingTimeMinutes: indexer.seedingTimeMinutes,
|
||||
rssEnabled: indexer.rssEnabled || false,
|
||||
}));
|
||||
|
||||
// Save to configuration (matches wizard format)
|
||||
const configService = getConfigService();
|
||||
await configService.setMany([
|
||||
{
|
||||
key: 'prowlarr_indexers',
|
||||
value: JSON.stringify(enabledIndexers),
|
||||
category: 'indexer',
|
||||
description: 'Prowlarr indexer settings (enabled, priority, seeding time)',
|
||||
},
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Indexer configuration saved',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Prowlarr] Failed to save indexer config:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to save indexer configuration',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Component: Admin Prowlarr Settings API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { url, apiKey } = await request.json();
|
||||
|
||||
if (!url || !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'URL and API key are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'prowlarr_url' },
|
||||
update: { value: url },
|
||||
create: { key: 'prowlarr_url', value: url },
|
||||
});
|
||||
|
||||
// Only update API key if it's not the masked value
|
||||
if (!apiKey.startsWith('••••')) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'prowlarr_api_key' },
|
||||
update: { value: apiKey },
|
||||
create: { key: 'prowlarr_api_key', value: apiKey },
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Admin] Prowlarr settings updated');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Prowlarr settings updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to update Prowlarr settings:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update settings',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Registration Settings API
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enabled, requireAdminApproval } = body;
|
||||
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
|
||||
await configService.setMany([
|
||||
{ key: 'auth.registration_enabled', value: enabled ? 'true' : 'false' },
|
||||
{ key: 'auth.require_admin_approval', value: requireAdminApproval ? 'true' : 'false' },
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Registration settings saved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to save registration settings:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Component: Admin Settings API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Fetch all configuration
|
||||
const configs = await prisma.configuration.findMany();
|
||||
const configMap = new Map(configs.map((c) => [c.key, c.value]));
|
||||
|
||||
// Mask sensitive values
|
||||
const maskValue = (key: string, value: string | null | undefined) => {
|
||||
const sensitiveKeys = ['token', 'api_key', 'password'];
|
||||
if (value && sensitiveKeys.some((k) => key.includes(k))) {
|
||||
return '••••••••••••';
|
||||
}
|
||||
return value || '';
|
||||
};
|
||||
|
||||
// Build response object
|
||||
const settings = {
|
||||
backendMode: configMap.get('system.backend_mode') || 'plex',
|
||||
plex: {
|
||||
url: configMap.get('plex_url') || '',
|
||||
token: maskValue('token', configMap.get('plex_token')),
|
||||
libraryId: configMap.get('plex_audiobook_library_id') || '',
|
||||
},
|
||||
audiobookshelf: {
|
||||
serverUrl: configMap.get('audiobookshelf.server_url') || '',
|
||||
apiToken: maskValue('api_token', configMap.get('audiobookshelf.api_token')),
|
||||
libraryId: configMap.get('audiobookshelf.library_id') || '',
|
||||
},
|
||||
oidc: {
|
||||
enabled: configMap.get('oidc.enabled') === 'true',
|
||||
providerName: configMap.get('oidc.provider_name') || '',
|
||||
issuerUrl: configMap.get('oidc.issuer_url') || '',
|
||||
clientId: configMap.get('oidc.client_id') || '',
|
||||
clientSecret: maskValue('client_secret', configMap.get('oidc.client_secret')),
|
||||
},
|
||||
registration: {
|
||||
enabled: configMap.get('auth.registration_enabled') === 'true',
|
||||
requireAdminApproval: configMap.get('auth.require_admin_approval') === 'true',
|
||||
},
|
||||
prowlarr: {
|
||||
url: configMap.get('prowlarr_url') || '',
|
||||
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')),
|
||||
},
|
||||
downloadClient: {
|
||||
type: configMap.get('download_client_type') || 'qbittorrent',
|
||||
url: configMap.get('download_client_url') || '',
|
||||
username: configMap.get('download_client_username') || '',
|
||||
password: maskValue('password', configMap.get('download_client_password')),
|
||||
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
|
||||
},
|
||||
paths: {
|
||||
downloadDir: configMap.get('download_dir') || '/downloads',
|
||||
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
|
||||
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
|
||||
},
|
||||
general: {
|
||||
appName: configMap.get('app_name') || 'ReadMeABook',
|
||||
allowRegistrations: configMap.get('allow_registrations') === 'true',
|
||||
maxConcurrentDownloads: parseInt(
|
||||
configMap.get('max_concurrent_downloads') || '3'
|
||||
),
|
||||
autoApproveRequests: configMap.get('auto_approve_requests') === 'true',
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json(settings);
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to fetch settings:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch settings' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Component: Admin Settings Test Download Client API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { type, url, username, password } = await request.json();
|
||||
|
||||
if (!type || !url || !username || !password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'All fields are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (type !== 'qbittorrent') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Only qBittorrent is currently supported' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// If password is masked, fetch the actual value from database
|
||||
let actualPassword = password;
|
||||
if (password.startsWith('••••')) {
|
||||
const storedPassword = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_client_password' },
|
||||
});
|
||||
|
||||
if (!storedPassword?.value) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No stored password found. Please re-enter your download client password.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
actualPassword = storedPassword.value;
|
||||
}
|
||||
|
||||
// Test connection with credentials
|
||||
const version = await QBittorrentService.testConnectionWithCredentials(
|
||||
url,
|
||||
username,
|
||||
actualPassword
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
version,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin Settings] Download client test failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to connect to download client',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Component: Admin Settings Test Plex API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { url, token } = await request.json();
|
||||
|
||||
if (!url || !token) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'URL and token are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// If token is masked, fetch the actual value from database
|
||||
let actualToken = token;
|
||||
if (token.startsWith('••••')) {
|
||||
const storedToken = await prisma.configuration.findUnique({
|
||||
where: { key: 'plex_token' },
|
||||
});
|
||||
|
||||
if (!storedToken?.value) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No stored token found. Please re-enter your Plex token.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
actualToken = storedToken.value;
|
||||
}
|
||||
|
||||
const plexService = getPlexService();
|
||||
|
||||
// Test connection and get server info
|
||||
const connectionResult = await plexService.testConnection(url, actualToken);
|
||||
|
||||
if (!connectionResult.success || !connectionResult.info) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: connectionResult.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get libraries
|
||||
const libraries = await plexService.getLibraries(url, actualToken);
|
||||
|
||||
// Format server name safely
|
||||
const serverName = connectionResult.info
|
||||
? `${connectionResult.info.platform || 'Plex Server'} v${connectionResult.info.version || 'Unknown'}`
|
||||
: 'Plex Server';
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
serverName,
|
||||
version: connectionResult.info?.version || 'Unknown',
|
||||
machineIdentifier: connectionResult.info?.machineIdentifier || 'unknown',
|
||||
libraries: libraries.map((lib) => ({
|
||||
id: lib.id,
|
||||
title: lib.title,
|
||||
type: lib.type,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin Settings] Plex test failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to connect to Plex',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Component: Admin Settings Test Prowlarr API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { url, apiKey } = await request.json();
|
||||
|
||||
if (!url || !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'URL and API key are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// If API key is masked, fetch the actual value from database
|
||||
let actualApiKey = apiKey;
|
||||
if (apiKey.startsWith('••••')) {
|
||||
const storedApiKey = await prisma.configuration.findUnique({
|
||||
where: { key: 'prowlarr_api_key' },
|
||||
});
|
||||
|
||||
if (!storedApiKey?.value) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No stored API key found. Please re-enter your Prowlarr API key.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
actualApiKey = storedApiKey.value;
|
||||
}
|
||||
|
||||
// Create a new ProwlarrService instance with test credentials
|
||||
const prowlarrService = new ProwlarrService(url, actualApiKey);
|
||||
|
||||
// Test connection and get indexers
|
||||
const indexers = await prowlarrService.getIndexers();
|
||||
|
||||
// Only return enabled indexers
|
||||
const enabledIndexers = indexers.filter((indexer) => indexer.enable);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
indexerCount: enabledIndexers.length,
|
||||
totalIndexers: indexers.length,
|
||||
indexers: enabledIndexers.map((indexer) => ({
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
protocol: indexer.protocol,
|
||||
supportsRss: indexer.capabilities?.supportsRss !== false,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Admin Settings] Prowlarr test failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to connect to Prowlarr',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* User Approval API
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { approve } = body; // true = approve, false = reject
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
registrationStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (user.registrationStatus !== 'pending_approval') {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is not pending approval' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (approve) {
|
||||
// Approve user
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { registrationStatus: 'approved' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `User ${user.plexUsername} has been approved`
|
||||
});
|
||||
} else {
|
||||
// Reject user (delete the account)
|
||||
await prisma.user.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `User ${user.plexUsername} has been rejected and removed`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to approve/reject user:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process user approval' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Component: Admin User Update API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { role } = body;
|
||||
|
||||
// Validate role
|
||||
if (!role || (role !== 'user' && role !== 'admin')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid role. Must be "user" or "admin"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent user from demoting themselves
|
||||
if (req.user && id === req.user.sub) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You cannot change your own role' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user is the setup admin
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
isSetupAdmin: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent changing setup admin role
|
||||
if (targetUser.isSetupAdmin && role !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot change the setup admin role. This account must always remain an admin.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update user role
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { role },
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to update user:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update user' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Pending Users API
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const pendingUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
registrationStatus: 'pending_approval'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
plexEmail: true,
|
||||
authProvider: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ users: pendingUsers });
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to fetch pending users:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch pending users' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Component: Admin Users API
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
plexEmail: true,
|
||||
role: true,
|
||||
isSetupAdmin: true,
|
||||
avatarUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
lastLoginAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
requests: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to fetch users:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch users' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user