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:
kikootwo
2026-02-10 15:06:20 -05:00
parent 4a38dd3da8
commit af0eaceb98
73 changed files with 3421 additions and 866 deletions
@@ -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 }
);
}
});
});
}
+3 -3
View File
@@ -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({
+26 -69
View File
@@ -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,
@@ -38,6 +38,7 @@ export async function PUT(
localPath,
category,
customPath,
postImportCategory,
} = body;
const config = await getConfigService();
@@ -76,6 +77,7 @@ export async function PUT(
localPath: localPath !== undefined ? localPath : existingClient.localPath,
category: category !== undefined ? category : existingClient.category,
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
postImportCategory: postImportCategory !== undefined ? (postImportCategory || undefined) : existingClient.postImportCategory,
};
// Validate path mapping if enabled
@@ -0,0 +1,104 @@
/**
* Component: Fetch Download Client Categories API
* Documentation: documentation/phase3/download-clients.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Categories');
/**
* POST - Fetch categories from a download client
* Accepts same connection config as the test endpoint
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const {
clientId,
type,
name: clientName,
url,
username,
password,
disableSSLVerify,
remotePathMappingEnabled,
remotePath,
localPath,
} = body;
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
if (!url) {
return NextResponse.json(
{ error: 'URL is required' },
{ status: 400 }
);
}
const config = await getConfigService();
const manager = getDownloadClientManager(config);
// If editing and password not provided, use stored password
let effectivePassword = password;
let effectiveUsername = username;
if (clientId && !password) {
const existingClients = await manager.getAllClients();
const existingClient = existingClients.find(c => c.id === clientId);
if (!existingClient) {
return NextResponse.json(
{ error: 'Client not found' },
{ status: 404 }
);
}
effectivePassword = existingClient.password;
if (!username && existingClient.username) {
effectiveUsername = existingClient.username;
}
}
const testConfig: DownloadClientConfig = {
id: 'categories-fetch',
type,
name: clientName || type,
enabled: true,
url,
username: effectiveUsername || '',
password: effectivePassword || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: 'readmeabook',
};
const service = await manager.createClientFromConfig(testConfig);
const categories = await service.getCategories();
return NextResponse.json({ success: true, categories });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error('Failed to fetch categories', { error: message });
return NextResponse.json(
{ success: false, error: message },
{ status: 400 }
);
}
});
});
}
@@ -63,6 +63,7 @@ export async function POST(request: NextRequest) {
localPath,
category,
customPath,
postImportCategory,
} = body;
// Validate type
@@ -138,6 +139,7 @@ export async function POST(request: NextRequest) {
localPath: localPath || undefined,
category: category || 'readmeabook',
customPath: customPath || undefined,
postImportCategory: postImportCategory || undefined,
};
// Test connection before saving