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
344 lines
9.1 KiB
TypeScript
344 lines
9.1 KiB
TypeScript
/**
|
|
* Notification Scheduler
|
|
* Handles scheduled and recurring notifications
|
|
*/
|
|
|
|
import { getNotificationService } from './service';
|
|
import {
|
|
ScheduledNotification,
|
|
NotificationRecipient,
|
|
NotificationPayload,
|
|
NotificationChannel,
|
|
NotificationPriority
|
|
} from './types';
|
|
|
|
export class NotificationScheduler {
|
|
private static instance: NotificationScheduler;
|
|
private scheduledNotifications: Map<string, ScheduledNotification> = new Map();
|
|
private checkInterval: NodeJS.Timeout | null = null;
|
|
private isRunning = false;
|
|
|
|
private constructor() {}
|
|
|
|
static getInstance(): NotificationScheduler {
|
|
if (!NotificationScheduler.instance) {
|
|
NotificationScheduler.instance = new NotificationScheduler();
|
|
}
|
|
return NotificationScheduler.instance;
|
|
}
|
|
|
|
/**
|
|
* Start the scheduler
|
|
*/
|
|
start(intervalMs: number = 60000): void {
|
|
if (this.isRunning) return;
|
|
|
|
this.isRunning = true;
|
|
console.log('[NotificationScheduler] Started');
|
|
|
|
// Check immediately
|
|
this.processScheduledNotifications();
|
|
|
|
// Set up interval
|
|
this.checkInterval = setInterval(() => {
|
|
this.processScheduledNotifications();
|
|
}, intervalMs);
|
|
}
|
|
|
|
/**
|
|
* Stop the scheduler
|
|
*/
|
|
stop(): void {
|
|
if (this.checkInterval) {
|
|
clearInterval(this.checkInterval);
|
|
this.checkInterval = null;
|
|
}
|
|
this.isRunning = false;
|
|
console.log('[NotificationScheduler] Stopped');
|
|
}
|
|
|
|
/**
|
|
* Schedule a notification
|
|
*/
|
|
schedule(
|
|
recipient: NotificationRecipient,
|
|
payload: NotificationPayload,
|
|
scheduledFor: Date | string,
|
|
options?: {
|
|
channels?: NotificationChannel[];
|
|
priority?: NotificationPriority;
|
|
recurring?: {
|
|
pattern: 'daily' | 'weekly' | 'monthly';
|
|
endDate?: string;
|
|
};
|
|
}
|
|
): ScheduledNotification {
|
|
const id = `sched-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
const scheduled: ScheduledNotification = {
|
|
id,
|
|
notification: {
|
|
recipientId: recipient.userId,
|
|
payload,
|
|
channels: options?.channels || ['inApp', 'email'],
|
|
priority: options?.priority || 'medium',
|
|
retryCount: 0
|
|
},
|
|
scheduledFor: typeof scheduledFor === 'string' ? scheduledFor : scheduledFor.toISOString(),
|
|
recurring: options?.recurring,
|
|
status: 'scheduled'
|
|
};
|
|
|
|
this.scheduledNotifications.set(id, scheduled);
|
|
console.log(`[NotificationScheduler] Scheduled notification ${id} for ${scheduled.scheduledFor}`);
|
|
|
|
return scheduled;
|
|
}
|
|
|
|
/**
|
|
* Cancel a scheduled notification
|
|
*/
|
|
cancel(id: string): boolean {
|
|
const scheduled = this.scheduledNotifications.get(id);
|
|
if (scheduled && scheduled.status === 'scheduled') {
|
|
scheduled.status = 'cancelled';
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get scheduled notification
|
|
*/
|
|
getScheduled(id: string): ScheduledNotification | undefined {
|
|
return this.scheduledNotifications.get(id);
|
|
}
|
|
|
|
/**
|
|
* Get all scheduled notifications for a user
|
|
*/
|
|
getScheduledForUser(userId: string): ScheduledNotification[] {
|
|
const result: ScheduledNotification[] = [];
|
|
this.scheduledNotifications.forEach(scheduled => {
|
|
if (scheduled.notification.recipientId === userId && scheduled.status === 'scheduled') {
|
|
result.push(scheduled);
|
|
}
|
|
});
|
|
return result.sort((a, b) =>
|
|
new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Schedule a plant care reminder
|
|
*/
|
|
schedulePlantReminder(
|
|
userId: string,
|
|
email: string,
|
|
plantId: string,
|
|
plantName: string,
|
|
reminderType: 'water' | 'fertilize' | 'prune' | 'harvest',
|
|
scheduledFor: Date
|
|
): ScheduledNotification {
|
|
const reminderMessages = {
|
|
water: `Time to water your ${plantName}!`,
|
|
fertilize: `Your ${plantName} needs fertilizing.`,
|
|
prune: `Consider pruning your ${plantName} for better growth.`,
|
|
harvest: `Your ${plantName} may be ready for harvest!`
|
|
};
|
|
|
|
return this.schedule(
|
|
{ userId, email },
|
|
{
|
|
type: 'plant_reminder',
|
|
title: `Plant Care Reminder: ${plantName}`,
|
|
message: reminderMessages[reminderType],
|
|
data: { plantId, plantName, reminderType },
|
|
actionUrl: `/plants/${plantId}`
|
|
},
|
|
scheduledFor,
|
|
{ channels: ['inApp', 'email', 'push'] }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Schedule weekly digest
|
|
*/
|
|
scheduleWeeklyDigest(userId: string, email: string): ScheduledNotification {
|
|
// Schedule for next Monday at 9 AM
|
|
const now = new Date();
|
|
const nextMonday = new Date(now);
|
|
nextMonday.setDate(now.getDate() + ((7 - now.getDay() + 1) % 7 || 7));
|
|
nextMonday.setHours(9, 0, 0, 0);
|
|
|
|
return this.schedule(
|
|
{ userId, email },
|
|
{
|
|
type: 'weekly_digest',
|
|
title: 'Your Weekly LocalGreenChain Summary',
|
|
message: 'Check out what happened this week!',
|
|
actionUrl: '/dashboard'
|
|
},
|
|
nextMonday,
|
|
{
|
|
channels: ['email'],
|
|
recurring: { pattern: 'weekly' }
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Schedule harvest alert
|
|
*/
|
|
scheduleHarvestAlert(
|
|
userId: string,
|
|
email: string,
|
|
batchId: string,
|
|
cropType: string,
|
|
estimatedHarvestDate: Date
|
|
): ScheduledNotification {
|
|
// Schedule alert 1 day before harvest
|
|
const alertDate = new Date(estimatedHarvestDate);
|
|
alertDate.setDate(alertDate.getDate() - 1);
|
|
|
|
return this.schedule(
|
|
{ userId, email },
|
|
{
|
|
type: 'harvest_ready',
|
|
title: `Harvest Coming Soon: ${cropType}`,
|
|
message: `Your ${cropType} batch will be ready for harvest tomorrow!`,
|
|
data: { batchId, cropType, estimatedHarvestDate: estimatedHarvestDate.toISOString() },
|
|
actionUrl: `/vertical-farm/batch/${batchId}`
|
|
},
|
|
alertDate,
|
|
{ channels: ['inApp', 'email', 'push'], priority: 'high' }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Process due notifications
|
|
*/
|
|
private async processScheduledNotifications(): Promise<void> {
|
|
const now = new Date();
|
|
const notificationService = getNotificationService();
|
|
|
|
for (const [id, scheduled] of this.scheduledNotifications.entries()) {
|
|
if (scheduled.status !== 'scheduled') continue;
|
|
|
|
const scheduledTime = new Date(scheduled.scheduledFor);
|
|
if (scheduledTime <= now) {
|
|
try {
|
|
// Send the notification
|
|
await notificationService.send(
|
|
{ userId: scheduled.notification.recipientId },
|
|
scheduled.notification.payload,
|
|
{
|
|
channels: scheduled.notification.channels,
|
|
priority: scheduled.notification.priority
|
|
}
|
|
);
|
|
|
|
// Handle recurring
|
|
if (scheduled.recurring) {
|
|
const endDate = scheduled.recurring.endDate
|
|
? new Date(scheduled.recurring.endDate)
|
|
: null;
|
|
|
|
if (!endDate || scheduledTime < endDate) {
|
|
// Schedule next occurrence
|
|
const nextDate = this.getNextOccurrence(scheduledTime, scheduled.recurring.pattern);
|
|
scheduled.scheduledFor = nextDate.toISOString();
|
|
console.log(`[NotificationScheduler] Rescheduled ${id} for ${scheduled.scheduledFor}`);
|
|
} else {
|
|
scheduled.status = 'sent';
|
|
}
|
|
} else {
|
|
scheduled.status = 'sent';
|
|
}
|
|
|
|
console.log(`[NotificationScheduler] Sent scheduled notification ${id}`);
|
|
} catch (error: any) {
|
|
console.error(`[NotificationScheduler] Failed to send ${id}:`, error.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate next occurrence date
|
|
*/
|
|
private getNextOccurrence(current: Date, pattern: 'daily' | 'weekly' | 'monthly'): Date {
|
|
const next = new Date(current);
|
|
|
|
switch (pattern) {
|
|
case 'daily':
|
|
next.setDate(next.getDate() + 1);
|
|
break;
|
|
case 'weekly':
|
|
next.setDate(next.getDate() + 7);
|
|
break;
|
|
case 'monthly':
|
|
next.setMonth(next.getMonth() + 1);
|
|
break;
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Get scheduler stats
|
|
*/
|
|
getStats(): {
|
|
isRunning: boolean;
|
|
total: number;
|
|
scheduled: number;
|
|
sent: number;
|
|
cancelled: number;
|
|
} {
|
|
let scheduled = 0;
|
|
let sent = 0;
|
|
let cancelled = 0;
|
|
|
|
this.scheduledNotifications.forEach(n => {
|
|
switch (n.status) {
|
|
case 'scheduled': scheduled++; break;
|
|
case 'sent': sent++; break;
|
|
case 'cancelled': cancelled++; break;
|
|
}
|
|
});
|
|
|
|
return {
|
|
isRunning: this.isRunning,
|
|
total: this.scheduledNotifications.size,
|
|
scheduled,
|
|
sent,
|
|
cancelled
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clean up old notifications
|
|
*/
|
|
cleanup(olderThanDays: number = 30): number {
|
|
const cutoff = new Date();
|
|
cutoff.setDate(cutoff.getDate() - olderThanDays);
|
|
|
|
let removed = 0;
|
|
for (const [id, scheduled] of this.scheduledNotifications.entries()) {
|
|
if (scheduled.status !== 'scheduled') {
|
|
const scheduledDate = new Date(scheduled.scheduledFor);
|
|
if (scheduledDate < cutoff) {
|
|
this.scheduledNotifications.delete(id);
|
|
removed++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
}
|
|
|
|
// Export singleton getter
|
|
export function getNotificationScheduler(): NotificationScheduler {
|
|
return NotificationScheduler.getInstance();
|
|
}
|