/** * NotificationService - Core notification management service * Handles multi-channel notification dispatch and management */ import { Notification, NotificationPayload, NotificationChannel, NotificationPriority, NotificationRecipient, NotificationStatus, NotificationType, UserNotificationPreferences, InAppNotification, NotificationStats, NotificationConfig } from './types'; import { EmailChannel } from './channels/email'; import { PushChannel } from './channels/push'; import { InAppChannel } from './channels/inApp'; export class NotificationService { private static instance: NotificationService; private notifications: Map = new Map(); private userPreferences: Map = new Map(); private stats: NotificationStats; private config: NotificationConfig; private emailChannel: EmailChannel; private pushChannel: PushChannel; private inAppChannel: InAppChannel; private constructor() { this.config = this.loadConfig(); this.stats = this.initializeStats(); this.emailChannel = new EmailChannel(this.config.email); this.pushChannel = new PushChannel(this.config.push); this.inAppChannel = new InAppChannel(); } static getInstance(): NotificationService { if (!NotificationService.instance) { NotificationService.instance = new NotificationService(); } return NotificationService.instance; } /** * Send a notification to a recipient */ async send( recipient: NotificationRecipient, payload: NotificationPayload, options?: { channels?: NotificationChannel[]; priority?: NotificationPriority; } ): Promise { const notification = this.createNotification(recipient, payload, options); // Store notification this.notifications.set(notification.id, notification); // Check user preferences and quiet hours const preferences = this.getUserPreferences(recipient.userId); const channels = this.filterChannelsByPreferences( notification.channels, preferences, payload.type ); if (channels.length === 0) { notification.status = 'delivered'; notification.deliveredAt = new Date().toISOString(); return notification; } // Check quiet hours if (this.isInQuietHours(preferences)) { // Queue for later if not urgent if (notification.priority !== 'urgent') { console.log(`[NotificationService] Notification ${notification.id} queued for after quiet hours`); return notification; } } // Send through each channel await this.dispatchNotification(notification, recipient, channels); return notification; } /** * Send notification to multiple recipients */ async sendBulk( recipients: NotificationRecipient[], payload: NotificationPayload, options?: { channels?: NotificationChannel[]; priority?: NotificationPriority; } ): Promise { const notifications: Notification[] = []; // Process in batches const batchSize = this.config.defaults.batchSize; for (let i = 0; i < recipients.length; i += batchSize) { const batch = recipients.slice(i, i + batchSize); const batchPromises = batch.map(recipient => this.send(recipient, payload, options) ); const batchResults = await Promise.allSettled(batchPromises); batchResults.forEach((result, index) => { if (result.status === 'fulfilled') { notifications.push(result.value); } else { console.error(`[NotificationService] Failed to send to ${batch[index].userId}:`, result.reason); } }); } return notifications; } /** * Get notifications for a user */ getUserNotifications(userId: string, options?: { unreadOnly?: boolean; type?: NotificationType; limit?: number; offset?: number; }): InAppNotification[] { return this.inAppChannel.getNotifications(userId, options); } /** * Mark notification as read */ markAsRead(notificationId: string, userId: string): boolean { const notification = this.notifications.get(notificationId); if (notification && notification.recipientId === userId) { notification.readAt = new Date().toISOString(); notification.status = 'read'; this.inAppChannel.markAsRead(notificationId); return true; } return false; } /** * Mark all notifications as read for a user */ markAllAsRead(userId: string): number { return this.inAppChannel.markAllAsRead(userId); } /** * Get unread count for a user */ getUnreadCount(userId: string): number { return this.inAppChannel.getUnreadCount(userId); } /** * Update user notification preferences */ updatePreferences(userId: string, preferences: Partial): UserNotificationPreferences { const current = this.getUserPreferences(userId); const updated: UserNotificationPreferences = { ...current, ...preferences, userId }; this.userPreferences.set(userId, updated); return updated; } /** * Get user notification preferences */ getUserPreferences(userId: string): UserNotificationPreferences { return this.userPreferences.get(userId) || this.getDefaultPreferences(userId); } /** * Get notification statistics */ getStats(): NotificationStats { return { ...this.stats }; } /** * Get a specific notification */ getNotification(id: string): Notification | undefined { return this.notifications.get(id); } /** * Retry failed notification */ async retry(notificationId: string): Promise { const notification = this.notifications.get(notificationId); if (!notification || notification.status !== 'failed') { return null; } if (notification.retryCount >= this.config.defaults.retryAttempts) { console.log(`[NotificationService] Max retries reached for ${notificationId}`); return notification; } notification.retryCount++; notification.status = 'pending'; notification.error = undefined; // Re-dispatch const recipient: NotificationRecipient = { userId: notification.recipientId }; await this.dispatchNotification(notification, recipient, notification.channels); return notification; } /** * Delete notification */ delete(notificationId: string, userId: string): boolean { const notification = this.notifications.get(notificationId); if (notification && notification.recipientId === userId) { this.notifications.delete(notificationId); this.inAppChannel.delete(notificationId); return true; } return false; } /** * Create notification object */ private createNotification( recipient: NotificationRecipient, payload: NotificationPayload, options?: { channels?: NotificationChannel[]; priority?: NotificationPriority; } ): Notification { const id = `notif-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; return { id, recipientId: recipient.userId, payload, channels: options?.channels || ['inApp', 'email'], priority: options?.priority || 'medium', status: 'pending', createdAt: new Date().toISOString(), retryCount: 0 }; } /** * Dispatch notification through channels */ private async dispatchNotification( notification: Notification, recipient: NotificationRecipient, channels: NotificationChannel[] ): Promise { const results: { channel: NotificationChannel; success: boolean; error?: string }[] = []; for (const channel of channels) { try { switch (channel) { case 'email': if (recipient.email) { await this.emailChannel.send({ to: recipient.email, subject: notification.payload.title, html: await this.emailChannel.renderTemplate(notification.payload), text: notification.payload.message }); results.push({ channel, success: true }); this.stats.byChannel.email.sent++; this.stats.byChannel.email.delivered++; } break; case 'push': if (recipient.pushToken) { await this.pushChannel.send({ token: recipient.pushToken, title: notification.payload.title, body: notification.payload.message, data: notification.payload.data }); results.push({ channel, success: true }); this.stats.byChannel.push.sent++; this.stats.byChannel.push.delivered++; } break; case 'inApp': await this.inAppChannel.send({ id: notification.id, userId: recipient.userId, type: notification.payload.type, title: notification.payload.title, message: notification.payload.message, actionUrl: notification.payload.actionUrl, imageUrl: notification.payload.imageUrl, read: false, createdAt: notification.createdAt }); results.push({ channel, success: true }); this.stats.byChannel.inApp.sent++; this.stats.byChannel.inApp.delivered++; break; } } catch (error: any) { console.error(`[NotificationService] Failed to send via ${channel}:`, error.message); results.push({ channel, success: false, error: error.message }); this.stats.byChannel[channel].failed++; } } // Update notification status const allSuccessful = results.every(r => r.success); const anySuccessful = results.some(r => r.success); if (allSuccessful) { notification.status = 'delivered'; notification.deliveredAt = new Date().toISOString(); this.stats.totalDelivered++; } else if (anySuccessful) { notification.status = 'delivered'; notification.deliveredAt = new Date().toISOString(); notification.error = results.filter(r => !r.success).map(r => `${r.channel}: ${r.error}`).join('; '); this.stats.totalDelivered++; } else { notification.status = 'failed'; notification.error = results.map(r => `${r.channel}: ${r.error}`).join('; '); this.stats.totalFailed++; } notification.sentAt = new Date().toISOString(); this.stats.totalSent++; this.stats.byType[notification.payload.type] = (this.stats.byType[notification.payload.type] || 0) + 1; } /** * Filter channels by user preferences */ private filterChannelsByPreferences( channels: NotificationChannel[], preferences: UserNotificationPreferences, type: NotificationType ): NotificationChannel[] { return channels.filter(channel => { // Check channel preference if (channel === 'email' && !preferences.email) return false; if (channel === 'push' && !preferences.push) return false; if (channel === 'inApp' && !preferences.inApp) return false; // Check type-specific preference switch (type) { case 'plant_reminder': return preferences.plantReminders; case 'transport_alert': return preferences.transportAlerts; case 'farm_alert': return preferences.farmAlerts; case 'harvest_ready': return preferences.harvestAlerts; case 'demand_match': return preferences.demandMatches; case 'weekly_digest': return preferences.weeklyDigest; default: return true; } }); } /** * Check if current time is within quiet hours */ private isInQuietHours(preferences: UserNotificationPreferences): boolean { if (!preferences.quietHoursStart || !preferences.quietHoursEnd) { return false; } const now = new Date(); const timezone = preferences.timezone || 'UTC'; try { const formatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: '2-digit', minute: '2-digit', hour12: false }); const currentTime = formatter.format(now); const [startHour, startMin] = preferences.quietHoursStart.split(':').map(Number); const [endHour, endMin] = preferences.quietHoursEnd.split(':').map(Number); const [currentHour, currentMin] = currentTime.split(':').map(Number); const currentMinutes = currentHour * 60 + currentMin; const startMinutes = startHour * 60 + startMin; const endMinutes = endHour * 60 + endMin; if (startMinutes <= endMinutes) { return currentMinutes >= startMinutes && currentMinutes < endMinutes; } else { // Quiet hours span midnight return currentMinutes >= startMinutes || currentMinutes < endMinutes; } } catch { return false; } } /** * Get default preferences */ private getDefaultPreferences(userId: string): UserNotificationPreferences { return { userId, email: true, push: true, inApp: true, plantReminders: true, transportAlerts: true, farmAlerts: true, harvestAlerts: true, demandMatches: true, weeklyDigest: true }; } /** * Initialize stats */ private initializeStats(): NotificationStats { return { totalSent: 0, totalDelivered: 0, totalFailed: 0, byChannel: { email: { sent: 0, delivered: 0, failed: 0 }, push: { sent: 0, delivered: 0, failed: 0 }, inApp: { sent: 0, delivered: 0, failed: 0 } }, byType: {} as Record }; } /** * Load configuration */ private loadConfig(): NotificationConfig { return { email: { provider: (process.env.EMAIL_PROVIDER as 'sendgrid' | 'nodemailer' | 'smtp') || 'nodemailer', apiKey: process.env.SENDGRID_API_KEY, from: process.env.EMAIL_FROM || 'noreply@localgreenchain.local', replyTo: process.env.EMAIL_REPLY_TO, smtp: { host: process.env.SMTP_HOST || 'localhost', port: parseInt(process.env.SMTP_PORT || '587'), secure: process.env.SMTP_SECURE === 'true', user: process.env.SMTP_USER || '', pass: process.env.SMTP_PASS || '' } }, push: { vapidPublicKey: process.env.VAPID_PUBLIC_KEY || '', vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || '', vapidSubject: process.env.VAPID_SUBJECT || 'mailto:admin@localgreenchain.local' }, defaults: { retryAttempts: 3, retryDelayMs: 5000, batchSize: 50 } }; } } // Export singleton getter export function getNotificationService(): NotificationService { return NotificationService.getInstance(); }