242 lines
6.7 KiB
TypeScript
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 }; |