Skip to main content

Overview

FitAiid implements Web Push Notifications using Service Workers and the Web Push Protocol to keep users engaged with real-time updates about their fitness journey.

Features

Workout Completion

Notifications when users complete a training session

Achievement Unlocked

Alerts when users unlock new achievements

Streak Milestones

Special notifications for 7-day, 30-day, and 100-day streaks

Inactivity Reminders

Gentle reminders when users haven’t trained in 3+ days

Architecture

1

Service Worker Registration

The client registers a service worker that handles push notifications in the background
2

User Subscription

When user enables notifications, the browser creates a push subscription with a unique endpoint
3

Backend Storage

The subscription is stored in MongoDB linked to the user account
4

Event Triggers

When events occur (workout completion, achievement unlock), the backend sends push notifications
5

Notification Display

The service worker receives the push and displays the notification to the user

Frontend Implementation

Service Worker Registration

async function initializeServiceWorker() {
  console.log('Initializing Service Worker...');
  
  if ('serviceWorker' in navigator) {
    try {
      const swPath = '../service-worker.js';
      const registration = await navigator.serviceWorker.register(swPath, {
        scope: '../'
      });
      console.log('Service Worker registered successfully:', registration);
      return registration;
    } catch (error) {
      console.error('Error registering Service Worker:', error);
      return null;
    }
  } else {
    console.warn('Service Worker not supported in this browser');
    return null;
  }
}

// Register SW when script loads
let swRegistration = null;
initializeServiceWorker().then(reg => {
  swRegistration = reg;
});

Enabling Push Notifications

async function enablePushNotifications() {
  try {
    console.log('Starting subscription process...');

    // Step 1: Use registered Service Worker
    let registration = swRegistration;
    if (!registration) {
      registration = await initializeServiceWorker();
      swRegistration = registration;
    }
    
    if (!registration) {
      throw new Error('Could not register Service Worker');
    }

    // Step 2: Request permissions
    const permission = await Notification.requestPermission();

    if (permission !== 'granted') {
      throw new Error('Notification permission denied by user');
    }

    // Step 3: Get VAPID public key from server
    const response = await fetch(`${CONFIG.API_URL}/api/notifications/vapid-public-key`);
    const { publicKey } = await response.json();

    // Step 4: Create push subscription
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(publicKey)
    });

    // Step 5: Save subscription to server
    const token = localStorage.getItem('token') || localStorage.getItem('authToken');
    const userId = localStorage.getItem('userId');

    const saveResponse = await fetch(`${CONFIG.API_URL}/api/notifications/subscribe`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': token ? `Bearer ${token}` : ''
      },
      body: JSON.stringify({
        subscription,
        userId: userId
      })
    });

    const result = await saveResponse.json();

    if (result.success) {
      console.log('Notifications activated successfully!');
      return true;
    }

    throw new Error(result.error || 'Unknown error saving subscription');

  } catch (error) {
    console.error('Error in activation process:', error);
    alert('Error activating notifications: ' + error.message);
    return false;
  }
}

VAPID Key Conversion Utility

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');

  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

Checking Subscription Status

async function checkPushSubscription(timeoutMs = 10000) {
  console.log('Checking if subscription is active...');

  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    return false;
  }

  try {
    let registration = swRegistration;
    
    if (!registration) {
      const registrationPromise = navigator.serviceWorker.ready;
      const timeoutPromise = new Promise((_, reject) =>
        setTimeout(() => reject(new Error('Timeout waiting for Service Worker')), timeoutMs)
      );

      registration = await Promise.race([registrationPromise, timeoutPromise]);
    }

    const subscription = await registration.pushManager.getSubscription();
    const isSubscribed = subscription !== null;

    console.log('Subscription status:', isSubscribed ? 'SUBSCRIBED' : 'NOT SUBSCRIBED');

    return isSubscribed;
  } catch (error) {
    console.error('Error checking subscription:', error.message);
    return false;
  }
}

Backend Implementation

Notification Routes

const express = require('express');
const router = express.Router();
const notificationService = require('../services/notificationService');
const notificationTriggers = require('../services/notificationTriggers');

// Get VAPID public key
router.get('/vapid-public-key', catchAsync((req, res) => {
  if (!process.env.VAPID_PUBLIC_KEY) {
    throw new AppError('VAPID keys not configured on server', 500);
  }
  
  res.json({ 
    publicKey: process.env.VAPID_PUBLIC_KEY 
  });
}));

// Save subscription
router.post('/subscribe', catchAsync(async (req, res) => {
  const { subscription } = req.body;
  
  if (!subscription) {
    throw new AppError('Missing subscription object in body', 400);
  }
  
  const userId = req.user?.id || req.body.userId;
  
  if (!userId) {
    throw new AppError('Authentication required or userId in body', 400);
  }
  
  // Save or update subscription
  const savedSubscription = await PushSubscription.findOneAndUpdate(
    { endpoint: subscription.endpoint },
    {
      userId,
      endpoint: subscription.endpoint,
      keys: subscription.keys
    },
    { upsert: true, new: true }
  );
  
  // Update notificationsEnabled flag in User
  const User = require('../models/User');
  await User.findByIdAndUpdate(userId, {
    notificationsEnabled: true
  });
  
  res.status(201).json({ 
    success: true,
    message: 'Subscription saved successfully',
    notificationsEnabled: true
  });
}));

// Send manual notification
router.post('/send', catchAsync(async (req, res) => {
  const { userId, title, body, url } = req.body;
  
  if (!userId || !title || !body) {
    throw new AppError('Missing required parameters: userId, title, body', 400);
  }
  
  const payload = {
    title,
    body,
    icon: '/logo192.png',
    badge: '/badge.png',
    data: { 
      url: url || '/', 
      timestamp: Date.now() 
    }
  };
  
  const result = await notificationService.sendNotificationToUser(userId, payload);
  
  if (!result.success) {
    throw new AppError(result.message || 'Error sending notification', 404);
  }
  
  res.json({ 
    success: true,
    message: `Notification sent to ${result.sent || 1} device(s)` 
  });
}));

module.exports = router;

Notification Service

const webpush = require('../config/webpush');
const PushSubscription = require('../models/PushSubscription');

class NotificationService {
  
  async sendNotificationToUser(userId, payload) {
    try {
      console.log(`Sending notification to user: ${userId}`);
      
      // Get all user subscriptions
      const subscriptions = await PushSubscription.find({ userId });
      
      if (subscriptions.length === 0) {
        console.log(`User ${userId} has no active subscriptions`);
        return { success: false, message: 'No subscription found' };
      }
      
      const notificationPayload = JSON.stringify(payload);
      
      // Send to all user devices
      const results = await Promise.allSettled(
        subscriptions.map(async (sub) => {
          try {
            await webpush.sendNotification({
              endpoint: sub.endpoint,
              keys: sub.keys
            }, notificationPayload);
            
            return { success: true, subscriptionId: sub._id };
            
          } catch (error) {
            // If subscription expired (410 Gone), delete it
            if (error.statusCode === 410) {
              await PushSubscription.deleteOne({ _id: sub._id });
            }
            
            return { success: false, subscriptionId: sub._id, error: error.message };
          }
        })
      );
      
      const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
      
      return { 
        success: successful > 0, 
        sent: successful, 
        total: subscriptions.length 
      };
      
    } catch (error) {
      console.error('Error in sendNotificationToUser:', error);
      return { success: false, error: error.message };
    }
  }

  async notifyEntrenamientoCompletado(userId, entrenamiento) {
    const payload = {
      title: '💪 Workout Completed!',
      body: `You completed ${entrenamiento.nombre}. Duration: ${entrenamiento.duracion} min`,
      icon: '/logo192.png',
      badge: '/badge.png',
      tag: 'entrenamiento-completado',
      data: {
        type: 'entrenamiento_completado',
        entrenamientoId: entrenamiento.id || entrenamiento._id,
        duracion: entrenamiento.duracion,
        calorias: entrenamiento.calorias,
        url: '/historial',
        timestamp: Date.now()
      },
      actions: [
        { action: 'ver', title: 'View Statistics' },
        { action: 'siguiente', title: 'Next Workout' }
      ]
    };

    return await this.sendNotificationToUser(userId, payload);
  }

  async notifyLogroDesbloqueado(userId, logro) {
    const payload = {
      title: '🏆 New Achievement Unlocked!',
      body: `${logro.nombre}: ${logro.descripcion}`,
      icon: logro.icono || '/logo192.png',
      badge: '/badge.png',
      tag: 'logro-desbloqueado',
      vibrate: [200, 100, 200, 100, 200],
      requireInteraction: true,
      data: {
        type: 'logro_desbloqueado',
        logroId: logro.id || logro._id,
        url: '/logros',
        timestamp: Date.now()
      },
      actions: [
        { action: 'ver', title: 'View Achievement' },
        { action: 'compartir', title: 'Share' }
      ]
    };

    return await this.sendNotificationToUser(userId, payload);
  }

  async notifyNuevaDiaRacha(userId, diasRacha) {
    let emoji = '🔥';
    let mensaje = `${diasRacha} consecutive days!`;
    
    // Special messages for important milestones
    if (diasRacha === 7) {
      emoji = '⭐';
      mensaje = '1 complete week! Keep it up';
    } else if (diasRacha === 30) {
      emoji = '🎉';
      mensaje = '1 consecutive month! You\'re amazing';
    } else if (diasRacha === 100) {
      emoji = '👑';
      mensaje = '100 days! You\'re a legend!';
    }

    const payload = {
      title: `${emoji} Active Streak!`,
      body: mensaje,
      icon: '/logo192.png',
      badge: '/badge.png',
      tag: 'racha-activa',
      requireInteraction: true,
      vibrate: [300, 100, 300],
      data: {
        type: 'racha_activa',
        diasRacha: diasRacha,
        url: '/perfil',
        timestamp: Date.now()
      },
      actions: [
        { action: 'ver', title: 'View Streak' },
        { action: 'compartir', title: 'Share' }
      ]
    };

    return await this.sendNotificationToUser(userId, payload);
  }

  async notifyRecordatorioInactividad(userId, diasInactivo) {
    const mensajes = [
      {
        dias: 3,
        title: '👋 We miss you!',
        body: '3 days have passed. Ready for your next workout?'
      },
      {
        dias: 5,
        title: '💙 Come back to train',
        body: 'Your progress is waiting. Don\'t lose your streak!'
      },
      {
        dias: 7,
        title: '🎯 A week without seeing you',
        body: 'Your goals are still here. Let\'s achieve them together!'
      },
      {
        dias: 14,
        title: '⏰ Time to come back!',
        body: '2 weeks have passed. Resume your workouts today.'
      }
    ];

    const mensajeData = mensajes.reverse().find(m => diasInactivo >= m.dias) || mensajes[0];

    const payload = {
      title: mensajeData.title,
      body: mensajeData.body,
      icon: '/logo192.png',
      badge: '/badge.png',
      tag: 'recordatorio-inactividad',
      requireInteraction: true,
      data: {
        type: 'recordatorio_inactividad',
        diasInactivo: diasInactivo,
        url: '/',
        timestamp: Date.now()
      },
      actions: [
        { action: 'entrenar', title: 'Train Now' },
        { action: 'close', title: 'Later' }
      ]
    };

    return await this.sendNotificationToUser(userId, payload);
  }
}

module.exports = new NotificationService();

Notification Triggers

Automatic notification triggers are integrated into workout completion:
// After workout registration
try {
  await notificationTriggers.onEntrenamientoCompletado(userId, {
    id: workoutProgress._id,
    nombre: workoutData.nombre || 'Workout',
    duracion: workoutData.duracionTotal || 45,
    calorias: workoutData.caloriasEstimadas || 300,
    tipo: workoutData.enfoque || 'General'
  });
  
  console.log('Notifications processed successfully');
} catch (errorNotif) {
  console.error('Error sending notifications (non-critical):', errorNotif.message);
}

Database Model

const mongoose = require('mongoose');

const pushSubscriptionSchema = new mongoose.Schema({
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true,
    index: true
  },
  endpoint: {
    type: String,
    required: true,
    unique: true
  },
  keys: {
    p256dh: {
      type: String,
      required: true
    },
    auth: {
      type: String,
      required: true
    }
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('PushSubscription', pushSubscriptionSchema);

VAPID Configuration

const webpush = require('web-push');

// Set VAPID details
webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

module.exports = webpush;
Environment Variables Required:
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
Generate VAPID keys using:
npx web-push generate-vapid-keys

Notification Types

Workout Completed

Sent immediately after completing all exercises in a workout session

Achievement Unlocked

Triggered when user reaches milestones (first workout, 10 workouts, 50 workouts)

Streak Milestone

Special notifications for 7-day, 30-day, and 100-day streaks

Inactivity Reminder

Scheduled notifications after 3, 5, 7, and 14 days of inactivity

API Endpoints

Get VAPID Public Key

GET /api/notifications/vapid-public-key

Subscribe to Notifications

POST /api/notifications/subscribe
Authorization: Bearer {token}
Content-Type: application/json

{
  "userId": "507f1f77bcf86cd799439011",
  "subscription": {
    "endpoint": "https://fcm.googleapis.com/fcm/send/...",
    "keys": {
      "p256dh": "BEl...",
      "auth": "k8J..."
    }
  }
}

Check Notification Status

GET /api/notifications/check-enabled/{userId}
Authorization: Bearer {token}
The system automatically cleans up expired subscriptions (HTTP 410 Gone responses) to maintain database efficiency.

Security Considerations

Rate Limiting: The frontend implements client-side rate limiting to prevent excessive server requests (30-second minimum interval).
const NOTIFICATION_CHECK_INTERVAL = 30000; // 30 seconds
User Validation: All notification endpoints validate that users can only manage their own subscriptions:
const userId = req.user?.id || req.body.userId;
if (!userId) {
  throw new AppError('Authentication required', 400);
}

Build docs developers (and LLMs) love