mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
dc7e557694
Introduces a full notification system with support for Discord and Pushover backends, event triggers, and message formatting. Adds backend services, processors, and API endpoints for managing notifications, as well as a new Notifications tab in the admin settings UI. Updates documentation, database schema, and tests to cover notification features and approval workflow improvements. Also changes project license from MIT to AGPL v3.
251 lines
8.1 KiB
TypeScript
251 lines
8.1 KiB
TypeScript
/**
|
|
* Component: Admin Request Approval API
|
|
* Documentation: documentation/admin-features/request-approval.md
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
|
import { prisma } from '@/lib/db';
|
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
import { z } from 'zod';
|
|
|
|
const logger = RMABLogger.create('API.Admin.Requests.Approve');
|
|
|
|
const ApprovalActionSchema = z.object({
|
|
action: z.enum(['approve', 'deny']),
|
|
});
|
|
|
|
/**
|
|
* POST /api/admin/requests/[id]/approve
|
|
* Approve or deny a request in 'awaiting_approval' status
|
|
*/
|
|
export async function POST(
|
|
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;
|
|
const body = await request.json();
|
|
|
|
// Validate action
|
|
const { action } = ApprovalActionSchema.parse(body);
|
|
|
|
// Fetch the request
|
|
const existingRequest = await prisma.request.findUnique({
|
|
where: { id },
|
|
include: {
|
|
audiobook: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
plexUsername: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!existingRequest) {
|
|
return NextResponse.json(
|
|
{ error: 'NotFound', message: 'Request not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Validate request is in 'awaiting_approval' status
|
|
if (existingRequest.status !== 'awaiting_approval') {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'InvalidStatus',
|
|
message: `Request is not awaiting approval (current status: ${existingRequest.status})`,
|
|
currentStatus: existingRequest.status,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Update request based on action
|
|
if (action === 'approve') {
|
|
const jobQueue = getJobQueueService();
|
|
|
|
// Check if request has a pre-selected torrent (from interactive search)
|
|
if (existingRequest.selectedTorrent) {
|
|
// User pre-selected a specific torrent - download that torrent directly
|
|
logger.info(`Request ${id} has pre-selected torrent, starting download`, {
|
|
requestId: id,
|
|
userId: existingRequest.userId,
|
|
adminId: req.user.sub,
|
|
});
|
|
|
|
// Trigger download job with pre-selected torrent
|
|
await jobQueue.addDownloadJob(
|
|
existingRequest.id,
|
|
{
|
|
id: existingRequest.audiobook.id,
|
|
title: existingRequest.audiobook.title,
|
|
author: existingRequest.audiobook.author,
|
|
},
|
|
existingRequest.selectedTorrent as any
|
|
);
|
|
|
|
// Update status to 'downloading' and clear selectedTorrent
|
|
const updatedRequest = await prisma.request.update({
|
|
where: { id },
|
|
data: {
|
|
status: 'downloading',
|
|
selectedTorrent: null as any, // Clear after use
|
|
},
|
|
include: {
|
|
audiobook: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
plexUsername: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Send notification for manual approval
|
|
await jobQueue.addNotificationJob(
|
|
'request_approved',
|
|
updatedRequest.id,
|
|
existingRequest.audiobook.title,
|
|
existingRequest.audiobook.author,
|
|
existingRequest.user.plexUsername || 'Unknown User'
|
|
).catch((error) => {
|
|
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
|
});
|
|
|
|
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading pre-selected torrent`, {
|
|
requestId: id,
|
|
userId: updatedRequest.userId,
|
|
audiobookTitle: existingRequest.audiobook.title,
|
|
adminId: req.user.sub,
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message: 'Request approved and download started with pre-selected torrent',
|
|
request: updatedRequest,
|
|
});
|
|
} else {
|
|
// No pre-selected torrent - use automatic search
|
|
logger.info(`Request ${id} using automatic search`, {
|
|
requestId: id,
|
|
userId: existingRequest.userId,
|
|
adminId: req.user.sub,
|
|
});
|
|
|
|
const updatedRequest = await prisma.request.update({
|
|
where: { id },
|
|
data: { status: 'pending' },
|
|
include: {
|
|
audiobook: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
plexUsername: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Trigger search job
|
|
await jobQueue.addSearchJob(updatedRequest.id, {
|
|
id: updatedRequest.audiobook.id,
|
|
title: updatedRequest.audiobook.title,
|
|
author: updatedRequest.audiobook.author,
|
|
asin: updatedRequest.audiobook.audibleAsin || undefined,
|
|
});
|
|
|
|
// Send notification for manual approval
|
|
await jobQueue.addNotificationJob(
|
|
'request_approved',
|
|
updatedRequest.id,
|
|
updatedRequest.audiobook.title,
|
|
updatedRequest.audiobook.author,
|
|
updatedRequest.user.plexUsername || 'Unknown User'
|
|
).catch((error) => {
|
|
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
|
});
|
|
|
|
logger.info(`Request ${id} approved by admin ${req.user.sub}`, {
|
|
requestId: id,
|
|
userId: updatedRequest.userId,
|
|
audiobookTitle: updatedRequest.audiobook.title,
|
|
adminId: req.user.sub,
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message: 'Request approved and search job triggered',
|
|
request: updatedRequest,
|
|
});
|
|
}
|
|
} else {
|
|
// Deny: Change status to 'denied'
|
|
const updatedRequest = await prisma.request.update({
|
|
where: { id },
|
|
data: { status: 'denied' },
|
|
include: {
|
|
audiobook: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
plexUsername: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
logger.info(`Request ${id} denied by admin ${req.user.sub}`, {
|
|
requestId: id,
|
|
userId: updatedRequest.userId,
|
|
audiobookTitle: updatedRequest.audiobook.title,
|
|
adminId: req.user.sub,
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message: 'Request denied',
|
|
request: updatedRequest,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to process approval action', {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
|
|
if (error instanceof z.ZodError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'ValidationError',
|
|
message: 'Invalid action. Must be "approve" or "deny"',
|
|
details: error.errors,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{
|
|
error: 'ApprovalError',
|
|
message: 'Failed to process approval action',
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|