Files
insta-recipe/src/lib/server/notifications/PushNotificationService.ts

242 lines
6.7 KiB
TypeScript

/**
* Push Notification Service for InstaRecipe Queue System
*
* Handles web push notifications for background processing updates
* when users are not actively viewing the application.
*/
// @ts-expect-error - web-push doesn't have TypeScript types, but we mock it anyway
import webpush from 'web-push';
import { queueConfig } from '../queue/config';
interface PushSubscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
interface NotificationPayload {
title?: string;
body: string;
type: 'success' | 'error' | 'progress';
itemId: string;
recipeName?: string;
tag?: string;
requireInteraction?: boolean;
analytics?: any;
}
class PushNotificationService {
private subscriptions = new Map<string, PushSubscription>();
private vapidKeys: { publicKey: string; privateKey: string } | null = null;
constructor() {
this.loadVapidKeys();
// Configure web-push with VAPID details
if (this.vapidKeys) {
webpush.setVapidDetails(
queueConfig.push.vapidEmail,
this.vapidKeys.publicKey,
this.vapidKeys.privateKey
);
}
}
/**
* Load VAPID keys for push notifications
* In production, these should be stored securely and loaded from environment
*/
private loadVapidKeys() {
// Load from config module which uses SvelteKit's $env/dynamic/private
this.vapidKeys = {
publicKey: queueConfig.push.vapidPublicKey,
privateKey: queueConfig.push.vapidPrivateKey
};
}
/**
* Get the public VAPID key for client-side subscription
*/
getPublicVapidKey(): string | null {
return this.vapidKeys?.publicKey || null;
}
/**
* Subscribe a client to push notifications
*/
async subscribe(clientId: string, subscription: PushSubscription): Promise<void> {
console.log(`[PushService] Subscribing client ${clientId}`);
this.subscriptions.set(clientId, subscription);
// In production, store subscriptions in database
// For development, we'll keep them in memory
}
/**
* Unsubscribe a client from push notifications
*/
async unsubscribe(clientId: string): Promise<void> {
console.log(`[PushService] Unsubscribing client ${clientId}`);
this.subscriptions.delete(clientId);
}
/**
* Send notification to all subscribed clients
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
if (this.subscriptions.size === 0) {
console.log('[PushService] No subscriptions, skipping notification');
return;
}
console.log(`[PushService] Sending notification to ${this.subscriptions.size} subscribers`);
console.log(`[PushService] Notification payload:`, payload);
// In a real implementation, this would use web-push library
// For development/demo purposes, we'll simulate the notification
const notificationData = {
...payload,
timestamp: new Date().toISOString()
};
for (const [clientId, subscription] of this.subscriptions) {
try {
await this.sendToSubscription(subscription, notificationData);
console.log(`[PushService] ✓ Sent notification to client ${clientId}`);
} catch (error) {
console.error(`[PushService] ✗ Failed to send to client ${clientId}:`, error);
// Remove invalid subscriptions
this.subscriptions.delete(clientId);
}
}
}
/**
* Send notification to specific subscription
*/
private async sendToSubscription(subscription: PushSubscription, data: any): Promise<void> {
try {
const payload = JSON.stringify(data);
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth
}
},
payload,
{
TTL: 60 * 60 * 24, // 24 hours
}
);
console.log(`[PushService] ✓ Sent notification to ${subscription.endpoint.substring(0, 50)}...`);
} catch (error) {
// Check if subscription is expired/invalid
if ((error as any).statusCode === 410) {
console.warn(`[PushService] Subscription expired: ${subscription.endpoint.substring(0, 50)}...`);
throw new Error('Subscription expired');
}
console.error('[PushService] Failed to send notification:', {
endpoint: subscription.endpoint.substring(0, 50) + '...',
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Send success notification when recipe extraction completes
*/
async notifySuccess(itemId: string, recipeName?: string, tandoorUrl?: string): Promise<void> {
const payload: NotificationPayload = {
type: 'success',
itemId,
recipeName,
body: recipeName
? `Recipe "${recipeName}" has been extracted and saved successfully!`
: 'Your recipe extraction is complete and ready to view.',
tag: `recipe-success-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_complete',
itemId,
timestamp: Date.now()
}
};
if (tandoorUrl) {
payload.body += ' View it in Tandoor.';
}
await this.sendNotification(payload);
}
/**
* Send error notification when recipe extraction fails
*/
async notifyError(itemId: string, error: string): Promise<void> {
const payload: NotificationPayload = {
type: 'error',
itemId,
body: `Recipe extraction failed: ${error}. Tap to retry.`,
tag: `recipe-error-${itemId}`,
requireInteraction: true,
analytics: {
event: 'recipe_extraction_failed',
itemId,
error,
timestamp: Date.now()
}
};
await this.sendNotification(payload);
}
/**
* Send progress notification for long-running extractions
*/
async notifyProgress(itemId: string, phase: string): Promise<void> {
const payload: NotificationPayload = {
type: 'progress',
itemId,
body: `Recipe extraction in progress: ${phase}`,
tag: `recipe-progress-${itemId}`,
requireInteraction: false,
analytics: {
event: 'recipe_extraction_progress',
itemId,
phase,
timestamp: Date.now()
}
};
await this.sendNotification(payload);
}
/**
* Get subscription count for monitoring
*/
getSubscriptionCount(): number {
return this.subscriptions.size;
}
/**
* Clear all subscriptions (for testing/cleanup)
*/
clearAllSubscriptions(): void {
console.log('[PushService] Clearing all subscriptions');
this.subscriptions.clear();
}
}
// Singleton instance
export const pushNotificationService = new PushNotificationService();
export type { PushSubscription, NotificationPayload };