diff --git a/DEPLOYMENT_QUICK_START.md b/DEPLOYMENT_QUICK_START.md new file mode 100644 index 0000000..6c31aa8 --- /dev/null +++ b/DEPLOYMENT_QUICK_START.md @@ -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 + +# 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`** diff --git a/PRODUCTION_DEPLOYMENT_CHECKLIST.md b/PRODUCTION_DEPLOYMENT_CHECKLIST.md new file mode 100644 index 0000000..1dd0734 --- /dev/null +++ b/PRODUCTION_DEPLOYMENT_CHECKLIST.md @@ -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 + +# 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 diff --git a/PUSH_NOTIFICATIONS_IMPLEMENTATION.md b/PUSH_NOTIFICATIONS_IMPLEMENTATION.md deleted file mode 100644 index 31b2947..0000000 --- a/PUSH_NOTIFICATIONS_IMPLEMENTATION.md +++ /dev/null @@ -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 -- getVapidPublicKey(token): Promise -- registerPushServiceWorker(): Promise -- subscribeToPush(token): Promise -- savePushSubscription(subscription, token): Promise -- getPushSubscription(): Promise -- unsubscribeFromPush(token): Promise -- isPushSubscribed(): Promise -- sendTestPushNotification(token): Promise -- getPushStatistics(token): Promise -- showLocalTestNotification(): Promise -``` - -**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 - -``` - ---- - -## 🔧 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= -VAPID_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. diff --git a/PUSH_NOTIFICATIONS_PERSISTENCE_SUMMARY.md b/PUSH_NOTIFICATIONS_PERSISTENCE_SUMMARY.md deleted file mode 100644 index ed9999d..0000000 --- a/PUSH_NOTIFICATIONS_PERSISTENCE_SUMMARY.md +++ /dev/null @@ -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 - -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 -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 - -Response: -{ - "success": true -} -``` - -### Get Notification Summary - -```bash -GET /api/v1/preferences/notifications/summary -Authorization: Bearer - -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 ✅ diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts index f0a1292..fa90f82 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts @@ -94,7 +94,7 @@ export class AuthService { } // Check if the invite code has reached its maximum number of uses - if (validatedInviteCode.maxUses && validatedInviteCode.uses >= validatedInviteCode.maxUses) { + if (validatedInviteCode.maxUses && validatedInviteCode.useCount >= validatedInviteCode.maxUses) { throw new BadRequestException('This invite code has reached its maximum number of uses'); } } @@ -152,7 +152,7 @@ export class AuthService { // Record invite code usage if applicable if (validatedInviteCode) { // Increment the usage counter - validatedInviteCode.uses += 1; + validatedInviteCode.useCount += 1; await this.inviteCodeRepository.save(validatedInviteCode); // Record the invite code usage @@ -216,11 +216,6 @@ export class AuthService { eulaAcceptedAt: savedUser.eulaAcceptedAt, eulaVersion: savedUser.eulaVersion, }, - family: { - id: savedFamily.id, - shareCode: savedFamily.shareCode, - role: 'parent', - }, tokens, deviceRegistered: true, }, diff --git a/maternal-app/maternal-app-backend/src/modules/invite-codes/invite-codes.service.ts b/maternal-app/maternal-app-backend/src/modules/invite-codes/invite-codes.service.ts index 6580988..597c833 100644 --- a/maternal-app/maternal-app-backend/src/modules/invite-codes/invite-codes.service.ts +++ b/maternal-app/maternal-app-backend/src/modules/invite-codes/invite-codes.service.ts @@ -43,13 +43,13 @@ export class InviteCodesService { if (params?.status === 'active') { query.where('invite_code.is_active = :isActive', { isActive: true }) .andWhere('(invite_code.expires_at IS NULL OR invite_code.expires_at > NOW())') - .andWhere('(invite_code.max_uses IS NULL OR invite_code.uses < invite_code.max_uses)'); + .andWhere('(invite_code.max_uses IS NULL OR invite_code.use_count < invite_code.max_uses)'); } else if (params?.status === 'inactive') { query.where('invite_code.is_active = :isActive', { isActive: false }); } else if (params?.status === 'expired') { query.where('invite_code.expires_at <= NOW()'); } else if (params?.status === 'exhausted') { - query.where('invite_code.max_uses IS NOT NULL AND invite_code.uses >= invite_code.max_uses'); + query.where('invite_code.max_uses IS NOT NULL AND invite_code.use_count >= invite_code.max_uses'); } const page = params?.page || 1; @@ -129,7 +129,7 @@ export class InviteCodesService { } // Check if max uses reached - if (inviteCode.maxUses && inviteCode.uses >= inviteCode.maxUses) { + if (inviteCode.maxUses && inviteCode.useCount >= inviteCode.maxUses) { return { isValid: false, reason: 'Invite code has reached maximum uses' }; } diff --git a/maternal-web/app/(auth)/forgot-password/page.tsx b/maternal-web/app/(auth)/forgot-password/page.tsx index b72552d..8950642 100644 --- a/maternal-web/app/(auth)/forgot-password/page.tsx +++ b/maternal-web/app/(auth)/forgot-password/page.tsx @@ -15,30 +15,31 @@ import { Email, ArrowBack } from '@mui/icons-material'; import { motion } from 'framer-motion'; import Link from 'next/link'; import apiClient from '@/lib/api/client'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; export default function ForgotPasswordPage() { const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const { error, showError, clearError, hasError } = useErrorMessage(); const [success, setSuccess] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!email.trim()) { - setError('Please enter your email address'); + showError({ message: 'Please enter your email address', code: 'VALIDATION_EMAIL_REQUIRED' }); return; } setLoading(true); - setError(''); + clearError(); try { await apiClient.post('/api/v1/auth/password/forgot', { email }); setSuccess(true); } catch (err: any) { - console.error('Forgot password error:', err); - setError(err.response?.data?.message || 'Failed to send reset email. Please try again.'); + showError(err); } finally { setLoading(false); } @@ -97,9 +98,9 @@ export default function ForgotPasswordPage() { - {error && ( - - {error} + {hasError && ( + + {formatErrorMessage(error)} )} diff --git a/maternal-web/app/(auth)/login/page.tsx b/maternal-web/app/(auth)/login/page.tsx index 459f979..852d013 100644 --- a/maternal-web/app/(auth)/login/page.tsx +++ b/maternal-web/app/(auth)/login/page.tsx @@ -28,6 +28,8 @@ import { startAuthentication } from '@simplewebauthn/browser'; import Link from 'next/link'; import { useTranslation } from '@/hooks/useTranslation'; import { useTheme } from '@mui/material/styles'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; const loginSchema = z.object({ email: z.string().email('Invalid email address'), @@ -40,7 +42,7 @@ export default function LoginPage() { const { t } = useTranslation('auth'); const theme = useTheme(); const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(null); + const { error, showError, clearError, hasError } = useErrorMessage(); const [isLoading, setIsLoading] = useState(false); const [isBiometricLoading, setIsBiometricLoading] = useState(false); const [isBiometricSupported, setIsBiometricSupported] = useState(false); @@ -74,7 +76,7 @@ export default function LoginPage() { }; const handleBiometricLogin = async () => { - setError(null); + clearError(); setIsBiometricLoading(true); try { @@ -100,11 +102,11 @@ export default function LoginPage() { } catch (err: any) { console.error('Biometric login failed:', err); if (err.name === 'NotAllowedError') { - setError('Biometric authentication was cancelled'); + showError({ message: 'Biometric authentication was cancelled', code: 'BIOMETRIC_CANCELLED' }); } else if (err.name === 'NotSupportedError') { - setError('Biometric authentication is not supported on this device'); + showError({ message: 'Biometric authentication is not supported on this device', code: 'BIOMETRIC_NOT_SUPPORTED' }); } else { - setError(err.response?.data?.message || err.message || 'Biometric login failed. Please try again.'); + showError(err); } } finally { setIsBiometricLoading(false); @@ -112,7 +114,7 @@ export default function LoginPage() { }; const onSubmit = async (data: LoginFormData) => { - setError(null); + clearError(); setIsLoading(true); try { @@ -127,7 +129,7 @@ export default function LoginPage() { mfaMethod: err.response.data.mfaMethod, }); } else { - setError(err.message || 'Failed to login. Please check your credentials.'); + showError(err); } } finally { setIsLoading(false); @@ -193,9 +195,9 @@ export default function LoginPage() { {t('login.subtitle')} - {error && ( - - {error} + {hasError && ( + + {formatErrorMessage(error)} )} diff --git a/maternal-web/app/(auth)/register/page.tsx b/maternal-web/app/(auth)/register/page.tsx index 09bd92a..812ef97 100644 --- a/maternal-web/app/(auth)/register/page.tsx +++ b/maternal-web/app/(auth)/register/page.tsx @@ -24,6 +24,8 @@ import { useAuth } from '@/lib/auth/AuthContext'; import Link from 'next/link'; import { useTheme } from '@mui/material/styles'; import apiClient from '@/lib/api/client'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; // Create a function to generate schema dynamically based on requireInviteCode const createRegisterSchema = (requireInviteCode: boolean) => z.object({ @@ -79,7 +81,7 @@ export default function RegisterPage() { const theme = useTheme(); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [error, setError] = useState(null); + const { error, showError, clearError, hasError } = useErrorMessage(); const [isLoading, setIsLoading] = useState(false); const [userAge, setUserAge] = useState(null); const [requiresParentalConsent, setRequiresParentalConsent] = useState(false); @@ -150,11 +152,11 @@ export default function RegisterPage() { }, [dateOfBirth]); const onSubmit = async (data: RegisterFormData) => { - setError(null); + clearError(); // Validate invite code if required if (requireInviteCode && (!data.inviteCode || data.inviteCode.trim() === '')) { - setError('Invite code is required to register'); + showError({ message: 'Invite code is required to register', code: 'VALIDATION_INVITE_CODE_REQUIRED' }); return; } @@ -172,7 +174,7 @@ export default function RegisterPage() { }); // Navigation to onboarding is handled in the register function } catch (err: any) { - setError(err.message || 'Failed to register. Please try again.'); + showError(err); } finally { setIsLoading(false); } @@ -224,9 +226,9 @@ export default function RegisterPage() { Start your journey to organized parenting - {error && ( - - {error} + {hasError && ( + + {formatErrorMessage(error)} )} @@ -285,14 +287,21 @@ export default function RegisterPage() { label="Invite Code" margin="normal" error={!!errors.inviteCode} - helperText={errors.inviteCode?.message || 'Enter your invite code to register'} - {...register('inviteCode')} + helperText={errors.inviteCode?.message || 'Invite codes are automatically converted to uppercase'} + {...register('inviteCode', { + setValueAs: (value) => value?.toUpperCase() || '', + })} + onChange={(e) => { + const uppercased = e.target.value.toUpperCase(); + setValue('inviteCode', uppercased); + }} disabled={isLoading} required inputProps={{ 'aria-required': 'true', 'aria-invalid': !!errors.inviteCode, 'aria-describedby': errors.inviteCode ? 'invite-code-error' : 'invite-code-helper', + style: { textTransform: 'uppercase' }, }} InputProps={{ sx: { borderRadius: 3 }, diff --git a/maternal-web/app/family/page.tsx b/maternal-web/app/family/page.tsx index cc872b2..91aa482 100644 --- a/maternal-web/app/family/page.tsx +++ b/maternal-web/app/family/page.tsx @@ -38,6 +38,8 @@ import { RoleInvitesSection } from '@/components/family/RoleInvitesSection'; import { motion } from 'framer-motion'; import { useTranslation } from '@/hooks/useTranslation'; import { useSelectedFamily } from '@/hooks/useSelectedFamily'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; export default function FamilyPage() { const { t } = useTranslation('family'); @@ -46,7 +48,7 @@ export default function FamilyPage() { const [family, setFamily] = useState(null); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); + const { error, showError, clearError, hasError } = useErrorMessage(); const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [joinDialogOpen, setJoinDialogOpen] = useState(false); const [removeDialogOpen, setRemoveDialogOpen] = useState(false); @@ -63,7 +65,7 @@ export default function FamilyPage() { fetchFamilyData(); } else { setLoading(false); - setError(t('messages.noFamilyFound')); + showError({ message: t('messages.noFamilyFound'), code: 'NO_FAMILY_FOUND' }); } }, [familyId, selectedIndex]); @@ -72,7 +74,7 @@ export default function FamilyPage() { try { setLoading(true); - setError(''); + clearError(); const [familyData, membersData] = await Promise.all([ familiesApi.getFamily(familyId), familiesApi.getFamilyMembers(familyId), @@ -86,8 +88,7 @@ export default function FamilyPage() { [familyId]: familyData.name })); } catch (err: any) { - console.error('Failed to fetch family data:', err); - setError(err.response?.data?.message || t('messages.failedToLoad')); + showError(err); } finally { setLoading(false); } @@ -186,8 +187,7 @@ export default function FamilyPage() { setRemoveDialogOpen(false); setMemberToRemove(null); } catch (err: any) { - console.error('Failed to remove member:', err); - setError(err.response?.data?.message || t('messages.failedToRemove')); + showError(err); } finally { setActionLoading(false); } @@ -281,9 +281,9 @@ export default function FamilyPage() { )} - {error && ( - setError('')}> - {error} + {hasError && ( + + {formatErrorMessage(error)} )} @@ -435,7 +435,7 @@ export default function FamilyPage() { setSnackbar({ open: true, message })} - onError={(message) => setError(message)} + onError={(err) => showError(err)} /> )} diff --git a/maternal-web/app/track/activity/page.tsx b/maternal-web/app/track/activity/page.tsx index 1dc3c4f..1969be3 100644 --- a/maternal-web/app/track/activity/page.tsx +++ b/maternal-web/app/track/activity/page.tsx @@ -51,6 +51,8 @@ import { useDispatch, useSelector } from 'react-redux'; import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice'; import { AppDispatch, RootState } from '@/store/store'; import ChildSelector from '@/components/common/ChildSelector'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; interface ActivityData { activityType: string; @@ -83,7 +85,7 @@ function ActivityTrackPage() { const [recentActivities, setRecentActivities] = useState([]); const [loading, setLoading] = useState(false); const [activitiesLoading, setActivitiesLoading] = useState(false); - const [error, setError] = useState(null); + const { error, showError, clearError, hasError } = useErrorMessage(); const [successMessage, setSuccessMessage] = useState(null); // Delete confirmation dialog @@ -131,19 +133,19 @@ function ActivityTrackPage() { const handleSubmit = async () => { if (!selectedChild?.id) { - setError('Please select a child'); + showError({ message: 'Please select a child', code: 'NO_CHILD_SELECTED' }); return; } // Validation if (!activityType) { - setError('Please select activity type'); + showError({ message: 'Please select activity type', code: 'VALIDATION_ERROR' }); return; } try { setLoading(true); - setError(null); + clearError(); const data: ActivityData = { activityType, @@ -167,7 +169,7 @@ function ActivityTrackPage() { await loadRecentActivities(); } catch (err: any) { console.error('Failed to save activity:', err); - setError(err.response?.data?.message || 'Failed to save activity'); + showError(err); } finally { setLoading(false); } @@ -197,7 +199,7 @@ function ActivityTrackPage() { await loadRecentActivities(); } catch (err: any) { console.error('Failed to delete activity:', err); - setError(err.response?.data?.message || 'Failed to delete activity'); + showError(err); } finally { setLoading(false); } @@ -306,9 +308,9 @@ function ActivityTrackPage() { /> - {error && ( - setError(null)}> - {error} + {hasError && ( + + {formatErrorMessage(error)} )} diff --git a/maternal-web/app/track/diaper/page.tsx b/maternal-web/app/track/diaper/page.tsx index 3f882b0..5092cf4 100644 --- a/maternal-web/app/track/diaper/page.tsx +++ b/maternal-web/app/track/diaper/page.tsx @@ -48,6 +48,8 @@ import { childrenApi, Child } from '@/lib/api/children'; import { motion } from 'framer-motion'; import { useLocalizedDate } from '@/hooks/useLocalizedDate'; import { useTranslation } from '@/hooks/useTranslation'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; import { useDispatch, useSelector } from 'react-redux'; import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice'; import { AppDispatch, RootState } from '@/store/store'; @@ -90,7 +92,7 @@ export default function DiaperTrackPage() { const [recentDiapers, setRecentDiapers] = useState([]); const [loading, setLoading] = useState(false); const [diapersLoading, setDiapersLoading] = useState(false); - const [error, setError] = useState(null); + const { error, showError, clearError, hasError } = useErrorMessage(); const [successMessage, setSuccessMessage] = useState(null); // Delete confirmation dialog @@ -215,24 +217,24 @@ export default function DiaperTrackPage() { const handleSubmit = async () => { if (!selectedChild?.id) { - setError('Please select a child'); + showError({ message: 'Please select a child', code: 'NO_CHILD_SELECTED' }); return; } // Validation if (!timestamp) { - setError('Please enter timestamp'); + showError({ message: 'Please enter timestamp', code: 'VALIDATION_ERROR' }); return; } if (conditions.length === 0) { - setError('Please select at least one condition'); + showError({ message: 'Please select at least one condition', code: 'VALIDATION_ERROR' }); return; } try { setLoading(true); - setError(null); + clearError(); const data: DiaperData = { diaperType, @@ -260,7 +262,7 @@ export default function DiaperTrackPage() { await loadRecentDiapers(); } catch (err: any) { console.error('Failed to save diaper:', err); - setError(err.response?.data?.message || 'Failed to save diaper change'); + showError(err); } finally { setLoading(false); } @@ -292,7 +294,7 @@ export default function DiaperTrackPage() { await loadRecentDiapers(); } catch (err: any) { console.error('Failed to delete diaper:', err); - setError(err.response?.data?.message || 'Failed to delete diaper change'); + showError(err); } finally { setLoading(false); } @@ -421,9 +423,9 @@ export default function DiaperTrackPage() { - {error && ( - setError(null)}> - {error} + {hasError && ( + + {formatErrorMessage(error)} )} diff --git a/maternal-web/app/track/feeding/page.tsx b/maternal-web/app/track/feeding/page.tsx index 596bd3e..9ce2eca 100644 --- a/maternal-web/app/track/feeding/page.tsx +++ b/maternal-web/app/track/feeding/page.tsx @@ -54,6 +54,8 @@ import { motion } from 'framer-motion'; import { useLocalizedDate } from '@/hooks/useLocalizedDate'; import { useTranslation } from '@/hooks/useTranslation'; import { UnitInput } from '@/components/forms/UnitInput'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; import { convertVolume, getUnitSymbol } from '@/lib/utils/unitConversion'; import { MeasurementSystem } from '@/hooks/useLocale'; import { useDispatch, useSelector } from 'react-redux'; @@ -106,7 +108,7 @@ function FeedingTrackPage() { const [recentFeedings, setRecentFeedings] = useState([]); const [loading, setLoading] = useState(false); const [feedingsLoading, setFeedingsLoading] = useState(false); - const [error, setError] = useState(null); + const { error, showError, clearError, hasError } = useErrorMessage(); const [successMessage, setSuccessMessage] = useState(null); // Delete confirmation dialog @@ -186,29 +188,29 @@ function FeedingTrackPage() { const handleSubmit = async () => { if (!selectedChild?.id) { - setError(t('common.selectChild')); + showError({ message: t('common.selectChild'), code: 'NO_CHILD_SELECTED' }); return; } // Validation if (feedingType === 'breast' && duration === 0 && timerSeconds === 0) { - setError(t('feeding.validation.durationRequired')); + showError({ message: t('feeding.validation.durationRequired'), code: 'VALIDATION_ERROR' }); return; } if (feedingType === 'bottle' && !amount) { - setError(t('feeding.validation.amountRequired')); + showError({ message: t('feeding.validation.amountRequired'), code: 'VALIDATION_ERROR' }); return; } if (feedingType === 'solid' && !foodDescription) { - setError(t('feeding.validation.foodRequired')); + showError({ message: t('feeding.validation.foodRequired'), code: 'VALIDATION_ERROR' }); return; } try { setLoading(true); - setError(null); + clearError(); const data: FeedingData = { feedingType, @@ -241,7 +243,7 @@ function FeedingTrackPage() { await loadRecentFeedings(); } catch (err: any) { console.error('Failed to save feeding:', err); - setError(err.response?.data?.message || t('feeding.error.saveFailed')); + showError(err); } finally { setLoading(false); } @@ -276,7 +278,7 @@ function FeedingTrackPage() { await loadRecentFeedings(); } catch (err: any) { console.error('Failed to delete feeding:', err); - setError(err.response?.data?.message || t('feeding.error.deleteFailed')); + showError(err); } finally { setLoading(false); } @@ -398,9 +400,9 @@ function FeedingTrackPage() { /> - {error && ( - setError(null)}> - {error} + {hasError && ( + + {formatErrorMessage(error)} )} diff --git a/maternal-web/app/track/growth/page.tsx b/maternal-web/app/track/growth/page.tsx index 087c927..00083b9 100644 --- a/maternal-web/app/track/growth/page.tsx +++ b/maternal-web/app/track/growth/page.tsx @@ -50,6 +50,8 @@ import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } fr import { AppDispatch, RootState } from '@/store/store'; import ChildSelector from '@/components/common/ChildSelector'; import { UnitInput } from '@/components/forms/UnitInput'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; interface GrowthData { weight?: number; // in kg @@ -84,7 +86,7 @@ function GrowthTrackPage() { const [recentGrowth, setRecentGrowth] = useState([]); const [loading, setLoading] = useState(false); const [growthLoading, setGrowthLoading] = useState(false); - const [error, setError] = useState(null); + const { error, showError, clearError, hasError } = useErrorMessage(); const [successMessage, setSuccessMessage] = useState(null); // Delete confirmation dialog @@ -132,31 +134,31 @@ function GrowthTrackPage() { const handleSubmit = async () => { if (!selectedChild?.id) { - setError(t('common.selectChild')); + showError({ message: t('common.selectChild'), code: 'NO_CHILD_SELECTED' }); return; } // Validation if (measurementType === 'weight' && weight === 0) { - setError('Please enter weight'); + showError({ message: 'Please enter weight', code: 'VALIDATION_ERROR' }); return; } if (measurementType === 'height' && height === 0) { - setError('Please enter height'); + showError({ message: 'Please enter height', code: 'VALIDATION_ERROR' }); return; } if (measurementType === 'head' && headCircumference === 0) { - setError('Please enter head circumference'); + showError({ message: 'Please enter head circumference', code: 'VALIDATION_ERROR' }); return; } if (measurementType === 'all' && (weight === 0 || height === 0 || headCircumference === 0)) { - setError('Please enter all measurements'); + showError({ message: 'Please enter all measurements', code: 'VALIDATION_ERROR' }); return; } try { setLoading(true); - setError(null); + clearError(); const data: GrowthData = { measurementType, @@ -181,7 +183,7 @@ function GrowthTrackPage() { await loadRecentGrowth(); } catch (err: any) { console.error('Failed to save growth:', err); - setError(err.response?.data?.message || 'Failed to save growth measurement'); + showError(err); } finally { setLoading(false); } @@ -212,7 +214,7 @@ function GrowthTrackPage() { await loadRecentGrowth(); } catch (err: any) { console.error('Failed to delete growth:', err); - setError(err.response?.data?.message || 'Failed to delete growth measurement'); + showError(err); } finally { setLoading(false); } @@ -324,9 +326,9 @@ function GrowthTrackPage() { /> - {error && ( - setError(null)}> - {error} + {hasError && ( + + {formatErrorMessage(error)} )} diff --git a/maternal-web/app/track/medicine/page.tsx b/maternal-web/app/track/medicine/page.tsx index ef1f933..af8a46c 100644 --- a/maternal-web/app/track/medicine/page.tsx +++ b/maternal-web/app/track/medicine/page.tsx @@ -53,6 +53,8 @@ import { useTranslation } from '@/hooks/useTranslation'; import { UnitInput } from '@/components/forms/UnitInput'; import { convertVolume, convertTemperature } from '@/lib/utils/unitConversion'; import { MeasurementSystem } from '@/hooks/useLocale'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; import { useDispatch, useSelector } from 'react-redux'; import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice'; import { AppDispatch, RootState } from '@/store/store'; @@ -121,7 +123,7 @@ function MedicalTrackPage() { const [recentActivities, setRecentActivities] = useState([]); const [loading, setLoading] = useState(false); const [activitiesLoading, setActivitiesLoading] = useState(false); - const [error, setError] = useState(null); + const { error, showError, clearError, hasError } = useErrorMessage(); const [successMessage, setSuccessMessage] = useState(null); // Delete confirmation dialog @@ -184,36 +186,36 @@ function MedicalTrackPage() { const handleSubmit = async () => { if (!selectedChild?.id) { - setError(t('common.selectChild')); + showError({ message: t('common.selectChild'), code: 'NO_CHILD_SELECTED' }); return; } // Validation based on activity type if (activityType === 'medication') { if (!medicineName) { - setError(t('health.medicineName.required')); + showError({ message: t('health.medicineName.required'), code: 'VALIDATION_ERROR' }); return; } const dosageValue = unit === 'ml' ? dosage : dosageText; if (!dosageValue || (unit === 'ml' && dosage === 0) || (unit !== 'ml' && !dosageText)) { - setError(t('health.dosage.required')); + showError({ message: t('health.dosage.required'), code: 'VALIDATION_ERROR' }); return; } } else if (activityType === 'temperature') { if (temperature === 0) { - setError('Please enter temperature'); + showError({ message: 'Please enter temperature', code: 'VALIDATION_ERROR' }); return; } } else if (activityType === 'doctor') { if (!visitType) { - setError('Please select visit type'); + showError({ message: 'Please select visit type', code: 'VALIDATION_ERROR' }); return; } } try { setLoading(true); - setError(null); + clearError(); let data: MedicationData | TemperatureData | DoctorVisitData; @@ -253,7 +255,7 @@ function MedicalTrackPage() { await loadRecentActivities(); } catch (err: any) { console.error('Failed to save activity:', err); - setError(err.response?.data?.message || 'Failed to save activity'); + showError(err); } finally { setLoading(false); } @@ -299,7 +301,7 @@ function MedicalTrackPage() { await loadRecentActivities(); } catch (err: any) { console.error('Failed to delete activity:', err); - setError(err.response?.data?.message || 'Failed to delete activity'); + showError(err); } finally { setLoading(false); } @@ -446,9 +448,9 @@ function MedicalTrackPage() { /> - {error && ( - setError(null)}> - {error} + {hasError && ( + + {formatErrorMessage(error)} )} diff --git a/maternal-web/app/track/sleep/page.tsx b/maternal-web/app/track/sleep/page.tsx index dbfcafd..c3dab4f 100644 --- a/maternal-web/app/track/sleep/page.tsx +++ b/maternal-web/app/track/sleep/page.tsx @@ -47,6 +47,8 @@ import { childrenApi, Child } from '@/lib/api/children'; import { motion } from 'framer-motion'; import { useLocalizedDate } from '@/hooks/useLocalizedDate'; import { useTranslation } from '@/hooks/useTranslation'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; import { useDispatch, useSelector } from 'react-redux'; import { fetchChildren, selectChild, selectSelectedChild, childrenSelectors } from '@/store/slices/childrenSlice'; import { AppDispatch, RootState } from '@/store/store'; @@ -91,7 +93,7 @@ export default function SleepTrackPage() { const [recentSleeps, setRecentSleeps] = useState([]); const [loading, setLoading] = useState(false); const [sleepsLoading, setSleepsLoading] = useState(false); - const [error, setError] = useState(null); + const { error, showError, clearError, hasError } = useErrorMessage(); const [successMessage, setSuccessMessage] = useState(null); // Delete confirmation dialog @@ -181,18 +183,18 @@ export default function SleepTrackPage() { const handleSubmit = async () => { if (!selectedChild?.id) { - setError('Please select a child'); + showError({ message: 'Please select a child', code: 'NO_CHILD_SELECTED' }); return; } // Validation if (!startTime) { - setError('Please enter start time'); + showError({ message: 'Please enter start time', code: 'VALIDATION_ERROR' }); return; } if (!isOngoing && !endTime) { - setError('Please enter end time or mark as ongoing'); + showError({ message: 'Please enter end time or mark as ongoing', code: 'VALIDATION_ERROR' }); return; } @@ -200,14 +202,14 @@ export default function SleepTrackPage() { const start = new Date(startTime); const end = new Date(endTime); if (end <= start) { - setError('End time must be after start time'); + showError({ message: 'End time must be after start time', code: 'VALIDATION_ERROR' }); return; } } try { setLoading(true); - setError(null); + clearError(); const data: SleepData = { startTime, @@ -236,7 +238,7 @@ export default function SleepTrackPage() { await loadRecentSleeps(); } catch (err: any) { console.error('Failed to save sleep:', err); - setError(err.response?.data?.message || 'Failed to save sleep'); + showError(err); } finally { setLoading(false); } @@ -268,7 +270,7 @@ export default function SleepTrackPage() { await loadRecentSleeps(); } catch (err: any) { console.error('Failed to delete sleep:', err); - setError(err.response?.data?.message || 'Failed to delete sleep'); + showError(err); } finally { setLoading(false); } @@ -380,9 +382,9 @@ export default function SleepTrackPage() { - {error && ( - setError(null)}> - {error} + {hasError && ( + + {formatErrorMessage(error)} )} diff --git a/maternal-web/components/children/ChildDialog.tsx b/maternal-web/components/children/ChildDialog.tsx index 8ba3dd1..1ecad70 100644 --- a/maternal-web/components/children/ChildDialog.tsx +++ b/maternal-web/components/children/ChildDialog.tsx @@ -15,6 +15,8 @@ import { import { Child, CreateChildData } from '@/lib/api/children'; import { useTranslation } from '@/hooks/useTranslation'; import { PhotoUpload } from '@/components/common/PhotoUpload'; +import { useErrorMessage } from '@/components/common/ErrorMessage'; +import { formatErrorMessage } from '@/lib/utils/errorHandler'; interface ChildDialogProps { open: boolean; @@ -33,7 +35,7 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false photoUrl: '', photoAlt: '', }); - const [error, setError] = useState(''); + const { error, showError, clearError, hasError } = useErrorMessage(); useEffect(() => { if (child) { @@ -53,8 +55,8 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false photoAlt: '', }); } - setError(''); - }, [child, open]); + clearError(); + }, [child, open, clearError]); const handleChange = (field: keyof CreateChildData) => ( e: React.ChangeEvent @@ -63,15 +65,15 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false }; const handleSubmit = async () => { - setError(''); + clearError(); // Validation if (!formData.name.trim()) { - setError(t('dialog.validation.nameRequired')); + showError({ message: t('dialog.validation.nameRequired'), code: 'VALIDATION_NAME_REQUIRED' }); return; } if (!formData.birthDate) { - setError(t('dialog.validation.birthDateRequired')); + showError({ message: t('dialog.validation.birthDateRequired'), code: 'VALIDATION_BIRTHDATE_REQUIRED' }); return; } @@ -80,7 +82,7 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false const today = new Date(); today.setHours(0, 0, 0, 0); if (selectedDate > today) { - setError(t('dialog.validation.birthDateFuture')); + showError({ message: t('dialog.validation.birthDateFuture'), code: 'VALIDATION_BIRTHDATE_FUTURE' }); return; } @@ -88,7 +90,7 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false await onSubmit(formData); onClose(); } catch (err: any) { - setError(err.message || t('errors.saveFailed')); + showError(err); } }; @@ -107,9 +109,9 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false id="child-dialog-description" sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 2 }} > - {error && ( - setError('')} role="alert"> - {error} + {hasError && ( + + {formatErrorMessage(error)} )} diff --git a/maternal-web/components/common/ErrorMessage.tsx b/maternal-web/components/common/ErrorMessage.tsx new file mode 100644 index 0000000..61dc9a9 --- /dev/null +++ b/maternal-web/components/common/ErrorMessage.tsx @@ -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 ( + + + {showErrorCode && extracted.code && ( + {extracted.code} + )} + {extracted.message} + + + ); +} + +/** + * 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 ( + + + {extracted.message} + + + ); +} + +/** + * 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 ( + + + Multiple errors occurred: +
    + {extractedErrors.map((err, index) => ( +
  • + {showErrorCodes && err.code && {err.code}: } + {err.message} +
  • + ))} +
+
+
+ ); +} + +/** + * Hook to manage error message state + */ +export function useErrorMessage() { + const [error, setError] = React.useState(null); + + const showError = (err: any) => { + setError(err); + }; + + const clearError = () => { + setError(null); + }; + + const hasError = error !== null; + + return { + error, + showError, + clearError, + hasError, + }; +} diff --git a/maternal-web/components/common/ErrorToast.tsx b/maternal-web/components/common/ErrorToast.tsx new file mode 100644 index 0000000..f288bd3 --- /dev/null +++ b/maternal-web/components/common/ErrorToast.tsx @@ -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 ( + + + {showErrorCode && extracted.code && ( + {extracted.code} + )} + {extracted.message} + + + ); +} + +/** + * Hook to manage error toast state + */ +export function useErrorToast() { + const [error, setError] = React.useState(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, + }; +} diff --git a/maternal-web/components/family/JoinFamilyDialog.tsx b/maternal-web/components/family/JoinFamilyDialog.tsx index 0ce8823..d4f011b 100644 --- a/maternal-web/components/family/JoinFamilyDialog.tsx +++ b/maternal-web/components/family/JoinFamilyDialog.tsx @@ -79,13 +79,16 @@ export function JoinFamilyDialog({ setShareCode(e.target.value)} + onChange={(e) => setShareCode(e.target.value.toUpperCase())} fullWidth required autoFocus disabled={isLoading} - placeholder="Enter family share code" - helperText="Ask a family member for their share code" + placeholder="XXXX-XXXX" + helperText="Share codes are automatically converted to uppercase" + inputProps={{ + style: { textTransform: 'uppercase' }, + }} /> diff --git a/maternal-web/lib/api/client.ts b/maternal-web/lib/api/client.ts index eceb5c5..06eb30b 100644 --- a/maternal-web/lib/api/client.ts +++ b/maternal-web/lib/api/client.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { tokenStorage } from '@/lib/utils/tokenStorage'; +import { logError } from '@/lib/utils/errorHandler'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3020'; @@ -25,12 +26,15 @@ apiClient.interceptors.request.use( } ); -// Response interceptor to handle token refresh +// Response interceptor to handle token refresh and error logging apiClient.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; + // Log all API errors for debugging and error tracking + logError(error, `API ${originalRequest?.method?.toUpperCase()} ${originalRequest?.url}`); + // Only handle token refresh on client side if (typeof window === 'undefined') { return Promise.reject(error); diff --git a/maternal-web/lib/auth/AuthContext.tsx b/maternal-web/lib/auth/AuthContext.tsx index 417af29..aeba0ca 100644 --- a/maternal-web/lib/auth/AuthContext.tsx +++ b/maternal-web/lib/auth/AuthContext.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react import { useRouter } from 'next/navigation'; import apiClient from '@/lib/api/client'; import { tokenStorage } from '@/lib/utils/tokenStorage'; +import { handleError, formatErrorMessage } from '@/lib/utils/errorHandler'; export interface User { id: string; @@ -214,8 +215,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { router.push('/'); } catch (error: any) { - console.error('Login failed:', error); - throw new Error(error.response?.data?.message || 'Login failed'); + const errorMessage = handleError(error, 'AuthContext.login'); + throw new Error(errorMessage); } }; @@ -285,8 +286,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // Redirect to onboarding router.push('/onboarding'); } catch (error: any) { - console.error('Registration failed:', error); - throw new Error(error.response?.data?.message || error.message || 'Registration failed'); + const errorMessage = handleError(error, 'AuthContext.register'); + throw new Error(errorMessage); } }; diff --git a/maternal-web/lib/utils/errorHandler.ts b/maternal-web/lib/utils/errorHandler.ts new file mode 100644 index 0000000..4bfca32 --- /dev/null +++ b/maternal-web/lib/utils/errorHandler.ts @@ -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 = { + // 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); +} diff --git a/maternal-web/public/sw.js b/maternal-web/public/sw.js index 25f6bab..5045cd0 100644 --- a/maternal-web/public/sw.js +++ b/maternal-web/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,a={};const s=(s,c)=>(s=new URL(s+".js",c).href,a[s]||new Promise(a=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=a,document.head.appendChild(e)}else e=s,importScripts(s),a()}).then(()=>{let e=a[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(c,i)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(a[n])return;let t={};const d=e=>s(e,n),r={module:{uri:n},exports:t,require:d};a[n]=Promise.all(c.map(e=>r[e]||d(e))).then(e=>(i(...e),t))}}define(["./workbox-4d767a27"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"4471892493745c9bb059364e414ea5ac"},{url:"/_next/static/Hyo8VeuH7updWxR-hbgDI/_buildManifest.js",revision:"673df67655213af81147283455f8956d"},{url:"/_next/static/Hyo8VeuH7updWxR-hbgDI/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/1091.c762d795c6885f94.js",revision:"c762d795c6885f94"},{url:"/_next/static/chunks/1188.88c14dc0b9d46cf9.js",revision:"88c14dc0b9d46cf9"},{url:"/_next/static/chunks/1255-b2f7fd83e387a9e1.js",revision:"b2f7fd83e387a9e1"},{url:"/_next/static/chunks/1280-077bbec6d00a7de6.js",revision:"077bbec6d00a7de6"},{url:"/_next/static/chunks/1514-a6ed8a01b9885870.js",revision:"a6ed8a01b9885870"},{url:"/_next/static/chunks/1543-530e0f57f7af68aa.js",revision:"530e0f57f7af68aa"},{url:"/_next/static/chunks/164f4fb6.cb2a48d4da4418c4.js",revision:"cb2a48d4da4418c4"},{url:"/_next/static/chunks/189-453061dd646fdba4.js",revision:"453061dd646fdba4"},{url:"/_next/static/chunks/1930-cd8328eb1cfa4178.js",revision:"cd8328eb1cfa4178"},{url:"/_next/static/chunks/2239-b3f7ecc33c6fd306.js",revision:"b3f7ecc33c6fd306"},{url:"/_next/static/chunks/2262-26293d6453fcc927.js",revision:"26293d6453fcc927"},{url:"/_next/static/chunks/2619-04bc32f026a0d946.js",revision:"04bc32f026a0d946"},{url:"/_next/static/chunks/2931.14c1e0fb7788f4ba.js",revision:"14c1e0fb7788f4ba"},{url:"/_next/static/chunks/2f0b94e8.3186a98eb4c9012b.js",revision:"3186a98eb4c9012b"},{url:"/_next/static/chunks/3127-49a95e7cb556ace3.js",revision:"49a95e7cb556ace3"},{url:"/_next/static/chunks/3452-86647d15ff7842a5.js",revision:"86647d15ff7842a5"},{url:"/_next/static/chunks/3460-a2b6a712ec21acfb.js",revision:"a2b6a712ec21acfb"},{url:"/_next/static/chunks/3664-56dedfcaec4aaceb.js",revision:"56dedfcaec4aaceb"},{url:"/_next/static/chunks/3762-96365c71edc342bf.js",revision:"96365c71edc342bf"},{url:"/_next/static/chunks/4199.bc1715114dd19eda.js",revision:"bc1715114dd19eda"},{url:"/_next/static/chunks/4337-6c756374da7aa8e3.js",revision:"6c756374da7aa8e3"},{url:"/_next/static/chunks/4648-bc0ced32f57b916b.js",revision:"bc0ced32f57b916b"},{url:"/_next/static/chunks/4710-9f9aefe46e6a48d5.js",revision:"9f9aefe46e6a48d5"},{url:"/_next/static/chunks/4bd1b696-100b9d70ed4e49c1.js",revision:"100b9d70ed4e49c1"},{url:"/_next/static/chunks/5125-c990fc036d2a6ce4.js",revision:"c990fc036d2a6ce4"},{url:"/_next/static/chunks/5380-9004e1ac3565daca.js",revision:"9004e1ac3565daca"},{url:"/_next/static/chunks/5385-7ecda8e4ba984edc.js",revision:"7ecda8e4ba984edc"},{url:"/_next/static/chunks/5482-7535aa0aab02d518.js",revision:"7535aa0aab02d518"},{url:"/_next/static/chunks/551.26e2933365d2f96d.js",revision:"26e2933365d2f96d"},{url:"/_next/static/chunks/6088-c165c565edce02be.js",revision:"c165c565edce02be"},{url:"/_next/static/chunks/6181-66be9b76f10d48f6.js",revision:"66be9b76f10d48f6"},{url:"/_next/static/chunks/6357-0263657691f8e0c3.js",revision:"0263657691f8e0c3"},{url:"/_next/static/chunks/658-1d9d4c0c8b5fb129.js",revision:"1d9d4c0c8b5fb129"},{url:"/_next/static/chunks/670-a4ca0f366ee779f5.js",revision:"a4ca0f366ee779f5"},{url:"/_next/static/chunks/6873-ff265086321345c8.js",revision:"ff265086321345c8"},{url:"/_next/static/chunks/6886-40f1779ffff00d58.js",revision:"40f1779ffff00d58"},{url:"/_next/static/chunks/710-7e96cbf5d461482a.js",revision:"7e96cbf5d461482a"},{url:"/_next/static/chunks/7359-1abfb9f346309354.js",revision:"1abfb9f346309354"},{url:"/_next/static/chunks/7741-0af8b5a61d8e63d3.js",revision:"0af8b5a61d8e63d3"},{url:"/_next/static/chunks/7855-72c79224370eff7b.js",revision:"72c79224370eff7b"},{url:"/_next/static/chunks/787-032067ae978e62a8.js",revision:"032067ae978e62a8"},{url:"/_next/static/chunks/7902-e1f71c3b4c62bff9.js",revision:"e1f71c3b4c62bff9"},{url:"/_next/static/chunks/7981-1205285ee8c556da.js",revision:"1205285ee8c556da"},{url:"/_next/static/chunks/8221-d51102291d5ddaf9.js",revision:"d51102291d5ddaf9"},{url:"/_next/static/chunks/8241-eaf1b9c6054e9ad8.js",revision:"eaf1b9c6054e9ad8"},{url:"/_next/static/chunks/8412-8ce7440f3599e2d9.js",revision:"8ce7440f3599e2d9"},{url:"/_next/static/chunks/8423-ac92fec5ac4dabe7.js",revision:"ac92fec5ac4dabe7"},{url:"/_next/static/chunks/8466-ffa71cea7998f777.js",revision:"ffa71cea7998f777"},{url:"/_next/static/chunks/8544.74f59dd908783038.js",revision:"74f59dd908783038"},{url:"/_next/static/chunks/8746-92ff3ad56eb06d6e.js",revision:"92ff3ad56eb06d6e"},{url:"/_next/static/chunks/8900-ff82add2eebd43fa.js",revision:"ff82add2eebd43fa"},{url:"/_next/static/chunks/9205-f540995b767df00b.js",revision:"f540995b767df00b"},{url:"/_next/static/chunks/9333-edf14831f0a39549.js",revision:"edf14831f0a39549"},{url:"/_next/static/chunks/9392-2887c5e5703ed90a.js",revision:"2887c5e5703ed90a"},{url:"/_next/static/chunks/9397-40b8ac68e22a4d87.js",revision:"40b8ac68e22a4d87"},{url:"/_next/static/chunks/9515-e88b59e87a9d336f.js",revision:"e88b59e87a9d336f"},{url:"/_next/static/chunks/9517-17518b5fffe76114.js",revision:"17518b5fffe76114"},{url:"/_next/static/chunks/9580-031d243edbbe82e5.js",revision:"031d243edbbe82e5"},{url:"/_next/static/chunks/9738-d4ae78df35beeba7.js",revision:"d4ae78df35beeba7"},{url:"/_next/static/chunks/ad2866b8.e13a3cf75ccf0eb8.js",revision:"e13a3cf75ccf0eb8"},{url:"/_next/static/chunks/app/(auth)/forgot-password/page-ca00943cf66c3e17.js",revision:"ca00943cf66c3e17"},{url:"/_next/static/chunks/app/(auth)/login/page-6337793313f43fb1.js",revision:"6337793313f43fb1"},{url:"/_next/static/chunks/app/(auth)/onboarding/page-f759aa2ff8e242a7.js",revision:"f759aa2ff8e242a7"},{url:"/_next/static/chunks/app/(auth)/register/page-26c9ab5378556580.js",revision:"26c9ab5378556580"},{url:"/_next/static/chunks/app/(auth)/reset-password/page-2eec6b4142e79702.js",revision:"2eec6b4142e79702"},{url:"/_next/static/chunks/app/_not-found/page-95f11f5fe94340f1.js",revision:"95f11f5fe94340f1"},{url:"/_next/static/chunks/app/ai-assistant/page-3edb2cda7412d8b4.js",revision:"3edb2cda7412d8b4"},{url:"/_next/static/chunks/app/analytics/advanced/page-8dce8adb1ed3736a.js",revision:"8dce8adb1ed3736a"},{url:"/_next/static/chunks/app/analytics/page-938a3b366d2969b4.js",revision:"938a3b366d2969b4"},{url:"/_next/static/chunks/app/api/ai/chat/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/login/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/password-reset/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/register/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/health/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/tracking/feeding/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/voice/transcribe/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/children/page-40f1bfa952ee593c.js",revision:"40f1bfa952ee593c"},{url:"/_next/static/chunks/app/family/page-d7ded6a4620cb8d8.js",revision:"d7ded6a4620cb8d8"},{url:"/_next/static/chunks/app/history/page-36e2f94462dd67ae.js",revision:"36e2f94462dd67ae"},{url:"/_next/static/chunks/app/insights/page-296df3d508143098.js",revision:"296df3d508143098"},{url:"/_next/static/chunks/app/layout-974781e155547ac5.js",revision:"974781e155547ac5"},{url:"/_next/static/chunks/app/legal/cookies/page-c39a3fa6e27a8806.js",revision:"c39a3fa6e27a8806"},{url:"/_next/static/chunks/app/legal/eula/page-8015f749ab4dd660.js",revision:"8015f749ab4dd660"},{url:"/_next/static/chunks/app/legal/page-3de074f0b9741bc6.js",revision:"3de074f0b9741bc6"},{url:"/_next/static/chunks/app/legal/privacy/page-3cb58024b6fd8e21.js",revision:"3cb58024b6fd8e21"},{url:"/_next/static/chunks/app/legal/terms/page-b5a1c96cae251767.js",revision:"b5a1c96cae251767"},{url:"/_next/static/chunks/app/logout/page-83925cf53bb9c692.js",revision:"83925cf53bb9c692"},{url:"/_next/static/chunks/app/offline/page-28c005360c2b2736.js",revision:"28c005360c2b2736"},{url:"/_next/static/chunks/app/page-c5729e7d614eb749.js",revision:"c5729e7d614eb749"},{url:"/_next/static/chunks/app/settings/page-c89cad68dc101709.js",revision:"c89cad68dc101709"},{url:"/_next/static/chunks/app/track/activity/page-3767427eaacf5fff.js",revision:"3767427eaacf5fff"},{url:"/_next/static/chunks/app/track/diaper/page-c62c6bfb393c13a1.js",revision:"c62c6bfb393c13a1"},{url:"/_next/static/chunks/app/track/feeding/page-5acbeddc0db06597.js",revision:"5acbeddc0db06597"},{url:"/_next/static/chunks/app/track/growth/page-aac0bf91cb288a19.js",revision:"aac0bf91cb288a19"},{url:"/_next/static/chunks/app/track/medicine/page-dcad90e6224b0800.js",revision:"dcad90e6224b0800"},{url:"/_next/static/chunks/app/track/page-dd5ade1eb19ad389.js",revision:"dd5ade1eb19ad389"},{url:"/_next/static/chunks/app/track/sleep/page-b586a2d14249bb9a.js",revision:"b586a2d14249bb9a"},{url:"/_next/static/chunks/bc98253f.2a96f718cf128d0e.js",revision:"2a96f718cf128d0e"},{url:"/_next/static/chunks/framework-bd61ec64032c2de7.js",revision:"bd61ec64032c2de7"},{url:"/_next/static/chunks/main-520e5ec2d671abe7.js",revision:"520e5ec2d671abe7"},{url:"/_next/static/chunks/main-app-02fc3649960ba6c7.js",revision:"02fc3649960ba6c7"},{url:"/_next/static/chunks/pages/_app-4b3fb5e477a0267f.js",revision:"4b3fb5e477a0267f"},{url:"/_next/static/chunks/pages/_error-c970d8b55ace1b48.js",revision:"c970d8b55ace1b48"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-898276ad52dbad4b.js",revision:"898276ad52dbad4b"},{url:"/_next/static/css/dd1dff55aa5f7521.css",revision:"dd1dff55aa5f7521"},{url:"/_next/static/media/19cfc7226ec3afaa-s.woff2",revision:"9dda5cfc9a46f256d0e131bb535e46f8"},{url:"/_next/static/media/21350d82a1f187e9-s.woff2",revision:"4e2553027f1d60eff32898367dd4d541"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/ba9851c3c22cd980-s.woff2",revision:"9e494903d6b0ffec1a1e14d34427d44d"},{url:"/_next/static/media/c5fe6dc8356a8c31-s.woff2",revision:"027a89e9ab733a145db70f09b8a18b42"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/apple-touch-icon.png",revision:"fa2d4d791b90148a18d49bc3bfd7a43a"},{url:"/check-updates.js",revision:"bc016a0ceb6c72a5fe9ba02ad05d78be"},{url:"/favicon-16x16.png",revision:"db2da3355c89a6149f6d9ee35ebe6bf3"},{url:"/favicon-32x32.png",revision:"0fd88d56aa584bd0546d05ffc63ef777"},{url:"/icon-192x192.png",revision:"b8ef7f117472c4399cceffea644eb8bd"},{url:"/icons/icon-128x128.png",revision:"96cff3b189d9c1daa1edf470290a90cd"},{url:"/icons/icon-144x144.png",revision:"b627c346c431d7e306005aec5f51baff"},{url:"/icons/icon-152x152.png",revision:"012071830c13d310e51f833baed531af"},{url:"/icons/icon-192x192.png",revision:"dfb20132ddb628237eccd4b0e2ee4aaa"},{url:"/icons/icon-384x384.png",revision:"d032b25376232878a2a29b5688992a8d"},{url:"/icons/icon-512x512.png",revision:"ffda0043571d60956f4e321cba706670"},{url:"/icons/icon-72x72.png",revision:"cc89e74126e7e1109f0186774b3c0d77"},{url:"/icons/icon-96x96.png",revision:"32813cdad5b636fc09eec01c7d705936"},{url:"/manifest.json",revision:"5cbf1ecd33b05c4772688ce7d00c2c23"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/push-sw.js",revision:"45385b2ab1bb9d8db17e3707d4364890"},{url:"/vercel.svg",revision:"61c6b19abff40ea7acd577be818f3976"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:a,event:s,state:c})=>a&&"opaqueredirect"===a.type?new Response(a.body,{status:200,statusText:"OK",headers:a.headers}):a}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/api\/.*$/i,new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/.*/i,new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET")}); +if(!self.define){let e,a={};const s=(s,c)=>(s=new URL(s+".js",c).href,a[s]||new Promise(a=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=a,document.head.appendChild(e)}else e=s,importScripts(s),a()}).then(()=>{let e=a[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(c,i)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(a[n])return;let t={};const f=e=>s(e,n),d={module:{uri:n},exports:t,require:f};a[n]=Promise.all(c.map(e=>d[e]||f(e))).then(e=>(i(...e),t))}}define(["./workbox-4d767a27"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"5a8f35cf06d319a9f3d015ceb3053479"},{url:"/_next/static/chunks/1188.fa631a3022361a07.js",revision:"fa631a3022361a07"},{url:"/_next/static/chunks/1255-b2f7fd83e387a9e1.js",revision:"b2f7fd83e387a9e1"},{url:"/_next/static/chunks/1280-077bbec6d00a7de6.js",revision:"077bbec6d00a7de6"},{url:"/_next/static/chunks/1506-57bcf8bf9f272f75.js",revision:"57bcf8bf9f272f75"},{url:"/_next/static/chunks/164f4fb6.cb2a48d4da4418c4.js",revision:"cb2a48d4da4418c4"},{url:"/_next/static/chunks/1733-cce5309a9609067d.js",revision:"cce5309a9609067d"},{url:"/_next/static/chunks/189-453061dd646fdba4.js",revision:"453061dd646fdba4"},{url:"/_next/static/chunks/1930-da5165ca7d15ef37.js",revision:"da5165ca7d15ef37"},{url:"/_next/static/chunks/2262-26293d6453fcc927.js",revision:"26293d6453fcc927"},{url:"/_next/static/chunks/2512.67f6c5ac1870b898.js",revision:"67f6c5ac1870b898"},{url:"/_next/static/chunks/2535-67793e78b7c2b0b4.js",revision:"67793e78b7c2b0b4"},{url:"/_next/static/chunks/2619-04bc32f026a0d946.js",revision:"04bc32f026a0d946"},{url:"/_next/static/chunks/2808-256909a929d60aea.js",revision:"256909a929d60aea"},{url:"/_next/static/chunks/2931.14c1e0fb7788f4ba.js",revision:"14c1e0fb7788f4ba"},{url:"/_next/static/chunks/2f0b94e8.3186a98eb4c9012b.js",revision:"3186a98eb4c9012b"},{url:"/_next/static/chunks/3127-49a95e7cb556ace3.js",revision:"49a95e7cb556ace3"},{url:"/_next/static/chunks/3324-0fdb02bda3e734f2.js",revision:"0fdb02bda3e734f2"},{url:"/_next/static/chunks/3423-c1add4fe74d2aa37.js",revision:"c1add4fe74d2aa37"},{url:"/_next/static/chunks/3539-a39b8a3af7932559.js",revision:"a39b8a3af7932559"},{url:"/_next/static/chunks/3664-56dedfcaec4aaceb.js",revision:"56dedfcaec4aaceb"},{url:"/_next/static/chunks/3882-3010edee225b0edd.js",revision:"3010edee225b0edd"},{url:"/_next/static/chunks/4199.bc1715114dd19eda.js",revision:"bc1715114dd19eda"},{url:"/_next/static/chunks/4259-dfb1be8f3af54c21.js",revision:"dfb1be8f3af54c21"},{url:"/_next/static/chunks/4337-6c756374da7aa8e3.js",revision:"6c756374da7aa8e3"},{url:"/_next/static/chunks/4710-9f9aefe46e6a48d5.js",revision:"9f9aefe46e6a48d5"},{url:"/_next/static/chunks/4bd1b696-100b9d70ed4e49c1.js",revision:"100b9d70ed4e49c1"},{url:"/_next/static/chunks/5125-c990fc036d2a6ce4.js",revision:"c990fc036d2a6ce4"},{url:"/_next/static/chunks/5380-9004e1ac3565daca.js",revision:"9004e1ac3565daca"},{url:"/_next/static/chunks/551.26e2933365d2f96d.js",revision:"26e2933365d2f96d"},{url:"/_next/static/chunks/5567-c2b5691445e8828d.js",revision:"c2b5691445e8828d"},{url:"/_next/static/chunks/6012-e07be77edf7a476f.js",revision:"e07be77edf7a476f"},{url:"/_next/static/chunks/6088-c165c565edce02be.js",revision:"c165c565edce02be"},{url:"/_next/static/chunks/6181-66be9b76f10d48f6.js",revision:"66be9b76f10d48f6"},{url:"/_next/static/chunks/6206-1b3859e1902bcca4.js",revision:"1b3859e1902bcca4"},{url:"/_next/static/chunks/6226-11979d85444f757f.js",revision:"11979d85444f757f"},{url:"/_next/static/chunks/658-13ff2003b6585776.js",revision:"13ff2003b6585776"},{url:"/_next/static/chunks/670-a4ca0f366ee779f5.js",revision:"a4ca0f366ee779f5"},{url:"/_next/static/chunks/6873-ff265086321345c8.js",revision:"ff265086321345c8"},{url:"/_next/static/chunks/6886-40f1779ffff00d58.js",revision:"40f1779ffff00d58"},{url:"/_next/static/chunks/710-3bb47c7f5ae91077.js",revision:"3bb47c7f5ae91077"},{url:"/_next/static/chunks/7741-0af8b5a61d8e63d3.js",revision:"0af8b5a61d8e63d3"},{url:"/_next/static/chunks/787-032067ae978e62a8.js",revision:"032067ae978e62a8"},{url:"/_next/static/chunks/7902-e1f71c3b4c62bff9.js",revision:"e1f71c3b4c62bff9"},{url:"/_next/static/chunks/8287-35c2c0bcf242655f.js",revision:"35c2c0bcf242655f"},{url:"/_next/static/chunks/8423-ac92fec5ac4dabe7.js",revision:"ac92fec5ac4dabe7"},{url:"/_next/static/chunks/8544.92db50305f3f0fa4.js",revision:"92db50305f3f0fa4"},{url:"/_next/static/chunks/8863-7b43165e8b5cae38.js",revision:"7b43165e8b5cae38"},{url:"/_next/static/chunks/9205-f540995b767df00b.js",revision:"f540995b767df00b"},{url:"/_next/static/chunks/9333-edf14831f0a39549.js",revision:"edf14831f0a39549"},{url:"/_next/static/chunks/9397-40b8ac68e22a4d87.js",revision:"40b8ac68e22a4d87"},{url:"/_next/static/chunks/9410-5387282e509502da.js",revision:"5387282e509502da"},{url:"/_next/static/chunks/9515-e88b59e87a9d336f.js",revision:"e88b59e87a9d336f"},{url:"/_next/static/chunks/9517-17518b5fffe76114.js",revision:"17518b5fffe76114"},{url:"/_next/static/chunks/9580-031d243edbbe82e5.js",revision:"031d243edbbe82e5"},{url:"/_next/static/chunks/9738-d4ae78df35beeba7.js",revision:"d4ae78df35beeba7"},{url:"/_next/static/chunks/978-7c7f8d135df0c6da.js",revision:"7c7f8d135df0c6da"},{url:"/_next/static/chunks/ad2866b8.e13a3cf75ccf0eb8.js",revision:"e13a3cf75ccf0eb8"},{url:"/_next/static/chunks/app/(auth)/forgot-password/page-a8d6f5adc0742141.js",revision:"a8d6f5adc0742141"},{url:"/_next/static/chunks/app/(auth)/login/page-c8c95dba39878d46.js",revision:"c8c95dba39878d46"},{url:"/_next/static/chunks/app/(auth)/onboarding/page-f7f3f2aa5aacd56d.js",revision:"f7f3f2aa5aacd56d"},{url:"/_next/static/chunks/app/(auth)/register/page-517f6e348b031bbc.js",revision:"517f6e348b031bbc"},{url:"/_next/static/chunks/app/(auth)/reset-password/page-dd8f7982e89af661.js",revision:"dd8f7982e89af661"},{url:"/_next/static/chunks/app/_not-found/page-95f11f5fe94340f1.js",revision:"95f11f5fe94340f1"},{url:"/_next/static/chunks/app/ai-assistant/page-c5d5fa03a029471e.js",revision:"c5d5fa03a029471e"},{url:"/_next/static/chunks/app/analytics/advanced/page-23e741d27cd10a0b.js",revision:"23e741d27cd10a0b"},{url:"/_next/static/chunks/app/analytics/page-8dadcf7c73e51f1d.js",revision:"8dadcf7c73e51f1d"},{url:"/_next/static/chunks/app/api/ai/chat/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/login/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/password-reset/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/register/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/health/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/tracking/feeding/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/voice/transcribe/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/children/page-02356cae972e2f08.js",revision:"02356cae972e2f08"},{url:"/_next/static/chunks/app/family/page-7757552688d71a0d.js",revision:"7757552688d71a0d"},{url:"/_next/static/chunks/app/history/page-8acf5da9bc57eb08.js",revision:"8acf5da9bc57eb08"},{url:"/_next/static/chunks/app/insights/page-c28e0ca0571b3133.js",revision:"c28e0ca0571b3133"},{url:"/_next/static/chunks/app/layout-5b9ea35002e7f082.js",revision:"5b9ea35002e7f082"},{url:"/_next/static/chunks/app/legal/cookies/page-d306fae299dcaa8d.js",revision:"d306fae299dcaa8d"},{url:"/_next/static/chunks/app/legal/eula/page-df0c2d6dfe66207d.js",revision:"df0c2d6dfe66207d"},{url:"/_next/static/chunks/app/legal/page-fd2f291410338661.js",revision:"fd2f291410338661"},{url:"/_next/static/chunks/app/legal/privacy/page-3571b073a1369e09.js",revision:"3571b073a1369e09"},{url:"/_next/static/chunks/app/legal/terms/page-b6f8f8f978ddd26f.js",revision:"b6f8f8f978ddd26f"},{url:"/_next/static/chunks/app/logout/page-73ff2e709702f808.js",revision:"73ff2e709702f808"},{url:"/_next/static/chunks/app/offline/page-b4f952b7b4c4bdc7.js",revision:"b4f952b7b4c4bdc7"},{url:"/_next/static/chunks/app/page-8070333e141ee91f.js",revision:"8070333e141ee91f"},{url:"/_next/static/chunks/app/settings/page-80ca87f4c25a4ee6.js",revision:"80ca87f4c25a4ee6"},{url:"/_next/static/chunks/app/track/activity/page-6d9d1b49f0ba2c7e.js",revision:"6d9d1b49f0ba2c7e"},{url:"/_next/static/chunks/app/track/diaper/page-f3d0ddc9995042cd.js",revision:"f3d0ddc9995042cd"},{url:"/_next/static/chunks/app/track/feeding/page-e7d7bbfca27e7d84.js",revision:"e7d7bbfca27e7d84"},{url:"/_next/static/chunks/app/track/growth/page-15aadf71b9fe6819.js",revision:"15aadf71b9fe6819"},{url:"/_next/static/chunks/app/track/medicine/page-144d695b30772f7a.js",revision:"144d695b30772f7a"},{url:"/_next/static/chunks/app/track/page-b5e4a39548d3e8a3.js",revision:"b5e4a39548d3e8a3"},{url:"/_next/static/chunks/app/track/sleep/page-be5c7a1126387120.js",revision:"be5c7a1126387120"},{url:"/_next/static/chunks/bc98253f.2a96f718cf128d0e.js",revision:"2a96f718cf128d0e"},{url:"/_next/static/chunks/framework-bd61ec64032c2de7.js",revision:"bd61ec64032c2de7"},{url:"/_next/static/chunks/main-520e5ec2d671abe7.js",revision:"520e5ec2d671abe7"},{url:"/_next/static/chunks/main-app-02fc3649960ba6c7.js",revision:"02fc3649960ba6c7"},{url:"/_next/static/chunks/pages/_app-4b3fb5e477a0267f.js",revision:"4b3fb5e477a0267f"},{url:"/_next/static/chunks/pages/_error-c970d8b55ace1b48.js",revision:"c970d8b55ace1b48"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-d2fb589b644c099b.js",revision:"d2fb589b644c099b"},{url:"/_next/static/css/dd1dff55aa5f7521.css",revision:"dd1dff55aa5f7521"},{url:"/_next/static/jQkbbA7-fKSC_UOSM5hXL/_buildManifest.js",revision:"673df67655213af81147283455f8956d"},{url:"/_next/static/jQkbbA7-fKSC_UOSM5hXL/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/media/19cfc7226ec3afaa-s.woff2",revision:"9dda5cfc9a46f256d0e131bb535e46f8"},{url:"/_next/static/media/21350d82a1f187e9-s.woff2",revision:"4e2553027f1d60eff32898367dd4d541"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/ba9851c3c22cd980-s.woff2",revision:"9e494903d6b0ffec1a1e14d34427d44d"},{url:"/_next/static/media/c5fe6dc8356a8c31-s.woff2",revision:"027a89e9ab733a145db70f09b8a18b42"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/apple-touch-icon.png",revision:"fa2d4d791b90148a18d49bc3bfd7a43a"},{url:"/check-updates.js",revision:"bc016a0ceb6c72a5fe9ba02ad05d78be"},{url:"/favicon-16x16.png",revision:"db2da3355c89a6149f6d9ee35ebe6bf3"},{url:"/favicon-32x32.png",revision:"0fd88d56aa584bd0546d05ffc63ef777"},{url:"/icon-192x192.png",revision:"b8ef7f117472c4399cceffea644eb8bd"},{url:"/icons/icon-128x128.png",revision:"96cff3b189d9c1daa1edf470290a90cd"},{url:"/icons/icon-144x144.png",revision:"b627c346c431d7e306005aec5f51baff"},{url:"/icons/icon-152x152.png",revision:"012071830c13d310e51f833baed531af"},{url:"/icons/icon-192x192.png",revision:"dfb20132ddb628237eccd4b0e2ee4aaa"},{url:"/icons/icon-384x384.png",revision:"d032b25376232878a2a29b5688992a8d"},{url:"/icons/icon-512x512.png",revision:"ffda0043571d60956f4e321cba706670"},{url:"/icons/icon-72x72.png",revision:"cc89e74126e7e1109f0186774b3c0d77"},{url:"/icons/icon-96x96.png",revision:"32813cdad5b636fc09eec01c7d705936"},{url:"/manifest.json",revision:"5cbf1ecd33b05c4772688ce7d00c2c23"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/push-sw.js",revision:"45385b2ab1bb9d8db17e3707d4364890"},{url:"/vercel.svg",revision:"61c6b19abff40ea7acd577be818f3976"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:a,event:s,state:c})=>a&&"opaqueredirect"===a.type?new Response(a.body,{status:200,statusText:"OK",headers:a.headers}):a}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/api\/.*$/i,new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/.*/i,new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET")}); diff --git a/maternal-web/update-tracking-errors.sh b/maternal-web/update-tracking-errors.sh new file mode 100644 index 0000000..a9fedc0 --- /dev/null +++ b/maternal-web/update-tracking-errors.sh @@ -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(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!" diff --git a/parentflow-admin/src/app/legal-pages/[id]/edit/page.tsx b/parentflow-admin/src/app/legal-pages/[id]/edit/page.tsx index 5d243bc..0fe331e 100644 --- a/parentflow-admin/src/app/legal-pages/[id]/edit/page.tsx +++ b/parentflow-admin/src/app/legal-pages/[id]/edit/page.tsx @@ -142,7 +142,7 @@ export default function EditLegalPagePage() { 'fullscreen', '|', 'guide', - ], + ] as const, }; }, []); diff --git a/parentflow-admin/src/app/page.tsx b/parentflow-admin/src/app/page.tsx index e6aaa5f..cb234b1 100644 --- a/parentflow-admin/src/app/page.tsx +++ b/parentflow-admin/src/app/page.tsx @@ -261,7 +261,7 @@ export default function DashboardPage() { cx="50%" cy="50%" labelLine={false} - label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`} + label={(props: any) => `${props.name} ${(props.percent * 100).toFixed(0)}%`} outerRadius={80} fill="#8884d8" dataKey="value" diff --git a/pre-deploy-check.sh b/pre-deploy-check.sh new file mode 100755 index 0000000..2851788 --- /dev/null +++ b/pre-deploy-check.sh @@ -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 "======================================" diff --git a/pwa_web_push_implementation_plan.md b/pwa_web_push_implementation_plan.md deleted file mode 100644 index 7246687..0000000 --- a/pwa_web_push_implementation_plan.md +++ /dev/null @@ -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= -VAPID_PRIVATE_KEY= -VAPID_SUBJECT=mailto:hello@parentflow.com - -# Push Notification Settings -PUSH_NOTIFICATIONS_ENABLED=true -PUSH_DEFAULT_TTL=86400 # 24 hours -PUSH_BATCH_SIZE=100 -``` - -### Generate VAPID Keys - -```bash -cd maternal-app-backend -npx web-push generate-vapid-keys - -# Output: -# Public Key: BN... -# Private Key: ... - -# Save to .env file -``` - -### Deliverables - -- ✅ VAPID keys generated and stored securely -- ✅ Environment variables configured -- ✅ Decision log updated - ---- - -## Phase 1 — Database Schema (0.5 day) - -### Migration: `CreatePushSubscriptionsTable` - -**File**: `maternal-app-backend/src/database/migrations/XXX-CreatePushSubscriptions.ts` - -```sql --- Push subscriptions table -CREATE TABLE push_subscriptions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - endpoint TEXT NOT NULL, - p256dh TEXT NOT NULL, -- encryption key - auth TEXT NOT NULL, -- auth secret - user_agent TEXT, - device_type VARCHAR(20), -- 'desktop', 'mobile', 'tablet' - browser VARCHAR(50), - is_active BOOLEAN DEFAULT true, - last_error TEXT, - failed_attempts INTEGER DEFAULT 0, - last_success_at TIMESTAMP, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - - CONSTRAINT unique_endpoint UNIQUE(endpoint) -); - -CREATE INDEX idx_push_subs_user_id ON push_subscriptions(user_id); -CREATE INDEX idx_push_subs_active ON push_subscriptions(is_active) WHERE is_active = true; - --- Notification queue table -CREATE TABLE notification_queue ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id VARCHAR(20) NOT NULL REFERENCES users(id), - notification_type VARCHAR(50) NOT NULL, -- 'activity_reminder', 'family_update', 'ai_response' - title VARCHAR(255) NOT NULL, - body TEXT NOT NULL, - icon_url TEXT, - badge_url TEXT, - action_url TEXT, - data JSONB, - priority VARCHAR(20) DEFAULT 'normal', -- 'low', 'normal', 'high', 'urgent' - status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'sent', 'failed', 'expired' - scheduled_at TIMESTAMP DEFAULT NOW(), - sent_at TIMESTAMP, - expires_at TIMESTAMP, - error_message TEXT, - retry_count INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT NOW() -); - -CREATE INDEX idx_notif_queue_status ON notification_queue(status) WHERE status = 'pending'; -CREATE INDEX idx_notif_queue_user ON notification_queue(user_id); -CREATE INDEX idx_notif_queue_scheduled ON notification_queue(scheduled_at); -``` - ---- - -## Phase 2 — Backend: Push Subscriptions Module (1 day) - -### Module Structure - -``` -src/modules/push-notifications/ -├── push-notifications.module.ts -├── push-notifications.service.ts -├── push-notifications.controller.ts -├── push-subscriptions.service.ts -├── entities/ -│ ├── push-subscription.entity.ts -│ └── notification-queue.entity.ts -└── dto/ - ├── subscribe.dto.ts - ├── send-notification.dto.ts - └── notification-payload.dto.ts -``` - -### Entity: `PushSubscription` - -```typescript -// push-subscription.entity.ts -import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; -import { User } from '../../database/entities/user.entity'; - -@Entity('push_subscriptions') -export class PushSubscription { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ name: 'user_id', type: 'varchar', length: 20 }) - userId: string; - - @ManyToOne(() => User) - @JoinColumn({ name: 'user_id' }) - user: User; - - @Column({ type: 'text' }) - endpoint: string; - - @Column({ type: 'text' }) - p256dh: string; - - @Column({ type: 'text' }) - auth: string; - - @Column({ name: 'user_agent', type: 'text', nullable: true }) - userAgent: string; - - @Column({ name: 'device_type', type: 'varchar', length: 20, nullable: true }) - deviceType: string; - - @Column({ type: 'varchar', length: 50, nullable: true }) - browser: string; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive: boolean; - - @Column({ name: 'last_error', type: 'text', nullable: true }) - lastError: string; - - @Column({ name: 'failed_attempts', type: 'int', default: 0 }) - failedAttempts: number; - - @Column({ name: 'last_success_at', type: 'timestamp', nullable: true }) - lastSuccessAt: Date; - - @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - createdAt: Date; - - @Column({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - updatedAt: Date; -} -``` - -### Controller: Subscription Management - -```typescript -// push-notifications.controller.ts -import { Controller, Post, Delete, Get, Body, Param, UseGuards, Request } from '@nestjs/common'; -import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; -import { PushSubscriptionsService } from './push-subscriptions.service'; -import { SubscribeDto } from './dto/subscribe.dto'; - -@Controller('api/v1/push') -@UseGuards(JwtAuthGuard) -export class PushNotificationsController { - constructor(private readonly subscriptionsService: PushSubscriptionsService) {} - - @Post('subscribe') - async subscribe(@Body() dto: SubscribeDto, @Request() req) { - const userId = req.user.id; - return this.subscriptionsService.subscribe(userId, dto); - } - - @Delete('unsubscribe/:endpoint') - async unsubscribe(@Param('endpoint') endpoint: string, @Request() req) { - const userId = req.user.id; - return this.subscriptionsService.unsubscribe(userId, endpoint); - } - - @Get('subscriptions') - async getSubscriptions(@Request() req) { - const userId = req.user.id; - return this.subscriptionsService.getUserSubscriptions(userId); - } - - @Get('public-key') - async getPublicKey() { - return { publicKey: process.env.VAPID_PUBLIC_KEY }; - } -} -``` - -### Service: Push Subscriptions - -```typescript -// push-subscriptions.service.ts -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { PushSubscription } from './entities/push-subscription.entity'; -import { SubscribeDto } from './dto/subscribe.dto'; - -@Injectable() -export class PushSubscriptionsService { - constructor( - @InjectRepository(PushSubscription) - private readonly subscriptionRepo: Repository, - ) {} - - async subscribe(userId: string, dto: SubscribeDto) { - // Parse user agent to detect device/browser - const deviceInfo = this.parseUserAgent(dto.userAgent); - - // Upsert by endpoint - const existing = await this.subscriptionRepo.findOne({ - where: { endpoint: dto.endpoint }, - }); - - if (existing) { - existing.userId = userId; - existing.p256dh = dto.keys.p256dh; - existing.auth = dto.keys.auth; - existing.userAgent = dto.userAgent; - existing.deviceType = deviceInfo.deviceType; - existing.browser = deviceInfo.browser; - existing.isActive = true; - existing.failedAttempts = 0; - existing.lastError = null; - existing.updatedAt = new Date(); - return this.subscriptionRepo.save(existing); - } - - return this.subscriptionRepo.save({ - userId, - endpoint: dto.endpoint, - p256dh: dto.keys.p256dh, - auth: dto.keys.auth, - userAgent: dto.userAgent, - deviceType: deviceInfo.deviceType, - browser: deviceInfo.browser, - }); - } - - async unsubscribe(userId: string, endpoint: string) { - await this.subscriptionRepo.update( - { userId, endpoint }, - { isActive: false, updatedAt: new Date() }, - ); - return { success: true }; - } - - async getUserSubscriptions(userId: string) { - return this.subscriptionRepo.find({ - where: { userId, isActive: true }, - select: ['id', 'endpoint', 'deviceType', 'browser', 'createdAt', 'lastSuccessAt'], - }); - } - - async getActiveSubscriptions(userId: string): Promise { - return this.subscriptionRepo.find({ - where: { userId, isActive: true }, - }); - } - - async markDeliverySuccess(subscriptionId: string) { - await this.subscriptionRepo.update(subscriptionId, { - lastSuccessAt: new Date(), - failedAttempts: 0, - lastError: null, - }); - } - - async markDeliveryFailure(subscriptionId: string, error: string) { - const subscription = await this.subscriptionRepo.findOne({ - where: { id: subscriptionId }, - }); - - if (!subscription) return; - - const failedAttempts = subscription.failedAttempts + 1; - const updates: any = { - failedAttempts, - lastError: error, - }; - - // Deactivate after 3 failed attempts or on 404/410 - if (failedAttempts >= 3 || error.includes('404') || error.includes('410')) { - updates.isActive = false; - } - - await this.subscriptionRepo.update(subscriptionId, updates); - } - - private parseUserAgent(ua: string): { deviceType: string; browser: string } { - // Simple UA parsing (consider using `ua-parser-js` for production) - const isMobile = /mobile/i.test(ua); - const isTablet = /tablet|ipad/i.test(ua); - - let browser = 'unknown'; - if (/chrome/i.test(ua)) browser = 'chrome'; - else if (/firefox/i.test(ua)) browser = 'firefox'; - else if (/safari/i.test(ua)) browser = 'safari'; - else if (/edge/i.test(ua)) browser = 'edge'; - - return { - deviceType: isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop', - browser, - }; - } -} -``` - ---- - -## Phase 3 — Backend: Push Notification Sender (1 day) - -### Service: Notification Dispatcher - -```typescript -// push-notifications.service.ts -import { Injectable, Logger } from '@nestjs/common'; -import * as webpush from 'web-push'; -import { PushSubscriptionsService } from './push-subscriptions.service'; -import { SendNotificationDto } from './dto/send-notification.dto'; - -@Injectable() -export class PushNotificationsService { - private readonly logger = new Logger(PushNotificationsService.name); - - constructor(private readonly subscriptionsService: PushSubscriptionsService) { - // Configure web-push with VAPID keys - webpush.setVapidDetails( - process.env.VAPID_SUBJECT, - process.env.VAPID_PUBLIC_KEY, - process.env.VAPID_PRIVATE_KEY, - ); - } - - async sendToUser(userId: string, notification: SendNotificationDto) { - const subscriptions = await this.subscriptionsService.getActiveSubscriptions(userId); - - if (subscriptions.length === 0) { - this.logger.warn(`No active push subscriptions for user ${userId}`); - return { sent: 0, failed: 0 }; - } - - const payload = JSON.stringify({ - title: notification.title, - body: notification.body, - icon: notification.icon || '/icons/icon-192x192.png', - badge: notification.badge || '/icons/badge-72x72.png', - tag: notification.tag, - data: notification.data, - requireInteraction: notification.requireInteraction || false, - }); - - const results = await Promise.allSettled( - subscriptions.map(sub => this.sendToSubscription(sub, payload)), - ); - - const sent = results.filter(r => r.status === 'fulfilled').length; - const failed = results.filter(r => r.status === 'rejected').length; - - this.logger.log(`Sent notifications to user ${userId}: ${sent} sent, ${failed} failed`); - - return { sent, failed }; - } - - private async sendToSubscription(subscription: PushSubscription, payload: string) { - try { - await webpush.sendNotification( - { - endpoint: subscription.endpoint, - keys: { - p256dh: subscription.p256dh, - auth: subscription.auth, - }, - }, - payload, - { - TTL: parseInt(process.env.PUSH_DEFAULT_TTL || '86400'), - vapidDetails: { - subject: process.env.VAPID_SUBJECT, - publicKey: process.env.VAPID_PUBLIC_KEY, - privateKey: process.env.VAPID_PRIVATE_KEY, - }, - }, - ); - - await this.subscriptionsService.markDeliverySuccess(subscription.id); - } catch (error) { - this.logger.error( - `Failed to send notification to subscription ${subscription.id}: ${error.message}`, - ); - await this.subscriptionsService.markDeliveryFailure( - subscription.id, - error.message, - ); - throw error; - } - } - - // Batch send to multiple users - async sendToUsers(userIds: string[], notification: SendNotificationDto) { - const results = await Promise.allSettled( - userIds.map(userId => this.sendToUser(userId, notification)), - ); - - return { - total: userIds.length, - results: results.map((r, i) => ({ - userId: userIds[i], - status: r.status, - data: r.status === 'fulfilled' ? r.value : null, - error: r.status === 'rejected' ? r.reason.message : null, - })), - }; - } -} -``` - ---- - -## Phase 4 — Frontend: Service Worker & Subscription (1 day) - -### Service Worker Registration - -**File**: `maternal-web/public/sw.js` - -```javascript -// Service Worker for Push Notifications -self.addEventListener('push', (event) => { - console.log('[SW] Push received:', event); - - const data = event.data ? event.data.json() : {}; - const title = data.title || 'ParentFlow'; - const options = { - body: data.body || '', - icon: data.icon || '/icons/icon-192x192.png', - badge: data.badge || '/icons/badge-72x72.png', - tag: data.tag || 'default', - data: data.data || {}, - requireInteraction: data.requireInteraction || false, - actions: data.actions || [], - vibrate: [200, 100, 200], - }; - - event.waitUntil( - self.registration.showNotification(title, options) - ); -}); - -self.addEventListener('notificationclick', (event) => { - console.log('[SW] Notification clicked:', event); - - event.notification.close(); - - const url = event.notification.data?.url || '/'; - - event.waitUntil( - clients.matchAll({ type: 'window', includeUncontrolled: true }) - .then((clientList) => { - // Focus existing window if available - for (const client of clientList) { - if (client.url === url && 'focus' in client) { - return client.focus(); - } - } - // Open new window - if (clients.openWindow) { - return clients.openWindow(url); - } - }) - ); -}); - -self.addEventListener('notificationclose', (event) => { - console.log('[SW] Notification closed:', event); -}); -``` - -### Push Subscription Hook - -**File**: `maternal-web/hooks/usePushNotifications.ts` - -```typescript -import { useState, useEffect } from 'react'; -import apiClient from '@/lib/api-client'; - -export function usePushNotifications() { - const [isSupported, setIsSupported] = useState(false); - const [isSubscribed, setIsSubscribed] = useState(false); - const [subscription, setSubscription] = useState(null); - - useEffect(() => { - setIsSupported( - 'serviceWorker' in navigator && - 'PushManager' in window && - 'Notification' in window - ); - }, []); - - const subscribe = async () => { - if (!isSupported) { - throw new Error('Push notifications not supported'); - } - - // Request notification permission - const permission = await Notification.requestPermission(); - if (permission !== 'granted') { - throw new Error('Notification permission denied'); - } - - // Register service worker - const registration = await navigator.serviceWorker.register('/sw.js'); - await navigator.serviceWorker.ready; - - // Get VAPID public key - const { data } = await apiClient.get('/api/v1/push/public-key'); - const publicKey = data.publicKey; - - // Subscribe to push - const pushSubscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicKey), - }); - - // Send subscription to backend - await apiClient.post('/api/v1/push/subscribe', { - endpoint: pushSubscription.endpoint, - keys: { - p256dh: arrayBufferToBase64(pushSubscription.getKey('p256dh')), - auth: arrayBufferToBase64(pushSubscription.getKey('auth')), - }, - userAgent: navigator.userAgent, - }); - - setSubscription(pushSubscription); - setIsSubscribed(true); - - return pushSubscription; - }; - - const unsubscribe = async () => { - if (!subscription) return; - - await subscription.unsubscribe(); - await apiClient.delete(`/api/v1/push/unsubscribe/${encodeURIComponent(subscription.endpoint)}`); - - setSubscription(null); - setIsSubscribed(false); - }; - - return { - isSupported, - isSubscribed, - subscribe, - unsubscribe, - }; -} - -// Helper functions -function urlBase64ToUint8Array(base64String: string): Uint8Array { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); - const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return outputArray; -} - -function arrayBufferToBase64(buffer: ArrayBuffer | null): string { - if (!buffer) return ''; - const bytes = new Uint8Array(buffer); - let binary = ''; - bytes.forEach(b => binary += String.fromCharCode(b)); - return window.btoa(binary); -} -``` - -### UI Component: Notification Settings - -**File**: `maternal-web/components/NotificationSettings.tsx` - -```typescript -'use client'; - -import { usePushNotifications } from '@/hooks/usePushNotifications'; -import { Button, Alert, Box, Typography } from '@mui/material'; - -export function NotificationSettings() { - const { isSupported, isSubscribed, subscribe, unsubscribe } = usePushNotifications(); - const [error, setError] = useState(null); - - const handleToggle = async () => { - try { - setError(null); - if (isSubscribed) { - await unsubscribe(); - } else { - await subscribe(); - } - } catch (err: any) { - setError(err.message || 'Failed to update notification settings'); - } - }; - - if (!isSupported) { - return ( - - Push notifications are not supported in your browser. - - ); - } - - return ( - - Push Notifications - - Receive real-time notifications about activity reminders, family updates, and more. - - - {error && {error}} - - - - {isSubscribed && ( - - ✓ Notifications enabled - - )} - - ); -} -``` - ---- - -## Phase 5 — Integration with Existing Features (1 day) - -### Use Cases - -1. **Activity Reminders** - - "Feeding due in 30 minutes" - - "Nap time reminder" - -2. **Family Updates** - - "Dad logged a feeding" - - "New photo added by Grandma" - -3. **AI Assistant Responses** - - "Your AI assistant has a new suggestion" - -4. **System Notifications** - - "Weekly report ready" - - "Invite accepted" - -### Example: Activity Reminder - -```typescript -// activities.service.ts -import { PushNotificationsService } from '../push-notifications/push-notifications.service'; - -@Injectable() -export class ActivitiesService { - constructor( - private readonly pushService: PushNotificationsService, - ) {} - - async scheduleReminder(activity: Activity) { - // Calculate next feeding time (3 hours) - const nextFeedingTime = new Date(activity.startedAt.getTime() + 3 * 60 * 60 * 1000); - - // Schedule notification - await this.pushService.sendToUser(activity.loggedBy, { - title: 'Feeding Reminder', - body: `Next feeding for ${activity.child.name} is due`, - icon: '/icons/feeding.png', - tag: `activity-reminder-${activity.id}`, - data: { - url: `/children/${activity.childId}`, - activityId: activity.id, - }, - }); - } -} -``` - ---- - -## Phase 6 — Testing & Validation (0.5 day) - -### Test Checklist - -- [ ] VAPID keys generated and configured -- [ ] Service worker registers successfully -- [ ] Permission request works on Chrome desktop -- [ ] Permission request works on Chrome Android -- [ ] Permission request works on Safari iOS (PWA) -- [ ] Subscription saved to database -- [ ] Notification appears with correct title/body -- [ ] Notification click navigates to correct URL -- [ ] Multiple devices per user supported -- [ ] Failed delivery deactivates subscription after 3 attempts -- [ ] 404/410 responses immediately deactivate subscription - ---- - -## Phase 7 — Deployment & Rollout (0.5 day) - -### Environment-Specific Configuration - -**Development:** -```bash -PUSH_NOTIFICATIONS_ENABLED=true -VAPID_SUBJECT=mailto:dev@parentflow.com -``` - -**Production:** -```bash -PUSH_NOTIFICATIONS_ENABLED=true -VAPID_SUBJECT=mailto:hello@parentflow.com -``` - -### Feature Flag - -Use existing settings system: - -```sql -INSERT INTO settings (key, value, type, description) -VALUES ('push_notifications_enabled', 'true', 'boolean', 'Enable web push notifications'); -``` - -### Monitoring - -Add metrics to admin dashboard: -- Total active subscriptions -- Notifications sent (last 24h) -- Success rate -- Failed subscriptions - ---- - -## Phase 8 — Future Enhancements - -1. **Notification Preferences** - - Per-notification-type toggles - - Quiet hours - - Do Not Disturb mode - -2. **Rich Notifications** - - Action buttons - - Images - - Progress indicators - -3. **Firebase Cloud Messaging (FCM)** - - Add FCM as alternative provider - - Auto-fallback for better delivery - -4. **Analytics** - - Open rates - - Click-through rates - - Conversion tracking - ---- - -## Acceptance Criteria - -✅ Users can subscribe to push notifications from web app -✅ Notifications appear within 3 seconds of sending -✅ Failed endpoints are auto-deactivated -✅ Multiple devices per user supported -✅ HTTPS enforced (required for Web Push) -✅ No VAPID keys in logs or client-side code -✅ Admin dashboard shows push metrics - ---- - -## Estimated Timeline - -**Total: 5 days** - -- Phase 0: Setup (0.5 day) -- Phase 1: Database (0.5 day) -- Phase 2: Backend subscriptions (1 day) -- Phase 3: Backend sender (1 day) -- Phase 4: Frontend implementation (1 day) -- Phase 5: Integration (1 day) -- Phase 6: Testing (0.5 day) -- Phase 7: Deployment (0.5 day) - ---- - ---- - -## ✅ 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)