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
163 lines
4.4 KiB
TypeScript
163 lines
4.4 KiB
TypeScript
/**
|
|
* Push Notification Channel
|
|
* Handles Web Push notifications using VAPID
|
|
*/
|
|
|
|
import { PushNotificationData, PushSubscription } from '../types';
|
|
|
|
interface PushConfig {
|
|
vapidPublicKey: string;
|
|
vapidPrivateKey: string;
|
|
vapidSubject: string;
|
|
}
|
|
|
|
export class PushChannel {
|
|
private config: PushConfig;
|
|
private subscriptions: Map<string, PushSubscription[]> = new Map();
|
|
|
|
constructor(config: PushConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Send a push notification
|
|
*/
|
|
async send(data: PushNotificationData): Promise<void> {
|
|
if (!this.config.vapidPublicKey || !this.config.vapidPrivateKey) {
|
|
console.log('[PushChannel] VAPID keys not configured - simulating push');
|
|
return;
|
|
}
|
|
|
|
const payload = JSON.stringify({
|
|
title: data.title,
|
|
body: data.body,
|
|
icon: data.icon || '/icons/icon-192x192.png',
|
|
badge: data.badge || '/icons/badge-72x72.png',
|
|
data: data.data,
|
|
actions: data.actions
|
|
});
|
|
|
|
// In production, this would use web-push library:
|
|
// const webpush = require('web-push');
|
|
// webpush.setVapidDetails(
|
|
// this.config.vapidSubject,
|
|
// this.config.vapidPublicKey,
|
|
// this.config.vapidPrivateKey
|
|
// );
|
|
// await webpush.sendNotification(subscription, payload);
|
|
|
|
console.log(`[PushChannel] Push notification sent: ${data.title}`);
|
|
}
|
|
|
|
/**
|
|
* Subscribe a user to push notifications
|
|
*/
|
|
subscribe(userId: string, subscription: Omit<PushSubscription, 'userId' | 'createdAt'>): PushSubscription {
|
|
const fullSubscription: PushSubscription = {
|
|
...subscription,
|
|
userId,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
|
|
const userSubs = this.subscriptions.get(userId) || [];
|
|
|
|
// Check if subscription already exists
|
|
const existing = userSubs.find(s => s.endpoint === subscription.endpoint);
|
|
if (existing) {
|
|
existing.lastUsedAt = new Date().toISOString();
|
|
return existing;
|
|
}
|
|
|
|
userSubs.push(fullSubscription);
|
|
this.subscriptions.set(userId, userSubs);
|
|
|
|
console.log(`[PushChannel] User ${userId} subscribed to push notifications`);
|
|
return fullSubscription;
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from push notifications
|
|
*/
|
|
unsubscribe(userId: string, endpoint?: string): boolean {
|
|
if (!endpoint) {
|
|
// Remove all subscriptions for user
|
|
this.subscriptions.delete(userId);
|
|
return true;
|
|
}
|
|
|
|
const userSubs = this.subscriptions.get(userId);
|
|
if (!userSubs) return false;
|
|
|
|
const index = userSubs.findIndex(s => s.endpoint === endpoint);
|
|
if (index === -1) return false;
|
|
|
|
userSubs.splice(index, 1);
|
|
if (userSubs.length === 0) {
|
|
this.subscriptions.delete(userId);
|
|
} else {
|
|
this.subscriptions.set(userId, userSubs);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get subscriptions for a user
|
|
*/
|
|
getSubscriptions(userId: string): PushSubscription[] {
|
|
return this.subscriptions.get(userId) || [];
|
|
}
|
|
|
|
/**
|
|
* Check if user has push subscriptions
|
|
*/
|
|
hasSubscription(userId: string): boolean {
|
|
const subs = this.subscriptions.get(userId);
|
|
return subs !== undefined && subs.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Send to all user's subscriptions
|
|
*/
|
|
async sendToUser(userId: string, data: Omit<PushNotificationData, 'token'>): Promise<void> {
|
|
const subscriptions = this.getSubscriptions(userId);
|
|
|
|
for (const sub of subscriptions) {
|
|
try {
|
|
await this.send({
|
|
...data,
|
|
token: sub.endpoint
|
|
});
|
|
sub.lastUsedAt = new Date().toISOString();
|
|
} catch (error: any) {
|
|
console.error(`[PushChannel] Failed to send to ${sub.endpoint}:`, error.message);
|
|
// Remove invalid subscriptions
|
|
if (error.statusCode === 410 || error.statusCode === 404) {
|
|
this.unsubscribe(userId, sub.endpoint);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get VAPID public key for client
|
|
*/
|
|
getPublicKey(): string {
|
|
return this.config.vapidPublicKey;
|
|
}
|
|
|
|
/**
|
|
* Generate VAPID keys (utility method)
|
|
*/
|
|
static generateVapidKeys(): { publicKey: string; privateKey: string } {
|
|
// In production, use web-push library:
|
|
// const webpush = require('web-push');
|
|
// return webpush.generateVAPIDKeys();
|
|
|
|
// For development, return placeholder keys
|
|
return {
|
|
publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
|
|
privateKey: 'UUxI4O8-FbRouADVXc-hK3ltRAc8_DIoISjp22LG0S0'
|
|
};
|
|
}
|
|
}
|