Push Notifications

Stacks.js Push provides a unified API for sending push notifications to web browsers, iOS, and Android devices.

Configuration

Push Configuration

// config/push.ts
export default {
  // Default provider
  default: 'firebase',

  providers: {
    // Firebase Cloud Messaging (Android, iOS, Web)
    firebase: {
      projectId: process.env.FIREBASE_PROJECT_ID,
      privateKey: process.env.FIREBASE_PRIVATE_KEY,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
    },

    // Apple Push Notification Service (iOS)
    apns: {
      keyId: process.env.APNS_KEY_ID,
      teamId: process.env.APNS_TEAM_ID,
      privateKey: process.env.APNS_PRIVATE_KEY,
      production: process.env.NODE_ENV === 'production',
    },

    // Web Push (Browsers)
    webPush: {
      publicKey: process.env.VAPID_PUBLIC_KEY,
      privateKey: process.env.VAPID_PRIVATE_KEY,
      subject: 'mailto:admin@example.com',
    },
  },

  // Default notification settings
  defaults: {
    ttl: 86400, // 24 hours
    priority: 'high',
    sound: 'default',
  },

  // Queue settings
  queue: {
    enabled: true,
    connection: 'redis',
    batch: 1000, // Send in batches of 1000
  },
}

Device Registration

Registering Devices

import { Push } from '@stacksjs/push'

// Register device token
await Push.registerDevice({
  userId: user.id,
  token: deviceToken,
  platform: 'ios', // 'ios' | 'android' | 'web'
  deviceId: uniqueDeviceId,
  metadata: {
    appVersion: '1.0.0',
    osVersion: '17.0',
    model: 'iPhone 15',
  },
})

// Unregister device
await Push.unregisterDevice(deviceToken)

// Get user's devices
const devices = await Push.getDevices(userId)

Web Push Subscription

// Frontend - Subscribe to web push
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  })

  // Send to backend
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
    headers: { 'Content-Type': 'application/json' },
  })
}

// Backend - Store subscription
router.post('/push/subscribe', async (request) => {
  const subscription = request.body

  await Push.registerDevice({
    userId: request.user.id,
    token: JSON.stringify(subscription),
    platform: 'web',
    deviceId: subscription.endpoint,
  })

  return { success: true }
})

iOS Registration

// iOS - AppDelegate.swift
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()

    // Send to your backend
    API.registerDevice(token: token, platform: "ios")
}

Android Registration

// Android - FirebaseMessagingService
class MyFirebaseMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        // Send to your backend
        API.registerDevice(token, "android")
    }
}

Sending Notifications

Basic Notification

import { Push } from '@stacksjs/push'

// Send to single user
await Push.send(userId, {
  title: 'New Message',
  body: 'You have a new message from John',
  icon: '/icons/notification.png',
  data: {
    type: 'message',
    messageId: '123',
  },
})

// Send to multiple users
await Push.sendToMany([userId1, userId2, userId3], {
  title: 'Announcement',
  body: 'Important system update',
})

// Send to all users
await Push.sendToAll({
  title: 'Maintenance Notice',
  body: 'System will be down for maintenance at midnight',
})

Platform-Specific Options

// With platform-specific options
await Push.send(userId, {
  title: 'Order Shipped',
  body: 'Your order #12345 has been shipped!',

  // iOS specific
  ios: {
    sound: 'notification.wav',
    badge: 5,
    category: 'ORDER_UPDATE',
    threadId: 'order-12345',
    contentAvailable: true,
    mutableContent: true,
    targetContentId: 'order-details',
  },

  // Android specific
  android: {
    channelId: 'orders',
    priority: 'high',
    ttl: 3600,
    collapseKey: 'order-updates',
    icon: 'ic_notification',
    color: '#4A90E2',
    clickAction: 'OPEN_ORDER',
    tag: 'order-12345',
  },

  // Web specific
  web: {
    icon: '/icons/order.png',
    badge: '/icons/badge.png',
    image: '/images/shipped.png',
    requireInteraction: true,
    actions: [
      { action: 'track', title: 'Track Package' },
      { action: 'dismiss', title: 'Dismiss' },
    ],
  },
})

Rich Notifications

// Notification with image
await Push.send(userId, {
  title: 'New Photo',
  body: 'John shared a photo with you',
  image: 'https://example.com/images/shared-photo.jpg',
})

// Notification with actions
await Push.send(userId, {
  title: 'Friend Request',
  body: 'Sarah wants to connect with you',
  actions: [
    { id: 'accept', title: 'Accept', icon: '/icons/check.png' },
    { id: 'decline', title: 'Decline', icon: '/icons/x.png' },
  ],
  data: {
    type: 'friend_request',
    fromUserId: '456',
  },
})

Silent/Background Notifications

// Silent notification (data only)
await Push.sendSilent(userId, {
  type: 'sync',
  lastSyncId: '12345',
})

// Content-available for iOS background refresh
await Push.send(userId, {
  ios: {
    contentAvailable: true,
  },
  data: {
    type: 'background_sync',
  },
})

Topics & Channels

Topic Subscriptions

// Subscribe user to topic
await Push.subscribeToTopic(userId, 'news')
await Push.subscribeToTopic(userId, 'promotions')

// Unsubscribe from topic
await Push.unsubscribeFromTopic(userId, 'promotions')

// Send to topic
await Push.sendToTopic('news', {
  title: 'Breaking News',
  body: 'Important update...',
})

// Get user's topics
const topics = await Push.getUserTopics(userId)

Android Notification Channels

// Define channels in config
// config/push.ts
export default {
  android: {
    channels: [
      {
        id: 'messages',
        name: 'Messages',
        description: 'Chat messages and conversations',
        importance: 'high',
        sound: 'message.wav',
        vibration: true,
        lights: true,
        lightColor: '#4A90E2',
      },
      {
        id: 'orders',
        name: 'Order Updates',
        description: 'Updates about your orders',
        importance: 'default',
      },
      {
        id: 'promotions',
        name: 'Promotions',
        description: 'Special offers and deals',
        importance: 'low',
      },
    ],
  },
}

// Send to specific channel
await Push.send(userId, {
  title: 'New Message',
  body: 'Hello!',
  android: {
    channelId: 'messages',
  },
})

Notification Service

Notification Model

// app/Models/Notification.ts
export default class Notification extends Model {
  static fields = {
    user_id: { type: 'foreignId', references: 'users' },
    type: { type: 'string' },
    title: { type: 'string' },
    body: { type: 'text' },
    data: { type: 'json', default: {} },
    read_at: { type: 'datetime', nullable: true },
    sent_at: { type: 'datetime', nullable: true },
    clicked_at: { type: 'datetime', nullable: true },
  }

  get isRead() {
    return this.read_at !== null
  }
}

Notification Classes

// app/Notifications/OrderShipped.ts
import { Notification } from '@stacksjs/notifications'

export class OrderShipped extends Notification {
  constructor(private order: Order) {
    super()
  }

  // Channels to send through
  via() {
    return ['push', 'database', 'mail']
  }

  // Push notification content
  toPush() {
    return {
      title: 'Order Shipped! 📦',
      body: `Your order #${this.order.number} is on its way`,
      data: {
        type: 'order_shipped',
        orderId: this.order.id,
      },
      ios: {
        badge: 1,
        sound: 'shipped.wav',
      },
      android: {
        channelId: 'orders',
      },
    }
  }

  // Database record
  toDatabase() {
    return {
      type: 'order_shipped',
      title: 'Order Shipped',
      body: `Your order #${this.order.number} has been shipped`,
      data: { orderId: this.order.id },
    }
  }

  // Email content
  toMail() {
    return new OrderShippedEmail(this.order)
  }
}

// Send notification
await user.notify(new OrderShipped(order))

Handling Notifications

Frontend Handler (Web)

// Service Worker - sw.js
self.addEventListener('push', (event) => {
  const data = event.data.json()

  const options = {
    body: data.body,
    icon: data.icon || '/icons/notification.png',
    badge: data.badge || '/icons/badge.png',
    image: data.image,
    data: data.data,
    actions: data.actions,
    requireInteraction: data.requireInteraction,
  }

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()

  const data = event.notification.data

  // Handle action button clicks
  if (event.action === 'accept') {
    // Handle accept action
  } else if (event.action === 'decline') {
    // Handle decline action
  } else {
    // Default click - open app
    event.waitUntil(
      clients.openWindow(`/notifications/${data.type}/${data.id}`)
    )
  }

  // Report click to backend
  fetch('/api/push/clicked', {
    method: 'POST',
    body: JSON.stringify({ notificationId: data.notificationId }),
  })
})

iOS Handler

// iOS - Handle notification tap
func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse,
                            withCompletionHandler completionHandler: @escaping () -> Void) {

    let userInfo = response.notification.request.content.userInfo

    if let type = userInfo["type"] as? String {
        switch type {
        case "message":
            navigateToMessage(userInfo["messageId"] as? String)
        case "order_shipped":
            navigateToOrder(userInfo["orderId"] as? String)
        default:
            break
        }
    }

    completionHandler()
}

Analytics

Tracking Delivery

// Track notification events
Push.onSent((notification, deviceCount) => {
  Analytics.track('notification_sent', {
    notificationId: notification.id,
    deviceCount,
  })
})

Push.onDelivered((notification, deviceToken) => {
  Analytics.track('notification_delivered', {
    notificationId: notification.id,
  })
})

Push.onClicked((notification, action) => {
  Analytics.track('notification_clicked', {
    notificationId: notification.id,
    action,
  })
})

Push.onDismissed((notification) => {
  Analytics.track('notification_dismissed', {
    notificationId: notification.id,
  })
})

Notification Stats

// Get notification statistics
const stats = await Push.getStats({
  from: startDate,
  to: endDate,
})

// {
//   sent: 10000,
//   delivered: 9500,
//   clicked: 2500,
//   clickRate: 0.263,
//   dismissed: 7000,
// }

// Stats by notification type
const byType = await Push.getStatsByType('order_shipped')

API Endpoints

// routes/api.ts
router.group({ prefix: '/push', middleware: ['auth'] }, () => {
  // Register device
  router.post('/register', async (request) => {
    const { token, platform, deviceId } = request.body

    await Push.registerDevice({
      userId: request.user.id,
      token,
      platform,
      deviceId,
    })

    return { success: true }
  })

  // Unregister device
  router.delete('/unregister', async (request) => {
    const { token } = request.body
    await Push.unregisterDevice(token)
    return { success: true }
  })

  // Get notification preferences
  router.get('/preferences', async (request) => {
    return await Push.getPreferences(request.user.id)
  })

  // Update preferences
  router.put('/preferences', async (request) => {
    await Push.updatePreferences(request.user.id, request.body)
    return { success: true }
  })

  // Subscribe to topic
  router.post('/topics/:topic/subscribe', async (request) => {
    await Push.subscribeToTopic(request.user.id, request.params.topic)
    return { success: true }
  })

  // Track click
  router.post('/clicked', async (request) => {
    const { notificationId } = request.body
    await Notification.where('id', notificationId).update({
      clicked_at: new Date(),
    })
    return { success: true }
  })
})

Best Practices

Notification Preferences

// Let users control notifications
const preferences = {
  enabled: true,
  categories: {
    messages: true,
    orders: true,
    promotions: false,
    marketing: false,
  },
  quietHours: {
    enabled: true,
    start: '22:00',
    end: '08:00',
    timezone: 'America/New_York',
  },
}

// Check before sending
async function shouldSendPush(userId, category) {
  const prefs = await Push.getPreferences(userId)

  if (!prefs.enabled) return false
  if (!prefs.categories[category]) return false

  if (prefs.quietHours.enabled) {
    const now = dayjs().tz(prefs.quietHours.timezone)
    const start = dayjs(prefs.quietHours.start, 'HH:mm')
    const end = dayjs(prefs.quietHours.end, 'HH:mm')

    if (now.isAfter(start) || now.isBefore(end)) {
      return false // In quiet hours
    }
  }

  return true
}

Rate Limiting

// Don't spam users
const limiter = new RateLimiter({
  key: `push:${userId}`,
  maxRequests: 10,
  windowMs: 3600000, // 1 hour
})

if (!await limiter.check()) {
  // Queue for later instead of dropping
  await Push.queue(userId, notification, { delay: 3600000 })
}

Batching

// Send to many users efficiently
await Push.sendToMany(userIds, notification, {
  batchSize: 1000,
  delay: 100, // ms between batches
})