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
Service Worker Registration
The client registers a service worker that handles push notifications in the background
User Subscription
When user enables notifications, the browser creates a push subscription with a unique endpoint
Event Triggers
When events occur (workout completion, achievement unlock), the backend sends push notifications
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:Generate VAPID keys using:
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
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);
}
Related Features
- AI Workouts - Workout completion triggers notifications
- Progress Tracking - Achievement unlocks send notifications
- Nutrition Plans - Future: Meal reminder notifications