/** * Web Push Notification Sender * Sends push notifications to subscribed users */ import webpush from 'web-push' import { getPushSubscriptionsByRoles, getPushSubscriptionsByUser, deletePushSubscription } from './pushDb' // Configure web-push with VAPID keys const vapidPublicKey = process.env.VAPID_PUBLIC_KEY || '' const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY || '' const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:support@logship.de' if (vapidPublicKey && vapidPrivateKey) { webpush.setVapidDetails( vapidSubject, vapidPublicKey, vapidPrivateKey ) console.log('[WebPush] ✅ VAPID configured successfully') } else { console.warn('[WebPush] ⚠️ VAPID keys not configured - push notifications will not work') } interface PushPayload { title: string body: string icon?: string badge?: string url?: string data?: any // Topic groups notifications on the recipient device — newer messages with // the same topic replace older ones. Keep distinct per notification kind. topic?: string } /** * Send push notification to specific roles */ export const sendPushToRoles = async (roleIds: number[], payload: PushPayload) => { if (!vapidPublicKey || !vapidPrivateKey) { console.warn('[WebPush] Skipping push - VAPID not configured') return { sent: 0, failed: 0, removed: 0 } } try { // Get all subscriptions for these roles const subscriptions = getPushSubscriptionsByRoles(roleIds) if (subscriptions.length === 0) { console.log('[WebPush] No subscriptions found for roles:', roleIds) return { sent: 0, failed: 0, removed: 0 } } console.log(`[WebPush] Sending to ${subscriptions.length} subscribers for roles:`, roleIds) const results = { sent: 0, failed: 0, removed: 0 } // Send to all subscriptions const promises = subscriptions.map(async (sub: any) => { try { const pushSubscription = { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } } await webpush.sendNotification( pushSubscription, JSON.stringify(payload), { // Time-to-live: 24 hours (86400 seconds) TTL: 86400, // High urgency for immediate delivery even in background urgency: 'high', // Topic for notification grouping — newer pushes with the same // topic replace older ones on the device. Keep per-kind. topic: payload.topic || 'new-orders' } ) results.sent++ console.log('[WebPush] ✅ Sent to:', sub.endpoint.substring(0, 50) + '...') } catch (error: any) { console.error('[WebPush] ❌ Failed to send:', error.message) // Remove subscription if it's expired (410 Gone) if (error.statusCode === 410 || error.statusCode === 404) { console.log('[WebPush] Removing expired subscription:', sub.endpoint) deletePushSubscription(sub.endpoint) results.removed++ } else { results.failed++ } } }) await Promise.all(promises) console.log('[WebPush] Results:', results) return results } catch (error: any) { console.error('[WebPush] ❌ Error sending push notifications:', error.message) return { sent: 0, failed: 0, removed: 0 } } } /** * Send a push notification to all of a single user's subscribed devices. * Used by the staff live-chat module to deliver a DM to a recipient who is * currently offline (no open WebSocket). Mirrors sendPushToRoles but keys on * user_id via getPushSubscriptionsByUser (already exists in pushDb — no schema * change). Self-contained: safe to no-op if VAPID isn't configured or the user * has no subscriptions. */ export const sendPushToUser = async (userId: number, payload: PushPayload) => { if (!vapidPublicKey || !vapidPrivateKey) { console.warn('[WebPush] Skipping push to user - VAPID not configured') return { sent: 0, failed: 0, removed: 0 } } try { const subscriptions = getPushSubscriptionsByUser(userId) if (!subscriptions || subscriptions.length === 0) { return { sent: 0, failed: 0, removed: 0 } } const results = { sent: 0, failed: 0, removed: 0 } const promises = subscriptions.map(async (sub: any) => { try { const pushSubscription = { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } } await webpush.sendNotification( pushSubscription, JSON.stringify(payload), { TTL: 86400, urgency: 'high', topic: payload.topic || 'chat' } ) results.sent++ } catch (error: any) { if (error.statusCode === 410 || error.statusCode === 404) { deletePushSubscription(sub.endpoint) results.removed++ } else { results.failed++ } } }) await Promise.all(promises) return results } catch (error: any) { console.error('[WebPush] ❌ Error sending push to user:', error.message) return { sent: 0, failed: 0, removed: 0 } } } /** * Build the payload for a chat direct-message push. Per-sender topic so a newer * DM from the same colleague replaces the older card on the device. */ export const buildChatMessagePayload = ( senderName: string, body: string, senderUserId: number | string ): PushPayload => { const name = (senderName || '').trim() || 'Kollege' return { title: `💬 Neue Nachricht von ${name}`, body: (body || '').toString().trim().slice(0, 140) || 'Neue Nachricht', icon: '/favicon.ico', badge: '/favicon.ico', url: `/?chat=${senderUserId}`, topic: `chat-${senderUserId}`, data: { type: 'chat_message', senderUserId, timestamp: Date.now() } } } /** * Send push notification for new orders */ export const sendNewOrderNotification = async (roleIds: number[], orderCount: number, orderDetails?: any) => { const title = orderCount === 1 ? '🔔 New Order Received' : `🔔 ${orderCount} New Orders Received` const body = orderCount === 1 ? 'You have 1 new order waiting to be processed' : `You have ${orderCount} new orders waiting to be processed` return sendPushToRoles(roleIds, { title, body, icon: '/favicon.ico', badge: '/favicon.ico', url: '/sales/orders', topic: 'new-orders', data: { type: 'new_orders', count: orderCount, timestamp: Date.now(), ...orderDetails } }) } /** * Build the shared payload for a ticket-handoff notification. Used by both the * web-push send and the in-app SSE/toast publisher so the user gets a * consistent message regardless of which channel reaches them first. */ export const buildRequestActionPayload = ( action: 'merchant-action' | 'support-action', ticket: { id: number | string, docNo: string, summary: string, orgName?: string, lastMessage?: string } ): PushPayload => { const isMerchant = action === 'merchant-action' const docLabel = `#${ticket.docNo}` const title = isMerchant ? `🎫 Ticket ${docLabel} — Ihre Aktion erforderlich` : `🎫 Ticket ${docLabel} — ${(ticket.orgName || '').trim() || 'Merchant'} hat geantwortet` const fallback = isMerchant ? 'Auf Ihr Ticket wird eine Rückmeldung benötigt.' : 'Der Merchant hat auf ein Ticket reagiert.' // Prefer the most recent update message (the one the user just entered) so the // toast reflects the latest reply; fall back to the ticket summary, then a // generic line. const body = ((ticket.lastMessage || '').trim() || (ticket.summary || '').trim()).slice(0, 140) || fallback const url = isMerchant ? `/requests/my-tickets/${ticket.id}` : `/requests/requests/${ticket.id}/edit` return { title, body, icon: '/favicon.ico', badge: '/favicon.ico', url, // Per-ticket topic so a later push for the same ticket replaces the // older one on the device (no duplicate cards stacking up). topic: `request-${ticket.id}`, data: { type: isMerchant ? 'request_merchant_action' : 'request_support_action', ticketId: ticket.id, docNo: ticket.docNo, timestamp: Date.now() } } } /** * Send push notification for a ticket handoff between merchant and support. * Mirrors the email path in `requests/[id]/action-notification.post.ts`: * - 'merchant-action' → support assigned the ticket to the merchant * - 'support-action' → merchant replied; ticket is back with support * The caller decides which role IDs receive the push (see route — uses the * org→role access map so only users whose role can access the ticket's org * are reached). */ export const sendRequestActionNotification = async ( roleIds: number[], action: 'merchant-action' | 'support-action', ticket: { id: number | string, docNo: string, summary: string, orgName?: string, lastMessage?: string } ) => { return sendPushToRoles(roleIds, buildRequestActionPayload(action, ticket)) }