mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-28 09:00:11 +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:
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Component: Notification Service
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { getEncryptionService } from '../encryption.service';
|
||||
import { RMABLogger } from '../../utils/logger';
|
||||
import { prisma } from '../../db';
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from './INotificationProvider';
|
||||
import { AppriseProvider } from './providers/apprise.provider';
|
||||
import { DiscordProvider } from './providers/discord.provider';
|
||||
import { NtfyProvider } from './providers/ntfy.provider';
|
||||
import { PushoverProvider } from './providers/pushover.provider';
|
||||
|
||||
const logger = RMABLogger.create('NotificationService');
|
||||
|
||||
// Provider registry
|
||||
const providers = new Map<string, INotificationProvider>();
|
||||
|
||||
export function registerProvider(provider: INotificationProvider): void {
|
||||
providers.set(provider.type, provider);
|
||||
}
|
||||
|
||||
export function getProvider(type: string): INotificationProvider | undefined {
|
||||
return providers.get(type);
|
||||
}
|
||||
|
||||
// Register built-in providers
|
||||
registerProvider(new AppriseProvider());
|
||||
registerProvider(new DiscordProvider());
|
||||
registerProvider(new NtfyProvider());
|
||||
registerProvider(new PushoverProvider());
|
||||
|
||||
export function getRegisteredProviderTypes(): string[] {
|
||||
return Array.from(providers.keys());
|
||||
}
|
||||
|
||||
export function getAllProviderMetadata(): ProviderMetadata[] {
|
||||
return Array.from(providers.values()).map((p) => p.metadata);
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
private encryptionService = getEncryptionService();
|
||||
|
||||
/**
|
||||
* Send notification to all enabled backends subscribed to the event
|
||||
*/
|
||||
async sendNotification(payload: NotificationPayload): Promise<void> {
|
||||
try {
|
||||
// Get all enabled backends subscribed to this event
|
||||
const backends = await prisma.notificationBackend.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
events: {
|
||||
array_contains: payload.event,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (backends.length === 0) {
|
||||
logger.debug(`No backends subscribed to event: ${payload.event}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Sending notification to ${backends.length} backend(s)`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Send to all backends in parallel (atomic per-backend)
|
||||
const results = await Promise.allSettled(
|
||||
backends.map((backend) =>
|
||||
this.sendToBackend(backend.type, backend.config, payload)
|
||||
)
|
||||
);
|
||||
|
||||
// Log results
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Log individual failures
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
logger.error(`Failed to send to backend ${backends[index].name}`, {
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
backend: backends[index].type,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
// Don't throw - non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notification to type-specific provider
|
||||
*/
|
||||
async sendToBackend(
|
||||
type: string,
|
||||
config: any,
|
||||
payload: NotificationPayload
|
||||
): Promise<void> {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
throw new Error(`Unsupported backend type: ${type}`);
|
||||
}
|
||||
|
||||
const decryptedConfig = this.decryptConfig(provider.sensitiveFields, config);
|
||||
return provider.send(decryptedConfig, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive config values before saving
|
||||
*/
|
||||
encryptConfig(type: string, config: any): any {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
const encrypted = { ...config };
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (encrypted[field] && !this.isEncrypted(encrypted[field])) {
|
||||
encrypted[field] = this.encryptionService.encrypt(encrypted[field]);
|
||||
}
|
||||
}
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive config values for API responses
|
||||
*/
|
||||
maskConfig(type: string, config: any): any {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
const masked = { ...config };
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (masked[field]) {
|
||||
masked[field] = '••••••••';
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive config values
|
||||
*/
|
||||
private decryptConfig(sensitiveFields: string[], config: any): any {
|
||||
const decrypted = { ...config };
|
||||
for (const field of sensitiveFields) {
|
||||
if (decrypted[field] && this.isEncrypted(decrypted[field])) {
|
||||
decrypted[field] = this.encryptionService.decrypt(decrypted[field]);
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is encrypted (has iv:authTag:data format)
|
||||
*/
|
||||
private isEncrypted(value: string): boolean {
|
||||
return value.includes(':') && value.split(':').length === 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationService: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!notificationService) {
|
||||
notificationService = new NotificationService();
|
||||
}
|
||||
return notificationService;
|
||||
}
|
||||
Reference in New Issue
Block a user