From b84271231bce9c3d6f2b315c64edda4c4116d3ea Mon Sep 17 00:00:00 2001 From: Andrei Date: Wed, 8 Oct 2025 22:31:19 +0000 Subject: [PATCH] feat: Initialize Web Push notifications infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of PWA push notifications implementation: - Generated VAPID keys for Web Push API - Created database schema (push_subscriptions, notification_queue) - Created TypeORM entities for push management - Installed web-push npm package - Created PushNotifications module skeleton - Updated implementation plan for ParentFlow architecture Database tables: - push_subscriptions: Store user push endpoints with VAPID keys - notification_queue: Queue notifications for async delivery Next steps: - Implement services (subscription management, notification sender) - Create REST API endpoints - Build Service Worker and frontend integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../maternal-app-backend/package-lock.json | 96 ++ .../maternal-app-backend/package.json | 1 + .../entities/notification-queue.entity.ts | 71 ++ .../entities/push-subscription.entity.ts | 61 ++ ...409000000-CreatePushNotificationsTables.ts | 86 ++ .../push-notifications.module.ts | 15 + pwa_web_push_implementation_plan.md | 858 ++++++++++++++++++ 7 files changed, 1188 insertions(+) create mode 100644 maternal-app/maternal-app-backend/src/database/entities/notification-queue.entity.ts create mode 100644 maternal-app/maternal-app-backend/src/database/entities/push-subscription.entity.ts create mode 100644 maternal-app/maternal-app-backend/src/database/migrations/1728409000000-CreatePushNotificationsTables.ts create mode 100644 maternal-app/maternal-app-backend/src/modules/push-notifications/push-notifications.module.ts create mode 100644 pwa_web_push_implementation_plan.md diff --git a/maternal-app/maternal-app-backend/package-lock.json b/maternal-app/maternal-app-backend/package-lock.json index d50db5a..92755ca 100644 --- a/maternal-app/maternal-app-backend/package-lock.json +++ b/maternal-app/maternal-app-backend/package-lock.json @@ -73,6 +73,7 @@ "socket.io": "^4.8.1", "typeorm": "^0.3.27", "uuid": "^13.0.0", + "web-push": "^3.6.7", "winston": "^3.18.3" }, "devDependencies": { @@ -7059,6 +7060,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -7278,6 +7288,18 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", @@ -7588,6 +7610,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -10534,6 +10562,15 @@ "dev": true, "license": "MIT" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -10550,6 +10587,19 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -12584,6 +12634,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -16458,6 +16514,46 @@ "@zxing/text-encoding": "0.9.0" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/web-push/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/maternal-app/maternal-app-backend/package.json b/maternal-app/maternal-app-backend/package.json index 853a553..0856792 100644 --- a/maternal-app/maternal-app-backend/package.json +++ b/maternal-app/maternal-app-backend/package.json @@ -85,6 +85,7 @@ "socket.io": "^4.8.1", "typeorm": "^0.3.27", "uuid": "^13.0.0", + "web-push": "^3.6.7", "winston": "^3.18.3" }, "devDependencies": { diff --git a/maternal-app/maternal-app-backend/src/database/entities/notification-queue.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/notification-queue.entity.ts new file mode 100644 index 0000000..eebc644 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/entities/notification-queue.entity.ts @@ -0,0 +1,71 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('notification_queue') +@Index(['userId']) +@Index(['status']) +@Index(['scheduledAt']) +export class NotificationQueue { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'varchar', length: 20 }) + userId: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'notification_type', type: 'varchar', length: 50 }) + notificationType: string; + + @Column({ type: 'varchar', length: 255 }) + title: string; + + @Column({ type: 'text' }) + body: string; + + @Column({ name: 'icon_url', type: 'text', nullable: true }) + iconUrl: string; + + @Column({ name: 'badge_url', type: 'text', nullable: true }) + badgeUrl: string; + + @Column({ name: 'action_url', type: 'text', nullable: true }) + actionUrl: string; + + @Column({ type: 'jsonb', nullable: true }) + data: Record; + + @Column({ type: 'varchar', length: 20, default: 'normal' }) + priority: string; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: string; + + @Column({ name: 'scheduled_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + scheduledAt: Date; + + @Column({ name: 'sent_at', type: 'timestamp', nullable: true }) + sentAt: Date; + + @Column({ name: 'expires_at', type: 'timestamp', nullable: true }) + expiresAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'retry_count', type: 'int', default: 0 }) + retryCount: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/maternal-app/maternal-app-backend/src/database/entities/push-subscription.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/push-subscription.entity.ts new file mode 100644 index 0000000..8ca390f --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/entities/push-subscription.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('push_subscriptions') +@Index(['userId']) +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', unique: true }) + 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; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/maternal-app/maternal-app-backend/src/database/migrations/1728409000000-CreatePushNotificationsTables.ts b/maternal-app/maternal-app-backend/src/database/migrations/1728409000000-CreatePushNotificationsTables.ts new file mode 100644 index 0000000..8649d12 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/1728409000000-CreatePushNotificationsTables.ts @@ -0,0 +1,86 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreatePushNotificationsTables1728409000000 implements MigrationInterface { + name = 'CreatePushNotificationsTables1728409000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Create push_subscriptions table + await queryRunner.query(` + 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, + auth TEXT NOT NULL, + user_agent TEXT, + device_type VARCHAR(20), + 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 indexes for push_subscriptions + await queryRunner.query(` + CREATE INDEX idx_push_subs_user_id ON push_subscriptions(user_id); + `); + + await queryRunner.query(` + CREATE INDEX idx_push_subs_active ON push_subscriptions(is_active) WHERE is_active = true; + `); + + // Create notification_queue table + await queryRunner.query(` + CREATE TABLE notification_queue ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notification_type VARCHAR(50) NOT NULL, + 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', + status VARCHAR(20) DEFAULT 'pending', + 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 indexes for notification_queue + await queryRunner.query(` + CREATE INDEX idx_notif_queue_status ON notification_queue(status) WHERE status = 'pending'; + `); + + await queryRunner.query(` + CREATE INDEX idx_notif_queue_user ON notification_queue(user_id); + `); + + await queryRunner.query(` + CREATE INDEX idx_notif_queue_scheduled ON notification_queue(scheduled_at); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop indexes + await queryRunner.query(`DROP INDEX IF EXISTS idx_notif_queue_scheduled;`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_notif_queue_user;`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_notif_queue_status;`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_push_subs_active;`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_push_subs_user_id;`); + + // Drop tables + await queryRunner.query(`DROP TABLE IF EXISTS notification_queue;`); + await queryRunner.query(`DROP TABLE IF EXISTS push_subscriptions;`); + } +} diff --git a/maternal-app/maternal-app-backend/src/modules/push-notifications/push-notifications.module.ts b/maternal-app/maternal-app-backend/src/modules/push-notifications/push-notifications.module.ts new file mode 100644 index 0000000..8714753 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/push-notifications/push-notifications.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PushSubscription } from '../../database/entities/push-subscription.entity'; +import { NotificationQueue } from '../../database/entities/notification-queue.entity'; +import { PushNotificationsController } from './push-notifications.controller'; +import { PushNotificationsService } from './push-notifications.service'; +import { PushSubscriptionsService } from './push-subscriptions.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([PushSubscription, NotificationQueue])], + controllers: [PushNotificationsController], + providers: [PushNotificationsService, PushSubscriptionsService], + exports: [PushNotificationsService, PushSubscriptionsService], +}) +export class PushNotificationsModule {} diff --git a/pwa_web_push_implementation_plan.md b/pwa_web_push_implementation_plan.md new file mode 100644 index 0000000..80096eb --- /dev/null +++ b/pwa_web_push_implementation_plan.md @@ -0,0 +1,858 @@ +# 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. + +**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) + +--- + +## 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)