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
+178 -40
View File
@@ -66,7 +66,7 @@ describe('NotificationService', () => {
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
await service.sendNotification({
@@ -92,7 +92,7 @@ describe('NotificationService', () => {
it('does not send if no backends are subscribed to the event', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([]);
const { NotificationService } = await import('@/lib/services/notification.service');
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
await service.sendNotification({
@@ -139,7 +139,7 @@ describe('NotificationService', () => {
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
await service.sendNotification({
@@ -156,19 +156,99 @@ describe('NotificationService', () => {
});
});
describe('sendDiscord', () => {
describe('sendToBackend', () => {
it('routes to Discord provider and decrypts config', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
await service.sendToBackend(
'discord',
{ webhookUrl: 'enc:https://discord.com/webhook', username: 'ReadMeABook' },
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
}
);
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:https://discord.com/webhook');
expect(fetchMock).toHaveBeenCalled();
const fetchCall = fetchMock.mock.calls[0];
// Decrypted URL should be used
expect(fetchCall[0]).toBe('https://discord.com/webhook');
});
it('routes to Pushover provider and decrypts config', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ status: 1 }),
});
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
// Use iv:authTag:data format to pass isEncrypted() check
await service.sendToBackend(
'pushover',
{ userKey: 'iv:tag:user123', appToken: 'iv:tag:app456', priority: 1 },
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
);
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:user123');
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:app456');
expect(fetchMock).toHaveBeenCalled();
});
it('throws error for unsupported backend type', async () => {
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
await expect(
service.sendToBackend(
'email',
{},
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
)
).rejects.toThrow('Unsupported backend type: email');
});
});
describe('DiscordProvider', () => {
it('sends Discord webhook with rich embed', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const { DiscordProvider } = await import('@/lib/services/notification');
const provider = new DiscordProvider();
await service.sendDiscord(
await provider.send(
{
webhookUrl: 'enc:https://discord.com/webhook',
webhookUrl: 'https://discord.com/webhook',
username: 'ReadMeABook',
},
{
@@ -181,12 +261,12 @@ describe('NotificationService', () => {
}
);
// Should call the webhook (URL decryption happens internally)
expect(fetchMock).toHaveBeenCalled();
const fetchCall = fetchMock.mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
expect(fetchCall[0]).toBe('https://discord.com/webhook');
expect(fetchCall[1].method).toBe('POST');
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
expect(body.username).toBe('ReadMeABook');
@@ -201,12 +281,12 @@ describe('NotificationService', () => {
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const { DiscordProvider } = await import('@/lib/services/notification');
const provider = new DiscordProvider();
await service.sendDiscord(
await provider.send(
{
webhookUrl: 'enc:https://discord.com/webhook',
webhookUrl: 'https://discord.com/webhook',
},
{
event: 'request_approved',
@@ -230,12 +310,12 @@ describe('NotificationService', () => {
text: async () => 'Bad Request',
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const { DiscordProvider } = await import('@/lib/services/notification');
const provider = new DiscordProvider();
await expect(
service.sendDiscord(
{ webhookUrl: 'enc:https://discord.com/webhook' },
provider.send(
{ webhookUrl: 'https://discord.com/webhook' },
{
event: 'request_approved',
requestId: 'req-1',
@@ -249,20 +329,20 @@ describe('NotificationService', () => {
});
});
describe('sendPushover', () => {
describe('PushoverProvider', () => {
it('sends Pushover notification with correct payload', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ status: 1 }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const { PushoverProvider } = await import('@/lib/services/notification');
const provider = new PushoverProvider();
await service.sendPushover(
await provider.send(
{
userKey: 'enc:user123',
appToken: 'enc:app456',
userKey: 'user123',
appToken: 'app456',
priority: 1,
},
{
@@ -275,7 +355,6 @@ describe('NotificationService', () => {
}
);
// Should call the Pushover API (credential decryption happens internally)
expect(fetchMock).toHaveBeenCalled();
const fetchCall = fetchMock.mock.calls[0];
@@ -296,13 +375,13 @@ describe('NotificationService', () => {
json: async () => ({ status: 1 }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const { PushoverProvider } = await import('@/lib/services/notification');
const provider = new PushoverProvider();
await service.sendPushover(
await provider.send(
{
userKey: 'enc:user123',
appToken: 'enc:app456',
userKey: 'user123',
appToken: 'app456',
},
{
event: 'request_approved',
@@ -325,12 +404,12 @@ describe('NotificationService', () => {
text: async () => 'invalid user key',
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const { PushoverProvider } = await import('@/lib/services/notification');
const provider = new PushoverProvider();
await expect(
service.sendPushover(
{ userKey: 'enc:user123', appToken: 'enc:app456' },
provider.send(
{ userKey: 'user123', appToken: 'app456' },
{
event: 'request_approved',
requestId: 'req-1',
@@ -344,11 +423,9 @@ describe('NotificationService', () => {
});
});
// Note: formatDiscordEmbed is a private method, tested indirectly through sendDiscord
describe('encryptConfig', () => {
it('encrypts sensitive Discord config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
const encrypted = service.encryptConfig('discord', {
@@ -362,7 +439,7 @@ describe('NotificationService', () => {
});
it('encrypts sensitive Pushover config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
const encrypted = service.encryptConfig('pushover', {
@@ -379,11 +456,72 @@ describe('NotificationService', () => {
});
});
// Note: decryptConfig is a private method, tested indirectly through sendDiscord/sendPushover
describe('getRegisteredProviderTypes', () => {
it('returns all registered provider type keys', async () => {
const { getRegisteredProviderTypes } = await import('@/lib/services/notification');
const types = getRegisteredProviderTypes();
expect(types).toContain('apprise');
expect(types).toContain('discord');
expect(types).toContain('ntfy');
expect(types).toContain('pushover');
expect(types).toHaveLength(4);
});
});
describe('getAllProviderMetadata', () => {
it('returns metadata for all registered providers', async () => {
const { getAllProviderMetadata } = await import('@/lib/services/notification');
const metadata = getAllProviderMetadata();
expect(metadata).toHaveLength(4);
const apprise = metadata.find((m) => m.type === 'apprise');
expect(apprise).toBeDefined();
expect(apprise!.displayName).toBe('Apprise');
expect(apprise!.iconLabel).toBe('A');
expect(apprise!.iconColor).toBe('bg-purple-500');
const discord = metadata.find((m) => m.type === 'discord');
expect(discord).toBeDefined();
expect(discord!.displayName).toBe('Discord');
expect(discord!.iconLabel).toBe('D');
expect(discord!.iconColor).toBe('bg-indigo-500');
expect(discord!.configFields.length).toBeGreaterThan(0);
const ntfy = metadata.find((m) => m.type === 'ntfy');
expect(ntfy).toBeDefined();
expect(ntfy!.displayName).toBe('ntfy');
expect(ntfy!.iconLabel).toBe('N');
const pushover = metadata.find((m) => m.type === 'pushover');
expect(pushover).toBeDefined();
expect(pushover!.displayName).toBe('Pushover');
expect(pushover!.iconLabel).toBe('P');
});
it('includes config field definitions with correct properties', async () => {
const { getAllProviderMetadata } = await import('@/lib/services/notification');
const metadata = getAllProviderMetadata();
const discord = metadata.find((m) => m.type === 'discord')!;
const webhookField = discord.configFields.find((f) => f.name === 'webhookUrl');
expect(webhookField).toBeDefined();
expect(webhookField!.required).toBe(true);
expect(webhookField!.type).toBe('text');
const pushover = metadata.find((m) => m.type === 'pushover')!;
const priorityField = pushover.configFields.find((f) => f.name === 'priority');
expect(priorityField).toBeDefined();
expect(priorityField!.type).toBe('select');
expect(priorityField!.options).toBeDefined();
expect(priorityField!.options!.length).toBe(5);
});
});
describe('maskConfig', () => {
it('masks sensitive Discord config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
const masked = service.maskConfig('discord', {
@@ -396,7 +534,7 @@ describe('NotificationService', () => {
});
it('masks sensitive Pushover config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
const masked = service.maskConfig('pushover', {