← back to Watches

push-notifications.js

298 lines

/**
 * Push Notification Module for Omega Watches PWA
 * Handles subscription management and sending push notifications
 */

import webpush from 'web-push';

// VAPID keys for push notifications
// In production, generate with: webpush generate-vapid-keys
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || 'YOUR_PUBLIC_KEY_HERE';
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || 'YOUR_PRIVATE_KEY_HERE';
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:admin@omegawatches.example.com';

// Configure web-push
if (VAPID_PUBLIC_KEY !== 'YOUR_PUBLIC_KEY_HERE') {
  webpush.setVapidDetails(
    VAPID_SUBJECT,
    VAPID_PUBLIC_KEY,
    VAPID_PRIVATE_KEY
  );
}

// In-memory storage for subscriptions (use database in production)
const subscriptions = new Map();

/**
 * Save push subscription
 */
export function saveSubscription(userId, subscription) {
  if (!userId || !subscription) {
    throw new Error('userId and subscription are required');
  }

  subscriptions.set(userId, {
    subscription,
    createdAt: Date.now(),
    lastUsed: Date.now()
  });

  console.log('[PushNotifications] Subscription saved for user:', userId);
  return true;
}

/**
 * Get subscription for user
 */
export function getSubscription(userId) {
  const data = subscriptions.get(userId);
  return data ? data.subscription : null;
}

/**
 * Remove subscription
 */
export function removeSubscription(userId) {
  const removed = subscriptions.delete(userId);
  console.log('[PushNotifications] Subscription removed:', userId);
  return removed;
}

/**
 * Get all subscriptions
 */
export function getAllSubscriptions() {
  return Array.from(subscriptions.values()).map(data => data.subscription);
}

/**
 * Send push notification to specific user
 */
export async function sendNotificationToUser(userId, payload) {
  const subscription = getSubscription(userId);

  if (!subscription) {
    throw new Error('No subscription found for user');
  }

  try {
    const result = await webpush.sendNotification(
      subscription,
      JSON.stringify(payload)
    );

    // Update last used timestamp
    const data = subscriptions.get(userId);
    if (data) {
      data.lastUsed = Date.now();
    }

    console.log('[PushNotifications] Notification sent to:', userId);
    return result;
  } catch (error) {
    console.error('[PushNotifications] Send error:', error);

    // Remove invalid subscriptions
    if (error.statusCode === 410 || error.statusCode === 404) {
      removeSubscription(userId);
    }

    throw error;
  }
}

/**
 * Send push notification to all subscribed users
 */
export async function sendNotificationToAll(payload) {
  const allSubscriptions = getAllSubscriptions();

  if (allSubscriptions.length === 0) {
    console.warn('[PushNotifications] No subscriptions to send to');
    return { sent: 0, failed: 0 };
  }

  const results = {
    sent: 0,
    failed: 0,
    errors: []
  };

  const promises = allSubscriptions.map(async (subscription) => {
    try {
      await webpush.sendNotification(
        subscription,
        JSON.stringify(payload)
      );
      results.sent++;
    } catch (error) {
      results.failed++;
      results.errors.push(error.message);
    }
  });

  await Promise.all(promises);

  console.log('[PushNotifications] Broadcast complete:', results);
  return results;
}

/**
 * Send price alert notification
 */
export async function sendPriceAlert(userId, watchData, priceChange) {
  const payload = {
    title: 'Price Alert!',
    body: `${watchData.model}: ${priceChange > 0 ? '+' : ''}${priceChange}%`,
    icon: '/icons/icon-192x192.svg',
    badge: '/icons/icon-72x72.svg',
    tag: `price-alert-${watchData.id}`,
    requireInteraction: true,
    data: {
      url: `/pwa-index.html?watch=${watchData.id}`,
      watchId: watchData.id,
      change: priceChange
    },
    actions: [
      { action: 'view', title: 'View Details' },
      { action: 'dismiss', title: 'Dismiss' }
    ]
  };

  return sendNotificationToUser(userId, payload);
}

/**
 * Send new watch notification
 */
export async function sendNewWatchNotification(watchData) {
  const payload = {
    title: 'New Watch Added',
    body: `${watchData.series}: ${watchData.model}`,
    icon: '/icons/icon-192x192.svg',
    badge: '/icons/icon-72x72.svg',
    tag: `new-watch-${watchData.id}`,
    data: {
      url: `/pwa-index.html?watch=${watchData.id}`,
      watchId: watchData.id
    }
  };

  return sendNotificationToAll(payload);
}

/**
 * Send trending watch notification
 */
export async function sendTrendingNotification(watchData, reason) {
  const payload = {
    title: 'Trending Watch',
    body: `${watchData.model} is trending! ${reason}`,
    icon: '/icons/icon-192x192.svg',
    badge: '/icons/icon-72x72.svg',
    tag: `trending-${watchData.id}`,
    data: {
      url: `/pwa-index.html?watch=${watchData.id}`,
      watchId: watchData.id
    }
  };

  return sendNotificationToAll(payload);
}

/**
 * Send custom notification
 */
export async function sendCustomNotification(userId, title, body, options = {}) {
  const payload = {
    title,
    body,
    icon: options.icon || '/icons/icon-192x192.svg',
    badge: options.badge || '/icons/icon-72x72.svg',
    tag: options.tag || 'custom-notification',
    data: options.data || {},
    ...options
  };

  if (userId === 'all') {
    return sendNotificationToAll(payload);
  } else {
    return sendNotificationToUser(userId, payload);
  }
}

/**
 * Get subscription statistics
 */
export function getSubscriptionStats() {
  const now = Date.now();
  const oneDay = 24 * 60 * 60 * 1000;
  const oneWeek = 7 * oneDay;

  let active = 0;
  let inactive = 0;

  for (const [userId, data] of subscriptions.entries()) {
    const daysSinceLastUsed = (now - data.lastUsed) / oneDay;

    if (daysSinceLastUsed <= 7) {
      active++;
    } else {
      inactive++;
    }
  }

  return {
    total: subscriptions.size,
    active,
    inactive,
    subscriptions: Array.from(subscriptions.keys())
  };
}

/**
 * Clean up old subscriptions
 */
export function cleanupOldSubscriptions(daysOld = 90) {
  const cutoff = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
  let removed = 0;

  for (const [userId, data] of subscriptions.entries()) {
    if (data.lastUsed < cutoff) {
      subscriptions.delete(userId);
      removed++;
    }
  }

  console.log(`[PushNotifications] Cleaned up ${removed} old subscriptions`);
  return removed;
}

/**
 * Generate VAPID keys (utility function)
 */
export function generateVapidKeys() {
  const keys = webpush.generateVAPIDKeys();
  return {
    publicKey: keys.publicKey,
    privateKey: keys.privateKey
  };
}

export default {
  saveSubscription,
  getSubscription,
  removeSubscription,
  getAllSubscriptions,
  sendNotificationToUser,
  sendNotificationToAll,
  sendPriceAlert,
  sendNewWatchNotification,
  sendTrendingNotification,
  sendCustomNotification,
  getSubscriptionStats,
  cleanupOldSubscriptions,
  generateVapidKeys,
  VAPID_PUBLIC_KEY
};