# PWA Web Push Notifications — ParentFlow Implementation Plan **Goal:** Implement Web Push Notifications for the ParentFlow web app using our existing NestJS backend with local VAPID (no Firebase initially). Enable real-time notifications for activity reminders, family updates, and AI assistant responses. **Status:** ✅ **COMPLETED** - Backend + Frontend Implementation Done **Updated:** October 8, 2025 **Tech Stack:** NestJS + Next.js + PostgreSQL + Redis --- ## Overview This plan adapts the generic PWA push implementation to ParentFlow's specific architecture: - **Backend**: NestJS (TypeScript) instead of FastAPI/Python - **Frontend**: Next.js web app with Service Worker - **Database**: PostgreSQL (existing) - **Cache/Queue**: Redis (existing) - **Notifications Library**: `web-push` npm package for VAPID - **Mobile Apps**: React Native with Expo Notifications (separate implementation) --- ## Phase 0 — Foundations & Setup (0.5 day) ### Tech Decisions ✅ **Backend**: NestJS (existing) with new `notifications` module ✅ **Frontend**: Next.js web app (existing at `maternal-web/`) ✅ **Push Protocol**: Web Push API with VAPID (Voluntary Application Server Identification) ✅ **Storage**: PostgreSQL with new `push_subscriptions` table ✅ **Queue**: Redis (existing) for async notification dispatch ✅ **Libraries**: - Backend: `web-push` npm package - Frontend: Native Web Push API + Service Worker ### Environment Variables Add to `.env` (backend): ```bash # VAPID Configuration VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:hello@parentflow.com # Push Notification Settings PUSH_NOTIFICATIONS_ENABLED=true PUSH_DEFAULT_TTL=86400 # 24 hours PUSH_BATCH_SIZE=100 ``` ### Generate VAPID Keys ```bash cd maternal-app-backend npx web-push generate-vapid-keys # Output: # Public Key: BN... # Private Key: ... # Save to .env file ``` ### Deliverables - ✅ VAPID keys generated and stored securely - ✅ Environment variables configured - ✅ Decision log updated --- ## Phase 1 — Database Schema (0.5 day) ### Migration: `CreatePushSubscriptionsTable` **File**: `maternal-app-backend/src/database/migrations/XXX-CreatePushSubscriptions.ts` ```sql -- Push subscriptions table CREATE TABLE push_subscriptions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, -- encryption key auth TEXT NOT NULL, -- auth secret user_agent TEXT, device_type VARCHAR(20), -- 'desktop', 'mobile', 'tablet' browser VARCHAR(50), is_active BOOLEAN DEFAULT true, last_error TEXT, failed_attempts INTEGER DEFAULT 0, last_success_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT unique_endpoint UNIQUE(endpoint) ); CREATE INDEX idx_push_subs_user_id ON push_subscriptions(user_id); CREATE INDEX idx_push_subs_active ON push_subscriptions(is_active) WHERE is_active = true; -- Notification queue table CREATE TABLE notification_queue ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id VARCHAR(20) NOT NULL REFERENCES users(id), notification_type VARCHAR(50) NOT NULL, -- 'activity_reminder', 'family_update', 'ai_response' title VARCHAR(255) NOT NULL, body TEXT NOT NULL, icon_url TEXT, badge_url TEXT, action_url TEXT, data JSONB, priority VARCHAR(20) DEFAULT 'normal', -- 'low', 'normal', 'high', 'urgent' status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'sent', 'failed', 'expired' scheduled_at TIMESTAMP DEFAULT NOW(), sent_at TIMESTAMP, expires_at TIMESTAMP, error_message TEXT, retry_count INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_notif_queue_status ON notification_queue(status) WHERE status = 'pending'; CREATE INDEX idx_notif_queue_user ON notification_queue(user_id); CREATE INDEX idx_notif_queue_scheduled ON notification_queue(scheduled_at); ``` --- ## Phase 2 — Backend: Push Subscriptions Module (1 day) ### Module Structure ``` src/modules/push-notifications/ ├── push-notifications.module.ts ├── push-notifications.service.ts ├── push-notifications.controller.ts ├── push-subscriptions.service.ts ├── entities/ │ ├── push-subscription.entity.ts │ └── notification-queue.entity.ts └── dto/ ├── subscribe.dto.ts ├── send-notification.dto.ts └── notification-payload.dto.ts ``` ### Entity: `PushSubscription` ```typescript // push-subscription.entity.ts import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; import { User } from '../../database/entities/user.entity'; @Entity('push_subscriptions') export class PushSubscription { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'user_id', type: 'varchar', length: 20 }) userId: string; @ManyToOne(() => User) @JoinColumn({ name: 'user_id' }) user: User; @Column({ type: 'text' }) endpoint: string; @Column({ type: 'text' }) p256dh: string; @Column({ type: 'text' }) auth: string; @Column({ name: 'user_agent', type: 'text', nullable: true }) userAgent: string; @Column({ name: 'device_type', type: 'varchar', length: 20, nullable: true }) deviceType: string; @Column({ type: 'varchar', length: 50, nullable: true }) browser: string; @Column({ name: 'is_active', type: 'boolean', default: true }) isActive: boolean; @Column({ name: 'last_error', type: 'text', nullable: true }) lastError: string; @Column({ name: 'failed_attempts', type: 'int', default: 0 }) failedAttempts: number; @Column({ name: 'last_success_at', type: 'timestamp', nullable: true }) lastSuccessAt: Date; @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) createdAt: Date; @Column({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) updatedAt: Date; } ``` ### Controller: Subscription Management ```typescript // push-notifications.controller.ts import { Controller, Post, Delete, Get, Body, Param, UseGuards, Request } from '@nestjs/common'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { PushSubscriptionsService } from './push-subscriptions.service'; import { SubscribeDto } from './dto/subscribe.dto'; @Controller('api/v1/push') @UseGuards(JwtAuthGuard) export class PushNotificationsController { constructor(private readonly subscriptionsService: PushSubscriptionsService) {} @Post('subscribe') async subscribe(@Body() dto: SubscribeDto, @Request() req) { const userId = req.user.id; return this.subscriptionsService.subscribe(userId, dto); } @Delete('unsubscribe/:endpoint') async unsubscribe(@Param('endpoint') endpoint: string, @Request() req) { const userId = req.user.id; return this.subscriptionsService.unsubscribe(userId, endpoint); } @Get('subscriptions') async getSubscriptions(@Request() req) { const userId = req.user.id; return this.subscriptionsService.getUserSubscriptions(userId); } @Get('public-key') async getPublicKey() { return { publicKey: process.env.VAPID_PUBLIC_KEY }; } } ``` ### Service: Push Subscriptions ```typescript // push-subscriptions.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PushSubscription } from './entities/push-subscription.entity'; import { SubscribeDto } from './dto/subscribe.dto'; @Injectable() export class PushSubscriptionsService { constructor( @InjectRepository(PushSubscription) private readonly subscriptionRepo: Repository, ) {} async subscribe(userId: string, dto: SubscribeDto) { // Parse user agent to detect device/browser const deviceInfo = this.parseUserAgent(dto.userAgent); // Upsert by endpoint const existing = await this.subscriptionRepo.findOne({ where: { endpoint: dto.endpoint }, }); if (existing) { existing.userId = userId; existing.p256dh = dto.keys.p256dh; existing.auth = dto.keys.auth; existing.userAgent = dto.userAgent; existing.deviceType = deviceInfo.deviceType; existing.browser = deviceInfo.browser; existing.isActive = true; existing.failedAttempts = 0; existing.lastError = null; existing.updatedAt = new Date(); return this.subscriptionRepo.save(existing); } return this.subscriptionRepo.save({ userId, endpoint: dto.endpoint, p256dh: dto.keys.p256dh, auth: dto.keys.auth, userAgent: dto.userAgent, deviceType: deviceInfo.deviceType, browser: deviceInfo.browser, }); } async unsubscribe(userId: string, endpoint: string) { await this.subscriptionRepo.update( { userId, endpoint }, { isActive: false, updatedAt: new Date() }, ); return { success: true }; } async getUserSubscriptions(userId: string) { return this.subscriptionRepo.find({ where: { userId, isActive: true }, select: ['id', 'endpoint', 'deviceType', 'browser', 'createdAt', 'lastSuccessAt'], }); } async getActiveSubscriptions(userId: string): Promise { return this.subscriptionRepo.find({ where: { userId, isActive: true }, }); } async markDeliverySuccess(subscriptionId: string) { await this.subscriptionRepo.update(subscriptionId, { lastSuccessAt: new Date(), failedAttempts: 0, lastError: null, }); } async markDeliveryFailure(subscriptionId: string, error: string) { const subscription = await this.subscriptionRepo.findOne({ where: { id: subscriptionId }, }); if (!subscription) return; const failedAttempts = subscription.failedAttempts + 1; const updates: any = { failedAttempts, lastError: error, }; // Deactivate after 3 failed attempts or on 404/410 if (failedAttempts >= 3 || error.includes('404') || error.includes('410')) { updates.isActive = false; } await this.subscriptionRepo.update(subscriptionId, updates); } private parseUserAgent(ua: string): { deviceType: string; browser: string } { // Simple UA parsing (consider using `ua-parser-js` for production) const isMobile = /mobile/i.test(ua); const isTablet = /tablet|ipad/i.test(ua); let browser = 'unknown'; if (/chrome/i.test(ua)) browser = 'chrome'; else if (/firefox/i.test(ua)) browser = 'firefox'; else if (/safari/i.test(ua)) browser = 'safari'; else if (/edge/i.test(ua)) browser = 'edge'; return { deviceType: isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop', browser, }; } } ``` --- ## Phase 3 — Backend: Push Notification Sender (1 day) ### Service: Notification Dispatcher ```typescript // push-notifications.service.ts import { Injectable, Logger } from '@nestjs/common'; import * as webpush from 'web-push'; import { PushSubscriptionsService } from './push-subscriptions.service'; import { SendNotificationDto } from './dto/send-notification.dto'; @Injectable() export class PushNotificationsService { private readonly logger = new Logger(PushNotificationsService.name); constructor(private readonly subscriptionsService: PushSubscriptionsService) { // Configure web-push with VAPID keys webpush.setVapidDetails( process.env.VAPID_SUBJECT, process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY, ); } async sendToUser(userId: string, notification: SendNotificationDto) { const subscriptions = await this.subscriptionsService.getActiveSubscriptions(userId); if (subscriptions.length === 0) { this.logger.warn(`No active push subscriptions for user ${userId}`); return { sent: 0, failed: 0 }; } const payload = JSON.stringify({ title: notification.title, body: notification.body, icon: notification.icon || '/icons/icon-192x192.png', badge: notification.badge || '/icons/badge-72x72.png', tag: notification.tag, data: notification.data, requireInteraction: notification.requireInteraction || false, }); const results = await Promise.allSettled( subscriptions.map(sub => this.sendToSubscription(sub, payload)), ); const sent = results.filter(r => r.status === 'fulfilled').length; const failed = results.filter(r => r.status === 'rejected').length; this.logger.log(`Sent notifications to user ${userId}: ${sent} sent, ${failed} failed`); return { sent, failed }; } private async sendToSubscription(subscription: PushSubscription, payload: string) { try { await webpush.sendNotification( { endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth, }, }, payload, { TTL: parseInt(process.env.PUSH_DEFAULT_TTL || '86400'), vapidDetails: { subject: process.env.VAPID_SUBJECT, publicKey: process.env.VAPID_PUBLIC_KEY, privateKey: process.env.VAPID_PRIVATE_KEY, }, }, ); await this.subscriptionsService.markDeliverySuccess(subscription.id); } catch (error) { this.logger.error( `Failed to send notification to subscription ${subscription.id}: ${error.message}`, ); await this.subscriptionsService.markDeliveryFailure( subscription.id, error.message, ); throw error; } } // Batch send to multiple users async sendToUsers(userIds: string[], notification: SendNotificationDto) { const results = await Promise.allSettled( userIds.map(userId => this.sendToUser(userId, notification)), ); return { total: userIds.length, results: results.map((r, i) => ({ userId: userIds[i], status: r.status, data: r.status === 'fulfilled' ? r.value : null, error: r.status === 'rejected' ? r.reason.message : null, })), }; } } ``` --- ## Phase 4 — Frontend: Service Worker & Subscription (1 day) ### Service Worker Registration **File**: `maternal-web/public/sw.js` ```javascript // Service Worker for Push Notifications self.addEventListener('push', (event) => { console.log('[SW] Push received:', event); const data = event.data ? event.data.json() : {}; const title = data.title || 'ParentFlow'; const options = { body: data.body || '', icon: data.icon || '/icons/icon-192x192.png', badge: data.badge || '/icons/badge-72x72.png', tag: data.tag || 'default', data: data.data || {}, requireInteraction: data.requireInteraction || false, actions: data.actions || [], vibrate: [200, 100, 200], }; event.waitUntil( self.registration.showNotification(title, options) ); }); self.addEventListener('notificationclick', (event) => { console.log('[SW] Notification clicked:', event); event.notification.close(); const url = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }) .then((clientList) => { // Focus existing window if available for (const client of clientList) { if (client.url === url && 'focus' in client) { return client.focus(); } } // Open new window if (clients.openWindow) { return clients.openWindow(url); } }) ); }); self.addEventListener('notificationclose', (event) => { console.log('[SW] Notification closed:', event); }); ``` ### Push Subscription Hook **File**: `maternal-web/hooks/usePushNotifications.ts` ```typescript import { useState, useEffect } from 'react'; import apiClient from '@/lib/api-client'; export function usePushNotifications() { const [isSupported, setIsSupported] = useState(false); const [isSubscribed, setIsSubscribed] = useState(false); const [subscription, setSubscription] = useState(null); useEffect(() => { setIsSupported( 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window ); }, []); const subscribe = async () => { if (!isSupported) { throw new Error('Push notifications not supported'); } // Request notification permission const permission = await Notification.requestPermission(); if (permission !== 'granted') { throw new Error('Notification permission denied'); } // Register service worker const registration = await navigator.serviceWorker.register('/sw.js'); await navigator.serviceWorker.ready; // Get VAPID public key const { data } = await apiClient.get('/api/v1/push/public-key'); const publicKey = data.publicKey; // Subscribe to push const pushSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey), }); // Send subscription to backend await apiClient.post('/api/v1/push/subscribe', { endpoint: pushSubscription.endpoint, keys: { p256dh: arrayBufferToBase64(pushSubscription.getKey('p256dh')), auth: arrayBufferToBase64(pushSubscription.getKey('auth')), }, userAgent: navigator.userAgent, }); setSubscription(pushSubscription); setIsSubscribed(true); return pushSubscription; }; const unsubscribe = async () => { if (!subscription) return; await subscription.unsubscribe(); await apiClient.delete(`/api/v1/push/unsubscribe/${encodeURIComponent(subscription.endpoint)}`); setSubscription(null); setIsSubscribed(false); }; return { isSupported, isSubscribed, subscribe, unsubscribe, }; } // Helper functions function urlBase64ToUint8Array(base64String: string): Uint8Array { 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; } function arrayBufferToBase64(buffer: ArrayBuffer | null): string { if (!buffer) return ''; const bytes = new Uint8Array(buffer); let binary = ''; bytes.forEach(b => binary += String.fromCharCode(b)); return window.btoa(binary); } ``` ### UI Component: Notification Settings **File**: `maternal-web/components/NotificationSettings.tsx` ```typescript 'use client'; import { usePushNotifications } from '@/hooks/usePushNotifications'; import { Button, Alert, Box, Typography } from '@mui/material'; export function NotificationSettings() { const { isSupported, isSubscribed, subscribe, unsubscribe } = usePushNotifications(); const [error, setError] = useState(null); const handleToggle = async () => { try { setError(null); if (isSubscribed) { await unsubscribe(); } else { await subscribe(); } } catch (err: any) { setError(err.message || 'Failed to update notification settings'); } }; if (!isSupported) { return ( Push notifications are not supported in your browser. ); } return ( Push Notifications Receive real-time notifications about activity reminders, family updates, and more. {error && {error}} {isSubscribed && ( ✓ Notifications enabled )} ); } ``` --- ## Phase 5 — Integration with Existing Features (1 day) ### Use Cases 1. **Activity Reminders** - "Feeding due in 30 minutes" - "Nap time reminder" 2. **Family Updates** - "Dad logged a feeding" - "New photo added by Grandma" 3. **AI Assistant Responses** - "Your AI assistant has a new suggestion" 4. **System Notifications** - "Weekly report ready" - "Invite accepted" ### Example: Activity Reminder ```typescript // activities.service.ts import { PushNotificationsService } from '../push-notifications/push-notifications.service'; @Injectable() export class ActivitiesService { constructor( private readonly pushService: PushNotificationsService, ) {} async scheduleReminder(activity: Activity) { // Calculate next feeding time (3 hours) const nextFeedingTime = new Date(activity.startedAt.getTime() + 3 * 60 * 60 * 1000); // Schedule notification await this.pushService.sendToUser(activity.loggedBy, { title: 'Feeding Reminder', body: `Next feeding for ${activity.child.name} is due`, icon: '/icons/feeding.png', tag: `activity-reminder-${activity.id}`, data: { url: `/children/${activity.childId}`, activityId: activity.id, }, }); } } ``` --- ## Phase 6 — Testing & Validation (0.5 day) ### Test Checklist - [ ] VAPID keys generated and configured - [ ] Service worker registers successfully - [ ] Permission request works on Chrome desktop - [ ] Permission request works on Chrome Android - [ ] Permission request works on Safari iOS (PWA) - [ ] Subscription saved to database - [ ] Notification appears with correct title/body - [ ] Notification click navigates to correct URL - [ ] Multiple devices per user supported - [ ] Failed delivery deactivates subscription after 3 attempts - [ ] 404/410 responses immediately deactivate subscription --- ## Phase 7 — Deployment & Rollout (0.5 day) ### Environment-Specific Configuration **Development:** ```bash PUSH_NOTIFICATIONS_ENABLED=true VAPID_SUBJECT=mailto:dev@parentflow.com ``` **Production:** ```bash PUSH_NOTIFICATIONS_ENABLED=true VAPID_SUBJECT=mailto:hello@parentflow.com ``` ### Feature Flag Use existing settings system: ```sql INSERT INTO settings (key, value, type, description) VALUES ('push_notifications_enabled', 'true', 'boolean', 'Enable web push notifications'); ``` ### Monitoring Add metrics to admin dashboard: - Total active subscriptions - Notifications sent (last 24h) - Success rate - Failed subscriptions --- ## Phase 8 — Future Enhancements 1. **Notification Preferences** - Per-notification-type toggles - Quiet hours - Do Not Disturb mode 2. **Rich Notifications** - Action buttons - Images - Progress indicators 3. **Firebase Cloud Messaging (FCM)** - Add FCM as alternative provider - Auto-fallback for better delivery 4. **Analytics** - Open rates - Click-through rates - Conversion tracking --- ## Acceptance Criteria ✅ Users can subscribe to push notifications from web app ✅ Notifications appear within 3 seconds of sending ✅ Failed endpoints are auto-deactivated ✅ Multiple devices per user supported ✅ HTTPS enforced (required for Web Push) ✅ No VAPID keys in logs or client-side code ✅ Admin dashboard shows push metrics --- ## Estimated Timeline **Total: 5 days** - Phase 0: Setup (0.5 day) - Phase 1: Database (0.5 day) - Phase 2: Backend subscriptions (1 day) - Phase 3: Backend sender (1 day) - Phase 4: Frontend implementation (1 day) - Phase 5: Integration (1 day) - Phase 6: Testing (0.5 day) - Phase 7: Deployment (0.5 day) --- --- ## ✅ IMPLEMENTATION SUMMARY (October 8, 2025) ### What Was Implemented #### Backend (NestJS) ✅ **Database Schema** - `push_subscriptions` table already exists in production ✅ **TypeORM Entity** - `src/database/entities/push-subscription.entity.ts` ✅ **Push Service** - `src/modules/push/push.service.ts` with full VAPID integration ✅ **Push Controller** - `src/modules/push/push.controller.ts` with REST endpoints ✅ **Push Module** - `src/modules/push/push.module.ts` integrated into AppModule ✅ **Notifications Integration** - Auto-sends push when creating notifications ✅ **VAPID Keys** - Generated and configured in `.env` **API Endpoints Created:** - `GET /api/v1/push/vapid-public-key` - Get VAPID public key - `POST /api/v1/push/subscriptions` - Subscribe to push notifications - `GET /api/v1/push/subscriptions` - List user subscriptions - `DELETE /api/v1/push/subscriptions` - Unsubscribe - `POST /api/v1/push/test` - Send test notification - `GET /api/v1/push/statistics` - Get push statistics #### Frontend (Next.js) ✅ **Service Worker** - `public/push-sw.js` for handling push events ✅ **Push Utilities** - `lib/push-notifications.ts` with full API client ✅ **UI Component** - `components/PushNotificationToggle.tsx` with toggle switch ✅ **Browser Support** - Chrome, Firefox, Edge, Safari (iOS 16.4+ PWA) **Key Features:** - No Firebase/OneSignal dependency (pure Web Push/VAPID) - Automatic error handling (404/410 auto-deactivates) - Multi-device support per user - Device type and browser tracking - Statistics and monitoring built-in - Auto-cleanup of inactive subscriptions ### Environment Configuration ```ini PUSH_NOTIFICATIONS_ENABLED=true VAPID_PUBLIC_KEY=BErlB-L0pDfv1q3W0SHs3ZXqyFi869OScpt5wJ2aNu2KKbLxLj4a-YO6SyuAamjRG_cqY65yt2agyXdMdy2wEXI VAPID_PRIVATE_KEY=Rg47clL1z4wSpsBTx4yIOIHHX9qh1W5TyBZwBfPIesk VAPID_SUBJECT=mailto:hello@parentflow.com PUSH_DEFAULT_TTL=86400 PUSH_BATCH_SIZE=100 ``` ### Integration with Existing Features The push notification system automatically sends notifications for: - ✅ Feeding reminders (based on patterns) - ✅ Sleep reminders (nap time suggestions) - ✅ Diaper change reminders - ✅ Medication reminders - ✅ Growth tracking reminders - ✅ Milestone alerts - ✅ Pattern anomalies ### Testing Status ✅ Backend compilation successful (0 errors) ✅ Backend running on port 3020 ✅ Service Worker created ✅ UI component created ⏳ End-to-end testing pending ⏳ Multi-device testing pending ⏳ Browser compatibility testing pending ### Next Steps 1. **Add Settings Persistence** - Store notification preferences in database 2. **Test End-to-End Flow** - Enable push in web app and verify 3. **Production Deployment** - Generate production VAPID keys 4. **Monitoring Setup** - Configure error tracking and analytics 5. **Rate Limiting** - Add rate limits to push endpoints ### Documentation See [PUSH_NOTIFICATIONS_IMPLEMENTATION.md](PUSH_NOTIFICATIONS_IMPLEMENTATION.md) for: - Complete architecture overview - API reference - Testing guide - Deployment checklist - Troubleshooting guide --- ## References - [Web Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) - [web-push npm package](https://www.npmjs.com/package/web-push) - [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) - [VAPID Specification](https://tools.ietf.org/html/rfc8292)