import webpush from 'web-push'; import { prisma } from './prisma'; import { logger } from '../logger'; // VAPID keys should be in .env. If not, we log a warning. const publicVapidKey = process.env.VAPID_PUBLIC_KEY; const privateVapidKey = process.env.VAPID_PRIVATE_KEY; const contactEmail = process.env.VAPID_EMAIL || 'mailto:support@xamle.studio'; if (publicVapidKey && privateVapidKey) { webpush.setVapidDetails(contactEmail, publicVapidKey, privateVapidKey); } else { logger.warn('[PUSH-SERVICE] VAPID keys are missing. Push notifications will not work.'); // To generate keys, you can use: webpush.generateVAPIDKeys() } export const pushService = { /** * Store a new subscription for a user */ async subscribe(userId: string, organizationId: string, subscription: any) { return prisma.pushSubscription.upsert({ where: { endpoint: subscription.endpoint }, update: { userId, organizationId, p256dh: subscription.keys.p256dh, auth: subscription.keys.auth }, create: { userId, organizationId, endpoint: subscription.endpoint, p256dh: subscription.keys.p256dh, auth: subscription.keys.auth } }); }, /** * Send a notification to all active subscriptions of a specific user */ async sendToUser(userId: string, organizationId: string, title: string, body: string, icon?: string, data?: Record) { const subscriptions = await prisma.pushSubscription.findMany({ where: { userId, organizationId } }); const payload = JSON.stringify({ title, body, icon: icon || 'https://xamle.studio/logo.png', data, timestamp: Date.now(), }); const results = await Promise.allSettled( subscriptions.map((sub: any) => { const pushConfig = { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }; return webpush.sendNotification(pushConfig, payload); }) ); for (let i = 0; i < results.length; i++) { if (results[i].status === 'rejected') { const error = (results[i] as PromiseRejectedResult).reason; if (error.statusCode === 410 || error.statusCode === 404) { await prisma.pushSubscription.delete({ where: { id: subscriptions[i].id } }).catch(() => {}); } } } }, /** * Send a notification to all active subscriptions of an organization */ async notifyOrganization(organizationId: string, title: string, body: string, icon?: string) { const subscriptions = await prisma.pushSubscription.findMany({ where: { organizationId } }); const payload = JSON.stringify({ title, body, icon: icon || 'https://xamle.studio/logo.png', timestamp: Date.now() }); const results = await Promise.allSettled( subscriptions.map((sub: any) => { const pushConfig = { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }; return webpush.sendNotification(pushConfig, payload); }) ); // Clean up failed subscriptions (e.g. expired or unsubscribed) for (let i = 0; i < results.length; i++) { if (results[i].status === 'rejected') { const error = (results[i] as PromiseRejectedResult).reason; if (error.statusCode === 410 || error.statusCode === 404) { logger.info(`[PUSH-SERVICE] Removing expired subscription: ${subscriptions[i].endpoint}`); await prisma.pushSubscription.delete({ where: { id: subscriptions[i].id } }).catch(() => {}); } } } } };