/** * 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(); 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 { 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 { 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; }