feat: Initialize Web Push notifications infrastructure
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
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
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 <noreply@anthropic.com>
This commit is contained in:
96
maternal-app/maternal-app-backend/package-lock.json
generated
96
maternal-app/maternal-app-backend/package-lock.json
generated
@@ -73,6 +73,7 @@
|
|||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"typeorm": "^0.3.27",
|
"typeorm": "^0.3.27",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"winston": "^3.18.3"
|
"winston": "^3.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -7059,6 +7060,15 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -7278,6 +7288,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/asn1js": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz",
|
||||||
@@ -7588,6 +7610,12 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||||
@@ -10534,6 +10562,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
@@ -10550,6 +10587,19 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/human-signals": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
@@ -12584,6 +12634,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
@@ -16458,6 +16514,46 @@
|
|||||||
"@zxing/text-encoding": "0.9.0"
|
"@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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"typeorm": "^0.3.27",
|
"typeorm": "^0.3.27",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"winston": "^3.18.3"
|
"winston": "^3.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreatePushNotificationsTables1728409000000 implements MigrationInterface {
|
||||||
|
name = 'CreatePushNotificationsTables1728409000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
// 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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
858
pwa_web_push_implementation_plan.md
Normal file
858
pwa_web_push_implementation_plan.md
Normal file
@@ -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=<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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
Reference in New Issue
Block a user