Files
maternal-app/pwa_web_push_implementation_plan.md
Andrei 9b31d56c1d
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
Backend CI/CD Pipeline / Lint and Test Backend (push) Has been cancelled
Backend CI/CD Pipeline / E2E Tests Backend (push) Has been cancelled
Backend CI/CD Pipeline / Build Backend Application (push) Has been cancelled
Backend CI/CD Pipeline / Performance Testing (push) Has been cancelled
feat: Add persistent global notification settings for admin panel
Implement database-backed notification settings that persist across restarts:

Backend changes:
- Updated DashboardService to read/write notification settings from database
- Added 6 notification settings keys to dbSettingsMap:
  * enable_email_notifications (boolean)
  * enable_push_notifications (boolean)
  * admin_notifications (boolean)
  * error_alerts (boolean)
  * new_user_alerts (boolean)
  * system_health_alerts (boolean)
- Settings are now retrieved from database with fallback defaults

Database:
- Seeded 6 default notification settings in settings table
- All notification toggles default to 'true'
- Settings persist across server restarts

Frontend:
- Admin settings page at /settings already configured
- Notifications tab contains all 6 toggle switches
- Settings are loaded from GET /api/v1/admin/dashboard/settings
- Settings are saved via POST /api/v1/admin/dashboard/settings

API Endpoints (already existed, now enhanced):
- GET /api/v1/admin/dashboard/settings - Returns all settings including notifications
- POST /api/v1/admin/dashboard/settings - Persists notification settings to database

Files modified:
- maternal-app-backend/src/modules/admin/dashboard/dashboard.service.ts

Benefits:
 Global notification settings are now persistent
 Admin can control email/push notifications globally
 Admin can configure alert preferences
 Settings survive server restarts and deployments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 23:09:13 +00:00

27 KiB

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):

# VAPID Configuration
VAPID_PUBLIC_KEY=<generated-public-key>
VAPID_PRIVATE_KEY=<generated-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

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

-- 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

// 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

// 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

// 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<PushSubscription>,
  ) {}

  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<PushSubscription[]> {
    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

// 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

// 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

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<PushSubscription | null>(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

'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<string | null>(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 (
      <Alert severity="warning">
        Push notifications are not supported in your browser.
      </Alert>
    );
  }

  return (
    <Box>
      <Typography variant="h6">Push Notifications</Typography>
      <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
        Receive real-time notifications about activity reminders, family updates, and more.
      </Typography>

      {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}

      <Button
        variant={isSubscribed ? 'outlined' : 'contained'}
        onClick={handleToggle}
      >
        {isSubscribed ? 'Disable Notifications' : 'Enable Notifications'}
      </Button>

      {isSubscribed && (
        <Typography variant="caption" color="success.main" sx={{ display: 'block', mt: 1 }}>
           Notifications enabled
        </Typography>
      )}
    </Box>
  );
}

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

// 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:

PUSH_NOTIFICATIONS_ENABLED=true
VAPID_SUBJECT=mailto:dev@parentflow.com

Production:

PUSH_NOTIFICATIONS_ENABLED=true
VAPID_SUBJECT=mailto:hello@parentflow.com

Feature Flag

Use existing settings system:

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

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 for:

  • Complete architecture overview
  • API reference
  • Testing guide
  • Deployment checklist
  • Troubleshooting guide

References