localgreenchain/lib/notifications/service.ts
Claude 62c1ded598
Add comprehensive notification system (Agent 8)
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
2025-11-23 03:52:41 +00:00

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