feat: Implement comprehensive error handling and production deployment pipeline
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

## Error Handling System
- Add centralized error handling utilities (errorHandler.ts)
- Create reusable error components (ErrorMessage, ErrorToast)
- Implement multilingual error support (preserves backend error messages in 5 languages)
- Update 15+ forms and components with consistent error handling
  - Auth forms: login, register, forgot-password
  - Family management: family page, join family dialog
  - Child management: child dialog
  - All tracking forms: feeding, sleep, diaper, medicine, growth, activity

## Production Build Fixes
- Fix backend TypeScript errors: InviteCode.uses → InviteCode.useCount (5 instances)
- Remove non-existent savedFamily variable from registration response
- Fix admin panel TypeScript errors: SimpleMDE toolbar type, PieChart label type

## User Experience Improvements
- Auto-uppercase invite code and share code inputs
- Visual feedback for case conversion with helper text
- Improved form validation with error codes

## CI/CD Pipeline
- Create comprehensive production deployment checklist (PRODUCTION_DEPLOYMENT_CHECKLIST.md)
- Add automated pre-deployment check script (pre-deploy-check.sh)
  - Validates frontend, backend, and admin panel builds
  - Checks git status, branch, and sync state
  - Verifies environment files and migrations
- Add quick start deployment guide (DEPLOYMENT_QUICK_START.md)
- Add production deployment automation template (deploy-production.sh)

## Cleanup
- Remove outdated push notifications documentation files
- Remove outdated PWA implementation plan

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andrei
2025-10-09 21:27:39 +00:00
parent 40dbb2287a
commit c22fa82521
29 changed files with 1810 additions and 2130 deletions

219
DEPLOYMENT_QUICK_START.md Normal file
View File

@@ -0,0 +1,219 @@
# Production Deployment - Quick Start Guide
**Last Updated**: October 9, 2025
---
## 🚀 Quick Deployment (5 Minutes)
### On Development Server
```bash
cd /root/maternal-app
# 1. Run pre-deployment checks
./pre-deploy-check.sh
# 2. Commit and push (if checks pass)
git add .
git commit -m "feat: describe your changes"
git push origin main
```
### On Production Server
```bash
# SSH to production
ssh user@production-server
# Run automated deployment
cd /var/www/maternal-app
./deploy.sh
# Monitor logs
pm2 logs --lines 100
```
---
## 📋 Manual Deployment Steps
### Development Server (Pre-Deploy)
1. **Test Builds**
```bash
cd /root/maternal-app/maternal-web && npm run build
cd /root/maternal-app/maternal-app/maternal-app-backend && npm run build
```
2. **Commit Changes**
```bash
git add .
git commit -m "your message"
git push origin main
```
### Production Server (Deploy)
1. **Backup**
```bash
pg_dump -U postgres -d parentflowprod -F c -f /backup/db_$(date +%Y%m%d).dump
```
2. **Pull Code**
```bash
cd /var/www/maternal-app
git pull origin main
```
3. **Install & Build**
```bash
# Frontend
cd maternal-web
npm ci --production
npm run build
# Backend
cd ../maternal-app/maternal-app-backend
npm ci --production
npm run build
```
4. **Migrate Database**
```bash
cd /var/www/maternal-app/maternal-app/maternal-app-backend
npm run migration:run
```
5. **Restart Services**
```bash
pm2 restart all
pm2 status
```
6. **Verify**
```bash
curl http://localhost:3020/api/v1/health
curl http://localhost:3030
```
---
## ⚡ Emergency Rollback
```bash
cd /var/www/maternal-app
# 1. Rollback code
git log -5 --oneline
git reset --hard <previous-commit>
# 2. Restore database
pg_restore -U postgres -d parentflowprod -c /backup/db_YYYYMMDD.dump
# 3. Rebuild & restart
cd maternal-web && npm run build
cd ../maternal-app/maternal-app-backend && npm run build
pm2 restart all
```
---
## 🔍 Health Checks
```bash
# Backend
curl http://localhost:3020/api/v1/health
# Frontend
curl http://localhost:3030
# PM2 Status
pm2 status
pm2 logs --lines 50
# Database
psql -U postgres -d parentflowprod -c "SELECT COUNT(*) FROM users;"
```
---
## 📁 Important Paths
**Development Server**:
- App: `/root/maternal-app/`
- Database: `10.0.0.207:5432/parentflowdev`
- Scripts: `/root/maternal-app/*.sh`
**Production Server**:
- App: `/var/www/maternal-app/`
- Database: `localhost:5432/parentflowprod`
- Backups: `/backup/`
- Logs: `/var/log/maternal-app-deploy-*.log`
---
## 🛠️ Common Issues
**Build Fails**:
```bash
rm -rf .next node_modules/.cache dist
npm install
npm run build
```
**Migration Fails**:
```bash
# Check migration history
npm run migration:show
# Rollback last migration
npm run migration:revert
```
**Service Won't Start**:
```bash
pm2 stop all
pm2 delete all
pm2 start ecosystem.config.js
```
**Database Connection Issues**:
```bash
# Check PostgreSQL is running
systemctl status postgresql
# Check connections
psql -U postgres -c "SELECT count(*) FROM pg_stat_activity;"
```
---
## 📞 Support
- **Full Documentation**: See `PRODUCTION_DEPLOYMENT_CHECKLIST.md`
- **Pre-Deploy Script**: `./pre-deploy-check.sh`
- **Production Deploy**: Copy deploy script from checklist to production server
---
## ✅ Pre-Deployment Checklist (Quick)
- [ ] All builds pass locally
- [ ] Tests pass
- [ ] Database migrations created
- [ ] `.env` files reviewed
- [ ] Backup created
- [ ] Team notified (if major release)
## ✅ Post-Deployment Checklist (Quick)
- [ ] Health checks pass
- [ ] Login works
- [ ] Critical features tested
- [ ] No errors in logs
- [ ] PM2 processes healthy
---
**For detailed instructions, see `PRODUCTION_DEPLOYMENT_CHECKLIST.md`**

View File

@@ -0,0 +1,745 @@
# Production Deployment Checklist & CI/CD Pipeline
**Project**: Maternal App (ParentFlow)
**Last Updated**: October 9, 2025
**Environment**: Development → Production
---
## Table of Contents
1. [Pre-Deployment Checklist](#pre-deployment-checklist)
2. [Development Server Steps](#development-server-steps)
3. [Production Server Steps](#production-server-steps)
4. [Database Sync Strategy](#database-sync-strategy)
5. [Deployment Automation Scripts](#deployment-automation-scripts)
6. [Rollback Procedure](#rollback-procedure)
7. [Post-Deployment Verification](#post-deployment-verification)
---
## Pre-Deployment Checklist
### ✅ Code Quality & Testing
- [ ] All TypeScript compilation errors resolved
- [ ] All ESLint warnings resolved
- [ ] Frontend production build succeeds (`npm run build` in maternal-web)
- [ ] Backend production build succeeds (`npm run build` in maternal-app-backend)
- [ ] Admin panel production build succeeds (`npm run build` in parentflow-admin)
- [ ] All unit tests pass
- [ ] Critical user flows manually tested
- [ ] No console errors in browser
- [ ] API endpoints tested with Postman/curl
### ✅ Database
- [ ] All migrations created and tested locally
- [ ] Database schema documented
- [ ] Backup of production database created
- [ ] Migration rollback scripts ready
- [ ] Seed data scripts updated (if needed)
### ✅ Configuration
- [ ] Environment variables reviewed (`.env.production`)
- [ ] API URLs point to production endpoints
- [ ] Database connection strings verified
- [ ] Redis/cache configuration verified
- [ ] Email service configuration verified
- [ ] File upload paths/S3 buckets verified
- [ ] SSL certificates valid and not expiring soon
### ✅ Security
- [ ] No sensitive data in git history
- [ ] No API keys in source code
- [ ] Rate limiting configured
- [ ] CORS settings reviewed
- [ ] CSP headers configured
- [ ] Authentication flows tested
- [ ] Permission checks verified
### ✅ Performance
- [ ] Images optimized
- [ ] Code splitting implemented
- [ ] Lazy loading configured
- [ ] Database indexes reviewed
- [ ] Cache strategy implemented
- [ ] CDN configured (if applicable)
---
## Development Server Steps
**Location**: `/root/maternal-app/` on development server
### Step 1: Clean Production Build (Frontend)
```bash
cd /root/maternal-app/maternal-web
# Clean previous builds
rm -rf .next
rm -rf node_modules/.cache
# Production build
npm run build
# Check for errors
# Expected: "✓ Compiled successfully" message
# Expected: No TypeScript or ESLint errors
```
**Expected Output**:
```
✓ Compiled successfully
✓ Generating static pages (39/39)
Route (app) Size First Load JS
...
```
### Step 2: Production Build (Backend)
```bash
cd /root/maternal-app/maternal-app/maternal-app-backend
# Clean build directory
rm -rf dist
# Production build
npm run build
# Check for errors
# Expected: "Successfully compiled X files" message
```
**Expected Output**:
```
Successfully compiled XX files with swc
```
### Step 2.5: Production Build (Admin Panel)
```bash
cd /root/maternal-app/parentflow-admin
# Clean previous builds
rm -rf .next
rm -rf node_modules/.cache
# Production build
npm run build
# Check for errors
# Expected: "✓ Compiled successfully" message
```
**Expected Output**:
```
✓ Compiled successfully
✓ Generating static pages (13/13)
Route (app) Size First Load JS
...
```
### Step 3: Fix Any Build Errors
If errors occur:
1. **TypeScript errors**: Fix type issues, missing imports
2. **Dependency errors**: Run `npm install`
3. **Environment errors**: Check `.env` files
**Common Issues**:
- Missing `@types/*` packages
- Incorrect import paths
- Environment variable references
- Missing database entities/columns
### Step 4: Database Migration Dry Run
```bash
cd /root/maternal-app/maternal-app/maternal-app-backend
# Check pending migrations
npm run migration:show
# Generate SQL for review (don't run yet)
npm run migration:generate -- -n ReviewChanges
# Review generated migration file in src/database/migrations/
```
### Step 5: Commit to Git
```bash
cd /root/maternal-app
# Review changes
git status
git diff
# Stage changes
git add .
# Commit with descriptive message
git commit -m "feat: [Description of features/fixes]
- Feature 1 description
- Feature 2 description
- Bug fixes
- Database migrations: [List migration files]
Deployment: Ready for production"
# Push to repository
git push origin main
```
### Step 6: Tag Release
```bash
# Create version tag
git tag -a v1.x.x -m "Release v1.x.x - [Brief description]"
git push origin v1.x.x
```
---
## Production Server Steps
**Location**: Production server
### Step 1: Backup Current State
```bash
# Backup database
pg_dump -U postgres -d parentflowprod -F c -f /backup/parentflowprod_$(date +%Y%m%d_%H%M%S).dump
# Backup application directory
tar -czf /backup/maternal-app_$(date +%Y%m%d_%H%M%S).tar.gz /var/www/maternal-app
# Verify backups
ls -lh /backup/
```
### Step 2: Pull Latest Code
```bash
cd /var/www/maternal-app
# Stash any local changes (shouldn't be any)
git stash
# Pull latest code
git fetch origin
git pull origin main
# Or checkout specific tag
# git checkout v1.x.x
# Verify correct version
git log -1 --oneline
```
### Step 3: Install Dependencies
```bash
# Frontend
cd /var/www/maternal-app/maternal-web
npm ci --production
# Backend
cd /var/www/maternal-app/maternal-app/maternal-app-backend
npm ci --production
```
### Step 4: Run Database Migrations
```bash
cd /var/www/maternal-app/maternal-app/maternal-app-backend
# Check pending migrations
npm run migration:show
# Run migrations
npm run migration:run
# Verify migrations applied
npm run migration:show
```
### Step 5: Build Applications
```bash
# Build frontend
cd /var/www/maternal-app/maternal-web
npm run build
# Build backend
cd /var/www/maternal-app/maternal-app/maternal-app-backend
npm run build
```
### Step 6: Restart Services
```bash
# Restart backend (PM2)
pm2 restart maternal-app-backend
# Restart frontend (PM2)
pm2 restart maternal-web
# Or restart all
pm2 restart all
# Check status
pm2 status
pm2 logs --lines 50
```
### Step 7: Clear Caches
```bash
# Clear Redis cache
redis-cli FLUSHDB
# Clear Next.js cache (if needed)
cd /var/www/maternal-app/maternal-web
rm -rf .next/cache
```
---
## Database Sync Strategy
### Database Comparison Script
```bash
#!/bin/bash
# File: scripts/compare-databases.sh
DEV_DB="parentflowdev"
PROD_DB="parentflowprod"
DEV_HOST="10.0.0.207"
PROD_HOST="production-db-host"
echo "Comparing database schemas..."
# Export schemas
pg_dump -h $DEV_HOST -U postgres -d $DEV_DB --schema-only > /tmp/dev_schema.sql
pg_dump -h $PROD_HOST -U postgres -d $PROD_DB --schema-only > /tmp/prod_schema.sql
# Compare
diff /tmp/dev_schema.sql /tmp/prod_schema.sql > /tmp/schema_diff.txt
if [ -s /tmp/schema_diff.txt ]; then
echo "⚠️ Schemas differ! Review /tmp/schema_diff.txt"
cat /tmp/schema_diff.txt
else
echo "✅ Schemas are identical"
fi
```
### Migration Workflow
**Development → Production**:
1. **Create Migration** (Dev):
```bash
npm run migration:generate -- -n DescriptiveName
```
2. **Test Migration** (Dev):
```bash
npm run migration:run
npm run migration:revert # Test rollback
npm run migration:run # Re-apply
```
3. **Commit Migration** (Dev):
```bash
git add src/database/migrations/*
git commit -m "feat: Add [description] migration"
```
4. **Apply to Production** (Prod):
```bash
git pull origin main
npm run migration:run
```
### Manual Database Sync
If migrations are out of sync:
```sql
-- Check migration history
SELECT * FROM migrations ORDER BY executed_at DESC LIMIT 10;
-- Compare tables
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
-- Compare columns for specific table
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users'
ORDER BY ordinal_position;
```
---
## Deployment Automation Scripts
### Auto-Deploy Script (Production Server)
Create: `/var/www/maternal-app/deploy.sh`
```bash
#!/bin/bash
set -e # Exit on error
echo "======================================"
echo "🚀 Starting Production Deployment"
echo "======================================"
# Configuration
APP_DIR="/var/www/maternal-app"
BACKUP_DIR="/backup"
BRANCH="main"
LOG_FILE="/var/log/maternal-app-deploy-$(date +%Y%m%d_%H%M%S).log"
# Redirect output to log
exec 1> >(tee -a "$LOG_FILE")
exec 2>&1
echo "[$(date)] Deployment started"
# Step 1: Backup
echo "📦 Creating backup..."
pg_dump -U postgres -d parentflowprod -F c -f "$BACKUP_DIR/parentflowprod_$(date +%Y%m%d_%H%M%S).dump"
tar -czf "$BACKUP_DIR/maternal-app_$(date +%Y%m%d_%H%M%S).tar.gz" "$APP_DIR" --exclude node_modules --exclude .next --exclude dist
echo "✅ Backup complete"
# Step 2: Pull code
echo "📥 Pulling latest code..."
cd "$APP_DIR"
git stash
git fetch origin
git pull origin "$BRANCH"
COMMIT=$(git log -1 --oneline)
echo "✅ Updated to: $COMMIT"
# Step 3: Install dependencies
echo "📦 Installing dependencies..."
cd "$APP_DIR/maternal-web"
npm ci --production
cd "$APP_DIR/maternal-app/maternal-app-backend"
npm ci --production
echo "✅ Dependencies installed"
# Step 4: Run migrations
echo "🗄️ Running database migrations..."
cd "$APP_DIR/maternal-app/maternal-app-backend"
npm run migration:run
echo "✅ Migrations complete"
# Step 5: Build applications
echo "🔨 Building applications..."
cd "$APP_DIR/maternal-web"
npm run build
cd "$APP_DIR/maternal-app/maternal-app-backend"
npm run build
echo "✅ Build complete"
# Step 6: Restart services
echo "🔄 Restarting services..."
pm2 restart all
pm2 save
echo "✅ Services restarted"
# Step 7: Health check
echo "🏥 Running health checks..."
sleep 5
BACKEND_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3020/api/v1/health || echo "000")
FRONTEND_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3030 || echo "000")
if [ "$BACKEND_STATUS" = "200" ] && [ "$FRONTEND_STATUS" = "200" ]; then
echo "✅ Health checks passed"
echo "[$(date)] Deployment successful!"
echo "======================================"
echo "🎉 Deployment Complete"
echo "======================================"
else
echo "❌ Health checks failed! Backend: $BACKEND_STATUS, Frontend: $FRONTEND_STATUS"
echo "⚠️ Consider rollback if issues persist"
fi
# Send notification (optional)
# curl -X POST https://your-webhook-url -d "Deployment complete: $COMMIT"
```
Make executable:
```bash
chmod +x /var/www/maternal-app/deploy.sh
```
Usage:
```bash
/var/www/maternal-app/deploy.sh
```
### Pre-Deploy Check Script (Development Server)
Create: `/root/maternal-app/pre-deploy-check.sh`
```bash
#!/bin/bash
set -e
echo "======================================"
echo "🔍 Pre-Deployment Checks"
echo "======================================"
ERRORS=0
# Check 1: Frontend build
echo "Checking frontend build..."
cd /root/maternal-app/maternal-web
if npm run build; then
echo "✅ Frontend build successful"
else
echo "❌ Frontend build failed"
ERRORS=$((ERRORS + 1))
fi
# Check 2: Backend build
echo "Checking backend build..."
cd /root/maternal-app/maternal-app/maternal-app-backend
if npm run build; then
echo "✅ Backend build successful"
else
echo "❌ Backend build failed"
ERRORS=$((ERRORS + 1))
fi
# Check 3: Uncommitted changes
echo "Checking for uncommitted changes..."
cd /root/maternal-app
if [ -z "$(git status --porcelain)" ]; then
echo "✅ No uncommitted changes"
else
echo "⚠️ Uncommitted changes detected:"
git status --short
fi
# Check 4: Pending migrations
echo "Checking for pending migrations..."
cd /root/maternal-app/maternal-app/maternal-app-backend
PENDING=$(npm run migration:show 2>&1 | grep "pending" | wc -l)
if [ "$PENDING" -gt 0 ]; then
echo "⚠️ $PENDING pending migrations found"
else
echo "✅ No pending migrations"
fi
# Summary
echo "======================================"
if [ $ERRORS -eq 0 ]; then
echo "✅ All checks passed! Ready to deploy."
echo "Next steps:"
echo " 1. git add ."
echo " 2. git commit -m 'your message'"
echo " 3. git push origin main"
echo " 4. Run deploy.sh on production server"
else
echo "❌ $ERRORS check(s) failed. Fix issues before deploying."
exit 1
fi
echo "======================================"
```
Make executable:
```bash
chmod +x /root/maternal-app/pre-deploy-check.sh
```
Usage:
```bash
cd /root/maternal-app
./pre-deploy-check.sh
```
---
## Rollback Procedure
### Quick Rollback (if deployment fails)
```bash
# 1. Restore previous code
cd /var/www/maternal-app
git log -5 --oneline # Find previous commit
git reset --hard <previous-commit-hash>
# 2. Restore database (if migrations ran)
pg_restore -U postgres -d parentflowprod -c /backup/parentflowprod_YYYYMMDD_HHMMSS.dump
# 3. Rebuild
cd /var/www/maternal-app/maternal-web && npm run build
cd /var/www/maternal-app/maternal-app/maternal-app-backend && npm run build
# 4. Restart services
pm2 restart all
```
### Migration Rollback
```bash
cd /var/www/maternal-app/maternal-app/maternal-app-backend
# Rollback last migration
npm run migration:revert
# Rollback multiple migrations
npm run migration:revert # Repeat N times
```
---
## Post-Deployment Verification
### Checklist
- [ ] Application accessible at production URL
- [ ] Login functionality works
- [ ] API endpoints responding
- [ ] Database queries working
- [ ] File uploads working
- [ ] Email sending working
- [ ] WebSocket connections working
- [ ] No JavaScript errors in console
- [ ] PM2 processes healthy (`pm2 status`)
- [ ] Database connections stable
- [ ] SSL certificate valid
- [ ] Logs clean (no critical errors)
### Health Check Commands
```bash
# Backend health
curl https://api.maternal.noru1.ro/api/v1/health
# Frontend accessibility
curl https://maternal.noru1.ro
# Check PM2 status
pm2 status
pm2 logs --lines 100
# Check database connections
psql -U postgres -d parentflowprod -c "SELECT COUNT(*) FROM users;"
# Monitor logs
tail -f /var/log/maternal-app/*.log
pm2 logs --lines 100 --raw
```
### Monitoring
```bash
# CPU/Memory usage
pm2 monit
# Database size
psql -U postgres -c "SELECT pg_size_pretty(pg_database_size('parentflowprod'));"
# Active connections
psql -U postgres -d parentflowprod -c "SELECT count(*) FROM pg_stat_activity;"
```
---
## Deployment Frequency
**Recommended Schedule**:
- **Hotfixes**: As needed (critical bugs)
- **Minor Updates**: Weekly (Friday afternoons)
- **Major Releases**: Bi-weekly or monthly
- **Database Migrations**: Bundle with releases
**Best Practices**:
- Deploy during low-traffic hours
- Have team member available for 1 hour post-deployment
- Test in staging environment first (if available)
- Communicate deployment to users (if user-facing changes)
---
## Environment Variables
### Development (.env.local)
```env
DATABASE_HOST=10.0.0.207
DATABASE_NAME=parentflowdev
NODE_ENV=development
API_URL=http://localhost:3020
```
### Production (.env.production)
```env
DATABASE_HOST=production-db-host
DATABASE_NAME=parentflowprod
NODE_ENV=production
API_URL=https://api.maternal.noru1.ro
```
**Security Note**: Never commit `.env` files to git!
---
## Emergency Contacts
- **Developer**: [Your contact]
- **DevOps**: [DevOps contact]
- **Database Admin**: [DBA contact]
- **Server Access**: [Server details]
---
## Changelog Template
```markdown
# Release v1.x.x - YYYY-MM-DD
## New Features
- Feature 1
- Feature 2
## Improvements
- Improvement 1
- Improvement 2
## Bug Fixes
- Fix 1
- Fix 2
## Database Changes
- Migration 1: Description
- Migration 2: Description
## Breaking Changes
- None / List breaking changes
## Deployment Notes
- Special instructions if any
```
---
**Last Updated**: October 9, 2025
**Version**: 1.0
**Maintained By**: Development Team

View File

@@ -1,568 +0,0 @@
# Push Notifications Implementation Summary
**Status**: ✅ **COMPLETED** (Backend + Frontend Integration Ready)
**Date**: October 8, 2025
**Implementation Type**: Web Push (VAPID) - No Firebase/OneSignal dependency
---
## 🎯 Overview
We successfully implemented a **streamlined, fully local Web Push notification system** for ParentFlow using the Web Push Protocol with VAPID keys. This allows browser-based push notifications without relying on third-party services like Firebase or OneSignal.
---
## 📦 Backend Implementation
### 1. Database Schema ✅
**Table**: `push_subscriptions` (Already exists in production)
```sql
CREATE TABLE push_subscriptions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id varchar(20) NOT NULL,
endpoint text NOT NULL UNIQUE,
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()
);
```
**Indexes**:
- `idx_push_subs_user_id` on `user_id`
- `idx_push_subs_active` on `is_active` (WHERE is_active = true)
- `unique_endpoint` on `endpoint`
### 2. TypeORM Entity ✅
**File**: `src/database/entities/push-subscription.entity.ts`
Features:
- Relationship with User entity (CASCADE delete)
- Tracks device type, browser, and subscription health
- Automatic timestamps (created_at, updated_at)
### 3. Push Service ✅
**File**: `src/modules/push/push.service.ts`
**Key Features**:
- VAPID configuration from environment variables
- Subscribe/unsubscribe management
- Send push notifications to individual users or groups
- Automatic error handling (404/410 = deactivate, retries for 5xx)
- User agent parsing for device/browser detection
- Statistics and cleanup utilities
**Main Methods**:
```typescript
- getPublicVapidKey(): string
- subscribe(userId, subscriptionData, userAgent): PushSubscription
- unsubscribe(userId, endpoint): void
- sendToUser(userId, payload): {sent, failed}
- sendToUsers(userIds[], payload): {sent, failed}
- sendTestNotification(userId): void
- cleanupInactiveSubscriptions(daysOld): number
- getStatistics(userId?): Statistics
```
### 4. Push Controller ✅
**File**: `src/modules/push/push.controller.ts`
**REST API Endpoints**:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/push/vapid-public-key` | Get VAPID public key for frontend |
| POST | `/api/v1/push/subscriptions` | Subscribe to push notifications |
| GET | `/api/v1/push/subscriptions` | Get user's active subscriptions |
| DELETE | `/api/v1/push/subscriptions?endpoint=...` | Unsubscribe from push |
| POST | `/api/v1/push/test` | Send test notification |
| GET | `/api/v1/push/statistics` | Get push statistics |
**Authentication**: All endpoints require JWT authentication (`JwtAuthGuard`)
### 5. Push Module ✅
**File**: `src/modules/push/push.module.ts`
Wired into main `AppModule` and exports `PushService` for use by other modules.
### 6. Notifications Integration ✅
**Updated**: `src/modules/notifications/notifications.service.ts`
**Features**:
- Automatic push notification when creating notifications
- Intelligent URL routing based on notification type
- Smart notifications (feeding, sleep, diaper reminders) now trigger push
- Medication reminders trigger push
- Anomaly detection triggers push
**Integration Flow**:
```
createNotification()
→ Save to DB
→ sendPushNotification()
→ PushService.sendToUser()
→ markAsSent/markAsFailed
```
### 7. Environment Configuration ✅
**File**: `maternal-app-backend/.env`
```ini
# Push Notifications (Web Push - VAPID)
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
```
**Security**: Keep `VAPID_PRIVATE_KEY` secret. Never expose in logs or client-side code.
---
## 🌐 Frontend Implementation
### 1. Service Worker ✅
**File**: `maternal-web/public/push-sw.js`
**Features**:
- Listens for push events
- Shows notifications with custom icons, badges, and data
- Handles notification clicks (focus existing window or open new)
- Tracks notification dismissals
- Test notification support
**Event Handlers**:
- `push` - Receive and display notifications
- `notificationclick` - Handle user clicks, navigate to URLs
- `notificationclose` - Track dismissals
- `message` - Handle messages from the app
### 2. Push Utilities ✅
**File**: `maternal-web/lib/push-notifications.ts`
**Utility Functions**:
```typescript
- isPushNotificationSupported(): boolean
- getNotificationPermission(): NotificationPermission
- requestNotificationPermission(): Promise<NotificationPermission>
- getVapidPublicKey(token): Promise<string>
- registerPushServiceWorker(): Promise<ServiceWorkerRegistration>
- subscribeToPush(token): Promise<PushSubscription>
- savePushSubscription(subscription, token): Promise<void>
- getPushSubscription(): Promise<PushSubscription | null>
- unsubscribeFromPush(token): Promise<void>
- isPushSubscribed(): Promise<boolean>
- sendTestPushNotification(token): Promise<void>
- getPushStatistics(token): Promise<any>
- showLocalTestNotification(): Promise<void>
```
**Key Features**:
- Browser compatibility checks
- VAPID key base64 conversion
- Service worker registration
- Subscription management
- Backend API integration
### 3. UI Component ✅
**File**: `maternal-web/components/PushNotificationToggle.tsx`
**Features**:
- Toggle switch to enable/disable push notifications
- Permission status display
- Error handling and user feedback
- Test notification button
- Loading states
- Dark mode support
- Responsive design
**Component States**:
- Unsupported browser warning
- Permission denied message
- Subscribed confirmation with test button
- Loading indicator
**Usage**:
```tsx
import PushNotificationToggle from '@/components/PushNotificationToggle';
// In settings page
<PushNotificationToggle />
```
---
## 🔧 Testing Guide
### Backend Testing
1. **Get VAPID Public Key**:
```bash
curl http://localhost:3020/api/v1/push/vapid-public-key
```
2. **Subscribe** (requires auth token):
```bash
curl -X POST http://localhost:3020/api/v1/push/subscriptions \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "...",
"auth": "..."
}
}'
```
3. **Send Test Notification**:
```bash
curl -X POST http://localhost:3020/api/v1/push/test \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
4. **Get Statistics**:
```bash
curl http://localhost:3020/api/v1/push/statistics \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
### Frontend Testing
1. **Open Web App**: Navigate to `http://maternal.noru1.ro`
2. **Go to Settings**: Find the Push Notification Toggle component
3. **Enable Notifications**:
- Click the toggle switch
- Grant permission when prompted
- Wait for confirmation
4. **Test Notification**:
- Click "Send Test Notification" button
- Check browser notifications
5. **Test Full Flow**:
- Create a feeding/sleep/diaper activity
- Wait for reminder notification (based on patterns)
- Check notification appears
### Browser Compatibility Testing
Test on:
- ✅ Chrome (Desktop & Mobile)
- ✅ Firefox (Desktop & Mobile)
- ✅ Edge (Desktop)
- ✅ Safari (iOS 16.4+ PWA only - must be installed to home screen)
---
## 📊 Notification Flow
```
1. User Action (e.g., feeding activity)
2. NotificationsService detects pattern
3. createNotification() called
4. Notification saved to DB
5. sendPushNotification() triggered
6. PushService.sendToUser() sends to all user's devices
7. Web Push sends to browser
8. Service Worker receives push event
9. Service Worker shows notification
10. User clicks notification
11. Service Worker navigates to URL
```
---
## 🔒 Security Considerations
### VAPID Keys
- ✅ Private key stored in `.env` (never committed to git)
- ✅ Public key safe to expose to frontend
- ✅ Subject configured as `mailto:` contact email
### Authentication
- ✅ All push endpoints require JWT authentication
- ✅ Users can only manage their own subscriptions
- ✅ Endpoint validation prevents injection attacks
### Data Privacy
- ✅ Subscription endpoints hashed in logs
- ✅ User agent data stored for analytics only
- ✅ Inactive subscriptions auto-cleaned after 90 days
- ✅ Cascade delete when user is deleted
### Rate Limiting
- ⚠️ **TODO**: Add rate limiting to push endpoints
- Recommended: 10 requests/minute per user for subscribe/unsubscribe
- Recommended: 100 notifications/day per user
---
## 📈 Monitoring & Maintenance
### Database Cleanup
Run periodic cleanup (recommended: daily cron job):
```sql
-- Delete inactive subscriptions older than 90 days
DELETE FROM push_subscriptions
WHERE is_active = false
AND updated_at < NOW() - INTERVAL '90 days';
```
Or use the service method:
```typescript
await pushService.cleanupInactiveSubscriptions(90);
```
### Statistics Monitoring
```typescript
const stats = await pushService.getStatistics();
// Returns:
// {
// totalSubscriptions: 150,
// activeSubscriptions: 142,
// inactiveSubscriptions: 8,
// byBrowser: { chrome: 100, firefox: 30, safari: 12 },
// byDeviceType: { mobile: 90, desktop: 50, tablet: 10 }
// }
```
### Error Monitoring
Check push subscription errors:
```sql
SELECT user_id, endpoint, last_error, failed_attempts
FROM push_subscriptions
WHERE is_active = false
AND last_error IS NOT NULL
ORDER BY updated_at DESC
LIMIT 20;
```
---
## 🚀 Deployment Checklist
### Production Deployment
- [x] Generate production VAPID keys (`npx web-push generate-vapid-keys`)
- [x] Add VAPID keys to production `.env`
- [ ] Set `VAPID_SUBJECT` to production email (`mailto:support@parentflow.com`)
- [ ] Enable HTTPS (required for Web Push)
- [ ] Test on all major browsers
- [ ] Set up monitoring for failed push deliveries
- [ ] Configure rate limiting
- [ ] Set up cleanup cron job
- [ ] Test notification appearance on mobile devices
- [ ] Verify service worker registration on production domain
### Environment Variables (Production)
```ini
PUSH_NOTIFICATIONS_ENABLED=true
VAPID_PUBLIC_KEY=<production-public-key>
VAPID_PRIVATE_KEY=<production-private-key>
VAPID_SUBJECT=mailto:support@parentflow.com
PUSH_DEFAULT_TTL=86400
PUSH_BATCH_SIZE=100
```
---
## 🎨 Customization Guide
### Notification Appearance
Edit in `maternal-web/public/push-sw.js`:
```javascript
const options = {
body: data.body,
icon: '/icons/icon-192x192.png', // Change app icon
badge: '/icons/icon-72x72.png', // Change badge icon
vibrate: [200, 100, 200], // Customize vibration pattern
tag: data.tag || 'default',
data: data.data || {},
requireInteraction: false, // Set true for persistent notifications
};
```
### Notification URLs
Edit URL routing in `notifications.service.ts`:
```typescript
private getNotificationUrl(notification: Notification): string {
switch (notification.type) {
case NotificationType.FEEDING_REMINDER:
return `/children/${notification.childId}/activities`;
// Add custom routes here
}
}
```
### Notification Triggers
Add custom notification triggers in `notifications.service.ts`:
```typescript
async createCustomNotification(userId: string, childId: string) {
await this.createNotification(
userId,
NotificationType.CUSTOM,
'Custom Title',
'Custom Message',
{
childId,
priority: NotificationPriority.HIGH,
metadata: { customData: 'value' }
}
);
}
```
---
## 🐛 Troubleshooting
### Common Issues
**Issue**: "Push notifications not supported"
**Solution**: Ensure HTTPS is enabled. Service Workers require secure context.
**Issue**: "Permission denied"
**Solution**: User must manually reset permissions in browser settings.
**Issue**: "Subscription failed"
**Solution**: Check VAPID public key is correctly fetched from backend.
**Issue**: "Notifications not appearing"
**Solution**: Check browser notification settings, ensure service worker is registered.
**Issue**: "Push fails with 404/410"
**Solution**: Subscription is invalid/expired. System auto-deactivates these.
**Issue**: "iOS not receiving notifications"
**Solution**: On iOS, app must be installed as PWA (Add to Home Screen).
### Debug Logs
**Browser Console**:
```javascript
// Check service worker registration
navigator.serviceWorker.getRegistrations().then(console.log);
// Check current subscription
navigator.serviceWorker.ready.then(reg =>
reg.pushManager.getSubscription().then(console.log)
);
// Check notification permission
console.log(Notification.permission);
```
**Backend Logs**:
```bash
# Check push service logs
tail -f /tmp/backend-dev.log | grep "\[Push\]"
```
---
## 📚 References
- [Web Push Notifications Guide](https://web.dev/push-notifications-overview/)
- [VAPID Protocol](https://datatracker.ietf.org/doc/html/rfc8292)
- [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
- [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
- [web-push NPM Package](https://www.npmjs.com/package/web-push)
---
## ✅ Completion Status
### Backend ✅
- [x] Database schema (already existed)
- [x] PushSubscription entity
- [x] PushService implementation
- [x] PushController with REST endpoints
- [x] PushModule integration
- [x] NotificationsService integration
- [x] Environment configuration
- [x] VAPID keys generated
### Frontend ✅
- [x] Service Worker (push-sw.js)
- [x] Push utilities library
- [x] PushNotificationToggle component
- [x] Browser compatibility checks
- [x] Error handling
### Testing 🔄
- [x] Backend compilation successful
- [x] Backend running on port 3020
- [ ] End-to-end push notification test
- [ ] Multi-device testing
- [ ] Browser compatibility testing
---
## 🎉 Next Steps
1. **Test End-to-End Flow**:
- Log in to web app
- Enable push notifications in settings
- Send test notification
- Create activities and verify smart notifications
2. **Production Deployment**:
- Generate production VAPID keys
- Update environment variables
- Deploy to production
- Test on production domain
3. **Monitoring Setup**:
- Set up error tracking for failed push sends
- Configure cleanup cron job
- Set up analytics for notification engagement
4. **Documentation**:
- Add push notification docs to user guide
- Create admin documentation for monitoring
- Update API documentation
---
**Implementation Complete!** 🚀
The push notification system is ready for testing and deployment.

View File

@@ -1,480 +0,0 @@
# Push Notifications Persistence Implementation
**Status**: ✅ **COMPLETED**
**Date**: October 8, 2025
---
## Overview
This document summarizes the implementation of **persistent notification preferences** for the ParentFlow push notifications system. Users' notification settings are now stored in the database and respected across sessions and devices.
---
## What Was Implemented
### 1. Enhanced User Entity ✅
**File**: `src/database/entities/user.entity.ts`
Updated the `preferences` JSONB column to include detailed notification settings:
```typescript
preferences?: {
notifications?: {
pushEnabled?: boolean; // Master toggle for push notifications
emailEnabled?: boolean; // Email notifications toggle
feedingReminders?: boolean; // Feeding reminder notifications
sleepReminders?: boolean; // Sleep reminder notifications
diaperReminders?: boolean; // Diaper change notifications
medicationReminders?: boolean; // Medication reminders
milestoneAlerts?: boolean; // Milestone achievement alerts
patternAnomalies?: boolean; // Pattern anomaly warnings
};
emailUpdates?: boolean;
darkMode?: boolean;
measurementUnit?: 'metric' | 'imperial';
timeFormat?: '12h' | '24h';
}
```
**Database**: Uses existing `users.preferences` JSONB column - no migration needed!
---
### 2. User Preferences Service ✅
**File**: `src/modules/users/user-preferences.service.ts`
Complete service for managing user preferences with the following methods:
#### Core Methods
- `getUserPreferences(userId)` - Get all preferences with defaults
- `updateUserPreferences(userId, preferences)` - Update any preference
- `updateNotificationPreferences(userId, notificationPreferences)` - Update notification settings only
#### Push Notification Helpers
- `enablePushNotifications(userId)` - Enable push notifications
- `disablePushNotifications(userId)` - Disable push notifications
- `isPushNotificationsEnabled(userId)` - Check if push is enabled
- `isNotificationTypeEnabled(userId, type)` - Check specific notification type
#### Utility Methods
- `getNotificationPreferencesSummary(userId)` - Get summary of enabled/disabled types
- `resetToDefaults(userId)` - Reset all preferences to defaults
**Default Values**: All notification types are **enabled by default** to ensure users receive important reminders.
---
### 3. Preferences Controller ✅
**File**: `src/modules/users/user-preferences.controller.ts`
REST API endpoints for managing preferences:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/preferences` | Get all user preferences |
| PUT | `/api/v1/preferences` | Update all preferences |
| PUT | `/api/v1/preferences/notifications` | Update notification preferences only |
| POST | `/api/v1/preferences/notifications/push/enable` | Enable push notifications |
| POST | `/api/v1/preferences/notifications/push/disable` | Disable push notifications |
| GET | `/api/v1/preferences/notifications/summary` | Get notification settings summary |
| POST | `/api/v1/preferences/reset` | Reset all preferences to defaults |
**Authentication**: All endpoints require JWT authentication
---
### 4. Users Module ✅
**File**: `src/modules/users/users.module.ts`
New NestJS module that:
- Imports User entity from TypeORM
- Provides UserPreferencesService and UserPreferencesController
- Exports UserPreferencesService for use by other modules
- Integrated into main AppModule
---
### 5. Updated Push Service ✅
**File**: `src/modules/push/push.service.ts`
Enhanced `sendToUser()` method to:
1. Check if user has push notifications enabled via `UserPreferencesService`
2. Skip sending if push is disabled at user level
3. Log when notifications are skipped due to preferences
```typescript
async sendToUser(userId: string, payload: PushNotificationPayload) {
// Check if user has push notifications enabled
const isPushEnabled = await this.userPreferencesService.isPushNotificationsEnabled(userId);
if (!isPushEnabled) {
this.logger.debug(`Push notifications disabled for user ${userId}, skipping`);
return { sent: 0, failed: 0 };
}
// Continue with sending...
}
```
---
### 6. Updated DTOs ✅
**File**: `src/modules/auth/dto/update-profile.dto.ts`
Created new DTOs to match the enhanced preference structure:
**NotificationPreferencesDto**:
- Validates all notification preference fields
- All fields optional with `@IsBoolean()` validation
**UserPreferencesDto**:
- Updated to use `NotificationPreferencesDto` instead of simple boolean
- Maintains backward compatibility
**UpdateProfileDto**:
- Uses updated `UserPreferencesDto`
- Allows updating preferences via profile endpoint
---
### 7. Frontend Integration ✅
**File**: `maternal-web/components/PushNotificationToggle.tsx`
Enhanced component to:
1. Save preference to backend when toggling push notifications
2. Call new `PUT /api/v1/preferences/notifications` endpoint
3. Handle errors gracefully (subscription works even if preference save fails)
```typescript
const savePreference = async (authToken: string, enabled: boolean) => {
const response = await fetch(`${apiUrl}/api/v1/preferences/notifications`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({
pushEnabled: enabled,
}),
});
};
```
---
## How It Works
### Flow Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ 1. User toggles push notifications in UI │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. Frontend subscribes/unsubscribes from push │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. Frontend saves preference to database │
│ PUT /api/v1/preferences/notifications │
│ { pushEnabled: true/false } │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. UserPreferencesService updates users.preferences │
│ (JSONB column) │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5. Later: NotificationsService creates notification │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 6. PushService checks user preferences before sending │
│ if (!isPushEnabled) return; // Skip sending │
└────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 7. Push sent only if user has push enabled │
└─────────────────────────────────────────────────────────────┘
```
---
## API Examples
### Get User Preferences
```bash
GET /api/v1/preferences
Authorization: Bearer <token>
Response:
{
"notifications": {
"pushEnabled": true,
"emailEnabled": true,
"feedingReminders": true,
"sleepReminders": true,
"diaperReminders": true,
"medicationReminders": true,
"milestoneAlerts": true,
"patternAnomalies": true
},
"emailUpdates": true,
"darkMode": false,
"measurementUnit": "metric",
"timeFormat": "12h"
}
```
### Update Notification Preferences
```bash
PUT /api/v1/preferences/notifications
Authorization: Bearer <token>
Content-Type: application/json
{
"pushEnabled": false,
"feedingReminders": false
}
Response:
{
"notifications": {
"pushEnabled": false,
"emailEnabled": true,
"feedingReminders": false, // Updated
"sleepReminders": true,
"diaperReminders": true,
"medicationReminders": true,
"milestoneAlerts": true,
"patternAnomalies": true
},
// ... other preferences
}
```
### Enable Push Notifications
```bash
POST /api/v1/preferences/notifications/push/enable
Authorization: Bearer <token>
Response:
{
"success": true
}
```
### Get Notification Summary
```bash
GET /api/v1/preferences/notifications/summary
Authorization: Bearer <token>
Response:
{
"enabled": true,
"enabledTypes": [
"feedingReminders",
"sleepReminders",
"diaperReminders",
"medicationReminders",
"milestoneAlerts",
"patternAnomalies"
],
"disabledTypes": []
}
```
---
## Database Storage
### Schema
The preferences are stored in the existing `users.preferences` JSONB column:
```sql
-- Example data
UPDATE users
SET preferences = '{
"notifications": {
"pushEnabled": true,
"emailEnabled": true,
"feedingReminders": true,
"sleepReminders": true,
"diaperReminders": true,
"medicationReminders": true,
"milestoneAlerts": true,
"patternAnomalies": true
},
"emailUpdates": true,
"darkMode": false,
"measurementUnit": "metric",
"timeFormat": "12h"
}'
WHERE id = 'usr_123';
```
### Query Examples
```sql
-- Get users with push notifications enabled
SELECT id, email, preferences->'notifications'->>'pushEnabled' as push_enabled
FROM users
WHERE preferences->'notifications'->>'pushEnabled' = 'true';
-- Get users with feeding reminders disabled
SELECT id, email
FROM users
WHERE preferences->'notifications'->>'feedingReminders' = 'false';
-- Update a specific preference
UPDATE users
SET preferences = jsonb_set(
preferences,
'{notifications,pushEnabled}',
'false'
)
WHERE id = 'usr_123';
```
---
## Default Behavior
### New Users
- All notification preferences default to **enabled**
- Push notifications are **enabled** by default
- Users can customize after onboarding
### Existing Users (Migration)
- Existing users without preferences get defaults on first access
- No database migration needed - handled by service layer
- Backward compatible with old preference format
---
## Key Features
**Persistent Across Sessions** - Settings saved to database, not local storage
**Multi-Device Sync** - Same preferences across all user's devices
**Granular Control** - Enable/disable specific notification types
**API-Driven** - RESTful endpoints for all preference operations
**Type-Safe** - Full TypeScript validation with DTOs
**Default Values** - Sensible defaults ensure notifications work out-of-box
**Audit Trail** - All changes logged via user updates
**Privacy-Focused** - User controls all notification types
---
## Testing
### Backend Testing
```bash
# Get preferences
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3020/api/v1/preferences
# Disable push notifications
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
http://localhost:3020/api/v1/preferences/notifications/push/disable
# Update specific preferences
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"feedingReminders": false, "sleepReminders": false}' \
http://localhost:3020/api/v1/preferences/notifications
```
### Frontend Testing
1. Open web app at `http://maternal.noru1.ro`
2. Navigate to Settings page
3. Toggle push notifications ON
4. Verify preference saved in database
5. Toggle push notifications OFF
6. Verify preference updated
7. Refresh page - verify setting persists
---
## Future Enhancements
### Planned Features
- [ ] **Per-Child Preferences** - Different notification settings per child
- [ ] **Time-Based Quiet Hours** - Disable notifications during sleep hours
- [ ] **Notification Frequency Control** - Limit number of notifications per day
- [ ] **Smart Suggestions** - ML-based preference recommendations
- [ ] **Bulk Operations** - Enable/disable all notification types at once
- [ ] **Advanced UI** - Rich settings page with toggles for each type
### API Extensions
- [ ] `GET /api/v1/preferences/notifications/children/:childId` - Per-child preferences
- [ ] `PUT /api/v1/preferences/notifications/quiet-hours` - Set quiet hours
- [ ] `POST /api/v1/preferences/notifications/bulk-update` - Bulk enable/disable
---
## Troubleshooting
### Common Issues
**Issue**: Preferences not persisting
**Solution**: Check JWT token is valid and user has permissions
**Issue**: Push still sending when disabled
**Solution**: Clear browser service worker cache, re-subscribe
**Issue**: Preferences showing as `null`
**Solution**: Service returns defaults for null values - working as intended
**Issue**: Cannot update preferences
**Solution**: Ensure request body matches `NotificationPreferencesDto` validation
---
## Summary
**All notification preferences are now persistent in the database**
**Users can customize notification types and push settings**
**Backend respects user preferences before sending push notifications**
**Frontend automatically saves preferences when toggling**
**Backend compiled successfully with 0 errors**
**RESTful API for all preference operations**
**Implementation Complete!** The push notification system now fully respects user preferences stored in the database. 🎉
---
## Documentation Updates
The main implementation documentation has been updated:
- [PUSH_NOTIFICATIONS_IMPLEMENTATION.md](PUSH_NOTIFICATIONS_IMPLEMENTATION.md) - Complete system overview
- [pwa_web_push_implementation_plan.md](pwa_web_push_implementation_plan.md) - Updated with completion status
---
**Last Updated**: October 8, 2025
**Status**: Production Ready ✅

View File

@@ -94,7 +94,7 @@ export class AuthService {
}
// Check if the invite code has reached its maximum number of uses
if (validatedInviteCode.maxUses && validatedInviteCode.uses >= validatedInviteCode.maxUses) {
if (validatedInviteCode.maxUses && validatedInviteCode.useCount >= validatedInviteCode.maxUses) {
throw new BadRequestException('This invite code has reached its maximum number of uses');
}
}
@@ -152,7 +152,7 @@ export class AuthService {
// Record invite code usage if applicable
if (validatedInviteCode) {
// Increment the usage counter
validatedInviteCode.uses += 1;
validatedInviteCode.useCount += 1;
await this.inviteCodeRepository.save(validatedInviteCode);
// Record the invite code usage
@@ -216,11 +216,6 @@ export class AuthService {
eulaAcceptedAt: savedUser.eulaAcceptedAt,
eulaVersion: savedUser.eulaVersion,
},
family: {
id: savedFamily.id,
shareCode: savedFamily.shareCode,
role: 'parent',
},
tokens,
deviceRegistered: true,
},

View File

@@ -43,13 +43,13 @@ export class InviteCodesService {
if (params?.status === 'active') {
query.where('invite_code.is_active = :isActive', { isActive: true })
.andWhere('(invite_code.expires_at IS NULL OR invite_code.expires_at > NOW())')
.andWhere('(invite_code.max_uses IS NULL OR invite_code.uses < invite_code.max_uses)');
.andWhere('(invite_code.max_uses IS NULL OR invite_code.use_count < invite_code.max_uses)');
} else if (params?.status === 'inactive') {
query.where('invite_code.is_active = :isActive', { isActive: false });
} else if (params?.status === 'expired') {
query.where('invite_code.expires_at <= NOW()');
} else if (params?.status === 'exhausted') {
query.where('invite_code.max_uses IS NOT NULL AND invite_code.uses >= invite_code.max_uses');
query.where('invite_code.max_uses IS NOT NULL AND invite_code.use_count >= invite_code.max_uses');
}
const page = params?.page || 1;
@@ -129,7 +129,7 @@ export class InviteCodesService {
}
// Check if max uses reached
if (inviteCode.maxUses && inviteCode.uses >= inviteCode.maxUses) {
if (inviteCode.maxUses && inviteCode.useCount >= inviteCode.maxUses) {
return { isValid: false, reason: 'Invite code has reached maximum uses' };
}

View File

@@ -15,30 +15,31 @@ import { Email, ArrowBack } from '@mui/icons-material';
import { motion } from 'framer-motion';
import Link from 'next/link';
import apiClient from '@/lib/api/client';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { error, showError, clearError, hasError } = useErrorMessage();
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
setError('Please enter your email address');
showError({ message: 'Please enter your email address', code: 'VALIDATION_EMAIL_REQUIRED' });
return;
}
setLoading(true);
setError('');
clearError();
try {
await apiClient.post('/api/v1/auth/password/forgot', { email });
setSuccess(true);
} catch (err: any) {
console.error('Forgot password error:', err);
setError(err.response?.data?.message || 'Failed to send reset email. Please try again.');
showError(err);
} finally {
setLoading(false);
}
@@ -97,9 +98,9 @@ export default function ForgotPasswordPage() {
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}

View File

@@ -28,6 +28,8 @@ import { startAuthentication } from '@simplewebauthn/browser';
import Link from 'next/link';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@mui/material/styles';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
@@ -40,7 +42,7 @@ export default function LoginPage() {
const { t } = useTranslation('auth');
const theme = useTheme();
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const { error, showError, clearError, hasError } = useErrorMessage();
const [isLoading, setIsLoading] = useState(false);
const [isBiometricLoading, setIsBiometricLoading] = useState(false);
const [isBiometricSupported, setIsBiometricSupported] = useState(false);
@@ -74,7 +76,7 @@ export default function LoginPage() {
};
const handleBiometricLogin = async () => {
setError(null);
clearError();
setIsBiometricLoading(true);
try {
@@ -100,11 +102,11 @@ export default function LoginPage() {
} catch (err: any) {
console.error('Biometric login failed:', err);
if (err.name === 'NotAllowedError') {
setError('Biometric authentication was cancelled');
showError({ message: 'Biometric authentication was cancelled', code: 'BIOMETRIC_CANCELLED' });
} else if (err.name === 'NotSupportedError') {
setError('Biometric authentication is not supported on this device');
showError({ message: 'Biometric authentication is not supported on this device', code: 'BIOMETRIC_NOT_SUPPORTED' });
} else {
setError(err.response?.data?.message || err.message || 'Biometric login failed. Please try again.');
showError(err);
}
} finally {
setIsBiometricLoading(false);
@@ -112,7 +114,7 @@ export default function LoginPage() {
};
const onSubmit = async (data: LoginFormData) => {
setError(null);
clearError();
setIsLoading(true);
try {
@@ -127,7 +129,7 @@ export default function LoginPage() {
mfaMethod: err.response.data.mfaMethod,
});
} else {
setError(err.message || 'Failed to login. Please check your credentials.');
showError(err);
}
} finally {
setIsLoading(false);
@@ -193,9 +195,9 @@ export default function LoginPage() {
{t('login.subtitle')}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}

View File

@@ -24,6 +24,8 @@ import { useAuth } from '@/lib/auth/AuthContext';
import Link from 'next/link';
import { useTheme } from '@mui/material/styles';
import apiClient from '@/lib/api/client';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
// Create a function to generate schema dynamically based on requireInviteCode
const createRegisterSchema = (requireInviteCode: boolean) => z.object({
@@ -79,7 +81,7 @@ export default function RegisterPage() {
const theme = useTheme();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const { error, showError, clearError, hasError } = useErrorMessage();
const [isLoading, setIsLoading] = useState(false);
const [userAge, setUserAge] = useState<number | null>(null);
const [requiresParentalConsent, setRequiresParentalConsent] = useState(false);
@@ -150,11 +152,11 @@ export default function RegisterPage() {
}, [dateOfBirth]);
const onSubmit = async (data: RegisterFormData) => {
setError(null);
clearError();
// Validate invite code if required
if (requireInviteCode && (!data.inviteCode || data.inviteCode.trim() === '')) {
setError('Invite code is required to register');
showError({ message: 'Invite code is required to register', code: 'VALIDATION_INVITE_CODE_REQUIRED' });
return;
}
@@ -172,7 +174,7 @@ export default function RegisterPage() {
});
// Navigation to onboarding is handled in the register function
} catch (err: any) {
setError(err.message || 'Failed to register. Please try again.');
showError(err);
} finally {
setIsLoading(false);
}
@@ -224,9 +226,9 @@ export default function RegisterPage() {
Start your journey to organized parenting
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}
@@ -285,14 +287,21 @@ export default function RegisterPage() {
label="Invite Code"
margin="normal"
error={!!errors.inviteCode}
helperText={errors.inviteCode?.message || 'Enter your invite code to register'}
{...register('inviteCode')}
helperText={errors.inviteCode?.message || 'Invite codes are automatically converted to uppercase'}
{...register('inviteCode', {
setValueAs: (value) => value?.toUpperCase() || '',
})}
onChange={(e) => {
const uppercased = e.target.value.toUpperCase();
setValue('inviteCode', uppercased);
}}
disabled={isLoading}
required
inputProps={{
'aria-required': 'true',
'aria-invalid': !!errors.inviteCode,
'aria-describedby': errors.inviteCode ? 'invite-code-error' : 'invite-code-helper',
style: { textTransform: 'uppercase' },
}}
InputProps={{
sx: { borderRadius: 3 },

View File

@@ -38,6 +38,8 @@ import { RoleInvitesSection } from '@/components/family/RoleInvitesSection';
import { motion } from 'framer-motion';
import { useTranslation } from '@/hooks/useTranslation';
import { useSelectedFamily } from '@/hooks/useSelectedFamily';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
export default function FamilyPage() {
const { t } = useTranslation('family');
@@ -46,7 +48,7 @@ export default function FamilyPage() {
const [family, setFamily] = useState<Family | null>(null);
const [members, setMembers] = useState<FamilyMember[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>('');
const { error, showError, clearError, hasError } = useErrorMessage();
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
@@ -63,7 +65,7 @@ export default function FamilyPage() {
fetchFamilyData();
} else {
setLoading(false);
setError(t('messages.noFamilyFound'));
showError({ message: t('messages.noFamilyFound'), code: 'NO_FAMILY_FOUND' });
}
}, [familyId, selectedIndex]);
@@ -72,7 +74,7 @@ export default function FamilyPage() {
try {
setLoading(true);
setError('');
clearError();
const [familyData, membersData] = await Promise.all([
familiesApi.getFamily(familyId),
familiesApi.getFamilyMembers(familyId),
@@ -86,8 +88,7 @@ export default function FamilyPage() {
[familyId]: familyData.name
}));
} catch (err: any) {
console.error('Failed to fetch family data:', err);
setError(err.response?.data?.message || t('messages.failedToLoad'));
showError(err);
} finally {
setLoading(false);
}
@@ -186,8 +187,7 @@ export default function FamilyPage() {
setRemoveDialogOpen(false);
setMemberToRemove(null);
} catch (err: any) {
console.error('Failed to remove member:', err);
setError(err.response?.data?.message || t('messages.failedToRemove'));
showError(err);
} finally {
setActionLoading(false);
}
@@ -281,9 +281,9 @@ export default function FamilyPage() {
</Paper>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}
@@ -435,7 +435,7 @@ export default function FamilyPage() {
<RoleInvitesSection
familyId={familyId!}
onSuccess={(message) => setSnackbar({ open: true, message })}
onError={(message) => setError(message)}
onError={(err) => showError(err)}
/>
</Grid>
)}

View File

@@ -51,6 +51,8 @@ import { useDispatch, useSelector } from 'react-redux';
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
import { AppDispatch, RootState } from '@/store/store';
import ChildSelector from '@/components/common/ChildSelector';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
interface ActivityData {
activityType: string;
@@ -83,7 +85,7 @@ function ActivityTrackPage() {
const [recentActivities, setRecentActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(false);
const [activitiesLoading, setActivitiesLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { error, showError, clearError, hasError } = useErrorMessage();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Delete confirmation dialog
@@ -131,19 +133,19 @@ function ActivityTrackPage() {
const handleSubmit = async () => {
if (!selectedChild?.id) {
setError('Please select a child');
showError({ message: 'Please select a child', code: 'NO_CHILD_SELECTED' });
return;
}
// Validation
if (!activityType) {
setError('Please select activity type');
showError({ message: 'Please select activity type', code: 'VALIDATION_ERROR' });
return;
}
try {
setLoading(true);
setError(null);
clearError();
const data: ActivityData = {
activityType,
@@ -167,7 +169,7 @@ function ActivityTrackPage() {
await loadRecentActivities();
} catch (err: any) {
console.error('Failed to save activity:', err);
setError(err.response?.data?.message || 'Failed to save activity');
showError(err);
} finally {
setLoading(false);
}
@@ -197,7 +199,7 @@ function ActivityTrackPage() {
await loadRecentActivities();
} catch (err: any) {
console.error('Failed to delete activity:', err);
setError(err.response?.data?.message || 'Failed to delete activity');
showError(err);
} finally {
setLoading(false);
}
@@ -306,9 +308,9 @@ function ActivityTrackPage() {
/>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}

View File

@@ -48,6 +48,8 @@ import { childrenApi, Child } from '@/lib/api/children';
import { motion } from 'framer-motion';
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
import { useTranslation } from '@/hooks/useTranslation';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
import { useDispatch, useSelector } from 'react-redux';
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
import { AppDispatch, RootState } from '@/store/store';
@@ -90,7 +92,7 @@ export default function DiaperTrackPage() {
const [recentDiapers, setRecentDiapers] = useState<Activity[]>([]);
const [loading, setLoading] = useState(false);
const [diapersLoading, setDiapersLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { error, showError, clearError, hasError } = useErrorMessage();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Delete confirmation dialog
@@ -215,24 +217,24 @@ export default function DiaperTrackPage() {
const handleSubmit = async () => {
if (!selectedChild?.id) {
setError('Please select a child');
showError({ message: 'Please select a child', code: 'NO_CHILD_SELECTED' });
return;
}
// Validation
if (!timestamp) {
setError('Please enter timestamp');
showError({ message: 'Please enter timestamp', code: 'VALIDATION_ERROR' });
return;
}
if (conditions.length === 0) {
setError('Please select at least one condition');
showError({ message: 'Please select at least one condition', code: 'VALIDATION_ERROR' });
return;
}
try {
setLoading(true);
setError(null);
clearError();
const data: DiaperData = {
diaperType,
@@ -260,7 +262,7 @@ export default function DiaperTrackPage() {
await loadRecentDiapers();
} catch (err: any) {
console.error('Failed to save diaper:', err);
setError(err.response?.data?.message || 'Failed to save diaper change');
showError(err);
} finally {
setLoading(false);
}
@@ -292,7 +294,7 @@ export default function DiaperTrackPage() {
await loadRecentDiapers();
} catch (err: any) {
console.error('Failed to delete diaper:', err);
setError(err.response?.data?.message || 'Failed to delete diaper change');
showError(err);
} finally {
setLoading(false);
}
@@ -421,9 +423,9 @@ export default function DiaperTrackPage() {
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}

View File

@@ -54,6 +54,8 @@ import { motion } from 'framer-motion';
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
import { useTranslation } from '@/hooks/useTranslation';
import { UnitInput } from '@/components/forms/UnitInput';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
import { convertVolume, getUnitSymbol } from '@/lib/utils/unitConversion';
import { MeasurementSystem } from '@/hooks/useLocale';
import { useDispatch, useSelector } from 'react-redux';
@@ -106,7 +108,7 @@ function FeedingTrackPage() {
const [recentFeedings, setRecentFeedings] = useState<Activity[]>([]);
const [loading, setLoading] = useState(false);
const [feedingsLoading, setFeedingsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { error, showError, clearError, hasError } = useErrorMessage();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Delete confirmation dialog
@@ -186,29 +188,29 @@ function FeedingTrackPage() {
const handleSubmit = async () => {
if (!selectedChild?.id) {
setError(t('common.selectChild'));
showError({ message: t('common.selectChild'), code: 'NO_CHILD_SELECTED' });
return;
}
// Validation
if (feedingType === 'breast' && duration === 0 && timerSeconds === 0) {
setError(t('feeding.validation.durationRequired'));
showError({ message: t('feeding.validation.durationRequired'), code: 'VALIDATION_ERROR' });
return;
}
if (feedingType === 'bottle' && !amount) {
setError(t('feeding.validation.amountRequired'));
showError({ message: t('feeding.validation.amountRequired'), code: 'VALIDATION_ERROR' });
return;
}
if (feedingType === 'solid' && !foodDescription) {
setError(t('feeding.validation.foodRequired'));
showError({ message: t('feeding.validation.foodRequired'), code: 'VALIDATION_ERROR' });
return;
}
try {
setLoading(true);
setError(null);
clearError();
const data: FeedingData = {
feedingType,
@@ -241,7 +243,7 @@ function FeedingTrackPage() {
await loadRecentFeedings();
} catch (err: any) {
console.error('Failed to save feeding:', err);
setError(err.response?.data?.message || t('feeding.error.saveFailed'));
showError(err);
} finally {
setLoading(false);
}
@@ -276,7 +278,7 @@ function FeedingTrackPage() {
await loadRecentFeedings();
} catch (err: any) {
console.error('Failed to delete feeding:', err);
setError(err.response?.data?.message || t('feeding.error.deleteFailed'));
showError(err);
} finally {
setLoading(false);
}
@@ -398,9 +400,9 @@ function FeedingTrackPage() {
/>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}

View File

@@ -50,6 +50,8 @@ import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } fr
import { AppDispatch, RootState } from '@/store/store';
import ChildSelector from '@/components/common/ChildSelector';
import { UnitInput } from '@/components/forms/UnitInput';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
interface GrowthData {
weight?: number; // in kg
@@ -84,7 +86,7 @@ function GrowthTrackPage() {
const [recentGrowth, setRecentGrowth] = useState<Activity[]>([]);
const [loading, setLoading] = useState(false);
const [growthLoading, setGrowthLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { error, showError, clearError, hasError } = useErrorMessage();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Delete confirmation dialog
@@ -132,31 +134,31 @@ function GrowthTrackPage() {
const handleSubmit = async () => {
if (!selectedChild?.id) {
setError(t('common.selectChild'));
showError({ message: t('common.selectChild'), code: 'NO_CHILD_SELECTED' });
return;
}
// Validation
if (measurementType === 'weight' && weight === 0) {
setError('Please enter weight');
showError({ message: 'Please enter weight', code: 'VALIDATION_ERROR' });
return;
}
if (measurementType === 'height' && height === 0) {
setError('Please enter height');
showError({ message: 'Please enter height', code: 'VALIDATION_ERROR' });
return;
}
if (measurementType === 'head' && headCircumference === 0) {
setError('Please enter head circumference');
showError({ message: 'Please enter head circumference', code: 'VALIDATION_ERROR' });
return;
}
if (measurementType === 'all' && (weight === 0 || height === 0 || headCircumference === 0)) {
setError('Please enter all measurements');
showError({ message: 'Please enter all measurements', code: 'VALIDATION_ERROR' });
return;
}
try {
setLoading(true);
setError(null);
clearError();
const data: GrowthData = {
measurementType,
@@ -181,7 +183,7 @@ function GrowthTrackPage() {
await loadRecentGrowth();
} catch (err: any) {
console.error('Failed to save growth:', err);
setError(err.response?.data?.message || 'Failed to save growth measurement');
showError(err);
} finally {
setLoading(false);
}
@@ -212,7 +214,7 @@ function GrowthTrackPage() {
await loadRecentGrowth();
} catch (err: any) {
console.error('Failed to delete growth:', err);
setError(err.response?.data?.message || 'Failed to delete growth measurement');
showError(err);
} finally {
setLoading(false);
}
@@ -324,9 +326,9 @@ function GrowthTrackPage() {
/>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}

View File

@@ -53,6 +53,8 @@ import { useTranslation } from '@/hooks/useTranslation';
import { UnitInput } from '@/components/forms/UnitInput';
import { convertVolume, convertTemperature } from '@/lib/utils/unitConversion';
import { MeasurementSystem } from '@/hooks/useLocale';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
import { useDispatch, useSelector } from 'react-redux';
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
import { AppDispatch, RootState } from '@/store/store';
@@ -121,7 +123,7 @@ function MedicalTrackPage() {
const [recentActivities, setRecentActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(false);
const [activitiesLoading, setActivitiesLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { error, showError, clearError, hasError } = useErrorMessage();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Delete confirmation dialog
@@ -184,36 +186,36 @@ function MedicalTrackPage() {
const handleSubmit = async () => {
if (!selectedChild?.id) {
setError(t('common.selectChild'));
showError({ message: t('common.selectChild'), code: 'NO_CHILD_SELECTED' });
return;
}
// Validation based on activity type
if (activityType === 'medication') {
if (!medicineName) {
setError(t('health.medicineName.required'));
showError({ message: t('health.medicineName.required'), code: 'VALIDATION_ERROR' });
return;
}
const dosageValue = unit === 'ml' ? dosage : dosageText;
if (!dosageValue || (unit === 'ml' && dosage === 0) || (unit !== 'ml' && !dosageText)) {
setError(t('health.dosage.required'));
showError({ message: t('health.dosage.required'), code: 'VALIDATION_ERROR' });
return;
}
} else if (activityType === 'temperature') {
if (temperature === 0) {
setError('Please enter temperature');
showError({ message: 'Please enter temperature', code: 'VALIDATION_ERROR' });
return;
}
} else if (activityType === 'doctor') {
if (!visitType) {
setError('Please select visit type');
showError({ message: 'Please select visit type', code: 'VALIDATION_ERROR' });
return;
}
}
try {
setLoading(true);
setError(null);
clearError();
let data: MedicationData | TemperatureData | DoctorVisitData;
@@ -253,7 +255,7 @@ function MedicalTrackPage() {
await loadRecentActivities();
} catch (err: any) {
console.error('Failed to save activity:', err);
setError(err.response?.data?.message || 'Failed to save activity');
showError(err);
} finally {
setLoading(false);
}
@@ -299,7 +301,7 @@ function MedicalTrackPage() {
await loadRecentActivities();
} catch (err: any) {
console.error('Failed to delete activity:', err);
setError(err.response?.data?.message || 'Failed to delete activity');
showError(err);
} finally {
setLoading(false);
}
@@ -446,9 +448,9 @@ function MedicalTrackPage() {
/>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}

View File

@@ -47,6 +47,8 @@ import { childrenApi, Child } from '@/lib/api/children';
import { motion } from 'framer-motion';
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
import { useTranslation } from '@/hooks/useTranslation';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
import { useDispatch, useSelector } from 'react-redux';
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
import { AppDispatch, RootState } from '@/store/store';
@@ -91,7 +93,7 @@ export default function SleepTrackPage() {
const [recentSleeps, setRecentSleeps] = useState<Activity[]>([]);
const [loading, setLoading] = useState(false);
const [sleepsLoading, setSleepsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { error, showError, clearError, hasError } = useErrorMessage();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Delete confirmation dialog
@@ -181,18 +183,18 @@ export default function SleepTrackPage() {
const handleSubmit = async () => {
if (!selectedChild?.id) {
setError('Please select a child');
showError({ message: 'Please select a child', code: 'NO_CHILD_SELECTED' });
return;
}
// Validation
if (!startTime) {
setError('Please enter start time');
showError({ message: 'Please enter start time', code: 'VALIDATION_ERROR' });
return;
}
if (!isOngoing && !endTime) {
setError('Please enter end time or mark as ongoing');
showError({ message: 'Please enter end time or mark as ongoing', code: 'VALIDATION_ERROR' });
return;
}
@@ -200,14 +202,14 @@ export default function SleepTrackPage() {
const start = new Date(startTime);
const end = new Date(endTime);
if (end <= start) {
setError('End time must be after start time');
showError({ message: 'End time must be after start time', code: 'VALIDATION_ERROR' });
return;
}
}
try {
setLoading(true);
setError(null);
clearError();
const data: SleepData = {
startTime,
@@ -236,7 +238,7 @@ export default function SleepTrackPage() {
await loadRecentSleeps();
} catch (err: any) {
console.error('Failed to save sleep:', err);
setError(err.response?.data?.message || 'Failed to save sleep');
showError(err);
} finally {
setLoading(false);
}
@@ -268,7 +270,7 @@ export default function SleepTrackPage() {
await loadRecentSleeps();
} catch (err: any) {
console.error('Failed to delete sleep:', err);
setError(err.response?.data?.message || 'Failed to delete sleep');
showError(err);
} finally {
setLoading(false);
}
@@ -380,9 +382,9 @@ export default function SleepTrackPage() {
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
{hasError && (
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
{formatErrorMessage(error)}
</Alert>
)}

View File

@@ -15,6 +15,8 @@ import {
import { Child, CreateChildData } from '@/lib/api/children';
import { useTranslation } from '@/hooks/useTranslation';
import { PhotoUpload } from '@/components/common/PhotoUpload';
import { useErrorMessage } from '@/components/common/ErrorMessage';
import { formatErrorMessage } from '@/lib/utils/errorHandler';
interface ChildDialogProps {
open: boolean;
@@ -33,7 +35,7 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
photoUrl: '',
photoAlt: '',
});
const [error, setError] = useState<string>('');
const { error, showError, clearError, hasError } = useErrorMessage();
useEffect(() => {
if (child) {
@@ -53,8 +55,8 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
photoAlt: '',
});
}
setError('');
}, [child, open]);
clearError();
}, [child, open, clearError]);
const handleChange = (field: keyof CreateChildData) => (
e: React.ChangeEvent<HTMLInputElement>
@@ -63,15 +65,15 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
};
const handleSubmit = async () => {
setError('');
clearError();
// Validation
if (!formData.name.trim()) {
setError(t('dialog.validation.nameRequired'));
showError({ message: t('dialog.validation.nameRequired'), code: 'VALIDATION_NAME_REQUIRED' });
return;
}
if (!formData.birthDate) {
setError(t('dialog.validation.birthDateRequired'));
showError({ message: t('dialog.validation.birthDateRequired'), code: 'VALIDATION_BIRTHDATE_REQUIRED' });
return;
}
@@ -80,7 +82,7 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate > today) {
setError(t('dialog.validation.birthDateFuture'));
showError({ message: t('dialog.validation.birthDateFuture'), code: 'VALIDATION_BIRTHDATE_FUTURE' });
return;
}
@@ -88,7 +90,7 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
await onSubmit(formData);
onClose();
} catch (err: any) {
setError(err.message || t('errors.saveFailed'));
showError(err);
}
};
@@ -107,9 +109,9 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
id="child-dialog-description"
sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}
>
{error && (
<Alert severity="error" onClose={() => setError('')} role="alert">
{error}
{hasError && (
<Alert severity="error" onClose={clearError} role="alert">
{formatErrorMessage(error)}
</Alert>
)}

View File

@@ -0,0 +1,142 @@
'use client';
import React from 'react';
import { Alert, AlertTitle, Box } from '@mui/material';
import { extractError } from '@/lib/utils/errorHandler';
export interface ErrorMessageProps {
error: any;
showErrorCode?: boolean;
variant?: 'standard' | 'filled' | 'outlined';
severity?: 'error' | 'warning';
onClose?: () => void;
sx?: any;
}
/**
* ErrorMessage Component
* Displays error messages inline (within forms, pages, etc.)
* Preserves backend multilingual error messages
*/
export function ErrorMessage({
error,
showErrorCode = false,
variant = 'standard',
severity = 'error',
onClose,
sx,
}: ErrorMessageProps) {
if (!error) {
return null;
}
const extracted = extractError(error);
return (
<Box sx={{ mb: 2, ...sx }}>
<Alert severity={severity} variant={variant} onClose={onClose}>
{showErrorCode && extracted.code && (
<AlertTitle sx={{ fontWeight: 600 }}>{extracted.code}</AlertTitle>
)}
{extracted.message}
</Alert>
</Box>
);
}
/**
* FieldError Component
* Displays field-specific error messages (for form fields)
*/
export interface FieldErrorProps {
error: any;
fieldName: string;
sx?: any;
}
export function FieldError({ error, fieldName, sx }: FieldErrorProps) {
if (!error) {
return null;
}
const extracted = extractError(error);
// Only show if this error is for the specific field
if (extracted.field !== fieldName) {
return null;
}
return (
<Box sx={{ mt: 0.5, ...sx }}>
<Alert severity="error" variant="outlined" sx={{ py: 0.5, px: 1 }}>
{extracted.message}
</Alert>
</Box>
);
}
/**
* ErrorList Component
* Displays multiple errors in a list
*/
export interface ErrorListProps {
errors: any[];
showErrorCodes?: boolean;
variant?: 'standard' | 'filled' | 'outlined';
onClose?: () => void;
sx?: any;
}
export function ErrorList({
errors,
showErrorCodes = false,
variant = 'standard',
onClose,
sx,
}: ErrorListProps) {
if (!errors || errors.length === 0) {
return null;
}
const extractedErrors = errors.map((err) => extractError(err));
return (
<Box sx={{ mb: 2, ...sx }}>
<Alert severity="error" variant={variant} onClose={onClose}>
<AlertTitle sx={{ fontWeight: 600 }}>Multiple errors occurred:</AlertTitle>
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
{extractedErrors.map((err, index) => (
<li key={index} style={{ marginBottom: '4px' }}>
{showErrorCodes && err.code && <strong>{err.code}: </strong>}
{err.message}
</li>
))}
</ul>
</Alert>
</Box>
);
}
/**
* Hook to manage error message state
*/
export function useErrorMessage() {
const [error, setError] = React.useState<any>(null);
const showError = (err: any) => {
setError(err);
};
const clearError = () => {
setError(null);
};
const hasError = error !== null;
return {
error,
showError,
clearError,
hasError,
};
}

View File

@@ -0,0 +1,80 @@
'use client';
import React from 'react';
import { Snackbar, Alert, AlertTitle } from '@mui/material';
import { extractError } from '@/lib/utils/errorHandler';
export interface ErrorToastProps {
error: any;
open: boolean;
onClose: () => void;
autoHideDuration?: number;
showErrorCode?: boolean;
}
/**
* ErrorToast Component
* Displays error messages as a toast notification
* Preserves backend multilingual error messages
*/
export function ErrorToast({
error,
open,
onClose,
autoHideDuration = 6000,
showErrorCode = false,
}: ErrorToastProps) {
if (!error) {
return null;
}
const extracted = extractError(error);
return (
<Snackbar
open={open}
autoHideDuration={autoHideDuration}
onClose={onClose}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<Alert onClose={onClose} severity="error" variant="filled" sx={{ width: '100%' }}>
{showErrorCode && extracted.code && (
<AlertTitle>{extracted.code}</AlertTitle>
)}
{extracted.message}
</Alert>
</Snackbar>
);
}
/**
* Hook to manage error toast state
*/
export function useErrorToast() {
const [error, setError] = React.useState<any>(null);
const [open, setOpen] = React.useState(false);
const showError = (err: any) => {
setError(err);
setOpen(true);
};
const hideError = () => {
setOpen(false);
};
const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') {
return;
}
hideError();
};
return {
error,
open,
showError,
hideError,
handleClose,
};
}

View File

@@ -79,13 +79,16 @@ export function JoinFamilyDialog({
<TextField
label="Share Code"
value={shareCode}
onChange={(e) => setShareCode(e.target.value)}
onChange={(e) => setShareCode(e.target.value.toUpperCase())}
fullWidth
required
autoFocus
disabled={isLoading}
placeholder="Enter family share code"
helperText="Ask a family member for their share code"
placeholder="XXXX-XXXX"
helperText="Share codes are automatically converted to uppercase"
inputProps={{
style: { textTransform: 'uppercase' },
}}
/>
</Box>
</DialogContent>

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import { tokenStorage } from '@/lib/utils/tokenStorage';
import { logError } from '@/lib/utils/errorHandler';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3020';
@@ -25,12 +26,15 @@ apiClient.interceptors.request.use(
}
);
// Response interceptor to handle token refresh
// Response interceptor to handle token refresh and error logging
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Log all API errors for debugging and error tracking
logError(error, `API ${originalRequest?.method?.toUpperCase()} ${originalRequest?.url}`);
// Only handle token refresh on client side
if (typeof window === 'undefined') {
return Promise.reject(error);

View File

@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react
import { useRouter } from 'next/navigation';
import apiClient from '@/lib/api/client';
import { tokenStorage } from '@/lib/utils/tokenStorage';
import { handleError, formatErrorMessage } from '@/lib/utils/errorHandler';
export interface User {
id: string;
@@ -214,8 +215,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
router.push('/');
} catch (error: any) {
console.error('Login failed:', error);
throw new Error(error.response?.data?.message || 'Login failed');
const errorMessage = handleError(error, 'AuthContext.login');
throw new Error(errorMessage);
}
};
@@ -285,8 +286,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
// Redirect to onboarding
router.push('/onboarding');
} catch (error: any) {
console.error('Registration failed:', error);
throw new Error(error.response?.data?.message || error.message || 'Registration failed');
const errorMessage = handleError(error, 'AuthContext.register');
throw new Error(errorMessage);
}
};

View File

@@ -0,0 +1,267 @@
/**
* Error Handler Utility
* Provides centralized error handling with multilingual support
* Preserves backend error messages which are already localized in 5 languages:
* - English (en)
* - Spanish (es)
* - French (fr)
* - Portuguese (pt)
* - Chinese (zh)
*/
export interface ErrorResponse {
code: string;
message: string;
field?: string;
details?: any;
}
export interface ExtractedError {
code: string;
message: string;
field?: string;
details?: any;
isBackendError: boolean;
}
/**
* Extract error information from various error types
* Prioritizes backend error messages to preserve multilingual support
*/
export function extractError(error: any): ExtractedError {
// Default error
const defaultError: ExtractedError = {
code: 'UNKNOWN_ERROR',
message: 'An unexpected error occurred. Please try again.',
isBackendError: false,
};
// If no error, return default
if (!error) {
return defaultError;
}
// Axios error response with backend error
if (error.response?.data?.error) {
const backendError = error.response.data.error;
return {
code: backendError.code || 'BACKEND_ERROR',
message: backendError.message || defaultError.message,
field: backendError.field,
details: backendError.details,
isBackendError: true,
};
}
// Axios error response with message
if (error.response?.data?.message) {
return {
code: error.response.data.code || 'BACKEND_ERROR',
message: error.response.data.message,
field: error.response.data.field,
details: error.response.data.details,
isBackendError: true,
};
}
// Network errors
if (error.code === 'ERR_NETWORK' || error.message === 'Network Error') {
return {
code: 'NETWORK_ERROR',
message: 'Unable to connect to the server. Please check your internet connection.',
isBackendError: false,
};
}
// Timeout errors
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
return {
code: 'TIMEOUT_ERROR',
message: 'Request timed out. Please try again.',
isBackendError: false,
};
}
// HTTP status code errors
if (error.response?.status) {
const status = error.response.status;
if (status === 401) {
return {
code: 'UNAUTHORIZED',
message: 'Your session has expired. Please log in again.',
isBackendError: false,
};
}
if (status === 403) {
return {
code: 'FORBIDDEN',
message: 'You do not have permission to perform this action.',
isBackendError: false,
};
}
if (status === 404) {
return {
code: 'NOT_FOUND',
message: 'The requested resource was not found.',
isBackendError: false,
};
}
if (status === 429) {
return {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests. Please try again later.',
isBackendError: false,
};
}
if (status >= 500) {
return {
code: 'SERVER_ERROR',
message: 'A server error occurred. Please try again later.',
isBackendError: false,
};
}
}
// Generic error with message
if (error.message) {
return {
code: error.code || 'ERROR',
message: error.message,
isBackendError: false,
};
}
// String error
if (typeof error === 'string') {
return {
code: 'ERROR',
message: error,
isBackendError: false,
};
}
return defaultError;
}
/**
* Format error message for display
* Preserves backend messages (which are already localized)
*/
export function formatErrorMessage(error: any): string {
const extracted = extractError(error);
return extracted.message;
}
/**
* Get user-friendly error message based on error code
* Only used for client-side errors; backend errors use their own messages
*/
export function getUserFriendlyMessage(errorCode: string): string {
const messages: Record<string, string> = {
// Network errors
NETWORK_ERROR: 'Unable to connect to the server. Please check your internet connection.',
TIMEOUT_ERROR: 'Request timed out. Please try again.',
// Authentication errors
UNAUTHORIZED: 'Your session has expired. Please log in again.',
FORBIDDEN: 'You do not have permission to perform this action.',
// HTTP errors
NOT_FOUND: 'The requested resource was not found.',
RATE_LIMIT_EXCEEDED: 'Too many requests. Please try again later.',
SERVER_ERROR: 'A server error occurred. Please try again later.',
// Generic errors
UNKNOWN_ERROR: 'An unexpected error occurred. Please try again.',
ERROR: 'An error occurred. Please try again.',
};
return messages[errorCode] || messages.UNKNOWN_ERROR;
}
/**
* Check if error is a specific type
*/
export function isErrorType(error: any, errorCode: string): boolean {
const extracted = extractError(error);
return extracted.code === errorCode;
}
/**
* Check if error is a network error
*/
export function isNetworkError(error: any): boolean {
return isErrorType(error, 'NETWORK_ERROR');
}
/**
* Check if error is an authentication error
*/
export function isAuthError(error: any): boolean {
return isErrorType(error, 'UNAUTHORIZED') || isErrorType(error, 'AUTH_TOKEN_EXPIRED');
}
/**
* Check if error is a validation error
*/
export function isValidationError(error: any): boolean {
const extracted = extractError(error);
return extracted.code.startsWith('VALIDATION_') || extracted.field !== undefined;
}
/**
* Get field-specific error message
*/
export function getFieldError(error: any, fieldName: string): string | null {
const extracted = extractError(error);
if (extracted.field === fieldName) {
return extracted.message;
}
if (extracted.details && typeof extracted.details === 'object') {
return extracted.details[fieldName] || null;
}
return null;
}
/**
* Log error for debugging (can be extended with error tracking service)
*/
export function logError(error: any, context?: string): void {
const extracted = extractError(error);
console.error('[Error Handler]', {
context,
code: extracted.code,
message: extracted.message,
field: extracted.field,
details: extracted.details,
isBackendError: extracted.isBackendError,
originalError: error,
});
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
// Example:
// if (window.Sentry) {
// window.Sentry.captureException(error, {
// tags: { code: extracted.code },
// contexts: { errorHandler: { context } },
// });
// }
}
/**
* Handle error and return user-friendly message
* Main function to use in catch blocks
*/
export function handleError(error: any, context?: string): string {
logError(error, context);
return formatErrorMessage(error);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,47 @@
#!/bin/bash
# Script to update all tracking forms to use new error handling
TRACKING_FILES=(
"/root/maternal-app/maternal-web/app/track/feeding/page.tsx"
"/root/maternal-app/maternal-web/app/track/sleep/page.tsx"
"/root/maternal-app/maternal-web/app/track/diaper/page.tsx"
"/root/maternal-app/maternal-web/app/track/medicine/page.tsx"
"/root/maternal-app/maternal-web/app/track/growth/page.tsx"
"/root/maternal-app/maternal-web/app/track/activity/page.tsx"
)
for file in "${TRACKING_FILES[@]}"; do
if [ -f "$file" ]; then
echo "Updating $file..."
# 1. Add imports at the top (after existing imports)
if ! grep -q "useErrorMessage" "$file"; then
# Find the last import line and add after it
sed -i '/^import.*from/a\import { useErrorMessage } from "@/components/common/ErrorMessage";\nimport { formatErrorMessage } from "@/lib/utils/errorHandler";' "$file" | head -1
fi
# 2. Replace error state declaration
sed -i 's/const \[error, setError\] = useState<string | null>(null);/const { error, showError, clearError, hasError } = useErrorMessage();/' "$file"
# 3. Replace setError('') with clearError()
sed -i "s/setError('')/clearError()/g" "$file"
sed -i 's/setError("")/clearError()/g' "$file"
sed -i 's/setError(null)/clearError()/g' "$file"
# 4. Replace setError with showError for error messages
sed -i 's/setError(\([^)]*\))/showError(\1)/g' "$file"
# 5. Replace error && with hasError &&
sed -i 's/error &&/hasError \&\&/g' "$file"
# 6. Replace {error} display with {formatErrorMessage(error)}
sed -i 's/{error}/{formatErrorMessage(error)}/g' "$file"
echo " ✓ Updated $file"
else
echo " ✗ File not found: $file"
fi
done
echo "Done updating tracking forms!"

View File

@@ -142,7 +142,7 @@ export default function EditLegalPagePage() {
'fullscreen',
'|',
'guide',
],
] as const,
};
}, []);

View File

@@ -261,7 +261,7 @@ export default function DashboardPage() {
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
label={(props: any) => `${props.name} ${(props.percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"

146
pre-deploy-check.sh Executable file
View File

@@ -0,0 +1,146 @@
#!/bin/bash
set -e
echo "======================================"
echo "🔍 Pre-Deployment Checks"
echo "======================================"
ERRORS=0
# Check 1: Frontend build
echo ""
echo "1⃣ Checking frontend build..."
cd /root/maternal-app/maternal-web
if npm run build > /tmp/frontend-build.log 2>&1; then
echo "✅ Frontend build successful"
else
echo "❌ Frontend build failed"
echo "See /tmp/frontend-build.log for details"
ERRORS=$((ERRORS + 1))
fi
# Check 2: Backend build
echo ""
echo "2⃣ Checking backend build..."
cd /root/maternal-app/maternal-app/maternal-app-backend
if npm run build > /tmp/backend-build.log 2>&1; then
echo "✅ Backend build successful"
else
echo "❌ Backend build failed"
echo "See /tmp/backend-build.log for details"
tail -20 /tmp/backend-build.log
ERRORS=$((ERRORS + 1))
fi
# Check 2.5: Admin Panel build
echo ""
echo "2⃣.5 Checking admin panel build..."
cd /root/maternal-app/parentflow-admin
if npm run build > /tmp/admin-build.log 2>&1; then
echo "✅ Admin panel build successful"
else
echo "❌ Admin panel build failed"
echo "See /tmp/admin-build.log for details"
tail -20 /tmp/admin-build.log
ERRORS=$((ERRORS + 1))
fi
# Check 3: Uncommitted changes
echo ""
echo "3⃣ Checking for uncommitted changes..."
cd /root/maternal-app
if [ -z "$(git status --porcelain)" ]; then
echo "✅ No uncommitted changes"
else
echo "⚠️ Uncommitted changes detected:"
git status --short
echo ""
echo "You may want to commit these before deploying"
fi
# Check 4: Current branch
echo ""
echo "4⃣ Checking current branch..."
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "✅ On branch: $BRANCH"
else
echo "⚠️ On branch: $BRANCH (not main/master)"
fi
# Check 5: Remote sync
echo ""
echo "5⃣ Checking if local is ahead of remote..."
git fetch origin --quiet
LOCAL=$(git rev-parse @)
REMOTE=$(git rev-parse @{u})
if [ "$LOCAL" = "$REMOTE" ]; then
echo "✅ Local and remote are in sync"
elif [ "$LOCAL" != "$REMOTE" ]; then
AHEAD=$(git rev-list --count @{u}..HEAD)
if [ "$AHEAD" -gt 0 ]; then
echo "⚠️ Local is $AHEAD commit(s) ahead of remote (need to push)"
else
echo "⚠️ Local is behind remote (need to pull)"
fi
fi
# Check 6: Pending migrations
echo ""
echo "6⃣ Checking for pending migrations..."
cd /root/maternal-app/maternal-app/maternal-app-backend
MIGRATIONS=$(find src/database/migrations -name "*.ts" 2>/dev/null | wc -l)
echo "Found $MIGRATIONS migration files"
# Check 7: Environment files
echo ""
echo "7⃣ Checking environment files..."
if [ -f "/root/maternal-app/maternal-web/.env.local" ]; then
echo "✅ Frontend .env.local exists"
else
echo "⚠️ Frontend .env.local not found"
fi
if [ -f "/root/maternal-app/maternal-app/maternal-app-backend/.env" ]; then
echo "✅ Backend .env exists"
else
echo "⚠️ Backend .env not found"
fi
if [ -f "/root/maternal-app/parentflow-admin/.env.local" ]; then
echo "✅ Admin panel .env.local exists"
else
echo "⚠️ Admin panel .env.local not found"
fi
# Summary
echo ""
echo "======================================"
if [ $ERRORS -eq 0 ]; then
echo "✅ All critical checks passed! Ready to deploy."
echo ""
echo "📋 Next steps:"
echo " 1. Review any warnings above"
echo " 2. git add . (if uncommitted changes)"
echo " 3. git commit -m 'your message'"
echo " 4. git push origin main"
echo " 5. SSH to production server"
echo " 6. Run: /var/www/maternal-app/deploy.sh"
echo ""
else
echo "$ERRORS critical check(s) failed!"
echo ""
echo "Fix the following before deploying:"
if [ -f /tmp/frontend-build.log ]; then
echo " - Frontend build errors"
fi
if [ -f /tmp/backend-build.log ]; then
echo " - Backend build errors"
fi
if [ -f /tmp/admin-build.log ]; then
echo " - Admin panel build errors"
fi
echo ""
exit 1
fi
echo "======================================"

View File

@@ -1,947 +0,0 @@
# 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)