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
|
// 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');
|
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
|
// Record invite code usage if applicable
|
||||||
if (validatedInviteCode) {
|
if (validatedInviteCode) {
|
||||||
// Increment the usage counter
|
// Increment the usage counter
|
||||||
validatedInviteCode.uses += 1;
|
validatedInviteCode.useCount += 1;
|
||||||
await this.inviteCodeRepository.save(validatedInviteCode);
|
await this.inviteCodeRepository.save(validatedInviteCode);
|
||||||
|
|
||||||
// Record the invite code usage
|
// Record the invite code usage
|
||||||
@@ -216,11 +216,6 @@ export class AuthService {
|
|||||||
eulaAcceptedAt: savedUser.eulaAcceptedAt,
|
eulaAcceptedAt: savedUser.eulaAcceptedAt,
|
||||||
eulaVersion: savedUser.eulaVersion,
|
eulaVersion: savedUser.eulaVersion,
|
||||||
},
|
},
|
||||||
family: {
|
|
||||||
id: savedFamily.id,
|
|
||||||
shareCode: savedFamily.shareCode,
|
|
||||||
role: 'parent',
|
|
||||||
},
|
|
||||||
tokens,
|
tokens,
|
||||||
deviceRegistered: true,
|
deviceRegistered: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ export class InviteCodesService {
|
|||||||
if (params?.status === 'active') {
|
if (params?.status === 'active') {
|
||||||
query.where('invite_code.is_active = :isActive', { isActive: true })
|
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.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') {
|
} else if (params?.status === 'inactive') {
|
||||||
query.where('invite_code.is_active = :isActive', { isActive: false });
|
query.where('invite_code.is_active = :isActive', { isActive: false });
|
||||||
} else if (params?.status === 'expired') {
|
} else if (params?.status === 'expired') {
|
||||||
query.where('invite_code.expires_at <= NOW()');
|
query.where('invite_code.expires_at <= NOW()');
|
||||||
} else if (params?.status === 'exhausted') {
|
} 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;
|
const page = params?.page || 1;
|
||||||
@@ -129,7 +129,7 @@ export class InviteCodesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if max uses reached
|
// 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' };
|
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 { motion } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import apiClient from '@/lib/api/client';
|
import apiClient from '@/lib/api/client';
|
||||||
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const { error, showError, clearError, hasError } = useErrorMessage();
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!email.trim()) {
|
if (!email.trim()) {
|
||||||
setError('Please enter your email address');
|
showError({ message: 'Please enter your email address', code: 'VALIDATION_EMAIL_REQUIRED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
clearError();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/v1/auth/password/forgot', { email });
|
await apiClient.post('/api/v1/auth/password/forgot', { email });
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Forgot password error:', err);
|
showError(err);
|
||||||
setError(err.response?.data?.message || 'Failed to send reset email. Please try again.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -97,9 +98,9 @@ export default function ForgotPasswordPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { startAuthentication } from '@simplewebauthn/browser';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email('Invalid email address'),
|
email: z.string().email('Invalid email address'),
|
||||||
@@ -40,7 +42,7 @@ export default function LoginPage() {
|
|||||||
const { t } = useTranslation('auth');
|
const { t } = useTranslation('auth');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const { error, showError, clearError, hasError } = useErrorMessage();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isBiometricLoading, setIsBiometricLoading] = useState(false);
|
const [isBiometricLoading, setIsBiometricLoading] = useState(false);
|
||||||
const [isBiometricSupported, setIsBiometricSupported] = useState(false);
|
const [isBiometricSupported, setIsBiometricSupported] = useState(false);
|
||||||
@@ -74,7 +76,7 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBiometricLogin = async () => {
|
const handleBiometricLogin = async () => {
|
||||||
setError(null);
|
clearError();
|
||||||
setIsBiometricLoading(true);
|
setIsBiometricLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -100,11 +102,11 @@ export default function LoginPage() {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Biometric login failed:', err);
|
console.error('Biometric login failed:', err);
|
||||||
if (err.name === 'NotAllowedError') {
|
if (err.name === 'NotAllowedError') {
|
||||||
setError('Biometric authentication was cancelled');
|
showError({ message: 'Biometric authentication was cancelled', code: 'BIOMETRIC_CANCELLED' });
|
||||||
} else if (err.name === 'NotSupportedError') {
|
} 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 {
|
} else {
|
||||||
setError(err.response?.data?.message || err.message || 'Biometric login failed. Please try again.');
|
showError(err);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsBiometricLoading(false);
|
setIsBiometricLoading(false);
|
||||||
@@ -112,7 +114,7 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: LoginFormData) => {
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
setError(null);
|
clearError();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -127,7 +129,7 @@ export default function LoginPage() {
|
|||||||
mfaMethod: err.response.data.mfaMethod,
|
mfaMethod: err.response.data.mfaMethod,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setError(err.message || 'Failed to login. Please check your credentials.');
|
showError(err);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -193,9 +195,9 @@ export default function LoginPage() {
|
|||||||
{t('login.subtitle')}
|
{t('login.subtitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { useAuth } from '@/lib/auth/AuthContext';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
import apiClient from '@/lib/api/client';
|
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
|
// Create a function to generate schema dynamically based on requireInviteCode
|
||||||
const createRegisterSchema = (requireInviteCode: boolean) => z.object({
|
const createRegisterSchema = (requireInviteCode: boolean) => z.object({
|
||||||
@@ -79,7 +81,7 @@ export default function RegisterPage() {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = 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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [userAge, setUserAge] = useState<number | null>(null);
|
const [userAge, setUserAge] = useState<number | null>(null);
|
||||||
const [requiresParentalConsent, setRequiresParentalConsent] = useState(false);
|
const [requiresParentalConsent, setRequiresParentalConsent] = useState(false);
|
||||||
@@ -150,11 +152,11 @@ export default function RegisterPage() {
|
|||||||
}, [dateOfBirth]);
|
}, [dateOfBirth]);
|
||||||
|
|
||||||
const onSubmit = async (data: RegisterFormData) => {
|
const onSubmit = async (data: RegisterFormData) => {
|
||||||
setError(null);
|
clearError();
|
||||||
|
|
||||||
// Validate invite code if required
|
// Validate invite code if required
|
||||||
if (requireInviteCode && (!data.inviteCode || data.inviteCode.trim() === '')) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +174,7 @@ export default function RegisterPage() {
|
|||||||
});
|
});
|
||||||
// Navigation to onboarding is handled in the register function
|
// Navigation to onboarding is handled in the register function
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to register. Please try again.');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -224,9 +226,9 @@ export default function RegisterPage() {
|
|||||||
Start your journey to organized parenting
|
Start your journey to organized parenting
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -285,14 +287,21 @@ export default function RegisterPage() {
|
|||||||
label="Invite Code"
|
label="Invite Code"
|
||||||
margin="normal"
|
margin="normal"
|
||||||
error={!!errors.inviteCode}
|
error={!!errors.inviteCode}
|
||||||
helperText={errors.inviteCode?.message || 'Enter your invite code to register'}
|
helperText={errors.inviteCode?.message || 'Invite codes are automatically converted to uppercase'}
|
||||||
{...register('inviteCode')}
|
{...register('inviteCode', {
|
||||||
|
setValueAs: (value) => value?.toUpperCase() || '',
|
||||||
|
})}
|
||||||
|
onChange={(e) => {
|
||||||
|
const uppercased = e.target.value.toUpperCase();
|
||||||
|
setValue('inviteCode', uppercased);
|
||||||
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
required
|
required
|
||||||
inputProps={{
|
inputProps={{
|
||||||
'aria-required': 'true',
|
'aria-required': 'true',
|
||||||
'aria-invalid': !!errors.inviteCode,
|
'aria-invalid': !!errors.inviteCode,
|
||||||
'aria-describedby': errors.inviteCode ? 'invite-code-error' : 'invite-code-helper',
|
'aria-describedby': errors.inviteCode ? 'invite-code-error' : 'invite-code-helper',
|
||||||
|
style: { textTransform: 'uppercase' },
|
||||||
}}
|
}}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
sx: { borderRadius: 3 },
|
sx: { borderRadius: 3 },
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import { RoleInvitesSection } from '@/components/family/RoleInvitesSection';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useSelectedFamily } from '@/hooks/useSelectedFamily';
|
import { useSelectedFamily } from '@/hooks/useSelectedFamily';
|
||||||
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
|
|
||||||
export default function FamilyPage() {
|
export default function FamilyPage() {
|
||||||
const { t } = useTranslation('family');
|
const { t } = useTranslation('family');
|
||||||
@@ -46,7 +48,7 @@ export default function FamilyPage() {
|
|||||||
const [family, setFamily] = useState<Family | null>(null);
|
const [family, setFamily] = useState<Family | null>(null);
|
||||||
const [members, setMembers] = useState<FamilyMember[]>([]);
|
const [members, setMembers] = useState<FamilyMember[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string>('');
|
const { error, showError, clearError, hasError } = useErrorMessage();
|
||||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||||
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
|
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
|
||||||
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
|
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
|
||||||
@@ -63,7 +65,7 @@ export default function FamilyPage() {
|
|||||||
fetchFamilyData();
|
fetchFamilyData();
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(t('messages.noFamilyFound'));
|
showError({ message: t('messages.noFamilyFound'), code: 'NO_FAMILY_FOUND' });
|
||||||
}
|
}
|
||||||
}, [familyId, selectedIndex]);
|
}, [familyId, selectedIndex]);
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ export default function FamilyPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
clearError();
|
||||||
const [familyData, membersData] = await Promise.all([
|
const [familyData, membersData] = await Promise.all([
|
||||||
familiesApi.getFamily(familyId),
|
familiesApi.getFamily(familyId),
|
||||||
familiesApi.getFamilyMembers(familyId),
|
familiesApi.getFamilyMembers(familyId),
|
||||||
@@ -86,8 +88,7 @@ export default function FamilyPage() {
|
|||||||
[familyId]: familyData.name
|
[familyId]: familyData.name
|
||||||
}));
|
}));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch family data:', err);
|
showError(err);
|
||||||
setError(err.response?.data?.message || t('messages.failedToLoad'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -186,8 +187,7 @@ export default function FamilyPage() {
|
|||||||
setRemoveDialogOpen(false);
|
setRemoveDialogOpen(false);
|
||||||
setMemberToRemove(null);
|
setMemberToRemove(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to remove member:', err);
|
showError(err);
|
||||||
setError(err.response?.data?.message || t('messages.failedToRemove'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -281,9 +281,9 @@ export default function FamilyPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -435,7 +435,7 @@ export default function FamilyPage() {
|
|||||||
<RoleInvitesSection
|
<RoleInvitesSection
|
||||||
familyId={familyId!}
|
familyId={familyId!}
|
||||||
onSuccess={(message) => setSnackbar({ open: true, message })}
|
onSuccess={(message) => setSnackbar({ open: true, message })}
|
||||||
onError={(message) => setError(message)}
|
onError={(err) => showError(err)}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||||||
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
|
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
|
||||||
import { AppDispatch, RootState } from '@/store/store';
|
import { AppDispatch, RootState } from '@/store/store';
|
||||||
import ChildSelector from '@/components/common/ChildSelector';
|
import ChildSelector from '@/components/common/ChildSelector';
|
||||||
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
|
|
||||||
interface ActivityData {
|
interface ActivityData {
|
||||||
activityType: string;
|
activityType: string;
|
||||||
@@ -83,7 +85,7 @@ function ActivityTrackPage() {
|
|||||||
const [recentActivities, setRecentActivities] = useState<Activity[]>([]);
|
const [recentActivities, setRecentActivities] = useState<Activity[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activitiesLoading, setActivitiesLoading] = 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);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
@@ -131,19 +133,19 @@ function ActivityTrackPage() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedChild?.id) {
|
if (!selectedChild?.id) {
|
||||||
setError('Please select a child');
|
showError({ message: 'Please select a child', code: 'NO_CHILD_SELECTED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!activityType) {
|
if (!activityType) {
|
||||||
setError('Please select activity type');
|
showError({ message: 'Please select activity type', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
clearError();
|
||||||
|
|
||||||
const data: ActivityData = {
|
const data: ActivityData = {
|
||||||
activityType,
|
activityType,
|
||||||
@@ -167,7 +169,7 @@ function ActivityTrackPage() {
|
|||||||
await loadRecentActivities();
|
await loadRecentActivities();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to save activity:', err);
|
console.error('Failed to save activity:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to save activity');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -197,7 +199,7 @@ function ActivityTrackPage() {
|
|||||||
await loadRecentActivities();
|
await loadRecentActivities();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete activity:', err);
|
console.error('Failed to delete activity:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to delete activity');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -306,9 +308,9 @@ function ActivityTrackPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import { childrenApi, Child } from '@/lib/api/children';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
|
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
|
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
|
||||||
import { AppDispatch, RootState } from '@/store/store';
|
import { AppDispatch, RootState } from '@/store/store';
|
||||||
@@ -90,7 +92,7 @@ export default function DiaperTrackPage() {
|
|||||||
const [recentDiapers, setRecentDiapers] = useState<Activity[]>([]);
|
const [recentDiapers, setRecentDiapers] = useState<Activity[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [diapersLoading, setDiapersLoading] = 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);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
@@ -215,24 +217,24 @@ export default function DiaperTrackPage() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedChild?.id) {
|
if (!selectedChild?.id) {
|
||||||
setError('Please select a child');
|
showError({ message: 'Please select a child', code: 'NO_CHILD_SELECTED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!timestamp) {
|
if (!timestamp) {
|
||||||
setError('Please enter timestamp');
|
showError({ message: 'Please enter timestamp', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conditions.length === 0) {
|
if (conditions.length === 0) {
|
||||||
setError('Please select at least one condition');
|
showError({ message: 'Please select at least one condition', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
clearError();
|
||||||
|
|
||||||
const data: DiaperData = {
|
const data: DiaperData = {
|
||||||
diaperType,
|
diaperType,
|
||||||
@@ -260,7 +262,7 @@ export default function DiaperTrackPage() {
|
|||||||
await loadRecentDiapers();
|
await loadRecentDiapers();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to save diaper:', err);
|
console.error('Failed to save diaper:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to save diaper change');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -292,7 +294,7 @@ export default function DiaperTrackPage() {
|
|||||||
await loadRecentDiapers();
|
await loadRecentDiapers();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete diaper:', err);
|
console.error('Failed to delete diaper:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to delete diaper change');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -421,9 +423,9 @@ export default function DiaperTrackPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import { motion } from 'framer-motion';
|
|||||||
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
|
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { UnitInput } from '@/components/forms/UnitInput';
|
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 { convertVolume, getUnitSymbol } from '@/lib/utils/unitConversion';
|
||||||
import { MeasurementSystem } from '@/hooks/useLocale';
|
import { MeasurementSystem } from '@/hooks/useLocale';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
@@ -106,7 +108,7 @@ function FeedingTrackPage() {
|
|||||||
const [recentFeedings, setRecentFeedings] = useState<Activity[]>([]);
|
const [recentFeedings, setRecentFeedings] = useState<Activity[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [feedingsLoading, setFeedingsLoading] = 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);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
@@ -186,29 +188,29 @@ function FeedingTrackPage() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedChild?.id) {
|
if (!selectedChild?.id) {
|
||||||
setError(t('common.selectChild'));
|
showError({ message: t('common.selectChild'), code: 'NO_CHILD_SELECTED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (feedingType === 'breast' && duration === 0 && timerSeconds === 0) {
|
if (feedingType === 'breast' && duration === 0 && timerSeconds === 0) {
|
||||||
setError(t('feeding.validation.durationRequired'));
|
showError({ message: t('feeding.validation.durationRequired'), code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feedingType === 'bottle' && !amount) {
|
if (feedingType === 'bottle' && !amount) {
|
||||||
setError(t('feeding.validation.amountRequired'));
|
showError({ message: t('feeding.validation.amountRequired'), code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feedingType === 'solid' && !foodDescription) {
|
if (feedingType === 'solid' && !foodDescription) {
|
||||||
setError(t('feeding.validation.foodRequired'));
|
showError({ message: t('feeding.validation.foodRequired'), code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
clearError();
|
||||||
|
|
||||||
const data: FeedingData = {
|
const data: FeedingData = {
|
||||||
feedingType,
|
feedingType,
|
||||||
@@ -241,7 +243,7 @@ function FeedingTrackPage() {
|
|||||||
await loadRecentFeedings();
|
await loadRecentFeedings();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to save feeding:', err);
|
console.error('Failed to save feeding:', err);
|
||||||
setError(err.response?.data?.message || t('feeding.error.saveFailed'));
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -276,7 +278,7 @@ function FeedingTrackPage() {
|
|||||||
await loadRecentFeedings();
|
await loadRecentFeedings();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete feeding:', err);
|
console.error('Failed to delete feeding:', err);
|
||||||
setError(err.response?.data?.message || t('feeding.error.deleteFailed'));
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -398,9 +400,9 @@ function FeedingTrackPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } fr
|
|||||||
import { AppDispatch, RootState } from '@/store/store';
|
import { AppDispatch, RootState } from '@/store/store';
|
||||||
import ChildSelector from '@/components/common/ChildSelector';
|
import ChildSelector from '@/components/common/ChildSelector';
|
||||||
import { UnitInput } from '@/components/forms/UnitInput';
|
import { UnitInput } from '@/components/forms/UnitInput';
|
||||||
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
|
|
||||||
interface GrowthData {
|
interface GrowthData {
|
||||||
weight?: number; // in kg
|
weight?: number; // in kg
|
||||||
@@ -84,7 +86,7 @@ function GrowthTrackPage() {
|
|||||||
const [recentGrowth, setRecentGrowth] = useState<Activity[]>([]);
|
const [recentGrowth, setRecentGrowth] = useState<Activity[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [growthLoading, setGrowthLoading] = 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);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
@@ -132,31 +134,31 @@ function GrowthTrackPage() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedChild?.id) {
|
if (!selectedChild?.id) {
|
||||||
setError(t('common.selectChild'));
|
showError({ message: t('common.selectChild'), code: 'NO_CHILD_SELECTED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (measurementType === 'weight' && weight === 0) {
|
if (measurementType === 'weight' && weight === 0) {
|
||||||
setError('Please enter weight');
|
showError({ message: 'Please enter weight', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (measurementType === 'height' && height === 0) {
|
if (measurementType === 'height' && height === 0) {
|
||||||
setError('Please enter height');
|
showError({ message: 'Please enter height', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (measurementType === 'head' && headCircumference === 0) {
|
if (measurementType === 'head' && headCircumference === 0) {
|
||||||
setError('Please enter head circumference');
|
showError({ message: 'Please enter head circumference', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (measurementType === 'all' && (weight === 0 || height === 0 || headCircumference === 0)) {
|
if (measurementType === 'all' && (weight === 0 || height === 0 || headCircumference === 0)) {
|
||||||
setError('Please enter all measurements');
|
showError({ message: 'Please enter all measurements', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
clearError();
|
||||||
|
|
||||||
const data: GrowthData = {
|
const data: GrowthData = {
|
||||||
measurementType,
|
measurementType,
|
||||||
@@ -181,7 +183,7 @@ function GrowthTrackPage() {
|
|||||||
await loadRecentGrowth();
|
await loadRecentGrowth();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to save growth:', err);
|
console.error('Failed to save growth:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to save growth measurement');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -212,7 +214,7 @@ function GrowthTrackPage() {
|
|||||||
await loadRecentGrowth();
|
await loadRecentGrowth();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete growth:', err);
|
console.error('Failed to delete growth:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to delete growth measurement');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -324,9 +326,9 @@ function GrowthTrackPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ import { useTranslation } from '@/hooks/useTranslation';
|
|||||||
import { UnitInput } from '@/components/forms/UnitInput';
|
import { UnitInput } from '@/components/forms/UnitInput';
|
||||||
import { convertVolume, convertTemperature } from '@/lib/utils/unitConversion';
|
import { convertVolume, convertTemperature } from '@/lib/utils/unitConversion';
|
||||||
import { MeasurementSystem } from '@/hooks/useLocale';
|
import { MeasurementSystem } from '@/hooks/useLocale';
|
||||||
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
|
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
|
||||||
import { AppDispatch, RootState } from '@/store/store';
|
import { AppDispatch, RootState } from '@/store/store';
|
||||||
@@ -121,7 +123,7 @@ function MedicalTrackPage() {
|
|||||||
const [recentActivities, setRecentActivities] = useState<Activity[]>([]);
|
const [recentActivities, setRecentActivities] = useState<Activity[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activitiesLoading, setActivitiesLoading] = 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);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
@@ -184,36 +186,36 @@ function MedicalTrackPage() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedChild?.id) {
|
if (!selectedChild?.id) {
|
||||||
setError(t('common.selectChild'));
|
showError({ message: t('common.selectChild'), code: 'NO_CHILD_SELECTED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation based on activity type
|
// Validation based on activity type
|
||||||
if (activityType === 'medication') {
|
if (activityType === 'medication') {
|
||||||
if (!medicineName) {
|
if (!medicineName) {
|
||||||
setError(t('health.medicineName.required'));
|
showError({ message: t('health.medicineName.required'), code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dosageValue = unit === 'ml' ? dosage : dosageText;
|
const dosageValue = unit === 'ml' ? dosage : dosageText;
|
||||||
if (!dosageValue || (unit === 'ml' && dosage === 0) || (unit !== 'ml' && !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;
|
return;
|
||||||
}
|
}
|
||||||
} else if (activityType === 'temperature') {
|
} else if (activityType === 'temperature') {
|
||||||
if (temperature === 0) {
|
if (temperature === 0) {
|
||||||
setError('Please enter temperature');
|
showError({ message: 'Please enter temperature', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (activityType === 'doctor') {
|
} else if (activityType === 'doctor') {
|
||||||
if (!visitType) {
|
if (!visitType) {
|
||||||
setError('Please select visit type');
|
showError({ message: 'Please select visit type', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
clearError();
|
||||||
|
|
||||||
let data: MedicationData | TemperatureData | DoctorVisitData;
|
let data: MedicationData | TemperatureData | DoctorVisitData;
|
||||||
|
|
||||||
@@ -253,7 +255,7 @@ function MedicalTrackPage() {
|
|||||||
await loadRecentActivities();
|
await loadRecentActivities();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to save activity:', err);
|
console.error('Failed to save activity:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to save activity');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -299,7 +301,7 @@ function MedicalTrackPage() {
|
|||||||
await loadRecentActivities();
|
await loadRecentActivities();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete activity:', err);
|
console.error('Failed to delete activity:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to delete activity');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -446,9 +448,9 @@ function MedicalTrackPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import { childrenApi, Child } from '@/lib/api/children';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
|
import { useLocalizedDate } from '@/hooks/useLocalizedDate';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
|
import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice';
|
||||||
import { AppDispatch, RootState } from '@/store/store';
|
import { AppDispatch, RootState } from '@/store/store';
|
||||||
@@ -91,7 +93,7 @@ export default function SleepTrackPage() {
|
|||||||
const [recentSleeps, setRecentSleeps] = useState<Activity[]>([]);
|
const [recentSleeps, setRecentSleeps] = useState<Activity[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [sleepsLoading, setSleepsLoading] = 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);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
@@ -181,18 +183,18 @@ export default function SleepTrackPage() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedChild?.id) {
|
if (!selectedChild?.id) {
|
||||||
setError('Please select a child');
|
showError({ message: 'Please select a child', code: 'NO_CHILD_SELECTED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!startTime) {
|
if (!startTime) {
|
||||||
setError('Please enter start time');
|
showError({ message: 'Please enter start time', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOngoing && !endTime) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,14 +202,14 @@ export default function SleepTrackPage() {
|
|||||||
const start = new Date(startTime);
|
const start = new Date(startTime);
|
||||||
const end = new Date(endTime);
|
const end = new Date(endTime);
|
||||||
if (end <= start) {
|
if (end <= start) {
|
||||||
setError('End time must be after start time');
|
showError({ message: 'End time must be after start time', code: 'VALIDATION_ERROR' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
clearError();
|
||||||
|
|
||||||
const data: SleepData = {
|
const data: SleepData = {
|
||||||
startTime,
|
startTime,
|
||||||
@@ -236,7 +238,7 @@ export default function SleepTrackPage() {
|
|||||||
await loadRecentSleeps();
|
await loadRecentSleeps();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to save sleep:', err);
|
console.error('Failed to save sleep:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to save sleep');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -268,7 +270,7 @@ export default function SleepTrackPage() {
|
|||||||
await loadRecentSleeps();
|
await loadRecentSleeps();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete sleep:', err);
|
console.error('Failed to delete sleep:', err);
|
||||||
setError(err.response?.data?.message || 'Failed to delete sleep');
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -380,9 +382,9 @@ export default function SleepTrackPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 3 }} onClose={clearError}>
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
import { Child, CreateChildData } from '@/lib/api/children';
|
import { Child, CreateChildData } from '@/lib/api/children';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { PhotoUpload } from '@/components/common/PhotoUpload';
|
import { PhotoUpload } from '@/components/common/PhotoUpload';
|
||||||
|
import { useErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
|
import { formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
|
|
||||||
interface ChildDialogProps {
|
interface ChildDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -33,7 +35,7 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
|
|||||||
photoUrl: '',
|
photoUrl: '',
|
||||||
photoAlt: '',
|
photoAlt: '',
|
||||||
});
|
});
|
||||||
const [error, setError] = useState<string>('');
|
const { error, showError, clearError, hasError } = useErrorMessage();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (child) {
|
if (child) {
|
||||||
@@ -53,8 +55,8 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
|
|||||||
photoAlt: '',
|
photoAlt: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setError('');
|
clearError();
|
||||||
}, [child, open]);
|
}, [child, open, clearError]);
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateChildData) => (
|
const handleChange = (field: keyof CreateChildData) => (
|
||||||
e: React.ChangeEvent<HTMLInputElement>
|
e: React.ChangeEvent<HTMLInputElement>
|
||||||
@@ -63,15 +65,15 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setError('');
|
clearError();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
setError(t('dialog.validation.nameRequired'));
|
showError({ message: t('dialog.validation.nameRequired'), code: 'VALIDATION_NAME_REQUIRED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!formData.birthDate) {
|
if (!formData.birthDate) {
|
||||||
setError(t('dialog.validation.birthDateRequired'));
|
showError({ message: t('dialog.validation.birthDateRequired'), code: 'VALIDATION_BIRTHDATE_REQUIRED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
if (selectedDate > today) {
|
if (selectedDate > today) {
|
||||||
setError(t('dialog.validation.birthDateFuture'));
|
showError({ message: t('dialog.validation.birthDateFuture'), code: 'VALIDATION_BIRTHDATE_FUTURE' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
|
|||||||
await onSubmit(formData);
|
await onSubmit(formData);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} 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"
|
id="child-dialog-description"
|
||||||
sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}
|
sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||||
>
|
>
|
||||||
{error && (
|
{hasError && (
|
||||||
<Alert severity="error" onClose={() => setError('')} role="alert">
|
<Alert severity="error" onClose={clearError} role="alert">
|
||||||
{error}
|
{formatErrorMessage(error)}
|
||||||
</Alert>
|
</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
|
<TextField
|
||||||
label="Share Code"
|
label="Share Code"
|
||||||
value={shareCode}
|
value={shareCode}
|
||||||
onChange={(e) => setShareCode(e.target.value)}
|
onChange={(e) => setShareCode(e.target.value.toUpperCase())}
|
||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="Enter family share code"
|
placeholder="XXXX-XXXX"
|
||||||
helperText="Ask a family member for their share code"
|
helperText="Share codes are automatically converted to uppercase"
|
||||||
|
inputProps={{
|
||||||
|
style: { textTransform: 'uppercase' },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { tokenStorage } from '@/lib/utils/tokenStorage';
|
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';
|
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(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config;
|
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
|
// Only handle token refresh on client side
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import apiClient from '@/lib/api/client';
|
import apiClient from '@/lib/api/client';
|
||||||
import { tokenStorage } from '@/lib/utils/tokenStorage';
|
import { tokenStorage } from '@/lib/utils/tokenStorage';
|
||||||
|
import { handleError, formatErrorMessage } from '@/lib/utils/errorHandler';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -214,8 +215,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Login failed:', error);
|
const errorMessage = handleError(error, 'AuthContext.login');
|
||||||
throw new Error(error.response?.data?.message || 'Login failed');
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -285,8 +286,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
// Redirect to onboarding
|
// Redirect to onboarding
|
||||||
router.push('/onboarding');
|
router.push('/onboarding');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Registration failed:', error);
|
const errorMessage = handleError(error, 'AuthContext.register');
|
||||||
throw new Error(error.response?.data?.message || error.message || 'Registration failed');
|
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',
|
'fullscreen',
|
||||||
'|',
|
'|',
|
||||||
'guide',
|
'guide',
|
||||||
],
|
] as const,
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export default function DashboardPage() {
|
|||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
labelLine={false}
|
labelLine={false}
|
||||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
label={(props: any) => `${props.name} ${(props.percent * 100).toFixed(0)}%`}
|
||||||
outerRadius={80}
|
outerRadius={80}
|
||||||
fill="#8884d8"
|
fill="#8884d8"
|
||||||
dataKey="value"
|
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