mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly. UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions. APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes. Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
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 { getNotificationService } from '@/lib/services/notification';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function GET(
|
||||
success: true,
|
||||
backend: {
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
config: notificationService.maskConfig(backend.type, backend.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -114,7 +114,7 @@ export async function PUT(
|
||||
});
|
||||
|
||||
// Encrypt new/changed values
|
||||
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig);
|
||||
finalConfig = notificationService.encryptConfig(existing.type, updatedConfig);
|
||||
}
|
||||
|
||||
// Update backend
|
||||
@@ -139,7 +139,7 @@ export async function PUT(
|
||||
success: true,
|
||||
backend: {
|
||||
...updated,
|
||||
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config),
|
||||
config: notificationService.maskConfig(updated.type, updated.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Component: Notification Providers Metadata API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getAllProviderMetadata } from '@/lib/services/notification';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Providers');
|
||||
|
||||
/**
|
||||
* GET /api/admin/notifications/providers
|
||||
* Returns metadata for all registered notification providers
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const providers = getAllProviderMetadata();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
providers,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch provider metadata', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'FetchError',
|
||||
message: 'Failed to fetch provider metadata',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,14 +6,14 @@
|
||||
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 { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification';
|
||||
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']),
|
||||
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }),
|
||||
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),
|
||||
@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
|
||||
// Mask sensitive config values
|
||||
const maskedBackends = backends.map((backend) => ({
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
config: notificationService.maskConfig(backend.type, backend.config),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -5,31 +5,17 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service';
|
||||
import { getNotificationService, getRegisteredProviderTypes, NotificationPayload } from '@/lib/services/notification';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Test');
|
||||
|
||||
const TestNotificationSchema = z.discriminatedUnion('mode', [
|
||||
// Test existing backend by ID (uses stored config)
|
||||
z.object({
|
||||
mode: z.literal('backend'),
|
||||
backendId: z.string(),
|
||||
}),
|
||||
// Test new config before saving
|
||||
z.object({
|
||||
mode: z.literal('config'),
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
config: z.record(z.any()),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Support legacy format without mode
|
||||
const LegacyTestNotificationSchema = z.object({
|
||||
// Flexible schema: supports both backendId and type+config formats
|
||||
const TestNotificationSchema = z.object({
|
||||
backendId: z.string().optional(),
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']).optional(),
|
||||
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }).optional(),
|
||||
config: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
@@ -42,66 +28,37 @@ export async function POST(request: NextRequest) {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = TestNotificationSchema.parse(body);
|
||||
|
||||
// Support legacy format for backward compatibility
|
||||
const legacyParsed = LegacyTestNotificationSchema.safeParse(body);
|
||||
|
||||
let type: NotificationBackendType;
|
||||
let type: string;
|
||||
let encryptedConfig: any;
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
if (legacyParsed.success) {
|
||||
// Legacy format
|
||||
if (legacyParsed.data.backendId) {
|
||||
// Test existing backend
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: legacyParsed.data.backendId },
|
||||
});
|
||||
if (parsed.backendId) {
|
||||
// Test existing backend by ID (uses stored config)
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: parsed.backendId },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type as NotificationBackendType;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else if (legacyParsed.data.type && legacyParsed.data.config) {
|
||||
// Test new config
|
||||
type = legacyParsed.data.type as NotificationBackendType;
|
||||
encryptedConfig = notificationService.encryptConfig(type, legacyParsed.data.config);
|
||||
} else {
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
|
||||
{ status: 400 }
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else if (parsed.type && parsed.config) {
|
||||
// Test new config before saving
|
||||
type = parsed.type;
|
||||
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
|
||||
} else {
|
||||
// New format with discriminated union
|
||||
const parsed = TestNotificationSchema.parse(body);
|
||||
|
||||
if (parsed.mode === 'backend') {
|
||||
// Test existing backend
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: parsed.backendId },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type as NotificationBackendType;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else {
|
||||
// Test new config
|
||||
type = parsed.type;
|
||||
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create test payload
|
||||
@@ -117,7 +74,7 @@ export async function POST(request: NextRequest) {
|
||||
// Send test notification synchronously (not via job queue)
|
||||
try {
|
||||
// Call sendToBackend directly
|
||||
await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload);
|
||||
await notificationService.sendToBackend(type, encryptedConfig, testPayload);
|
||||
|
||||
logger.info(`Test notification sent successfully for ${type}`, {
|
||||
adminId: req.user?.sub,
|
||||
|
||||
Reference in New Issue
Block a user