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

948 lines
27 KiB
Markdown

# 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=<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
```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<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
```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<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`
```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<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
```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)