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
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:
219
DEPLOYMENT_QUICK_START.md
Normal file
219
DEPLOYMENT_QUICK_START.md
Normal 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`**
|
||||
745
PRODUCTION_DEPLOYMENT_CHECKLIST.md
Normal file
745
PRODUCTION_DEPLOYMENT_CHECKLIST.md
Normal 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
|
||||
@@ -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.
|
||||
@@ -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 ✅
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
142
maternal-web/components/common/ErrorMessage.tsx
Normal file
142
maternal-web/components/common/ErrorMessage.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
80
maternal-web/components/common/ErrorToast.tsx
Normal file
80
maternal-web/components/common/ErrorToast.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
267
maternal-web/lib/utils/errorHandler.ts
Normal file
267
maternal-web/lib/utils/errorHandler.ts
Normal 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
47
maternal-web/update-tracking-errors.sh
Normal file
47
maternal-web/update-tracking-errors.sh
Normal 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!"
|
||||
@@ -142,7 +142,7 @@ export default function EditLegalPagePage() {
|
||||
'fullscreen',
|
||||
'|',
|
||||
'guide',
|
||||
],
|
||||
] as const,
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
146
pre-deploy-check.sh
Executable 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 "======================================"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user