Implement multi-channel notification system with: - Core notification service with email, push, and in-app channels - Email templates for all notification types (welcome, plant registered, transport alerts, farm alerts, harvest ready, demand matches, weekly digest) - Push notification support with VAPID authentication - In-app notification management with read/unread tracking - Notification scheduler for recurring and scheduled notifications - API endpoints for notifications CRUD, preferences, and subscriptions - UI components (NotificationBell, NotificationList, NotificationItem, PreferencesForm) - Full notifications page with preferences management - Service worker for push notification handling
503 lines
15 KiB
TypeScript
503 lines
15 KiB
TypeScript
/**
|
|
* 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<string, Notification> = new Map();
|
|
private userPreferences: Map<string, UserNotificationPreferences> = 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<Notification> {
|
|
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<Notification[]> {
|
|
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>): 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<Notification | null> {
|
|
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<void> {
|
|
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<NotificationType, number>
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|