mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add notification system with admin UI and backend
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.
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Component: Notification Backend Individual API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Id');
|
||||
|
||||
const UpdateBackendSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
config: z.record(z.any()).optional(),
|
||||
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/notifications/[id]
|
||||
* Get single notification backend (sensitive values masked)
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Notification backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Mask sensitive config values
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
backend: {
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch notification backend', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'FetchError',
|
||||
message: 'Failed to fetch notification backend',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/notifications/[id]
|
||||
* Update notification backend
|
||||
*/
|
||||
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 updates = UpdateBackendSchema.parse(body);
|
||||
|
||||
// Get existing backend
|
||||
const existing = await prisma.notificationBackend.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Notification backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Handle config updates (preserve masked values, encrypt new values)
|
||||
let finalConfig = existing.config;
|
||||
if (updates.config) {
|
||||
const existingConfig = existing.config as any;
|
||||
const updatedConfig = updates.config as any;
|
||||
|
||||
// Check if masked values need to be preserved
|
||||
Object.keys(updatedConfig).forEach((key) => {
|
||||
if (updatedConfig[key] === '••••••••') {
|
||||
// Preserve existing encrypted value
|
||||
updatedConfig[key] = existingConfig[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Encrypt new/changed values
|
||||
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig);
|
||||
}
|
||||
|
||||
// Update backend
|
||||
const updateData: any = {};
|
||||
if (updates.name) updateData.name = updates.name;
|
||||
if (updates.config) updateData.config = finalConfig;
|
||||
if (updates.events) updateData.events = updates.events;
|
||||
if (updates.enabled !== undefined) updateData.enabled = updates.enabled;
|
||||
|
||||
const updated = await prisma.notificationBackend.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
logger.info(`Updated notification backend: ${updated.name}`, {
|
||||
backendId: id,
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
// Return with masked values
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
backend: {
|
||||
...updated,
|
||||
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update notification backend', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'UpdateError',
|
||||
message: 'Failed to update notification backend',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/notifications/[id]
|
||||
* Delete notification backend
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Check if backend exists
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Notification backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete backend
|
||||
await prisma.notificationBackend.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
logger.info(`Deleted notification backend: ${backend.name}`, {
|
||||
backendId: id,
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Notification backend deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete notification backend', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'DeleteError',
|
||||
message: 'Failed to delete notification backend',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Component: Notification Backend API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications');
|
||||
|
||||
const CreateBackendSchema = z.object({
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
name: z.string().min(1),
|
||||
config: z.record(z.any()),
|
||||
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/notifications
|
||||
* List all notification backends (sensitive values masked)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const backends = await prisma.notificationBackend.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Mask sensitive config values
|
||||
const maskedBackends = backends.map((backend) => ({
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
backends: maskedBackends,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch notification backends', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'FetchError',
|
||||
message: 'Failed to fetch notification backends',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/notifications
|
||||
* Create new notification backend
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, name, config, events, enabled } = CreateBackendSchema.parse(body);
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Encrypt sensitive config values
|
||||
const encryptedConfig = notificationService.encryptConfig(type, config);
|
||||
|
||||
// Create backend
|
||||
const backend = await prisma.notificationBackend.create({
|
||||
data: {
|
||||
type,
|
||||
name,
|
||||
config: encryptedConfig,
|
||||
events,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created notification backend: ${name} (${type})`, {
|
||||
backendId: backend.id,
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
// Return with masked values
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
backend: {
|
||||
...backend,
|
||||
config: notificationService.maskConfig(type, backend.config),
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create notification backend', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'CreateError',
|
||||
message: 'Failed to create notification backend',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Component: Notification Test API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Test');
|
||||
|
||||
const TestNotificationSchema = z.object({
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
config: z.record(z.any()),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/notifications/test
|
||||
* Test notification with provided config (synchronous)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, config } = TestNotificationSchema.parse(body);
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Encrypt config values
|
||||
const encryptedConfig = notificationService.encryptConfig(type, config);
|
||||
|
||||
// Create test payload
|
||||
const testPayload: NotificationPayload = {
|
||||
event: 'request_available',
|
||||
requestId: 'test-request-id',
|
||||
title: "The Hitchhiker's Guide to the Galaxy",
|
||||
author: 'Douglas Adams',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Send test notification synchronously (not via job queue)
|
||||
try {
|
||||
// Call sendToBackend directly
|
||||
await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload);
|
||||
|
||||
logger.info(`Test notification sent successfully for ${type}`, {
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Test notification sent successfully',
|
||||
});
|
||||
} catch (notificationError) {
|
||||
logger.error(`Test notification failed for ${type}`, {
|
||||
error: notificationError instanceof Error ? notificationError.message : String(notificationError),
|
||||
adminId: req.user?.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'NotificationError',
|
||||
message: notificationError instanceof Error ? notificationError.message : 'Failed to send test notification',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to test notification', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'TestError',
|
||||
message: 'Failed to test notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -75,42 +75,123 @@ export async function POST(
|
||||
|
||||
// Update request based on action
|
||||
if (action === 'approve') {
|
||||
// Approve: Change status to 'pending' and trigger search job
|
||||
const updatedRequest = await prisma.request.update({
|
||||
where: { id },
|
||||
data: { status: 'pending' },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger search job
|
||||
const jobQueue = getJobQueueService();
|
||||
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,
|
||||
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}`, {
|
||||
requestId: id,
|
||||
userId: updatedRequest.userId,
|
||||
audiobookTitle: updatedRequest.audiobook.title,
|
||||
adminId: req.user.sub,
|
||||
});
|
||||
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 search job triggered',
|
||||
request: updatedRequest,
|
||||
});
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user