import { EmailService } from './email.service'; import { SMSService } from './sms.service'; import { PushService } from './push.service'; import { TemplateService } from './template.service'; import { RedisService } from './redis.service'; import { loadNotificationConfig } from '../config/notification.config'; import type { Notification, NotificationChannel, NotificationResult, NotificationPreference, DeduplicationKey } from '../types/notification.types'; import type { TemplateResolutionOptions } from '../types/template.types'; export interface RateLimitResult { allowed: boolean; currentCount: number; limit: number; remaining: number; resetInSeconds: number; } interface DeduplicationEntry { externalIds: Set; expiresAt: number; } export class NotificationService { private static instance: NotificationService; private emailService: EmailService; private smsService: SMSService; private pushService: PushService; private redisService: RedisService; private config: ReturnType; private pendingDeduplication = new Map(); private preferenceCache = new Map(); private constructor() { this.emailService = EmailService.getInstance(); this.smsService = SMSService.getInstance(); this.pushService = PushService.getInstance(); this.config = loadNotificationConfig(); this.redisService = RedisService.getInstance({ url: this.config.redis.url }); } static getInstance(): NotificationService { if (!NotificationService.instance) { NotificationService.instance = new NotificationService(); } return NotificationService.instance; } async send(notification: Notification): Promise { switch (notification.channel) { case 'email': return this.emailService.send(notification); case 'sms': return this.smsService.send(notification); case 'push': return this.pushService.send(notification); default: throw new Error(`Unknown notification channel: ${(notification as any).channel}`); } } async sendWithDeduplication( notification: Notification, dedupKey: DeduplicationKey ): Promise { const dedupId = `${dedupKey.userId}:${dedupKey.templateId}:${dedupKey.key}`; const windowSeconds = dedupKey.windowSeconds || this.config.redis.dedupWindowSeconds; const entry = this.pendingDeduplication.get(dedupId); if (entry && Date.now() < entry.expiresAt && entry.externalIds.size > 0) { return { notificationId: `dedup-${Date.now()}`, channel: notification.channel, status: 'pending', error: 'Duplicate notification within deduplication window', }; } const result = await this.send(notification); if (result.status === 'sent') { const now = Date.now(); this.pendingDeduplication.set(dedupId, { externalIds: new Set([result.externalId!]), expiresAt: now + windowSeconds * 1000, }); } return result; } async setPreference( userId: string, channel: NotificationPreference['channel'], enabled: boolean, categories?: string[] ): Promise { const preference: NotificationPreference = { userId, channel, enabled, categories: categories || [], updatedAt: new Date(), }; this.preferenceCache.set(`${userId}:${channel}`, preference); return preference; } async getPreference( userId: string, channel: NotificationPreference['channel'] ): Promise { return this.preferenceCache.get(`${userId}:${channel}`) || null; } async shouldSend( userId: string, channel: NotificationPreference['channel'], category: string ): Promise { const preference = await this.getPreference(userId, channel); if (!preference) { return true; } if (!preference.enabled) { return false; } if (preference.categories.length === 0) { return true; } return preference.categories.includes(category); } async sendWithPreferences( notification: Notification, category: string ): Promise { const userId = notification.channel === 'push' ? notification.userId : `user-${Date.now()}`; const shouldSend = await this.shouldSend( userId, notification.channel, category ); if (!shouldSend) { return { notificationId: `pref-${Date.now()}`, channel: notification.channel, status: 'pending', error: 'Notification disabled for user preference', }; } return this.send(notification); } async sendWithTemplate( recipient: string, options: TemplateResolutionOptions & { channel?: NotificationChannel } ): Promise { const channel = options.channel || 'email'; const templateService = TemplateService.getInstance(); const resolved = templateService.resolveTemplate({ templateId: options.templateId, locale: options.locale, variables: options.variables, fallbackLocale: options.fallbackLocale, }); if (!resolved) { return { notificationId: `${channel}-${Date.now()}`, channel, status: 'failed', error: `Template not found: ${options.templateId}`, }; } if (resolved.channel !== channel) { return { notificationId: `${channel}-${Date.now()}`, channel, status: 'failed', error: `Template ${options.templateId} is for channel '${resolved.channel}', not '${channel}'`, }; } switch (channel) { case 'email': return this.emailService.sendWithTemplate(recipient, options); case 'sms': return this.smsService.send({ channel: 'sms', to: recipient, body: resolved.body, }); case 'push': return this.pushService.send({ channel: 'push', userId: recipient, title: resolved.subject || '', body: resolved.body, }); default: return { notificationId: `${channel}-${Date.now()}`, channel, status: 'failed', error: `Unknown channel: ${channel}`, }; } } async checkRateLimit( identifier: string, channel: NotificationChannel, customLimit?: number, customWindowSeconds?: number ): Promise { const limit = customLimit || this.getLimitForChannel(channel); const windowSeconds = customWindowSeconds || this.config.rateLimits.windowSeconds; const key = `rl:${channel}:${identifier}`; const currentCount = await this.redisService.increment(key, windowSeconds); const ttl = await this.redisService.getTTL(key); return { allowed: currentCount <= limit, currentCount, limit, remaining: Math.max(0, limit - currentCount), resetInSeconds: Math.max(1, ttl), }; } async deduplicateNotification( dedupKey: DeduplicationKey, customWindowSeconds?: number ): Promise { const dedupId = `dedup:${dedupKey.userId}:${dedupKey.templateId}:${dedupKey.key}`; const windowSeconds = customWindowSeconds || dedupKey.windowSeconds || this.config.redis.dedupWindowSeconds; const wasSet = await this.redisService.setIfNotExists(dedupId, '1', windowSeconds); return wasSet; } getRateLimitConfig(): { emailPerMinute: number; smsPerMinute: number; pushPerMinute: number; windowSeconds: number; } { return { emailPerMinute: this.config.rateLimits.emailPerMinute, smsPerMinute: this.config.rateLimits.smsPerMinute, pushPerMinute: this.config.rateLimits.pushPerMinute, windowSeconds: this.config.rateLimits.windowSeconds, }; } getTemplateService(): TemplateService { return TemplateService.getInstance(); } private getLimitForChannel(channel: NotificationChannel): number { switch (channel) { case 'email': return this.config.rateLimits.emailPerMinute; case 'sms': return this.config.rateLimits.smsPerMinute; case 'push': return this.config.rateLimits.pushPerMinute; } } }