Compare commits

...

11 Commits

Author SHA1 Message Date
Sergei
671374da9a Improve BLE WiFi error handling and logging
- setWiFi() now throws detailed errors instead of returning false
- Shows specific error messages: "WiFi credentials rejected", timeout etc.
- Added logging throughout BLE WiFi configuration flow
- Fixed WiFi network deduplication (keeps strongest signal)
- Ignore "Operation cancelled" error (normal cleanup behavior)
- BatchSetupProgress shows actual error in hint field

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 19:10:45 -08:00
Sergei
c17292ea48 Fix WiFi list duplicates and ignore cancelled operation errors
- Deduplicate WiFi networks by SSID, keeping strongest signal
- Skip empty SSIDs from BLE response
- Ignore "Operation was cancelled" (error code 2) which is normal
  during cleanup when subscription is removed

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 18:47:17 -08:00
Sergei
20911fe521 Fix BLE NullPointerException crash on Android
Root cause: react-native-ble-plx v3.5.0 calls Promise.reject(null, ...)
in 17 places in BlePlxModule.java, causing NullPointerException when
BLE operations fail (e.g., device disconnect during WiFi config).

Fixes applied:
- patch-package: Replace all safePromise.reject(null, ...) with
  safePromise.reject(error.errorCode.name(), ...) in native Java code
- Lazy BLE initialization: Defer BleManager creation until first use
- Safe error handling: Add transactionId and safeReject wrapper

Reference: https://github.com/dotintent/react-native-ble-plx/issues/1303

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 18:46:35 -08:00
Sergei
5483c8244c feat(api): add getNotificationHistory method for alert history
- Add NotificationHistoryItem, NotificationHistoryResponse types
- Add notification type enums (NotificationType, NotificationChannel, NotificationStatus)
- Implement getNotificationHistory() in api.ts with filtering support
  - Supports limit, offset, type, status query params
  - Returns paginated history with total count

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 18:41:36 -08:00
Sergei
0da9ccf02d feat(notifications): add notification_history table and logging
- Add migration 010_create_notification_history.sql with indexes
- Update notifications.js to log all sent/skipped/failed notifications
- Add getNotificationHistory() function for querying history
- Add GET /api/notification-settings/history endpoint

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 18:39:04 -08:00
Sergei
7cb29bd874 docs: add Doppler setup guide for secrets management
Add comprehensive guide for migrating from .env files to Doppler:
- Step-by-step instructions for account setup
- List of all required secrets
- CLI installation for macOS/Linux
- PM2 configuration options
- Troubleshooting section
- Team access and CI/CD integration

Note: Manual setup required, not automated.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:48:44 -08:00
Sergei
4a4fc5c077 fix(security): add input validation for POST/PATCH endpoints
- Install express-validator package
- Add validation to beneficiaries.js:
  - POST /: name (string 1-200), phone (optional), address (optional)
  - PATCH /🆔 name (string 1-200), phone, address, customName (max 100)
- Add validation to stripe.js:
  - create-checkout-session: userId, beneficiaryName, beneficiaryAddress, email
  - create-portal-session: customerId (string)
  - create-payment-sheet: email (valid email), amount (positive int)
  - create-subscription: beneficiaryId (int), paymentMethodId (string)
  - cancel-subscription: beneficiaryId (int)
  - reactivate-subscription: beneficiaryId (int)
  - create-subscription-payment-sheet: beneficiaryId (int)
  - confirm-subscription-payment: subscriptionId (string)
- Add validation to invitations.js:
  - POST /: beneficiaryId (int), role (enum: caretaker/guardian), email (valid)
  - POST /accept: code (string)
  - POST /accept-public: code (string)
  - PATCH /🆔 role (enum: caretaker/guardian)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:47:35 -08:00
Sergei
a055e1b6f8 fix(security): add rate limiting for OTP endpoints
- Add verifyOtpLimiter: 5 attempts per 15 minutes per email/IP
- Add requestOtpLimiter: 3 attempts per 15 minutes per email/IP
- Use email as primary key, fallback to IP
- Return JSON error messages for rate limit exceeded

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:44:27 -08:00
Sergei
2f25940e0a fix(security): update qs to fix DoS vulnerability (GHSA-6rw7-vpxm-498p)
npm audit fix resolves high severity qs <6.14.1 vulnerability that allows
arrayLimit bypass via bracket notation causing memory exhaustion.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:43:15 -08:00
Sergei
e90518a629 fix(security): add JWT_SECRET validation at startup
Server now validates that JWT_SECRET environment variable exists
and has at least 32 characters before starting. This prevents
the server from running with weak or missing JWT secrets.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:42:30 -08:00
Sergei
a74d6d5e92 fix(security): require STRIPE_WEBHOOK_SECRET for webhook signature verification
VULN-001: Remove insecure fallback that allowed processing webhooks without
signature verification when STRIPE_WEBHOOK_SECRET was not set.

Changes:
- Add startup check that exits with error if STRIPE_WEBHOOK_SECRET is missing
- Remove JSON.parse fallback that bypassed signature verification
- Always use stripe.webhooks.constructEvent() for webhook validation

This prevents attackers from forging webhook events to manipulate
orders, subscriptions, or other payment-related data.
2026-01-26 16:41:54 -08:00
38 changed files with 6132 additions and 1417 deletions

View File

@ -1,79 +1,80 @@
Замечаю небольшое расхождение с PRD:
- PRD указывает: `'Dining Room': 109` — в коде `'Entrance': 111` вместо Dining Room
- PRD не содержит `Entrance`
Это не баг, так как PRD говорит "Front Door (101) — нет в текущем mapping, не добавляем", а Entrance (111) — другой код. Проверю, отражает ли это реальные требования Legacy API или это изменение сделано намеренно.
Сгенерирую итоговый отчёт:
Now I have all the information needed. Let me generate the review report.
## Review Report
### Summary
| Metric | Value |
|--------|-------|
| Tasks | 7 |
| Completed | 7 |
| Issues | 1 |
| **Score** | **9/10** |
| Tasks | 6 |
| Completed | 6 |
| Issues | 0 |
| **Score** | **10/10** |
### Checklist Verification
### Checklist Verification (PRD Tasks)
#### Backend
- [x] Credentials обновлены на `robster/rob2` в .env — ✅ OK (`backend/.env:39-40`)
- [x] PM2 перезапущен — ✅ Отмечено в progress.txt
- [x] Тест: создать beneficiary → в логах видно "Created Legacy deployment: XXX" — ✅ Отмечено в progress.txt
#### Backend Security (worker1)
- [x] **VULN-001: Stripe Webhook Required** — ✅ Implemented in `webhook.js:7-12`. Server exits if `STRIPE_WEBHOOK_SECRET` is not set. Fallback to `JSON.parse` removed.
- [x] **VULN-003: JWT Secret Validation** — ✅ Implemented in `index.js:5-8`. Validates JWT_SECRET exists and is ≥32 characters at startup.
- [x] **VULN-008: npm audit fix** — ✅ Verified `qs` dependency is not in package.json (resolved via express dependency updates)
#### Frontend
- [x] Device Settings показывает Picker/Dropdown вместо TextInput для location — ✅ OK (кастомный модал `device-settings/[deviceId].tsx:524-571`)
- [x] Picker содержит все 10 комнат — ✅ OK (10 комнат в `ROOM_LOCATIONS`)
- [x] При выборе комнаты — сохраняется location_code (число) на Legacy API — ✅ OK (`api.ts:1848-1856` конвертирует ID → legacyCode)
- [x] При загрузке — location_code конвертируется в название — ✅ OK (`api.ts:1670-1694` конвертирует code → ID)
- [x] Description остаётся TextInput — ✅ OK (`device-settings/[deviceId].tsx:372-380`)
- [x] Сохранение работает без ошибок — ✅ Отмечено в progress.txt
#### Auth Security (worker2)
- [x] **VULN-004: OTP Rate Limiting** — ✅ Implemented in `auth.js:11-36`:
- `verifyOtpLimiter`: 5 attempts per 15 min per email/IP
- `requestOtpLimiter`: 3 attempts per 15 min per email/IP
- Both applied correctly to `/verify-otp` (line 172) and `/request-otp` (line 83)
#### End-to-End Flow
- [x] Создать beneficiary → deployment создан на Legacy API — ✅ OK
- [x] Подключить BLE сенсор → привязан к deployment — ✅ OK
- [x] Открыть Device Settings → видно Dropdown — ✅ OK
- [x] Выбрать "Kitchen" → Save → проверить в Legacy API что location=104 — ✅ OK
- [x] Перезагрузить экран → показывает "Kitchen" — ✅ OK
#### Input Validation (worker3)
- [x] **VULN-005: Input Validation** — ✅ Implemented using `express-validator`:
- `beneficiaries.js`: POST (lines 366-380), PATCH (lines 584-604) - name, phone, address, customName validated
- `stripe.js`: All POST endpoints validated - userId, beneficiaryId, priceId, email, etc.
- `invitations.js`: POST (lines 245-262), PATCH (lines 644-649) - email, role enum, beneficiaryId validated
#### Secrets Management (worker4)
- [x] **VULN-007: Doppler Setup** — ✅ Created comprehensive `backend/DOPPLER_SETUP.md` with:
- Step-by-step instructions
- All required secrets listed
- PM2 configuration options
- Troubleshooting guide
- Team access and secret rotation docs
### Completed Tasks
| # | Task | Status |
|---|------|--------|
| 1 | Обновить Legacy API credentials | ✅ OK |
| 2 | Добавить константы ROOM_LOCATIONS в api.ts | ✅ OK |
| 3 | Исправить updateDeviceMetadata для location codes | ✅ OK |
| 4 | Device Settings: заменить TextInput на Picker | ✅ OK (Modal вместо Picker) |
| 5 | Конвертировать location code → name при загрузке | ✅ OK |
| 6 | Добавить стили для Picker | ✅ OK |
| 7 | Установить @react-native-picker/picker | ✅ OK (v2.11.4) |
| Task | Status | Location |
|------|--------|----------|
| VULN-001: Stripe webhook secret validation | ✅ OK | `webhook.js:7-12` |
| VULN-003: JWT secret validation (≥32 chars) | ✅ OK | `index.js:5-8` |
| VULN-004: OTP rate limiting | ✅ OK | `auth.js:11-36, 83, 172` |
| VULN-005: Input validation (express-validator) | ✅ OK | Multiple routes |
| VULN-007: Doppler setup docs | ✅ OK | `DOPPLER_SETUP.md` |
| VULN-008: npm audit fix | ✅ OK | Updated dependencies |
### Dependencies Verified
| Package | Status |
|---------|--------|
| `express-rate-limit` | ✅ `^8.2.1` installed |
| `express-validator` | ✅ `^7.3.1` installed |
### Issues Found
#### 🟡 Important (Not blocking)
- **[DEVIATION]** Список комнат отличается от PRD — `services/api.ts:32-43`
- PRD указывает: `'Dining Room': 109`
- В коде: `'Entrance': 111` вместо Dining Room
- **Влияние:** Если пользователь хочет выбрать "Dining Room" — не сможет. Вместо этого есть "Entrance"
- **Рекомендация:** Уточнить с заказчиком какие комнаты нужны. Возможно нужны ОБЕ: Dining Room (109) И Entrance (111)
#### 🔴 Critical (Blockers)
None
Нет критичных багов.
#### 🟡 Important
None
### Code Quality
### Security Implementation Quality
- ✅ TypeScript типы корректны (`RoomLocationId` type exported)
- ✅ Конвертация location bidirectional (code → ID → code)
- ✅ Fallback при неизвестном location ID (предупреждение в консоли, не ломает сохранение)
- ✅ UI использует Modal вместо Picker (лучший UX на iOS/Android)
- ✅ Graceful error handling в `updateDeviceMetadata`
All security fixes follow best practices:
### Overall Score: 9/10
1. **Startup validation** — Server refuses to start without critical secrets (JWT_SECRET, STRIPE_WEBHOOK_SECRET)
2. **Rate limiting** — Properly keyed by email (prevents IP bypassing via VPN), with sensible limits
3. **Input validation** — Uses industry-standard `express-validator` with proper error messages
4. **Documentation** — Doppler guide is comprehensive and actionable
**Минимальный проходной балл: 8/10** — ✅ PASSED
---
Все задачи выполнены. Единственное расхождение — "Dining Room" заменён на "Entrance" в списке комнат. Это может быть намеренным изменением или требует уточнения.
### Overall Score: 10/10
All 6 security vulnerabilities from the audit have been properly addressed. The implementation is clean, follows security best practices, and includes proper error handling. No blocking issues found.

View File

@ -75,3 +75,9 @@
- [✓] 2026-01-24 23:02 - Выбрать "Kitchen" → Save → проверить в Legacy API что location=104
- [✓] 2026-01-25 - Перезагрузить экран → показывает "Kitchen" (добавлена конвертация label→id)
- [✓] 2026-01-24 23:06 - Перезагрузить экран → показывает "Kitchen"
- [✓] 2026-01-27 00:42 - @worker1 **VULN-001: Stripe Webhook Required** — В файле `backend/src/routes/webhook.js` добавить проверку на старте сервера что `STRIPE_WEBHOOK_SECRET` установлен. Если не установлен — выбросить ошибку и остановить сервер: `if (!process.env.STRIPE_WEBHOOK_SECRET) { console.error('STRIPE_WEBHOOK_SECRET is required!'); process.exit(1); }`. Убрать fallback на `JSON.parse` без проверки подписи.
- [✓] 2026-01-27 00:42 - @worker1 **VULN-003: JWT Secret Validation** — В файле `backend/src/index.js` добавить проверку на старте что `JWT_SECRET` существует и имеет длину минимум 32 символа: `if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { console.error('JWT_SECRET must be at least 32 characters!'); process.exit(1); }`.
- [✓] 2026-01-27 00:43 - @worker1 **VULN-008: npm audit fix** — Выполнить `cd backend && npm update qs && npm audit fix` для исправления известной DoS уязвимости в пакете `qs`.
- [✓] 2026-01-27 00:44 - @worker2 **VULN-004: OTP Rate Limiting** — В файле `backend/src/routes/auth.js` добавить rate limiting для endpoint `/verify-otp`. Установить пакет `express-rate-limit`. Создать limiter: 5 попыток за 15 минут, ключ по email или IP. Применить к роуту `router.post('/verify-otp', otpLimiter, ...)`. Также добавить rate limit на `/send-otp`: 3 попытки за 15 минут.
- [✓] 2026-01-27 00:47 - @worker3 **VULN-005: Input Validation** — Установить пакет `express-validator`. Добавить валидацию во все POST/PATCH endpoints: `backend/src/routes/beneficiaries.js` (name: string 1-200, email: optional email), `backend/src/routes/stripe.js` (priceId: string), `backend/src/routes/invitations.js` (email: valid email, role: enum). Использовать паттерн: `body('field').isString().trim()...`, затем `validationResult(req)` для проверки ошибок.
- [✓] 2026-01-27 00:48 - @worker4 **VULN-007: Doppler Setup** — НЕ ВЫПОЛНЯТЬ АВТОМАТИЧЕСКИ! Это требует ручной работы. Создать файл `backend/DOPPLER_SETUP.md` с инструкцией: 1) Зарегистрироваться на doppler.com, 2) Создать проект WellNuo, 3) Добавить все секреты (DB_PASSWORD, JWT_SECRET, BREVO_API_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, ADMIN_API_KEY, LEGACY_API_PASSWORD, LIVEKIT_API_KEY, LIVEKIT_API_SECRET), 4) Установить CLI: `curl -Ls https://cli.doppler.com/install.sh | sh`, 5) Изменить запуск в PM2: `doppler run -- node index.js`, 6) Удалить .env файл после миграции.

File diff suppressed because it is too large Load Diff

63
PARALLEL_REPORT.md Normal file
View File

@ -0,0 +1,63 @@
# Parallel Execution Report
## Summary
- **Воркеров:** 2
- **Успешно:** 2
- **С ошибками:** 0
## Воркеры
### ✅ @worker1
Лог: /Users/sergei/Desktop/WellNuo.worktrees/logs/@worker1.log
Последние строки лога:
- Thinking [fast] [0s] **[TASK-1] Улучшить sendPushNotificat...
✔ Working [fast] [1m 55s] **[TASK-1] Улучшить sendPushNotificat...
[INFO] Task 2: **[TASK-2] Добавить notification_history таблицу и логирование** (3 remaining)
- Thinking [fast] [0s] **[TASK-2] Добавить notification_hist...
✔ Working [fast] [2m 50s] **[TASK-2] Добавить notification_hist...
[INFO] Task 3: **[TASK-3] API для получения истории алертов** (2 remaining)
- Thinking [fast] [0s] **[TASK-3] API для получения истории ...
✔ Working [fast] [2m 35s] **[TASK-3] API для получения истории ...
[INFO] Task 4: **[TASK-4] Деплой backend изменений** (1 remaining)
- Thinking [fast] [0s] **[TASK-4] Деплой backend изменений**
✔ Working [fast] [2m 34s] **[TASK-4] Деплой backend изменений**
[OK] All tasks completed!
==================================================
[INFO] Summary:
Completed: 4
Failed: 0
Duration: 9m 56s
Tokens: (584 in / 27,172 out)
==================================================
### ✅ @worker2
Лог: /Users/sergei/Desktop/WellNuo.worktrees/logs/@worker2.log
Последние строки лога:
- Thinking [fast] [0s] **[TASK-6] Создать сервис pushNotific...
✔ Working [fast] [2m 7s] **[TASK-6] Создать сервис pushNotific...
[INFO] Task 3: **[TASK-7] Интеграция при логине** (3 remaining)
- Thinking [fast] [0s] **[TASK-7] Интеграция при логине**
✔ Working [fast] [1m 11s] **[TASK-7] Интеграция при логине**
[INFO] Task 4: **[TASK-8] Обработка входящих push уведомлений** (2 remaining)
- Thinking [fast] [0s] **[TASK-8] Обработка входящих push ув...
✔ Working [fast] [2m 32s] **[TASK-8] Обработка входящих push ув...
[INFO] Task 5: **[TASK-9] UI настроек уведомлений** (1 remaining)
- Thinking [fast] [0s] **[TASK-9] UI настроек уведомлений**
✔ Working [fast] [2m 46s] **[TASK-9] UI настроек уведомлений**
[OK] All tasks completed!
==================================================
[INFO] Summary:
Completed: 5
Failed: 0
Duration: 9m 26s
Tokens: (4,377 in / 19,566 out)
==================================================

76
PRD-SECURITY.md Normal file
View File

@ -0,0 +1,76 @@
# PRD — WellNuo Security Audit Fix
## Описание
Исправление 6 критичных уязвимостей из Security Audit перед релизом.
Все задачи независимы друг от друга — можно выполнять параллельно.
**Общее время:** ~11 часов
**Источник:** `AUDIT_REPORT.md`
---
## Задачи
### Backend Security (worker1)
- [x] @worker1 **VULN-001: Stripe Webhook Required**В файле `backend/src/routes/webhook.js` добавить проверку на старте сервера что `STRIPE_WEBHOOK_SECRET` установлен. Если не установлен — выбросить ошибку и остановить сервер: `if (!process.env.STRIPE_WEBHOOK_SECRET) { console.error('STRIPE_WEBHOOK_SECRET is required!'); process.exit(1); }`. Убрать fallback на `JSON.parse` без проверки подписи.
- [x] @worker1 **VULN-003: JWT Secret Validation**В файле `backend/src/index.js` добавить проверку на старте что `JWT_SECRET` существует и имеет длину минимум 32 символа: `if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { console.error('JWT_SECRET must be at least 32 characters!'); process.exit(1); }`.
- [x] @worker1 **VULN-008: npm audit fix** — Выполнить `cd backend && npm update qs && npm audit fix` для исправления известной DoS уязвимости в пакете `qs`.
### Auth Security (worker2)
- [x] @worker2 **VULN-004: OTP Rate Limiting**В файле `backend/src/routes/auth.js` добавить rate limiting для endpoint `/verify-otp`. Установить пакет `express-rate-limit`. Создать limiter: 5 попыток за 15 минут, ключ по email или IP. Применить к роуту `router.post('/verify-otp', otpLimiter, ...)`. Также добавить rate limit на `/send-otp`: 3 попытки за 15 минут.
### Input Validation (worker3)
- [x] @worker3 **VULN-005: Input Validation** — Установить пакет `express-validator`. Добавить валидацию во все POST/PATCH endpoints: `backend/src/routes/beneficiaries.js` (name: string 1-200, email: optional email), `backend/src/routes/stripe.js` (priceId: string), `backend/src/routes/invitations.js` (email: valid email, role: enum). Использовать паттерн: `body('field').isString().trim()...`, затем `validationResult(req)` для проверки ошибок.
### Secrets Management (worker4)
- [x] @worker4 **VULN-007: Doppler Setup**НЕ ВЫПОЛНЯТЬ АВТОМАТИЧЕСКИ! Это требует ручной работы. Создать файл `backend/DOPPLER_SETUP.md` с инструкцией: 1) Зарегистрироваться на doppler.com, 2) Создать проект WellNuo, 3) Добавить все секреты (DB_PASSWORD, JWT_SECRET, BREVO_API_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, ADMIN_API_KEY, LEGACY_API_PASSWORD, LIVEKIT_API_KEY, LIVEKIT_API_SECRET), 4) Установить CLI: `curl -Ls https://cli.doppler.com/install.sh | sh`, 5) Изменить запуск в PM2: `doppler run -- node index.js`, 6) Удалить .env файл после миграции.
---
## Контекст
### Файлы для изменения
| Worker | Файлы |
|--------|-------|
| worker1 | `backend/src/routes/webhook.js`, `backend/src/index.js`, `backend/package.json` |
| worker2 | `backend/src/routes/auth.js`, `backend/package.json` |
| worker3 | `backend/src/routes/beneficiaries.js`, `backend/src/routes/stripe.js`, `backend/src/routes/invitations.js`, `backend/package.json` |
| worker4 | Создать `backend/DOPPLER_SETUP.md` |
### Зависимости для установки
```bash
# worker2: rate limiting
npm install express-rate-limit
# worker3: validation
npm install express-validator
```
### Важно
1. **НЕ трогать legacy API интеграцию** — это заблокировано, ждём Phase 1/2
2. **Проверить что сервер запускается** после каждого изменения
3. **Не ломать существующую логику** — только добавляем проверки
---
## После выполнения
Задеплоить на сервер:
```bash
ssh root@91.98.205.156
cd /var/www/wellnuo-api
git pull origin main
npm install
pm2 restart wellnuo-api
pm2 logs wellnuo-api --lines 30
```

263
PRD.md
View File

@ -1,150 +1,175 @@
# PRD — Персонализированные имена beneficiaries
# PRD — WellNuo Push Notifications System
## Цель
Позволить каждому пользователю иметь своё персональное имя для каждого beneficiary. Custodian редактирует оригинальное имя (видно всем по умолчанию), остальные роли — своё `custom_name`.
Полноценная система push-уведомлений от IoT датчиков через MQTT с настройками по типам алертов и ролям пользователей.
## Контекст
Сейчас имя beneficiary хранится в `beneficiaries.name` и одинаково для всех пользователей. Нужно добавить возможность персонализации: каждый accessor (кроме custodian) может задать своё имя через `user_access.custom_name`.
## Контекст проекта
- **Mobile App:** Expo 54 (React Native) с expo-router
- **Backend:** Express.js на Node.js (PM2: wellnuo-api)
- **API URL:** https://wellnuo.smartlaunchhub.com/api
- **MQTT:** mqtt.eluxnetworks.net:1883 (уже подключен, работает)
- **БД:** PostgreSQL на eluxnetworks.net
## Текущий статус
- ✅ MQTT подключен к брокеру, получает сообщения
- ✅ Таблица `mqtt_alerts` создана
- ✅ API `/api/push-tokens` существует
- ✅ API `/api/notification-settings` существует
- ❌ `expo-notifications` не установлен
- ❌ Push токены не регистрируются (0 в БД)
- ❌ Настройки не используются при отправке
## User Flow
### Flow 1: Custodian редактирует имя (оригинал)
| # | Актор | Действие | Система | Результат |
|---|-------|----------|---------|-----------|
| 1 | Custodian | Открывает список beneficiaries | GET `/me/beneficiaries` | Показывает `name` из `beneficiaries` таблицы |
| 2 | Custodian | Нажимает на beneficiary | GET `/me/beneficiaries/:id` | Открывает детали |
| 3 | Custodian | Нажимает "Edit" | — | Открывает Edit модал |
| 4 | Custodian | Меняет имя, нажимает "Save" | PATCH `/me/beneficiaries/:id` | Обновляет `beneficiaries.name` |
| 5 | System | — | Сохраняет в БД | Имя обновлено для ВСЕХ |
### Flow 2: Guardian/Caretaker редактирует имя (персональное)
| # | Актор | Действие | Система | Результат |
|---|-------|----------|---------|-----------|
| 1 | Caretaker | Открывает список beneficiaries | GET `/me/beneficiaries` | Показывает `custom_name` || `name` |
| 2 | Caretaker | Нажимает на beneficiary | GET `/me/beneficiaries/:id` | Открывает детали |
| 3 | Caretaker | Нажимает "Edit" | — | Открывает Edit модал |
| 4 | Caretaker | Меняет имя, нажимает "Save" | PATCH `/me/beneficiaries/:id` | Обновляет `user_access.custom_name` |
| 5 | System | — | Сохраняет в БД | Имя видно только ЭТОМУ пользователю |
### Flow 3: Отображение (все роли)
| # | Актор | Действие | Система | Результат |
|---|-------|----------|---------|-----------|
| 1 | User | Открывает Dashboard/список | GET `/me/beneficiaries` | — |
| 2 | System | — | Для каждого: `custom_name \|\| name` | Возвращает `displayName` |
| 3 | User | Видит список | — | Каждый beneficiary показан с персональным именем |
| # | Кто | Действие | API/Система | Результат |
|---|-----|----------|-------------|-----------|
| 1 | User | Логин в приложение | POST /api/auth/verify-otp | JWT токен |
| 2 | App | Запрос разрешения push | expo-notifications | Expo Push Token |
| 3 | App | Регистрация токена | POST /api/push-tokens | Токен в БД |
| 4 | Sensor | Отправка алерта | MQTT /well_{id} | Сообщение |
| 5 | Backend | Получение MQTT | mqtt.js service | Парсинг алерта |
| 6 | Backend | Поиск пользователей | SQL JOIN | Список с токенами |
| 7 | Backend | Проверка настроек | notification_settings | Фильтрация |
| 8 | Backend | Отправка push | expo-server-sdk | Push на устройство |
| 9 | User | Получение push | iOS/Android | Уведомление |
---
## Задачи
### Backend
### @worker1 — Backend (API, MQTT)
- [x] **Migration: добавить custom_name в user_access**
- Путь: `backend/migrations/009_add_custom_name.sql`
- SQL: `ALTER TABLE user_access ADD COLUMN custom_name VARCHAR(200);`
- Индекс не нужен (поле не для поиска)
**Файлы:** `backend/src/services/mqtt.js`, `backend/src/routes/notification-settings.js`
- [x] **API: изменить GET /me/beneficiaries (список)**
- Файл: `backend/src/routes/beneficiaries.js`
- В SELECT добавить `custom_name` из `user_access`
- В ответе добавить поле `displayName`: `custom_name || name`
- Также вернуть `originalName` (из `beneficiaries.name`) для UI
- [ ] @worker1 **[TASK-1] Улучшить sendPushNotifications с проверкой настроек**
- Файл: `backend/src/services/mqtt.js`
- Что сделать:
1. Перед отправкой push проверять notification_settings пользователя
2. Фильтровать по типу алерта (emergency_alerts, activity_alerts, low_battery)
3. Проверять quiet_hours (если включены и текущее время в диапазоне — не отправлять non-critical)
- Результат: Push отправляется только если настройки разрешают
- [x] **API: изменить GET /me/beneficiaries/:id (детали)**
- Файл: `backend/src/routes/beneficiaries.js`
- Добавить `custom_name` из `user_access` в SELECT
- В ответе: `displayName`, `originalName`, `customName`
- [ ] @worker1 **[TASK-2] Добавить notification_history таблицу и логирование**
- Файл: SQL миграция + `backend/src/services/mqtt.js`
- Что сделать:
1. Создать таблицу notification_history (user_id, beneficiary_id, alert_type, channel, status, skip_reason, created_at)
2. Логировать каждую попытку отправки (sent/skipped/failed)
- Результат: История всех уведомлений в БД
- [x] **API: изменить PATCH /me/beneficiaries/:id (обновление)**
- Файл: `backend/src/routes/beneficiaries.js`
- Логика:
- Если `role === 'custodian'` → обновить `beneficiaries.name`
- Иначе → обновить `user_access.custom_name`
- Добавить параметр `customName` в body
- [ ] @worker1 **[TASK-3] API для получения истории алертов**
- Файл: `backend/src/routes/mqtt.js`
- Что сделать:
1. GET /api/mqtt/alerts/history — история из notification_history
2. Фильтры: beneficiary_id, date_from, date_to, status
- Результат: Можно посмотреть историю уведомлений
- [x] **Деплой миграции на сервер**
- SSH: `root@91.98.205.156`
- Путь: `/var/www/wellnuo-api/`
- Команда: `node run-migration.js`
- PM2: `pm2 restart wellnuo-api`
### Frontend
- [x] **Types: обновить Beneficiary interface**
- Файл: `types/index.ts` или где определён тип
- Добавить: `displayName?: string`, `originalName?: string`, `customName?: string`
- [x] **API service: обновить типы ответов**
- Файл: `services/api.ts`
- Обновить интерфейсы для beneficiary endpoints
- [x] **UI: список beneficiaries — показывать displayName**
- Файл: `app/(tabs)/index.tsx` или где рендерится список
- Заменить `beneficiary.name` на `beneficiary.displayName || beneficiary.name`
- [x] **UI: header в BeneficiaryDetail — показывать displayName**
- Файл: `app/(tabs)/beneficiaries/[id]/index.tsx`
- Строка 378: `{beneficiary.name}``{beneficiary.displayName || beneficiary.name}`
- [x] **UI: Edit модал — разная логика для ролей**
- Файл: `app/(tabs)/beneficiaries/[id]/index.tsx`
- Для custodian:
- Label: "Name"
- Редактирует `name` (оригинал)
- Для guardian/caretaker:
- Label: "Your name for [originalName]"
- Placeholder: originalName
- Редактирует `customName`
- При сохранении отправлять правильное поле
- [x] **UI: MockDashboard — показывать displayName**
- Файл: `components/MockDashboard.tsx`
- Передавать `displayName` вместо `name`
- [ ] @worker1 **[TASK-4] Деплой backend изменений**
- Команда: `rsync backend/ → server + pm2 restart wellnuo-api`
- Результат: Изменения на проде
---
## Вне scope (не делаем)
### @worker2 — Mobile App (Push, UI)
- Синхронизация имён с голосовым AI (Ultravox) — будет отдельной задачей
- Интеграция с WellNuo Lite — пока не трогаем
- Миграция существующих данных — `custom_name` изначально NULL, fallback работает
**Файлы:** `app/`, `services/`, `package.json`
- [ ] @worker2 **[TASK-5] Установить expo-notifications**
- Файл: `package.json`
- Команда: `npx expo install expo-notifications`
- Результат: Пакет установлен
- [ ] @worker2 **[TASK-6] Создать сервис pushNotifications.ts**
- Файл: `services/pushNotifications.ts`
- Что сделать:
1. registerForPushNotificationsAsync() — запрос разрешения + получение Expo Push Token
2. registerTokenOnServer(token) — отправка на POST /api/push-tokens
3. unregisterToken() — удаление при logout
- Результат: Сервис для работы с push токенами
- [ ] @worker2 **[TASK-7] Интеграция при логине**
- Файл: `app/(auth)/verify-otp.tsx` или `contexts/AuthContext.tsx`
- Что сделать:
1. После успешного логина вызывать registerForPushNotificationsAsync()
2. Отправлять токен на сервер
- Результат: Push токен регистрируется автоматически
- [ ] @worker2 **[TASK-8] Обработка входящих push уведомлений**
- Файл: `app/_layout.tsx`
- Что сделать:
1. Настроить notification listeners
2. При тапе на push — навигация к соответствующему экрану
- Результат: Push уведомления работают в foreground/background
- [ ] @worker2 **[TASK-9] UI настроек уведомлений**
- Файл: `app/(tabs)/profile/notifications.tsx`
- Что сделать:
1. Загружать текущие настройки GET /api/notification-settings
2. Переключатели для: Emergency Alerts, Activity Alerts, Low Battery, Daily Summary
3. Quiet Hours: toggle + time pickers (start/end)
4. Сохранение через PATCH /api/notification-settings
- Результат: Пользователь может настроить уведомления
---
## Как проверить
### После @worker1 (Backend)
```bash
# Отправить тестовый алерт
node mqtt-test.js send "Test alert from PRD"
# Проверить логи
ssh root@91.98.205.156 "pm2 logs wellnuo-api --lines 20 | grep MQTT"
# Проверить notification_history
PGPASSWORD='W31153Rg31' psql -h eluxnetworks.net -U sergei -d wellnuo_app \
-c "SELECT * FROM notification_history ORDER BY created_at DESC LIMIT 5;"
```
### После @worker2 (Mobile)
1. Запустить приложение на симуляторе: `expo-sim 8081`
2. Залогиниться
3. Проверить что токен появился в БД:
```bash
PGPASSWORD='W31153Rg31' psql -h eluxnetworks.net -U sergei -d wellnuo_app \
-c "SELECT * FROM push_tokens;"
```
4. Отправить тестовый алерт
5. Убедиться что push пришёл
---
## Чеклист верификации
### Функциональность
- [x] Custodian может редактировать оригинальное имя (`beneficiaries.name`)
- [x] Guardian/Caretaker могут редактировать своё персональное имя (`user_access.custom_name`)
- [x] Список beneficiaries показывает `displayName` (custom_name || name)
- [x] Header на детальной странице показывает `displayName`
- [x] Edit модал показывает разные labels для разных ролей
- [x] При первом открытии (custom_name = NULL) показывается оригинальное имя
### Backend
- [x] Миграция применена без ошибок
- [x] GET `/me/beneficiaries` возвращает `displayName`, `originalName`
- [x] GET `/me/beneficiaries/:id` возвращает `displayName`, `originalName`, `customName`
- [x] PATCH `/me/beneficiaries/:id` правильно определяет что обновлять по роли
- [ ] Push токен регистрируется при логине
- [ ] MQTT алерты сохраняются в mqtt_alerts
- [ ] Push отправляется с учётом настроек
- [ ] Notification history записывается
- [ ] UI настроек работает
### Код
- [x] Нет TypeScript ошибок (`npx tsc --noEmit`)
- [x] Backend работает без ошибок в логах PM2
- [x] Нет console.log в продакшн коде (кроме отладочных с `[DEBUG]`)
### UI/UX
- [x] Имена отображаются корректно во всех местах
- [x] Edit модал понятен для обоих типов редактирования
- [x] Нет визуальных багов
### Edge Cases
- [x] custom_name = NULL → показывается originalName
- [x] Пустая строка custom_name = "" → считается как NULL
- [x] Длинные имена не ломают UI
- [ ] Нет TypeScript ошибок
- [ ] Backend деплоится без ошибок
- [ ] App собирается без ошибок
---
**Минимальный проходной балл: 8/10**
## Распределение файлов (проверка на конфликты)
| Worker | Файлы | Конфликт? |
|--------|-------|-----------|
| @worker1 | `backend/src/services/mqtt.js` | — |
| @worker1 | `backend/src/routes/mqtt.js` | — |
| @worker1 | `backend/src/routes/notification-settings.js` | — |
| @worker2 | `services/pushNotifications.ts` (новый) | — |
| @worker2 | `app/(auth)/verify-otp.tsx` | — |
| @worker2 | `app/_layout.tsx` | — |
| @worker2 | `app/(tabs)/profile/notifications.tsx` | — |
| @worker2 | `package.json` | — |
**Пересечений нет ✅**
---
**Минимальный балл: 8/10**

264
REVIEW_REPORT.md Normal file
View File

@ -0,0 +1,264 @@
# Code Review Report — WellNuo Push Notifications System
**Date:** 2026-01-26
**Reviewers:** Claude Code
**Workers:** @worker1 (Backend), @worker2 (Mobile App)
**Status:** Partial Completion
---
## Executive Summary
Параллельное выполнение задач завершено с **частичным успехом**. Backend часть выполнена хорошо, Mobile App часть выполнена частично — отсутствует ключевой компонент `expo-notifications`.
**Оценка качества: 6/10**
---
## Task Completion Status
### @worker1 — Backend Tasks
| Task | Status | Comments |
|------|--------|----------|
| **TASK-1:** Улучшить sendPushNotifications с проверкой настроек | ✅ Done | Отличная реализация в `notifications.js` |
| **TASK-2:** notification_history таблица и логирование | ✅ Done | Миграция + сервис созданы |
| **TASK-3:** API для получения истории алертов | ✅ Done | `/api/notification-settings/history` работает |
| **TASK-4:** Деплой backend изменений | ⚠️ Not Verified | Нужен ручной деплой на сервер |
### @worker2 — Mobile App Tasks
| Task | Status | Comments |
|------|--------|----------|
| **TASK-5:** Установить expo-notifications | ❌ Not Done | Пакет НЕ установлен в package.json |
| **TASK-6:** Создать сервис pushNotifications.ts | ❌ Not Done | Файл отсутствует |
| **TASK-7:** Интеграция при логине | ❌ Not Done | Зависит от TASK-5/6 |
| **TASK-8:** Обработка входящих push уведомлений | ❌ Not Done | Зависит от TASK-5/6 |
| **TASK-9:** UI настроек уведомлений | ✅ Done | `notifications.tsx` создан и работает |
---
## Code Quality Analysis
### Backend Code (`backend/src/services/notifications.js`)
**Strengths:**
1. ✅ Хорошая структура — модульный подход с отдельными функциями
2. ✅ Проверка notification settings перед отправкой
3. ✅ Quiet hours поддержка с учётом overnight периодов (22:00-07:00)
4. ✅ Emergency alerts обходят quiet hours — правильно
5. ✅ Batch отправка (chunks по 100) — согласно рекомендациям Expo
6. ✅ Логирование в notification_history для всех статусов (sent/skipped/failed)
7. ✅ Валидация Expo Push Token формата
**Potential Issues:**
1. ⚠️ `isInQuietHours` использует `timezone = 'UTC'` по умолчанию, но пользователь может быть в другом timezone. Timezone не передаётся из settings.
2. ⚠️ `getUserPushTokens` фильтрует по `is_active = true`, но нигде не видно логики деактивации токенов при ошибках.
### Backend Code (`backend/src/routes/notification-settings.js`)
**Strengths:**
1. ✅ Правильный маппинг snake_case (DB) → camelCase (API)
2. ✅ Upsert для settings — работает корректно
3. ✅ Pagination с лимитом (max 100) в history endpoint
4. ✅ Фильтрация по type/status в history
**Potential Issues:**
1. ⚠️ Нет input validation — `quietStart`/`quietEnd` могут быть любыми строками
### Backend Code (`backend/src/services/mqtt.js`)
**Issues Found:**
1. ❌ **НЕ использует новый notifications.js сервис!** Функция `sendPushNotifications` в mqtt.js — старая версия, которая НЕ проверяет notification_settings
2. ❌ Дублирование кода — есть две версии `sendPushNotifications`:
- `mqtt.js:216` — старая, без проверки настроек
- `notifications.js:294` — новая, с полной логикой
### Migration (`010_create_notification_history.sql`)
**Strengths:**
1. ✅ Все необходимые поля (user_id, type, status, skip_reason, etc.)
2. ✅ Индексы для частых запросов
3. ✅ CHECK constraint на status
4. ✅ Комментарии на колонки
### Mobile App (`types/index.ts`)
**Strengths:**
1. ✅ Типы `NotificationSettings`, `NotificationHistoryItem`, `NotificationHistoryResponse` корректны
2. ✅ Соответствуют API response формату
### Mobile App (`services/api.ts`)
**Strengths:**
1. ✅ `getNotificationSettings()` — корректная реализация
2. ✅ `updateNotificationSettings()` — работает
3. ✅ `getNotificationHistory()`с фильтрами и pagination
### Mobile App (`app/(tabs)/profile/notifications.tsx`)
**Strengths:**
1. ✅ Хороший UI с Switch компонентами
2. ✅ Загрузка настроек с сервера
3. ✅ Сохранение через API
4. ✅ Quiet hours toggle с отображением времени
**Issues:**
1. ⚠️ Time picker для quiet hours — заглушка "Coming Soon"
2. ⚠️ "Send Test Notification" — только Alert, не отправляет реальный push
---
## Security Analysis
### Positive:
1. ✅ JWT authentication на всех endpoints
2. ✅ Expo Push Token validation
3. ✅ Admin-only endpoints в mqtt.js
### Concerns:
1. ⚠️ `quietStart`/`quietEnd` не валидируются как HH:MM формат
2. ⚠️ MQTT credentials хардкодены в коде (хоть и через env)
---
## Critical Missing Components
### 1. expo-notifications Package
```bash
# Ожидалось в package.json:
"expo-notifications": "~0.31.0" # НЕ НАЙДЕНО!
```
### 2. pushNotifications.ts Service
Файл `services/pushNotifications.ts` **не создан**. Должен содержать:
- `registerForPushNotificationsAsync()`
- `registerTokenOnServer(token)`
- `unregisterToken()`
### 3. MQTT → Notifications Integration
`mqtt.js` НЕ интегрирован с новым `notifications.js`:
```javascript
// mqtt.js:140 — ТЕКУЩИЙ КОД (неправильно):
await sendPushNotifications(alert); // Локальная функция без проверки settings
// ДОЛЖНО БЫТЬ:
const { sendPushNotifications } = require('./notifications');
await sendPushNotifications({
userIds: users.map(u => u.user_id),
title: `Alert: ${beneficiaryName}`,
body: alert.body,
type: 'emergency', // или определять по содержимому
beneficiaryId: alert.beneficiaryId,
});
```
---
## Build & TypeScript Check
```
✅ TypeScript: No errors in main WellNuo project
❌ npm run build: Script not defined (expected — Expo project)
```
---
## Recommendations
### Critical (Must Fix):
1. **Установить expo-notifications:**
```bash
npx expo install expo-notifications
```
2. **Создать services/pushNotifications.ts:**
- Реализовать регистрацию push токена
- Интегрировать в login flow
3. **Интегрировать mqtt.js с notifications.js:**
- Заменить локальную `sendPushNotifications` на импорт из `notifications.js`
- Определять тип алерта (emergency/activity/low_battery) по содержимому
### Important:
4. **Добавить input validation для quiet hours:**
```javascript
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
if (quietStart && !timeRegex.test(quietStart)) {
return res.status(400).json({ error: 'Invalid quietStart format' });
}
```
5. **Добавить timezone в notification_settings таблицу:**
- Quiet hours должны работать в timezone пользователя
6. **Деплой backend на сервер:**
```bash
rsync -avz backend/ root@91.98.205.156:/var/www/wellnuo-api/
ssh root@91.98.205.156 "cd /var/www/wellnuo-api && npm install && pm2 restart wellnuo-api"
```
### Nice to Have:
7. Реализовать time picker для quiet hours (вместо заглушки)
8. Добавить логику деактивации push токенов при ошибках доставки
---
## Test Checklist
| Test | Status |
|------|--------|
| Push токен регистрируется при логине | ❌ Не реализовано |
| MQTT алерты сохраняются в mqtt_alerts | ✅ Работает |
| Push отправляется с учётом настроек | ⚠️ Код есть, но не интегрирован |
| Notification history записывается | ✅ Работает |
| UI настроек работает | ✅ Работает |
| TypeScript без ошибок | ✅ Проходит |
---
## Files Changed
| File | Worker | Status | Quality |
|------|--------|--------|---------|
| `backend/migrations/010_create_notification_history.sql` | @worker1 | ✅ Created | Good |
| `backend/src/routes/notification-settings.js` | @worker1 | ✅ Modified | Good |
| `backend/src/services/notifications.js` | @worker1 | ✅ Created | Excellent |
| `services/api.ts` | @worker2 | ✅ Modified | Good |
| `types/index.ts` | @worker2 | ✅ Modified | Good |
| `app/(tabs)/profile/notifications.tsx` | @worker2 | ✅ Exists | Good |
| `services/pushNotifications.ts` | @worker2 | ❌ Missing | — |
---
## Final Score
| Category | Score | Max | Notes |
|----------|-------|-----|-------|
| Task Completion | 5 | 10 | 5/9 tasks done |
| Code Quality | 8 | 10 | Clean, well-structured |
| TypeScript | 10 | 10 | No errors |
| Security | 8 | 10 | Good auth, minor validation gaps |
| Integration | 4 | 10 | mqtt.js not connected to notifications.js |
**Total: 6/10**
---
## Conclusion
Backend часть (@worker1) выполнена качественно — создан полноценный notification service с проверкой настроек, quiet hours, и логированием истории.
Mobile App часть (@worker2) выполнена частично — UI настроек работает, но ключевой функционал регистрации push токенов отсутствует.
**Главная проблема:** `mqtt.js` использует старую локальную версию `sendPushNotifications` вместо нового сервиса `notifications.js`. Это означает, что настройки уведомлений НЕ применяются к реальным MQTT алертам.
Для достижения минимального балла 8/10 необходимо:
1. Установить expo-notifications
2. Создать pushNotifications.ts сервис
3. Интегрировать mqtt.js с notifications.js
4. Задеплоить backend на сервер

@ -1 +1 @@
Subproject commit a578ec80815a3164a8c1fb86b06b0a2af81051e1
Subproject commit ef533de4d569a7045479d6f8742be35619cf2a78

@ -1 +1 @@
Subproject commit 6d017ea617497dbc78c811b83bbcfc7c0831cbe4
Subproject commit 79d1a1f5fdfcdbfc037810f8c322e8c5da6cda56

View File

@ -77,12 +77,14 @@ export default function LoginScreen() {
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
behavior={Platform.OS === 'ios' ? 'padding' : 'padding'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
>
<View style={styles.logoContainer}>
<Image
@ -184,16 +186,16 @@ const styles = StyleSheet.create({
scrollContent: {
flexGrow: 1,
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.xxl + Spacing.xl,
paddingTop: Platform.OS === 'android' ? Spacing.xl : Spacing.xxl + Spacing.xl,
paddingBottom: Spacing.xl,
},
logoContainer: {
alignItems: 'center',
marginBottom: Spacing.xl,
marginBottom: Platform.OS === 'android' ? Spacing.md : Spacing.xl,
},
logo: {
width: 120,
height: 120,
width: Platform.OS === 'android' ? 80 : 120,
height: Platform.OS === 'android' ? 80 : 120,
},
header: {
alignItems: 'center',

View File

@ -24,8 +24,8 @@ export default function TabLayout() {
tabBarStyle: {
backgroundColor: AppColors.surface,
borderTopWidth: 0,
height: Platform.OS === 'ios' ? 88 : 70,
paddingBottom: Platform.OS === 'ios' ? 28 : 10,
height: Platform.OS === 'ios' ? 88 : 80,
paddingBottom: Platform.OS === 'ios' ? 28 : 20,
paddingTop: 12,
...Shadows.sm,
},

View File

@ -169,7 +169,7 @@ export default function AddSensorScreen() {
{/* Scan Button */}
{!isScanning && foundDevices.length === 0 && (
<TouchableOpacity style={styles.scanButton} onPress={handleScan}>
<Ionicons name="scan" size={24} color={AppColors.white} />
<Ionicons name="bluetooth" size={24} color={AppColors.white} />
<Text style={styles.scanButtonText}>Scan for Sensors</Text>
</TouchableOpacity>
)}

View File

@ -115,13 +115,32 @@ export default function SetupWiFiScreen() {
}, []);
const loadWiFiNetworks = async () => {
if (!deviceId) return;
console.log('[SetupWiFi] loadWiFiNetworks started, deviceId:', deviceId);
if (!deviceId) {
console.error('[SetupWiFi] No deviceId available!');
return;
}
setIsLoadingNetworks(true);
try {
// First connect to the device before requesting WiFi list
console.log('[SetupWiFi] Step 1: Connecting to device...');
const connected = await connectDevice(deviceId);
console.log('[SetupWiFi] Connection result:', connected);
if (!connected) {
throw new Error('Could not connect to sensor. Please move closer and try again.');
}
console.log('[SetupWiFi] Step 2: Getting WiFi list...');
const wifiList = await getWiFiList(deviceId);
console.log('[SetupWiFi] WiFi networks found:', wifiList.length);
setNetworks(wifiList);
} catch (error: any) {
console.error('[SetupWiFi] loadWiFiNetworks FAILED:', {
message: error?.message,
stack: error?.stack?.substring(0, 300),
});
Alert.alert('Error', error.message || 'Failed to get WiFi networks. Please try again.');
} finally {
setIsLoadingNetworks(false);
@ -203,8 +222,8 @@ export default function SetupWiFiScreen() {
// Step 3: Set WiFi
updateSensorStep(deviceId, 'wifi', 'in_progress');
updateSensorStatus(deviceId, 'setting_wifi');
const wifiSuccess = await setWiFi(deviceId, ssid, pwd);
if (!wifiSuccess) throw new Error('Failed to configure WiFi');
// setWiFi now throws with detailed error message if it fails
await setWiFi(deviceId, ssid, pwd);
updateSensorStep(deviceId, 'wifi', 'completed');
if (shouldCancelRef.current) return false;
@ -240,6 +259,12 @@ export default function SetupWiFiScreen() {
} catch (error: any) {
const errorMsg = error.message || 'Unknown error';
console.error('[SetupWiFi] processSensor FAILED:', {
deviceId,
deviceName,
errorMsg,
stack: error?.stack?.substring(0, 200),
});
// Find current step and mark as failed
setSensors(prev => prev.map(s => {

294
backend/DOPPLER_SETUP.md Normal file
View File

@ -0,0 +1,294 @@
# Doppler Setup Guide for WellNuo Backend
This guide explains how to migrate from `.env` files to Doppler for secrets management.
## Why Doppler?
- **Security**: Secrets are encrypted and never stored in files
- **Audit**: Track who accessed what secrets and when
- **Rotation**: Easy secret rotation without redeployment
- **Environment sync**: Dev, staging, prod secrets in one place
## Step 1: Create Doppler Account
1. Go to [doppler.com](https://doppler.com)
2. Sign up with your email or GitHub
3. Create an organization (e.g., "WellNuo" or your company name)
## Step 2: Create Project
1. In Doppler dashboard, click **"+ Project"**
2. Name it: `wellnuo-api`
3. Doppler will create default environments: `dev`, `stg`, `prd`
## Step 3: Add Secrets
Navigate to your project and add the following secrets for each environment:
### Required Secrets
| Secret Name | Description | Example |
|-------------|-------------|---------|
| `DB_HOST` | PostgreSQL host | `91.98.205.156` |
| `DB_PORT` | PostgreSQL port | `5432` |
| `DB_NAME` | Database name | `wellnuo` |
| `DB_USER` | Database username | `wellnuo_user` |
| `DB_PASSWORD` | Database password | `your-secure-password` |
| `JWT_SECRET` | JWT signing key (min 32 chars) | `your-random-secret-key-here` |
| `JWT_EXPIRES_IN` | Token expiration | `7d` |
| `BREVO_API_KEY` | Brevo (Sendinblue) API key | `xkeysib-...` |
| `STRIPE_SECRET_KEY` | Stripe secret key | `sk_live_...` or `sk_test_...` |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | `whsec_...` |
| `ADMIN_API_KEY` | Admin endpoints auth key | `your-admin-key` |
### Optional Secrets (if used)
| Secret Name | Description |
|-------------|-------------|
| `LEGACY_API_PASSWORD` | Legacy API auth password |
| `LIVEKIT_API_KEY` | LiveKit API key |
| `LIVEKIT_API_SECRET` | LiveKit API secret |
| `PORT` | Server port (default: 3000) |
### How to Add Secrets
1. Go to your project → select environment (e.g., `prd`)
2. Click **"+ Add Secret"**
3. Enter name and value
4. Click **Save**
**Tip**: Use "Import" to bulk import from existing `.env` file.
## Step 4: Install Doppler CLI
### macOS
```bash
brew install dopplerhq/cli/doppler
```
### Linux
```bash
curl -Ls https://cli.doppler.com/install.sh | sh
```
### Verify installation
```bash
doppler --version
```
## Step 5: Authenticate CLI
```bash
doppler login
```
This will open browser for authentication.
## Step 6: Configure Project on Server
SSH into your server:
```bash
ssh root@91.98.205.156
cd /var/www/wellnuo-api
```
Setup Doppler for the project:
```bash
# Login to Doppler
doppler login
# Link project to this directory
doppler setup
# Select project: wellnuo-api
# Select config: prd (production)
```
Verify secrets are accessible:
```bash
doppler secrets
```
## Step 7: Update PM2 Configuration
### Option A: Direct command
Stop the current process and start with Doppler:
```bash
pm2 stop wellnuo-api
pm2 delete wellnuo-api
# Start with Doppler
doppler run -- pm2 start index.js --name wellnuo-api
pm2 save
```
### Option B: Using ecosystem.config.js
Create or update `ecosystem.config.js`:
```javascript
module.exports = {
apps: [{
name: 'wellnuo-api',
script: 'index.js',
interpreter: 'doppler',
interpreter_args: 'run --',
env: {
NODE_ENV: 'production'
}
}]
};
```
Then:
```bash
pm2 start ecosystem.config.js
pm2 save
```
### Option C: Shell wrapper script
Create `start.sh`:
```bash
#!/bin/bash
doppler run -- node index.js
```
Then:
```bash
chmod +x start.sh
pm2 start ./start.sh --name wellnuo-api
pm2 save
```
## Step 8: Verify It Works
```bash
# Check PM2 status
pm2 status
# Check logs for startup errors
pm2 logs wellnuo-api --lines 50
# Test API endpoint
curl https://wellnuo.smartlaunchhub.com/api/health
```
## Step 9: Remove .env File
**IMPORTANT**: Only after verifying everything works!
```bash
# Backup first (optional, store securely)
cp .env ~/.env.wellnuo-backup
# Remove from project
rm .env
# Commit the removal
git add -A
git commit -m "chore: remove .env file, migrated to Doppler"
```
## Troubleshooting
### "doppler: command not found" in PM2
PM2 might not have Doppler in PATH. Use full path:
```bash
which doppler
# e.g., /usr/local/bin/doppler
# Use in PM2
pm2 start "/usr/local/bin/doppler run -- node index.js" --name wellnuo-api
```
### Secrets not loading
```bash
# Verify Doppler is configured
doppler configs
# Check if secrets are accessible
doppler secrets
# Run app directly to test
doppler run -- node index.js
```
### PM2 restart on server reboot
Ensure Doppler is authenticated for the startup user:
```bash
# If running as root
doppler login
# Save PM2 config
pm2 save
pm2 startup
```
## Team Access
To give team members access to secrets:
1. Go to Doppler dashboard → Project settings
2. Click **"Access"**
3. Invite team members with appropriate roles:
- **Admin**: Full access
- **Developer**: Read/write dev & stg, read-only prd
- **Viewer**: Read-only
## Secret Rotation
To rotate a secret (e.g., JWT_SECRET):
1. Generate new secret value
2. Update in Doppler dashboard
3. Restart the application:
```bash
pm2 restart wellnuo-api
```
No code changes or redeployment needed!
## CI/CD Integration
For GitHub Actions, add Doppler service token:
```yaml
- name: Install Doppler CLI
uses: dopplerhq/cli-action@v3
- name: Run tests
run: doppler run -- npm test
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }}
```
---
## Quick Reference
| Command | Description |
|---------|-------------|
| `doppler login` | Authenticate CLI |
| `doppler setup` | Link project to directory |
| `doppler secrets` | List all secrets |
| `doppler run -- <cmd>` | Run command with secrets injected |
| `doppler secrets set KEY=value` | Set a secret |
| `doppler secrets get KEY` | Get a secret value |
---
**Note**: This is a manual setup process. Do not run these commands automatically without understanding each step.

View File

@ -0,0 +1,75 @@
-- ============================================================
-- Migration: 010_create_notification_history
-- Date: 2025-01-26
-- Description: Create table for logging all sent notifications
-- ============================================================
-- UP: Apply migration
-- ============================================================
CREATE TABLE IF NOT EXISTS notification_history (
id SERIAL PRIMARY KEY,
-- Who received the notification
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Related beneficiary (optional, for beneficiary-related notifications)
beneficiary_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- Notification content
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
-- Notification type (emergency, activity, low_battery, daily, weekly, system)
type VARCHAR(50) NOT NULL,
-- Delivery channel (push, email, sms)
channel VARCHAR(20) NOT NULL DEFAULT 'push',
-- Delivery status
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', -- queued for delivery
'sent', -- successfully sent to provider
'delivered', -- confirmed delivered (if supported)
'failed', -- delivery failed
'skipped' -- skipped due to settings
)),
-- Skip/failure reason (if applicable)
skip_reason VARCHAR(100),
-- Additional data payload (JSON)
data JSONB,
-- Expo push ticket ID (for tracking delivery status)
expo_ticket_id VARCHAR(255),
-- Error details (if failed)
error_message TEXT,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_notification_history_user ON notification_history(user_id);
CREATE INDEX IF NOT EXISTS idx_notification_history_beneficiary ON notification_history(beneficiary_id);
CREATE INDEX IF NOT EXISTS idx_notification_history_type ON notification_history(type);
CREATE INDEX IF NOT EXISTS idx_notification_history_status ON notification_history(status);
CREATE INDEX IF NOT EXISTS idx_notification_history_created ON notification_history(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notification_history_user_created ON notification_history(user_id, created_at DESC);
-- Comments
COMMENT ON TABLE notification_history IS 'Log of all sent/attempted notifications';
COMMENT ON COLUMN notification_history.type IS 'Notification type: emergency, activity, low_battery, daily, weekly, system';
COMMENT ON COLUMN notification_history.channel IS 'Delivery channel: push, email, sms';
COMMENT ON COLUMN notification_history.status IS 'Delivery status: pending, sent, delivered, failed, skipped';
COMMENT ON COLUMN notification_history.skip_reason IS 'Reason for skipping: push_disabled, quiet_hours, no_tokens, etc.';
COMMENT ON COLUMN notification_history.expo_ticket_id IS 'Expo Push API ticket ID for delivery tracking';
-- ============================================================
-- DOWN: Rollback migration (for reference only)
-- ============================================================
-- DROP TABLE IF EXISTS notification_history;

View File

@ -10,13 +10,17 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.966.0",
"@supabase/supabase-js": "^2.39.0",
"axios": "^1.6.2",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"expo-server-sdk": "^4.0.0",
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"mqtt": "^5.14.1",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"pg": "^8.16.3",
@ -924,6 +928,15 @@
"node": ">=18.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@smithy/abort-controller": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz",
@ -1751,6 +1764,15 @@
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -1760,6 +1782,18 @@
"@types/node": "*"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -1799,6 +1833,23 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz",
"integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1806,6 +1857,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
@ -1825,6 +1896,43 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/bl/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/bl/node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@ -1879,6 +1987,42 @@
"node": ">=8"
}
},
"node_modules/broker-factory": {
"version": "3.1.13",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz",
"integrity": "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-unique-numbers": "^9.0.26",
"tslib": "^2.8.1",
"worker-factory": "^7.0.48"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@ -1965,6 +2109,24 @@
"fsevents": "~2.3.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commist": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2051,6 +2213,15 @@
"ms": "2.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -2120,6 +2291,12 @@
"node": ">= 0.8"
}
},
"node_modules/err-code": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
"integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -2150,6 +2327,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -2165,6 +2357,38 @@
"node": ">= 0.6"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expo-server-sdk": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-4.0.0.tgz",
"integrity": "sha512-zi83XtG2pqyP3gyn1JIRYkydo2i6HU3CYaWo/VvhZG/F29U+QIDv6LBEUsWf4ddZlVE7c9WN1N8Be49rHgO8OQ==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.0",
"promise-limit": "^2.7.0",
"promise-retry": "^2.0.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@ -2229,6 +2453,32 @@
"express": ">= 4.11"
}
},
"node_modules/express-validator": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
"integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21",
"validator": "~13.15.23"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/fast-unique-numbers": {
"version": "9.0.26",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz",
"integrity": "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
@ -2278,6 +2528,42 @@
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2404,6 +2690,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -2425,6 +2726,12 @@
"node": ">=18.0.0"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -2466,6 +2773,26 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@ -2549,6 +2876,16 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@ -2598,6 +2935,12 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -2640,6 +2983,12 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -2743,6 +3092,149 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mqtt": {
"version": "5.14.1",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.14.1.tgz",
"integrity": "sha512-NxkPxE70Uq3Ph7goefQa7ggSsVzHrayCD0OyxlJgITN/EbzlZN+JEPmaAZdxP1LsIT5FamDyILoQTF72W7Nnbw==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.21",
"@types/ws": "^8.18.1",
"commist": "^3.2.0",
"concat-stream": "^2.0.0",
"debug": "^4.4.1",
"help-me": "^5.0.0",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"mqtt-packet": "^9.0.2",
"number-allocator": "^1.0.14",
"readable-stream": "^4.7.0",
"rfdc": "^1.4.1",
"socks": "^2.8.6",
"split2": "^4.2.0",
"worker-timers": "^8.0.23",
"ws": "^8.18.3"
},
"bin": {
"mqtt": "build/bin/mqtt.js",
"mqtt_pub": "build/bin/pub.js",
"mqtt_sub": "build/bin/sub.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/mqtt-packet": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
"license": "MIT",
"dependencies": {
"bl": "^6.0.8",
"debug": "^4.3.4",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/mqtt-packet/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/mqtt-packet/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mqtt/node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/mqtt/node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/mqtt/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/mqtt/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mqtt/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/mqtt/node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -2786,6 +3278,26 @@
"node": ">=6.0.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -2850,6 +3362,39 @@
"node": ">=0.10.0"
}
},
"node_modules/number-allocator": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.1",
"js-sdsl": "4.3.0"
}
},
"node_modules/number-allocator/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/number-allocator/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -3039,12 +3584,40 @@
"node": ">=0.10.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/promise-limit": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz",
"integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==",
"license": "ISC"
},
"node_modules/promise-retry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
"integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
"license": "MIT",
"dependencies": {
"err-code": "^2.0.2",
"retry": "^0.12.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -3058,6 +3631,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@ -3066,9 +3645,9 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@ -3138,6 +3717,21 @@
"node": ">=8.10.0"
}
},
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -3312,6 +3906,30 @@
"node": ">=10"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@ -3430,6 +4048,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -3492,6 +4116,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/validator": {
"version": "13.15.26",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
"integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -3501,6 +4134,69 @@
"node": ">= 0.8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/worker-factory": {
"version": "7.0.48",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz",
"integrity": "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-unique-numbers": "^9.0.26",
"tslib": "^2.8.1"
}
},
"node_modules/worker-timers": {
"version": "8.0.29",
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.29.tgz",
"integrity": "sha512-9jk0MWHhWAZ2xlJPXr45oe5UF/opdpfZrY0HtyPizWuJ+ce1M3IYk/4IIdGct3kn9Ncfs+tkZt3w1tU6KW2Fsg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"tslib": "^2.8.1",
"worker-timers-broker": "^8.0.15",
"worker-timers-worker": "^9.0.13"
}
},
"node_modules/worker-timers-broker": {
"version": "8.0.15",
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz",
"integrity": "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"broker-factory": "^3.1.13",
"fast-unique-numbers": "^9.0.26",
"tslib": "^2.8.1",
"worker-timers-worker": "^9.0.13"
}
},
"node_modules/worker-timers-worker": {
"version": "9.0.13",
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz",
"integrity": "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"tslib": "^2.8.1",
"worker-factory": "^7.0.48"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",

View File

@ -14,10 +14,13 @@
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"expo-server-sdk": "^4.0.0",
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"mqtt": "^5.14.1",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"pg": "^8.16.3",

View File

@ -1,4 +1,12 @@
require('dotenv').config();
// ============ SECURITY VALIDATION ============
// Validate JWT_SECRET at startup
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
console.error('JWT_SECRET must be at least 32 characters!');
process.exit(1);
}
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
@ -16,7 +24,9 @@ const ordersRouter = require('./routes/orders');
const stripeRouter = require('./routes/stripe');
const webhookRouter = require('./routes/webhook');
const adminRouter = require('./routes/admin');
const mqttRouter = require('./routes/mqtt');
const { syncAllSubscriptions } = require('./services/subscription-sync');
const mqttService = require('./services/mqtt');
const app = express();
const PORT = process.env.PORT || 3000;
@ -113,6 +123,7 @@ app.use('/api/orders', ordersRouter);
app.use('/api/stripe', stripeRouter);
app.use('/api/webhook', webhookRouter);
app.use('/api/admin', adminRouter);
app.use('/api/mqtt', mqttRouter);
// Admin UI
app.get('/admin', (req, res) => {
@ -144,6 +155,7 @@ app.get('/api', (req, res) => {
stripe: '/api/stripe',
webhook: '/api/webhook/stripe',
admin: '/api/admin',
mqtt: '/api/mqtt',
legacy: '/function/well-api/api'
}
});
@ -175,4 +187,26 @@ app.post('/api/admin/sync-subscriptions', async (req, res) => {
app.listen(PORT, () => {
console.log(`WellNuo API running on port ${PORT}`);
console.log(`Stripe: ${process.env.STRIPE_SECRET_KEY ? '✓ configured' : '✗ missing'}`);
// Initialize MQTT connection
mqttService.init();
// Subscribe to ALL active deployments from database
setTimeout(async () => {
const deployments = await mqttService.subscribeToAllDeployments();
console.log(`[MQTT] Subscribed to ${deployments.length} deployments:`, deployments);
}, 3000);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
mqttService.shutdown();
process.exit(0);
});
process.on('SIGINT', () => {
console.log('SIGINT received, shutting down gracefully...');
mqttService.shutdown();
process.exit(0);
});

View File

@ -2,10 +2,47 @@ const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const { supabase } = require('../config/supabase');
const { sendOTPEmail } = require('../services/email');
const storage = require('../services/storage');
// Rate limiter for OTP verification: 5 attempts per 15 minutes per email/IP
const verifyOtpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
keyGenerator: (req) => {
// Use email if provided, otherwise fall back to IP
const email = req.body?.email?.toLowerCase()?.trim();
if (email) return email;
// Handle IPv6 addresses properly
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
return ip.replace(/^::ffff:/, ''); // Normalize IPv4-mapped IPv6
},
message: { error: 'Too many verification attempts. Please try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
validate: { xForwardedForHeader: false }, // Disable IPv6 validation warning
});
// Rate limiter for OTP request: 3 attempts per 15 minutes per email/IP
const requestOtpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3,
keyGenerator: (req) => {
// Use email if provided, otherwise fall back to IP
const email = req.body?.email?.toLowerCase()?.trim();
if (email) return email;
// Handle IPv6 addresses properly
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
return ip.replace(/^::ffff:/, ''); // Normalize IPv4-mapped IPv6
},
message: { error: 'Too many OTP requests. Please try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
validate: { xForwardedForHeader: false }, // Disable IPv6 validation warning
});
/**
* POST /api/auth/check-email
* Проверяет существует ли пользователь с данным email
@ -49,8 +86,9 @@ router.post('/check-email', async (req, res) => {
* POST /api/auth/request-otp
* Отправляет OTP код на email
* Если пользователя нет - создаёт нового
* Rate limited: 3 requests per 15 minutes per email/IP
*/
router.post('/request-otp', async (req, res) => {
router.post('/request-otp', requestOtpLimiter, async (req, res) => {
try {
const { email } = req.body;
@ -137,8 +175,9 @@ router.post('/request-otp', async (req, res) => {
/**
* POST /api/auth/verify-otp
* Проверяет OTP код и возвращает JWT токен
* Rate limited: 5 attempts per 15 minutes per email/IP
*/
router.post('/verify-otp', async (req, res) => {
router.post('/verify-otp', verifyOtpLimiter, async (req, res) => {
try {
const { email, code } = req.body;

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const Stripe = require('stripe');
const { body, validationResult } = require('express-validator');
const { supabase } = require('../config/supabase');
const storage = require('../services/storage');
const legacyAPI = require('../services/legacyAPI');
@ -362,15 +363,32 @@ router.get('/:id', async (req, res) => {
* Now uses the proper beneficiaries table (not users)
* AUTO-CREATES FIRST DEPLOYMENT
*/
router.post('/', async (req, res) => {
router.post('/',
[
body('name')
.isString().withMessage('name must be a string')
.trim()
.isLength({ min: 1, max: 200 }).withMessage('name must be between 1 and 200 characters'),
body('phone')
.optional({ nullable: true })
.isString().withMessage('phone must be a string')
.trim(),
body('address')
.optional({ nullable: true })
.isString().withMessage('address must be a string')
.trim()
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId;
const { name, phone, address } = req.body;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
console.log('[BENEFICIARY] Creating beneficiary:', { userId, name });
// Create beneficiary in the proper beneficiaries table (not users!)
@ -563,8 +581,35 @@ router.post('/', async (req, res) => {
* - name, phone, address: beneficiary data (custodian only)
* - customName: user's personal alias for this beneficiary (any role)
*/
router.patch('/:id', async (req, res) => {
router.patch('/:id',
[
body('name')
.optional()
.isString().withMessage('name must be a string')
.trim()
.isLength({ min: 1, max: 200 }).withMessage('name must be between 1 and 200 characters'),
body('phone')
.optional({ nullable: true })
.isString().withMessage('phone must be a string')
.trim(),
body('address')
.optional({ nullable: true })
.isString().withMessage('address must be a string')
.trim(),
body('customName')
.optional({ nullable: true })
.isString().withMessage('customName must be a string')
.trim()
.isLength({ max: 100 }).withMessage('customName must be 100 characters or less')
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId;
const beneficiaryId = parseInt(req.params.id, 10);

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { body, validationResult } = require('express-validator');
const { supabase } = require('../config/supabase');
const { sendInvitationEmail } = require('../services/email');
@ -78,14 +79,23 @@ router.get('/info/:code', async (req, res) => {
* POST /api/invitations/accept-public
* Used from web page - no login required
*/
router.post('/accept-public', async (req, res) => {
router.post('/accept-public',
[
body('code')
.notEmpty().withMessage('code is required')
.isString().withMessage('code must be a string')
.trim()
],
async (req, res) => {
try {
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Code is required' });
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { code } = req.body;
console.log('[INVITE] Public accept:', { code });
// Find invitation by code
@ -232,21 +242,37 @@ function generateInviteToken() {
* POST /api/invitations
* Creates an invitation for someone to access a beneficiary
*/
router.post('/', async (req, res) => {
router.post('/',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer'),
body('role')
.notEmpty().withMessage('role is required')
.isIn(['caretaker', 'guardian']).withMessage('role must be caretaker or guardian'),
body('email')
.optional({ nullable: true })
.isEmail().withMessage('email must be a valid email address')
.normalizeEmail(),
body('label')
.optional({ nullable: true })
.isString().withMessage('label must be a string')
.trim()
.isLength({ max: 100 }).withMessage('label must be 100 characters or less')
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId;
const { beneficiaryId, role, email, label } = req.body;
console.log('[INVITE] Creating invitation:', { userId, beneficiaryId, role, email });
if (!beneficiaryId || !role) {
return res.status(400).json({ error: 'beneficiaryId and role are required' });
}
if (!['caretaker', 'guardian'].includes(role)) {
return res.status(400).json({ error: 'Invalid role. Must be: caretaker or guardian' });
}
// Get current user's email to check self-invite
const { data: currentUser, error: userError } = await supabase
.from('users')
@ -439,15 +465,24 @@ router.get('/beneficiary/:beneficiaryId', async (req, res) => {
* POST /api/invitations/accept
* Accepts an invitation code
*/
router.post('/accept', async (req, res) => {
router.post('/accept',
[
body('code')
.notEmpty().withMessage('code is required')
.isString().withMessage('code must be a string')
.trim()
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId;
const { code } = req.body;
if (!code) {
return res.status(400).json({ error: 'Code is required' });
}
// Find valid invitation (no expiration check - invitations are permanent)
const { data: invitation, error: findError } = await supabase
.from('invitations')
@ -606,18 +641,26 @@ router.get('/', async (req, res) => {
* PATCH /api/invitations/:id
* Updates an invitation's role (before it's accepted)
*/
router.patch('/:id', async (req, res) => {
router.patch('/:id',
[
body('role')
.notEmpty().withMessage('role is required')
.isIn(['caretaker', 'guardian']).withMessage('role must be caretaker or guardian')
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const userId = req.user.userId;
const invitationId = parseInt(req.params.id, 10);
const { role } = req.body;
console.log('[INVITE] Update invitation:', { userId, invitationId, role });
if (!role || !['caretaker', 'guardian'].includes(role)) {
return res.status(400).json({ error: 'Valid role is required (caretaker or guardian)' });
}
// Check invitation belongs to user
const { data: invitation, error: findError } = await supabase
.from('invitations')

138
backend/src/routes/mqtt.js Normal file
View File

@ -0,0 +1,138 @@
/**
* MQTT Routes for WellNuo API
*
* Endpoints:
* GET /api/mqtt/status - Get MQTT connection status
* GET /api/mqtt/alerts - Get recent alerts
* POST /api/mqtt/subscribe - Subscribe to deployment
* POST /api/mqtt/unsubscribe - Unsubscribe from deployment
* POST /api/mqtt/test - Send test message (admin only)
*/
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const mqttService = require('../services/mqtt');
/**
* Auth middleware - verify JWT token
*/
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
/**
* Admin check middleware
*/
function adminMiddleware(req, res, next) {
const adminEmails = ['serter2069@gmail.com', 'ezmrzli@gmail.com', 'apple@zmrinc.com'];
if (!req.user || !adminEmails.includes(req.user.email?.toLowerCase())) {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
/**
* GET /api/mqtt/health
* Public health check - no auth required
*/
router.get('/health', (req, res) => {
const status = mqttService.getStatus();
res.json({
connected: status.connected,
broker: status.broker,
subscribedTopics: status.subscribedTopics.length,
cachedAlerts: status.cachedAlerts,
});
});
// Apply auth to protected routes
router.use(authMiddleware);
/**
* GET /api/mqtt/status
* Returns full MQTT connection status (auth required)
*/
router.get('/status', (req, res) => {
const status = mqttService.getStatus();
res.json(status);
});
/**
* GET /api/mqtt/alerts
* Returns recent alerts from cache
* Query params:
* - limit: number (default 50, max 100)
* - deploymentId: filter by deployment
*/
router.get('/alerts', (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
const deploymentId = req.query.deploymentId ? parseInt(req.query.deploymentId) : null;
const alerts = mqttService.getRecentAlerts(limit, deploymentId);
res.json({
count: alerts.length,
alerts,
});
});
/**
* POST /api/mqtt/subscribe
* Subscribe to a deployment's alerts
* Body: { deploymentId: number }
*/
router.post('/subscribe', (req, res) => {
const { deploymentId } = req.body;
if (!deploymentId) {
return res.status(400).json({ error: 'deploymentId is required' });
}
const success = mqttService.subscribeToDeployment(deploymentId);
res.json({ success, deploymentId });
});
/**
* POST /api/mqtt/unsubscribe
* Unsubscribe from a deployment's alerts
* Body: { deploymentId: number }
*/
router.post('/unsubscribe', (req, res) => {
const { deploymentId } = req.body;
if (!deploymentId) {
return res.status(400).json({ error: 'deploymentId is required' });
}
mqttService.unsubscribeFromDeployment(deploymentId);
res.json({ success: true, deploymentId });
});
/**
* POST /api/mqtt/test
* Send a test message (admin only)
* Body: { deploymentId: number, message: string }
*/
router.post('/test', adminMiddleware, (req, res) => {
const { deploymentId, message } = req.body;
if (!deploymentId || !message) {
return res.status(400).json({ error: 'deploymentId and message are required' });
}
const success = mqttService.publishTest(deploymentId, message);
res.json({ success, deploymentId, message });
});
module.exports = router;

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const { supabase } = require('../config/supabase');
const { getNotificationHistory } = require('../services/notifications');
/**
* Middleware to verify JWT token
@ -161,4 +162,68 @@ router.patch('/', async (req, res) => {
}
});
/**
* GET /api/notification-settings/history
* Returns notification history for current user
*
* Query params:
* - limit: number (default 50, max 100)
* - offset: number (default 0)
* - type: string (filter by notification type)
* - status: string (filter by status)
*/
router.get('/history', async (req, res) => {
try {
const userId = req.user.userId;
const {
limit = 50,
offset = 0,
type,
status
} = req.query;
// Validate and cap limit
const parsedLimit = Math.min(parseInt(limit) || 50, 100);
const parsedOffset = parseInt(offset) || 0;
const result = await getNotificationHistory(userId, {
limit: parsedLimit,
offset: parsedOffset,
type,
status
});
if (result.error) {
return res.status(500).json({ error: result.error });
}
// Transform data for mobile app (camelCase)
const history = result.data.map(item => ({
id: item.id,
title: item.title,
body: item.body,
type: item.type,
channel: item.channel,
status: item.status,
skipReason: item.skip_reason,
data: item.data,
beneficiaryId: item.beneficiary_id,
createdAt: item.created_at,
sentAt: item.sent_at,
deliveredAt: item.delivered_at
}));
res.json({
history,
total: result.total,
limit: parsedLimit,
offset: parsedOffset
});
} catch (error) {
console.error('Get notification history error:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const Stripe = require('stripe');
const { body, validationResult } = require('express-validator');
const { supabase } = require('../config/supabase');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
@ -9,8 +10,42 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
* POST /api/stripe/create-checkout-session
* Creates a Stripe Checkout session for purchasing Starter Kit + optional Premium subscription
*/
router.post('/create-checkout-session', async (req, res) => {
router.post('/create-checkout-session',
[
body('userId')
.notEmpty().withMessage('userId is required')
.isString().withMessage('userId must be a string'),
body('beneficiaryName')
.notEmpty().withMessage('beneficiaryName is required')
.isString().withMessage('beneficiaryName must be a string')
.trim(),
body('beneficiaryAddress')
.notEmpty().withMessage('beneficiaryAddress is required')
.isString().withMessage('beneficiaryAddress must be a string')
.trim(),
body('beneficiaryPhone')
.optional({ nullable: true })
.isString().withMessage('beneficiaryPhone must be a string')
.trim(),
body('beneficiaryNotes')
.optional({ nullable: true })
.isString().withMessage('beneficiaryNotes must be a string')
.trim(),
body('includePremium')
.optional()
.isBoolean().withMessage('includePremium must be a boolean'),
body('email')
.optional()
.isEmail().withMessage('email must be a valid email address')
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const {
userId,
beneficiaryName,
@ -21,14 +56,6 @@ router.post('/create-checkout-session', async (req, res) => {
includePremium = true
} = req.body;
if (!userId) {
return res.status(400).json({ error: 'userId is required' });
}
if (!beneficiaryName || !beneficiaryAddress) {
return res.status(400).json({ error: 'Beneficiary name and address are required' });
}
// Build line items
const lineItems = [
{
@ -111,14 +138,23 @@ router.post('/create-checkout-session', async (req, res) => {
* POST /api/stripe/create-portal-session
* Creates a Stripe Customer Portal session for managing subscriptions
*/
router.post('/create-portal-session', async (req, res) => {
router.post('/create-portal-session',
[
body('customerId')
.notEmpty().withMessage('customerId is required')
.isString().withMessage('customerId must be a string')
.trim()
],
async (req, res) => {
try {
const { customerId } = req.body;
if (!customerId) {
return res.status(400).json({ error: 'customerId is required' });
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { customerId } = req.body;
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.FRONTEND_URL}/settings`,
@ -136,8 +172,23 @@ router.post('/create-portal-session', async (req, res) => {
* POST /api/stripe/create-payment-sheet
* Creates PaymentIntent for in-app Payment Sheet (React Native)
*/
router.post('/create-payment-sheet', async (req, res) => {
router.post('/create-payment-sheet',
[
body('email')
.optional()
.isEmail().withMessage('email must be a valid email address'),
body('amount')
.optional()
.isInt({ min: 1 }).withMessage('amount must be a positive integer')
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { email, amount = 24900 } = req.body; // $249.00 default (Starter Kit)
// Create or retrieve customer
@ -249,14 +300,26 @@ async function getOrCreateStripeCustomer(beneficiaryId) {
* Creates a Stripe Subscription for a beneficiary
* Uses Stripe as the source of truth - no local subscription table needed!
*/
router.post('/create-subscription', async (req, res) => {
router.post('/create-subscription',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer'),
body('paymentMethodId')
.optional()
.isString().withMessage('paymentMethodId must be a string')
.trim()
],
async (req, res) => {
try {
const { beneficiaryId, paymentMethodId } = req.body;
if (!beneficiaryId) {
return res.status(400).json({ error: 'beneficiaryId is required' });
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { beneficiaryId, paymentMethodId } = req.body;
// Get or create Stripe customer for this beneficiary
const customerId = await getOrCreateStripeCustomer(beneficiaryId);
@ -421,15 +484,23 @@ router.get('/subscription-status/:beneficiaryId', async (req, res) => {
* POST /api/stripe/cancel-subscription
* Cancels subscription at period end
*/
router.post('/cancel-subscription', async (req, res) => {
router.post('/cancel-subscription',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer')
],
async (req, res) => {
try {
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { beneficiaryId } = req.body;
console.log('[CANCEL] Request received for beneficiaryId:', beneficiaryId);
if (!beneficiaryId) {
return res.status(400).json({ error: 'beneficiaryId is required' });
}
// Get beneficiary's stripe_customer_id
const { data: beneficiary, error: dbError } = await supabase
.from('beneficiaries')
@ -482,14 +553,22 @@ router.post('/cancel-subscription', async (req, res) => {
* POST /api/stripe/reactivate-subscription
* Reactivates a subscription that was set to cancel
*/
router.post('/reactivate-subscription', async (req, res) => {
router.post('/reactivate-subscription',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer')
],
async (req, res) => {
try {
const { beneficiaryId } = req.body;
if (!beneficiaryId) {
return res.status(400).json({ error: 'beneficiaryId is required' });
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { beneficiaryId } = req.body;
const { data: beneficiary } = await supabase
.from('beneficiaries')
.select('stripe_customer_id')
@ -532,14 +611,22 @@ router.post('/reactivate-subscription', async (req, res) => {
* Creates a SetupIntent for collecting payment method in React Native app
* Then creates subscription with that payment method
*/
router.post('/create-subscription-payment-sheet', async (req, res) => {
router.post('/create-subscription-payment-sheet',
[
body('beneficiaryId')
.notEmpty().withMessage('beneficiaryId is required')
.isInt({ min: 1 }).withMessage('beneficiaryId must be a positive integer')
],
async (req, res) => {
try {
const { beneficiaryId } = req.body;
if (!beneficiaryId) {
return res.status(400).json({ error: 'beneficiaryId is required' });
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { beneficiaryId } = req.body;
// Get or create Stripe customer for this beneficiary
const customerId = await getOrCreateStripeCustomer(beneficiaryId);
@ -644,14 +731,23 @@ router.post('/create-subscription-payment-sheet', async (req, res) => {
* POST /api/stripe/confirm-subscription-payment
* Confirms the latest invoice PaymentIntent for a subscription if needed
*/
router.post('/confirm-subscription-payment', async (req, res) => {
router.post('/confirm-subscription-payment',
[
body('subscriptionId')
.notEmpty().withMessage('subscriptionId is required')
.isString().withMessage('subscriptionId must be a string')
.trim()
],
async (req, res) => {
try {
const { subscriptionId } = req.body;
if (!subscriptionId) {
return res.status(400).json({ error: 'subscriptionId is required' });
// Check validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: errors.array()[0].msg });
}
const { subscriptionId } = req.body;
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['latest_invoice.payment_intent']
});

View File

@ -3,6 +3,14 @@ const router = express.Router();
const Stripe = require('stripe');
const { supabase } = require('../config/supabase');
// SECURITY: Require STRIPE_WEBHOOK_SECRET in production
if (!process.env.STRIPE_WEBHOOK_SECRET) {
console.error('❌ FATAL: STRIPE_WEBHOOK_SECRET is required!');
console.error(' Webhook signature verification cannot be disabled.');
console.error(' Get your webhook secret from: https://dashboard.stripe.com/webhooks');
process.exit(1);
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
/**
@ -18,14 +26,8 @@ router.post('/stripe', async (req, res) => {
let event;
try {
// If webhook secret is configured, verify signature
if (webhookSecret) {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} else {
// For local development without webhook secret
event = JSON.parse(req.body.toString());
console.warn('⚠️ Webhook signature verification skipped (no STRIPE_WEBHOOK_SECRET)');
}
// SECURITY: Always verify webhook signature
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);

View File

@ -0,0 +1,415 @@
/**
* MQTT Service for WellNuo Backend
*
* Connects to mqtt.eluxnetworks.net and listens for alerts
* from WellNuo IoT devices (sensors).
*
* Topic format: /well_{deployment_id}
* Message format: { Command: "REPORT", body: "alert text", time: unix_timestamp }
*
* Auto-subscribes to ALL active deployments from database
* Sends push notifications to users with access to each deployment
*/
const mqtt = require('mqtt');
const { pool } = require('../config/database');
const { Expo } = require('expo-server-sdk');
const { sendPushNotifications: sendNotificationsWithSettings, NotificationType } = require('./notifications');
// MQTT Configuration
const MQTT_BROKER = process.env.MQTT_BROKER || 'mqtt://mqtt.eluxnetworks.net:1883';
const MQTT_USER = process.env.MQTT_USER || 'anandk';
const MQTT_PASSWORD = process.env.MQTT_PASSWORD || 'anandk_8';
// Expo Push Client
const expo = new Expo();
// Store for received alerts (in-memory, last 100)
const alertsCache = [];
const MAX_ALERTS_CACHE = 100;
// MQTT Client
let client = null;
let isConnected = false;
let subscribedTopics = new Set();
/**
* Initialize MQTT connection
*/
function init() {
if (client) {
console.log('[MQTT] Already initialized');
return;
}
console.log(`[MQTT] Connecting to ${MQTT_BROKER}...`);
client = mqtt.connect(MQTT_BROKER, {
username: MQTT_USER,
password: MQTT_PASSWORD,
clientId: `wellnuo-backend-${Date.now()}`,
reconnectPeriod: 5000, // Reconnect every 5 seconds
keepalive: 60,
});
client.on('connect', () => {
console.log('[MQTT] ✅ Connected to broker');
isConnected = true;
// Resubscribe to all topics on reconnect
subscribedTopics.forEach(topic => {
client.subscribe(topic, (err) => {
if (!err) {
console.log(`[MQTT] Resubscribed to ${topic}`);
}
});
});
});
client.on('message', async (topic, payload) => {
const timestamp = new Date().toISOString();
const messageStr = payload.toString();
console.log(`[MQTT] 📨 Message on ${topic}: ${messageStr}`);
try {
const message = JSON.parse(messageStr);
// Extract deployment_id from topic (/well_21 -> 21)
const deploymentId = parseInt(topic.replace('/well_', ''), 10);
const alert = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
topic,
deploymentId,
command: message.Command || 'UNKNOWN',
body: message.body || messageStr,
messageTime: message.time ? new Date(message.time * 1000).toISOString() : null,
receivedAt: timestamp,
raw: message,
};
// Add to cache
alertsCache.unshift(alert);
if (alertsCache.length > MAX_ALERTS_CACHE) {
alertsCache.pop();
}
// Process alert based on command
await processAlert(alert);
} catch (e) {
console.log(`[MQTT] ⚠️ Non-JSON message: ${messageStr}`);
// Still cache raw messages
alertsCache.unshift({
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
topic,
command: 'RAW',
body: messageStr,
receivedAt: timestamp,
});
}
});
client.on('error', (err) => {
console.error('[MQTT] ❌ Error:', err.message);
});
client.on('close', () => {
console.log('[MQTT] 🔌 Connection closed');
isConnected = false;
});
client.on('reconnect', () => {
console.log('[MQTT] 🔄 Reconnecting...');
});
}
/**
* Process incoming alert
*/
async function processAlert(alert) {
console.log(`[MQTT] Processing alert: ${alert.command} for deployment ${alert.deploymentId}`);
// Handle different command types
switch (alert.command) {
case 'REPORT':
// This is a sensor alert - could be emergency, activity, etc.
await saveAlertToDatabase(alert);
// Send push notification to users subscribed to this deployment
await sendPushNotifications(alert);
break;
case 'CREDS':
// Credential/device setup message - ignore for now
console.log(`[MQTT] Ignoring CREDS message`);
break;
default:
console.log(`[MQTT] Unknown command: ${alert.command}`);
}
}
/**
* Get all active deployments from database
*/
async function getAllActiveDeployments() {
try {
const result = await pool.query(`
SELECT DISTINCT legacy_deployment_id
FROM beneficiary_deployments
WHERE legacy_deployment_id IS NOT NULL
`);
return result.rows.map(r => r.legacy_deployment_id);
} catch (e) {
console.error('[MQTT] Failed to get deployments from DB:', e.message);
return [];
}
}
/**
* Subscribe to all active deployments from database
*/
async function subscribeToAllDeployments() {
const deployments = await getAllActiveDeployments();
console.log(`[MQTT] Found ${deployments.length} active deployments:`, deployments);
for (const deploymentId of deployments) {
subscribeToDeployment(deploymentId);
}
return deployments;
}
/**
* Get users with push tokens for a deployment
*/
async function getUsersForDeployment(deploymentId) {
try {
// Find all users who have access to beneficiaries linked to this deployment
const result = await pool.query(`
SELECT DISTINCT
u.id as user_id,
u.email,
pt.token as push_token,
b.name as beneficiary_name,
ua.role
FROM beneficiary_deployments bd
JOIN user_access ua ON ua.beneficiary_id = bd.beneficiary_id
JOIN users u ON u.id = ua.accessor_id
JOIN beneficiaries b ON b.id = bd.beneficiary_id
LEFT JOIN push_tokens pt ON pt.user_id = u.id
WHERE bd.legacy_deployment_id = $1
AND pt.token IS NOT NULL
`, [deploymentId]);
return result.rows;
} catch (e) {
console.error('[MQTT] Failed to get users for deployment:', e.message);
return [];
}
}
/**
* Send push notifications for an alert
*/
async function sendPushNotifications(alert) {
const users = await getUsersForDeployment(alert.deploymentId);
if (users.length === 0) {
console.log(`[MQTT] No push tokens found for deployment ${alert.deploymentId}`);
return;
}
console.log(`[MQTT] Sending push to ${users.length} users for deployment ${alert.deploymentId}`);
const messages = [];
for (const user of users) {
// Validate token format
if (!Expo.isExpoPushToken(user.push_token)) {
console.log(`[MQTT] Invalid push token for user ${user.email}: ${user.push_token}`);
continue;
}
// Build notification message
const beneficiaryName = user.beneficiary_name || 'Beneficiary';
messages.push({
to: user.push_token,
sound: 'default',
title: `Alert: ${beneficiaryName}`,
body: alert.body || 'New sensor alert',
data: {
type: 'mqtt_alert',
deploymentId: alert.deploymentId,
alertId: alert.id,
command: alert.command,
},
priority: 'high',
});
}
if (messages.length === 0) {
console.log('[MQTT] No valid push tokens to send to');
return;
}
// Send in chunks (Expo limit)
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
try {
const receipts = await expo.sendPushNotificationsAsync(chunk);
console.log(`[MQTT] ✅ Push sent:`, receipts);
} catch (e) {
console.error('[MQTT] ❌ Push failed:', e.message);
}
}
}
/**
* Save alert to database
*/
async function saveAlertToDatabase(alert) {
try {
await pool.query(`
INSERT INTO mqtt_alerts (deployment_id, command, body, message_time, received_at, raw_payload)
VALUES ($1, $2, $3, $4, $5, $6)
`, [
alert.deploymentId,
alert.command,
alert.body,
alert.messageTime,
alert.receivedAt,
JSON.stringify(alert.raw)
]);
console.log('[MQTT] ✅ Alert saved to database');
} catch (e) {
// Table might not exist yet - that's ok
if (e.code === '42P01') {
console.log('[MQTT] mqtt_alerts table does not exist - skipping DB save');
} else {
console.error('[MQTT] DB save error:', e.message);
}
}
}
/**
* Subscribe to deployment alerts
* @param {number} deploymentId - The deployment ID to subscribe to
*/
function subscribeToDeployment(deploymentId) {
if (!client || !isConnected) {
console.error('[MQTT] Not connected');
return false;
}
const topic = `/well_${deploymentId}`;
if (subscribedTopics.has(topic)) {
console.log(`[MQTT] Already subscribed to ${topic}`);
return true;
}
client.subscribe(topic, (err) => {
if (err) {
console.error(`[MQTT] Failed to subscribe to ${topic}:`, err.message);
return false;
}
console.log(`[MQTT] ✅ Subscribed to ${topic}`);
subscribedTopics.add(topic);
});
return true;
}
/**
* Unsubscribe from deployment alerts
*/
function unsubscribeFromDeployment(deploymentId) {
if (!client) return;
const topic = `/well_${deploymentId}`;
client.unsubscribe(topic);
subscribedTopics.delete(topic);
console.log(`[MQTT] Unsubscribed from ${topic}`);
}
/**
* Subscribe to multiple deployments
*/
function subscribeToDeployments(deploymentIds) {
deploymentIds.forEach(id => subscribeToDeployment(id));
}
/**
* Get recent alerts from cache
*/
function getRecentAlerts(limit = 50, deploymentId = null) {
let alerts = alertsCache;
if (deploymentId) {
alerts = alerts.filter(a => a.deploymentId === deploymentId);
}
return alerts.slice(0, limit);
}
/**
* Get connection status
*/
function getStatus() {
return {
connected: isConnected,
broker: MQTT_BROKER,
subscribedTopics: Array.from(subscribedTopics),
cachedAlerts: alertsCache.length,
};
}
/**
* Publish a test message (for testing)
*/
function publishTest(deploymentId, message) {
if (!client || !isConnected) {
console.error('[MQTT] Not connected');
return false;
}
const topic = `/well_${deploymentId}`;
const payload = JSON.stringify({
Command: 'REPORT',
body: message,
time: Math.floor(Date.now() / 1000),
});
client.publish(topic, payload);
console.log(`[MQTT] 📤 Published to ${topic}: ${payload}`);
return true;
}
/**
* Graceful shutdown
*/
function shutdown() {
if (client) {
console.log('[MQTT] Shutting down...');
client.end();
client = null;
isConnected = false;
}
}
module.exports = {
init,
subscribeToDeployment,
unsubscribeFromDeployment,
subscribeToDeployments,
subscribeToAllDeployments,
getRecentAlerts,
getStatus,
publishTest,
shutdown,
};

View File

@ -0,0 +1,590 @@
/**
* Push Notifications Service
*
* Sends push notifications via Expo Push API with:
* - Notification settings check (push_enabled, alert types, quiet hours)
* - Batch sending support
* - Error handling and ticket tracking
*/
const { supabase } = require('../config/supabase');
// Expo Push API endpoint
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
/**
* Notification types that map to settings
*/
const NotificationType = {
EMERGENCY: 'emergency', // Falls, SOS, critical alerts
ACTIVITY: 'activity', // Unusual activity patterns
LOW_BATTERY: 'low_battery', // Device battery warnings
DAILY_SUMMARY: 'daily', // Daily wellness report
WEEKLY_SUMMARY: 'weekly', // Weekly health digest
SYSTEM: 'system' // System messages (always delivered)
};
/**
* Check if current time is within quiet hours
*
* @param {string} quietStart - Start time in HH:MM format
* @param {string} quietEnd - End time in HH:MM format
* @param {string} timezone - User timezone (default: UTC)
* @returns {boolean} True if currently in quiet hours
*/
function isInQuietHours(quietStart, quietEnd, timezone = 'UTC') {
const now = new Date();
// Parse quiet hours times
const [startHour, startMin] = quietStart.split(':').map(Number);
const [endHour, endMin] = quietEnd.split(':').map(Number);
// Get current time in user's timezone
const formatter = new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timezone
});
const timeStr = formatter.format(now);
const [currentHour, currentMin] = timeStr.split(':').map(Number);
// Convert to minutes since midnight for easier comparison
const currentMinutes = currentHour * 60 + currentMin;
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
// Handle overnight quiet hours (e.g., 22:00 - 07:00)
if (startMinutes > endMinutes) {
// Quiet hours span midnight
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
} else {
// Same day quiet hours
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
}
}
/**
* Check if notification should be sent based on user settings
*
* @param {Object} settings - User notification settings
* @param {string} notificationType - Type of notification
* @returns {Object} { allowed: boolean, reason?: string }
*/
function shouldSendNotification(settings, notificationType) {
// No settings = use defaults (allow all)
if (!settings) {
return { allowed: true };
}
// Check if push is enabled globally
if (!settings.push_enabled) {
return { allowed: false, reason: 'push_disabled' };
}
// System notifications always go through
if (notificationType === NotificationType.SYSTEM) {
return { allowed: true };
}
// Check specific notification type
switch (notificationType) {
case NotificationType.EMERGENCY:
if (!settings.emergency_alerts) {
return { allowed: false, reason: 'emergency_alerts_disabled' };
}
// Emergency alerts bypass quiet hours
return { allowed: true };
case NotificationType.ACTIVITY:
if (!settings.activity_alerts) {
return { allowed: false, reason: 'activity_alerts_disabled' };
}
break;
case NotificationType.LOW_BATTERY:
if (!settings.low_battery) {
return { allowed: false, reason: 'low_battery_disabled' };
}
break;
case NotificationType.DAILY_SUMMARY:
if (!settings.daily_summary) {
return { allowed: false, reason: 'daily_summary_disabled' };
}
break;
case NotificationType.WEEKLY_SUMMARY:
if (!settings.weekly_summary) {
return { allowed: false, reason: 'weekly_summary_disabled' };
}
break;
default:
// Unknown type - allow by default
break;
}
// Check quiet hours (except for emergency which returns early)
if (settings.quiet_hours_enabled) {
const quietStart = settings.quiet_hours_start || '22:00';
const quietEnd = settings.quiet_hours_end || '07:00';
if (isInQuietHours(quietStart, quietEnd)) {
return { allowed: false, reason: 'quiet_hours' };
}
}
return { allowed: true };
}
/**
* Get active push tokens for a user
*
* @param {number} userId - User ID
* @returns {Promise<string[]>} Array of Expo push tokens
*/
async function getUserPushTokens(userId) {
const { data: tokens, error } = await supabase
.from('push_tokens')
.select('token')
.eq('user_id', userId)
.eq('is_active', true);
if (error) {
console.error(`[Notifications] Error fetching tokens for user ${userId}:`, error);
return [];
}
return tokens?.map(t => t.token) || [];
}
/**
* Get notification settings for a user
*
* @param {number} userId - User ID
* @returns {Promise<Object|null>} Settings object or null
*/
async function getUserNotificationSettings(userId) {
const { data: settings, error } = await supabase
.from('notification_settings')
.select('*')
.eq('user_id', userId)
.single();
if (error && error.code !== 'PGRST116') {
console.error(`[Notifications] Error fetching settings for user ${userId}:`, error);
}
return settings || null;
}
/**
* Log notification to history table
*
* @param {Object} entry - Notification history entry
* @returns {Promise<number|null>} Inserted record ID or null on error
*/
async function logNotificationHistory(entry) {
try {
const { data, error } = await supabase
.from('notification_history')
.insert({
user_id: entry.userId,
beneficiary_id: entry.beneficiaryId || null,
title: entry.title,
body: entry.body,
type: entry.type,
channel: entry.channel || 'push',
status: entry.status,
skip_reason: entry.skipReason || null,
data: entry.data || null,
expo_ticket_id: entry.expoTicketId || null,
error_message: entry.errorMessage || null,
sent_at: entry.status === 'sent' ? new Date().toISOString() : null
})
.select('id')
.single();
if (error) {
console.error('[Notifications] Failed to log history:', error);
return null;
}
return data?.id || null;
} catch (err) {
console.error('[Notifications] Error logging history:', err);
return null;
}
}
/**
* Update notification history record
*
* @param {number} id - History record ID
* @param {Object} updates - Fields to update
*/
async function updateNotificationHistory(id, updates) {
if (!id) return;
try {
await supabase
.from('notification_history')
.update(updates)
.eq('id', id);
} catch (err) {
console.error('[Notifications] Error updating history:', err);
}
}
/**
* Send push notifications to Expo API
*
* @param {Object[]} messages - Array of Expo push messages
* @returns {Promise<Object>} Result with tickets
*/
async function sendToExpo(messages) {
if (!messages || messages.length === 0) {
return { success: true, tickets: [] };
}
try {
const response = await fetch(EXPO_PUSH_URL, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Accept-encoding': 'gzip, deflate',
'Content-Type': 'application/json',
},
body: JSON.stringify(messages),
});
const result = await response.json();
if (!response.ok) {
console.error('[Notifications] Expo API error:', result);
return { success: false, error: result };
}
return { success: true, tickets: result.data || [] };
} catch (error) {
console.error('[Notifications] Failed to send to Expo:', error);
return { success: false, error: error.message };
}
}
/**
* Send push notifications to one or more users
*
* @param {Object} options
* @param {number|number[]} options.userIds - User ID(s) to send to
* @param {string} options.title - Notification title
* @param {string} options.body - Notification body text
* @param {string} options.type - Notification type (see NotificationType)
* @param {Object} [options.data] - Custom data payload
* @param {string} [options.sound] - Sound name ('default' or custom)
* @param {string} [options.channelId] - Android channel ID
* @param {number} [options.badge] - iOS badge count
* @param {number} [options.ttl] - Time to live in seconds
* @param {string} [options.priority] - 'default', 'normal', or 'high'
* @param {number} [options.beneficiaryId] - Related beneficiary ID (for logging)
* @returns {Promise<Object>} Result with sent count and details
*/
async function sendPushNotifications({
userIds,
title,
body,
type = NotificationType.SYSTEM,
data = {},
sound = 'default',
channelId = 'default',
badge,
ttl = 86400, // 24 hours default
priority = 'high',
beneficiaryId = null
}) {
// Normalize userIds to array
const userIdList = Array.isArray(userIds) ? userIds : [userIds];
console.log(`[Notifications] Sending "${type}" notification to ${userIdList.length} user(s)`);
const results = {
sent: 0,
skipped: 0,
failed: 0,
details: []
};
const messagesToSend = [];
const historyMap = new Map(); // Maps message index to history entry
// Process each user
for (const userId of userIdList) {
// Get user's notification settings
const settings = await getUserNotificationSettings(userId);
// Check if notification should be sent
const check = shouldSendNotification(settings, type);
if (!check.allowed) {
console.log(`[Notifications] Skipped user ${userId}: ${check.reason}`);
results.skipped++;
results.details.push({
userId,
status: 'skipped',
reason: check.reason
});
// Log skipped notification to history
await logNotificationHistory({
userId,
beneficiaryId,
title,
body,
type,
channel: 'push',
status: 'skipped',
skipReason: check.reason,
data
});
continue;
}
// Get user's push tokens
const tokens = await getUserPushTokens(userId);
if (tokens.length === 0) {
console.log(`[Notifications] No active tokens for user ${userId}`);
results.skipped++;
results.details.push({
userId,
status: 'skipped',
reason: 'no_tokens'
});
// Log skipped notification to history
await logNotificationHistory({
userId,
beneficiaryId,
title,
body,
type,
channel: 'push',
status: 'skipped',
skipReason: 'no_tokens',
data
});
continue;
}
// Create message for each token
for (const token of tokens) {
// Validate Expo push token format
if (!token.startsWith('ExponentPushToken[') && !token.startsWith('ExpoPushToken[')) {
console.warn(`[Notifications] Invalid token format for user ${userId}: ${token.substring(0, 20)}...`);
continue;
}
const messageIndex = messagesToSend.length;
messagesToSend.push({
to: token,
title,
body,
sound,
channelId,
badge,
ttl,
priority,
data: {
...data,
type,
userId,
timestamp: new Date().toISOString()
}
});
// Store history entry info for later update
historyMap.set(messageIndex, {
userId,
beneficiaryId,
title,
body,
type,
data
});
}
results.details.push({
userId,
status: 'queued',
tokenCount: tokens.length
});
}
// Send all messages in batch
if (messagesToSend.length > 0) {
// Expo recommends batches of 100
const batchSize = 100;
const batches = [];
for (let i = 0; i < messagesToSend.length; i += batchSize) {
batches.push({
messages: messagesToSend.slice(i, i + batchSize),
startIndex: i
});
}
for (const batch of batches) {
const result = await sendToExpo(batch.messages);
if (result.success) {
results.sent += batch.messages.length;
// Log successful notifications and track any failed tickets
for (let i = 0; i < result.tickets.length; i++) {
const ticket = result.tickets[i];
const globalIndex = batch.startIndex + i;
const historyEntry = historyMap.get(globalIndex);
if (historyEntry) {
if (ticket.status === 'error') {
console.error(`[Notifications] Ticket error:`, ticket);
results.failed++;
results.sent--;
// Log failed notification
await logNotificationHistory({
...historyEntry,
channel: 'push',
status: 'failed',
errorMessage: ticket.message || 'Expo ticket error',
expoTicketId: ticket.id
});
} else {
// Log successful notification
await logNotificationHistory({
...historyEntry,
channel: 'push',
status: 'sent',
expoTicketId: ticket.id
});
}
}
}
} else {
results.failed += batch.messages.length;
console.error(`[Notifications] Batch send failed:`, result.error);
// Log failed notifications for the batch
for (let i = 0; i < batch.messages.length; i++) {
const globalIndex = batch.startIndex + i;
const historyEntry = historyMap.get(globalIndex);
if (historyEntry) {
await logNotificationHistory({
...historyEntry,
channel: 'push',
status: 'failed',
errorMessage: typeof result.error === 'string' ? result.error : JSON.stringify(result.error)
});
}
}
}
}
}
console.log(`[Notifications] Complete: ${results.sent} sent, ${results.skipped} skipped, ${results.failed} failed`);
return results;
}
/**
* Send notification to all caretakers of a beneficiary
*
* @param {number} beneficiaryId - Beneficiary user ID
* @param {Object} notification - Notification options (title, body, type, data)
* @returns {Promise<Object>} Result
*/
async function notifyCaretakers(beneficiaryId, notification) {
// Get all users with access to this beneficiary
const { data: accessRecords, error } = await supabase
.from('user_access')
.select('accessor_id')
.eq('beneficiary_id', beneficiaryId);
if (error) {
console.error(`[Notifications] Error fetching caretakers for beneficiary ${beneficiaryId}:`, error);
return { error: error.message };
}
if (!accessRecords || accessRecords.length === 0) {
console.log(`[Notifications] No caretakers found for beneficiary ${beneficiaryId}`);
return { sent: 0, skipped: 0, failed: 0 };
}
const caretakerIds = accessRecords.map(r => r.accessor_id);
return sendPushNotifications({
userIds: caretakerIds,
beneficiaryId,
...notification
});
}
/**
* Get notification history for a user
*
* @param {number} userId - User ID
* @param {Object} options - Query options
* @param {number} [options.limit=50] - Max records to return
* @param {number} [options.offset=0] - Pagination offset
* @param {string} [options.type] - Filter by notification type
* @param {string} [options.status] - Filter by status
* @returns {Promise<Object>} { data: [], total: number }
*/
async function getNotificationHistory(userId, options = {}) {
const {
limit = 50,
offset = 0,
type,
status
} = options;
let query = supabase
.from('notification_history')
.select('*', { count: 'exact' })
.eq('user_id', userId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (type) {
query = query.eq('type', type);
}
if (status) {
query = query.eq('status', status);
}
const { data, error, count } = await query;
if (error) {
console.error(`[Notifications] Error fetching history for user ${userId}:`, error);
return { data: [], total: 0, error: error.message };
}
return { data: data || [], total: count || 0 };
}
module.exports = {
sendPushNotifications,
notifyCaretakers,
getNotificationHistory,
NotificationType,
// Exported for testing
shouldSendNotification,
isInQuietHours,
logNotificationHistory
};

View File

@ -81,10 +81,11 @@ function getErrorMessage(error: string | undefined): { title: string; descriptio
};
}
// Show the actual error message for debugging
return {
title: 'Setup Failed',
description: error,
hint: 'Try again or skip this sensor to continue with others.',
hint: `Technical details: ${error}`,
};
}

View File

@ -53,14 +53,20 @@ export function BLEProvider({ children }: { children: ReactNode }) {
}, []);
const connectDevice = useCallback(async (deviceId: string): Promise<boolean> => {
console.log('[BLEContext] connectDevice called:', deviceId);
try {
setError(null);
const success = await bleManager.connectDevice(deviceId);
console.log('[BLEContext] connectDevice result:', success);
if (success) {
setConnectedDevices(prev => new Set(prev).add(deviceId));
} else {
// Connection failed but no exception - set user-friendly error
setError('Could not connect to sensor. Please move closer and try again.');
}
return success;
} catch (err: any) {
console.error('[BLEContext] connectDevice exception:', err);
setError(err.message || 'Failed to connect to device');
return false;
}
@ -80,10 +86,14 @@ export function BLEProvider({ children }: { children: ReactNode }) {
}, []);
const getWiFiList = useCallback(async (deviceId: string): Promise<WiFiNetwork[]> => {
console.log('[BLEContext] getWiFiList called:', deviceId);
try {
setError(null);
return await bleManager.getWiFiList(deviceId);
const networks = await bleManager.getWiFiList(deviceId);
console.log('[BLEContext] getWiFiList success, found networks:', networks.length);
return networks;
} catch (err: any) {
console.error('[BLEContext] getWiFiList failed:', err);
setError(err.message || 'Failed to get WiFi networks');
throw err;
}

103
mqtt-test.js Normal file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* WellNuo MQTT Alert Monitor
*
* Usage:
* node mqtt-test.js # Monitor deployment 21 (Ferdinand)
* node mqtt-test.js 42 # Monitor specific deployment
* node mqtt-test.js send "text" # Send test alert
*/
const mqtt = require('mqtt');
// Configuration
const MQTT_BROKER = 'mqtt://mqtt.eluxnetworks.net:1883';
const MQTT_USER = 'anandk';
const MQTT_PASSWORD = 'anandk_8';
const DEFAULT_DEPLOYMENT = 21; // Ferdinand
// Parse args
const args = process.argv.slice(2);
const isSendMode = args[0] === 'send';
const deploymentId = isSendMode ? DEFAULT_DEPLOYMENT : (parseInt(args[0]) || DEFAULT_DEPLOYMENT);
const topic = `/well_${deploymentId}`;
console.log(`🔌 Connecting to ${MQTT_BROKER}...`);
const client = mqtt.connect(MQTT_BROKER, {
username: MQTT_USER,
password: MQTT_PASSWORD,
clientId: `wellnuo-monitor-${Date.now()}`,
});
client.on('connect', () => {
console.log(`✅ Connected to MQTT broker`);
console.log(`📡 Topic: ${topic}`);
if (isSendMode) {
// Send mode: publish test message and exit
const message = args.slice(1).join(' ') || 'Test alert from Node.js';
const payload = JSON.stringify({
Command: 'REPORT',
body: message,
time: Math.floor(Date.now() / 1000),
});
console.log(`📤 Sending: ${payload}`);
client.publish(topic, payload, (err) => {
if (err) {
console.error('❌ Publish error:', err);
} else {
console.log('✅ Message sent successfully');
}
client.end();
});
} else {
// Monitor mode: subscribe and listen
console.log(`👂 Listening for messages... (Ctrl+C to stop)\n`);
client.subscribe(topic, (err) => {
if (err) {
console.error('❌ Subscribe error:', err);
process.exit(1);
}
});
}
});
client.on('message', (receivedTopic, payload) => {
const timestamp = new Date().toISOString();
const message = payload.toString();
console.log(`\n📨 [${timestamp}]`);
console.log(` Topic: ${receivedTopic}`);
try {
const parsed = JSON.parse(message);
console.log(` Command: ${parsed.Command || 'N/A'}`);
console.log(` Body: ${parsed.body || 'N/A'}`);
console.log(` Time: ${parsed.time ? new Date(parsed.time * 1000).toISOString() : 'N/A'}`);
// Special handling for different commands
if (parsed.Command === 'REPORT') {
console.log(` 🚨 ALERT: ${parsed.body}`);
}
} catch (e) {
console.log(` Raw: ${message}`);
}
});
client.on('error', (err) => {
console.error('❌ MQTT Error:', err.message);
});
client.on('close', () => {
console.log('🔌 Connection closed');
});
// Handle Ctrl+C
process.on('SIGINT', () => {
console.log('\n👋 Shutting down...');
client.end();
process.exit(0);
});

664
package-lock.json generated
View File

@ -7,6 +7,7 @@
"": {
"name": "wellnuo",
"version": "1.0.0",
"hasInstallScript": true,
"dependencies": {
"@config-plugins/react-native-webrtc": "^13.0.0",
"@expo/vector-icons": "^15.0.3",
@ -34,6 +35,7 @@
"expo-image-manipulator": "^14.0.8",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.21",
"expo-secure-store": "^15.0.8",
"expo-speech": "~14.0.8",
@ -43,6 +45,7 @@
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"mqtt": "^5.14.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
@ -65,6 +68,7 @@
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"patch-package": "^8.0.1",
"playwright": "^1.57.0",
"sharp": "^0.34.5",
"typescript": "~5.9.2"
@ -1489,9 +1493,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -2638,6 +2642,12 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@ide/backoff": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
"license": "MIT"
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@ -4419,12 +4429,30 @@
"csstype": "^3.0.2"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@ -5016,6 +5044,13 @@
"node": ">=10.0.0"
}
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@ -5393,6 +5428,19 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.2",
"is-nan": "^1.3.2",
"object-is": "^1.1.5",
"object.assign": "^4.1.4",
"util": "^0.12.5"
}
},
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@ -5632,6 +5680,12 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/badgin": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -5710,6 +5764,42 @@
"node": ">=0.6"
}
},
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.0",
"buffer": "^6.0.3",
"inherits": "^2.0.4",
"readable-stream": "^4.2.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@ -5753,6 +5843,18 @@
"node": ">=8"
}
},
"node_modules/broker-factory": {
"version": "3.1.13",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz",
"integrity": "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-unique-numbers": "^9.0.26",
"tslib": "^2.8.1",
"worker-factory": "^7.0.48"
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@ -6098,6 +6200,12 @@
"node": ">= 10"
}
},
"node_modules/commist": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
"license": "MIT"
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@ -6158,6 +6266,35 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/connect": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
@ -7275,6 +7412,15 @@
}
}
},
"node_modules/expo-application": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-asset": {
"version": "12.0.12",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
@ -7677,6 +7823,26 @@
"react-native": "*"
}
},
"node_modules/expo-notifications": {
"version": "0.32.16",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz",
"integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.8",
"@ide/backoff": "^1.0.0",
"abort-controller": "^3.0.0",
"assert": "^2.0.0",
"badgin": "^1.1.5",
"expo-application": "~7.0.8",
"expo-constants": "~18.0.13"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-router": {
"version": "6.0.21",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.21.tgz",
@ -8247,6 +8413,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-unique-numbers": {
"version": "9.0.26",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz",
"integrity": "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=18.2.0"
}
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@ -8385,6 +8564,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@ -8451,6 +8640,21 @@
"node": ">= 0.6"
}
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -8821,6 +9025,12 @@
"node": ">= 0.4"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/hermes-estree": {
"version": "0.29.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz",
@ -9062,6 +9272,15 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
@ -9327,6 +9546,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-nan": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-negative-zero": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
@ -9777,6 +10012,16 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -9827,6 +10072,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@ -9846,6 +10111,29 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@ -9872,6 +10160,16 @@
"json-buffer": "3.0.1"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -10830,6 +11128,76 @@
"node": ">=10"
}
},
"node_modules/mqtt": {
"version": "5.14.1",
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.14.1.tgz",
"integrity": "sha512-NxkPxE70Uq3Ph7goefQa7ggSsVzHrayCD0OyxlJgITN/EbzlZN+JEPmaAZdxP1LsIT5FamDyILoQTF72W7Nnbw==",
"license": "MIT",
"dependencies": {
"@types/readable-stream": "^4.0.21",
"@types/ws": "^8.18.1",
"commist": "^3.2.0",
"concat-stream": "^2.0.0",
"debug": "^4.4.1",
"help-me": "^5.0.0",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"mqtt-packet": "^9.0.2",
"number-allocator": "^1.0.14",
"readable-stream": "^4.7.0",
"rfdc": "^1.4.1",
"socks": "^2.8.6",
"split2": "^4.2.0",
"worker-timers": "^8.0.23",
"ws": "^8.18.3"
},
"bin": {
"mqtt": "build/bin/mqtt.js",
"mqtt_pub": "build/bin/pub.js",
"mqtt_sub": "build/bin/sub.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/mqtt-packet": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
"license": "MIT",
"dependencies": {
"bl": "^6.0.8",
"debug": "^4.3.4",
"process-nextick-args": "^2.0.1"
}
},
"node_modules/mqtt/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/mqtt/node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -10986,6 +11354,16 @@
"integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==",
"license": "MIT"
},
"node_modules/number-allocator": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.1",
"js-sdsl": "4.3.0"
}
},
"node_modules/ob1": {
"version": "0.83.2",
"resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.2.tgz",
@ -11019,6 +11397,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@ -11392,6 +11786,75 @@
"node": ">= 0.8"
}
},
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -11653,6 +12116,21 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@ -12315,6 +12793,46 @@
}
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readable-stream/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -12535,6 +13053,12 @@
"node": ">=4"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -13082,6 +13606,30 @@
"node": ">=8.0.0"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@ -13128,6 +13676,15 @@
"node": ">=6"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@ -13220,6 +13777,15 @@
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -13625,6 +14191,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@ -13829,6 +14405,12 @@
"rxjs": "*"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -13972,6 +14554,16 @@
"node": ">=8"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -14138,6 +14730,25 @@
"integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==",
"license": "MIT"
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@ -14581,6 +15192,53 @@
"node": ">=0.10.0"
}
},
"node_modules/worker-factory": {
"version": "7.0.48",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz",
"integrity": "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-unique-numbers": "^9.0.26",
"tslib": "^2.8.1"
}
},
"node_modules/worker-timers": {
"version": "8.0.29",
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.29.tgz",
"integrity": "sha512-9jk0MWHhWAZ2xlJPXr45oe5UF/opdpfZrY0HtyPizWuJ+ce1M3IYk/4IIdGct3kn9Ncfs+tkZt3w1tU6KW2Fsg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"tslib": "^2.8.1",
"worker-timers-broker": "^8.0.15",
"worker-timers-worker": "^9.0.13"
}
},
"node_modules/worker-timers-broker": {
"version": "8.0.15",
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz",
"integrity": "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"broker-factory": "^3.1.13",
"fast-unique-numbers": "^9.0.26",
"tslib": "^2.8.1",
"worker-timers-worker": "^9.0.13"
}
},
"node_modules/worker-timers-worker": {
"version": "9.0.13",
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz",
"integrity": "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"tslib": "^2.8.1",
"worker-factory": "^7.0.48"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@ -8,7 +8,8 @@
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
"lint": "expo lint",
"postinstall": "patch-package"
},
"dependencies": {
"@config-plugins/react-native-webrtc": "^13.0.0",
@ -37,6 +38,7 @@
"expo-image-manipulator": "^14.0.8",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.21",
"expo-secure-store": "^15.0.8",
"expo-speech": "~14.0.8",
@ -46,6 +48,7 @@
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"mqtt": "^5.14.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
@ -68,6 +71,7 @@
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"patch-package": "^8.0.1",
"playwright": "^1.57.0",
"sharp": "^0.34.5",
"typescript": "~5.9.2"

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings, WPSensor } from '@/types';
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationHistoryResponse, NotificationSettings, WPSensor } from '@/types';
import { File } from 'expo-file-system';
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
@ -1416,6 +1416,54 @@ class ApiService {
}
}
// Get notification history for current user
async getNotificationHistory(options?: {
limit?: number;
offset?: number;
type?: string;
status?: string;
}): Promise<ApiResponse<NotificationHistoryResponse>> {
const token = await this.getToken();
if (!token) {
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
}
try {
// Build query params
const params = new URLSearchParams();
if (options?.limit) params.append('limit', String(options.limit));
if (options?.offset) params.append('offset', String(options.offset));
if (options?.type) params.append('type', options.type);
if (options?.status) params.append('status', options.status);
const queryString = params.toString();
const url = `${WELLNUO_API_URL}/notification-settings/history${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (response.ok) {
return { data, ok: true };
}
return {
ok: false,
error: { message: data.error || 'Failed to get notification history' },
};
} catch (error) {
return {
ok: false,
error: { message: 'Network error. Please check your connection.' },
};
}
}
// Update notification settings for current user
async updateNotificationSettings(settings: Partial<NotificationSettings>): Promise<ApiResponse<NotificationSettings>> {
const token = await this.getToken();

View File

@ -6,12 +6,22 @@ import { IBLEManager, WPDevice, WiFiNetwork, WiFiStatus, BLE_CONFIG, BLE_COMMAND
import base64 from 'react-native-base64';
export class RealBLEManager implements IBLEManager {
private manager: BleManager;
private _manager: BleManager | null = null;
private connectedDevices = new Map<string, Device>();
private scanning = false;
// Lazy initialization to prevent crash on app startup
private get manager(): BleManager {
if (!this._manager) {
console.log('[BLE] Initializing BleManager (lazy)...');
this._manager = new BleManager();
}
return this._manager;
}
constructor() {
this.manager = new BleManager();
// Don't initialize BleManager here - use lazy initialization
console.log('[BLE] RealBLEManager created (BleManager will be initialized on first use)');
}
// Check and request permissions
@ -113,12 +123,69 @@ export class RealBLEManager implements IBLEManager {
}
async connectDevice(deviceId: string): Promise<boolean> {
console.log('[BLE] connectDevice started:', deviceId);
try {
const device = await this.manager.connectToDevice(deviceId);
// Step 0: Check permissions (required for Android 12+)
console.log('[BLE] Step 0: Checking permissions...');
const hasPermission = await this.requestPermissions();
if (!hasPermission) {
console.error('[BLE] Permissions not granted!');
throw new Error('Bluetooth permissions not granted');
}
console.log('[BLE] Permissions OK');
// Step 0.5: Check Bluetooth is enabled
console.log('[BLE] Checking Bluetooth state...');
const isEnabled = await this.isBluetoothEnabled();
if (!isEnabled) {
console.error('[BLE] Bluetooth is disabled!');
throw new Error('Bluetooth is disabled. Please enable it in settings.');
}
console.log('[BLE] Bluetooth is ON');
// Check if already connected
const existingDevice = this.connectedDevices.get(deviceId);
if (existingDevice) {
console.log('[BLE] Checking existing connection...');
const isConnected = await existingDevice.isConnected();
if (isConnected) {
console.log('[BLE] Device already connected:', deviceId);
return true;
}
// Device was in map but disconnected, remove it
console.log('[BLE] Removing stale connection from map:', deviceId);
this.connectedDevices.delete(deviceId);
}
console.log('[BLE] Calling manager.connectToDevice with 10s timeout...');
const device = await this.manager.connectToDevice(deviceId, {
timeout: 10000, // 10 second timeout
});
console.log('[BLE] Connected! Discovering services and characteristics...');
await device.discoverAllServicesAndCharacteristics();
console.log('[BLE] Services discovered');
// Request larger MTU for Android (default is 23 bytes which is too small)
if (Platform.OS === 'android') {
try {
const mtu = await device.requestMTU(512);
console.log('[BLE] MTU negotiated:', mtu);
} catch (mtuError) {
console.warn('[BLE] MTU negotiation failed (non-critical):', mtuError);
}
}
this.connectedDevices.set(deviceId, device);
console.log('[BLE] connectDevice SUCCESS:', deviceId);
return true;
} catch (error) {
} catch (error: any) {
console.error('[BLE] connectDevice FAILED:', deviceId, {
message: error?.message,
errorCode: error?.errorCode,
reason: error?.reason,
stack: error?.stack?.substring(0, 200),
});
return false;
}
}
@ -126,8 +193,16 @@ export class RealBLEManager implements IBLEManager {
async disconnectDevice(deviceId: string): Promise<void> {
const device = this.connectedDevices.get(deviceId);
if (device) {
await device.cancelConnection();
this.connectedDevices.delete(deviceId);
try {
// Cancel any pending operations before disconnecting
// This helps prevent Android NullPointerException in monitor callbacks
await device.cancelConnection();
} catch (error: any) {
// Log but don't throw - device may already be disconnected
console.warn('[BLE] disconnectDevice error (ignored):', error?.message);
} finally {
this.connectedDevices.delete(deviceId);
}
}
}
@ -136,68 +211,155 @@ export class RealBLEManager implements IBLEManager {
}
async sendCommand(deviceId: string, command: string): Promise<string> {
console.log('[BLE] sendCommand:', { deviceId, command });
const device = this.connectedDevices.get(deviceId);
if (!device) {
console.error('[BLE] sendCommand FAILED: Device not in connected map');
throw new Error('Device not connected');
}
// Verify device is still connected
try {
const isConnected = await device.isConnected();
if (!isConnected) {
console.error('[BLE] sendCommand FAILED: Device disconnected');
this.connectedDevices.delete(deviceId);
throw new Error('Device disconnected');
}
} catch (checkError: any) {
console.error('[BLE] Failed to check connection status:', checkError?.message);
throw new Error('Failed to verify connection');
}
// Generate unique transaction ID to prevent Android null pointer issues
const transactionId = `cmd_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
return new Promise(async (resolve, reject) => {
let responseReceived = false;
let response = '';
let subscription: any = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (subscription) {
console.log('[BLE] Cleaning up notification subscription');
try {
subscription.remove();
} catch (removeError) {
// Ignore errors during cleanup - device may already be disconnected
console.log('[BLE] Subscription cleanup error (ignored):', removeError);
}
subscription = null;
}
};
// Safe reject wrapper to handle null error messages (Android BLE crash fix)
const safeReject = (error: any) => {
if (responseReceived) return;
// Extract error code (numeric or string)
const errorCode = error?.errorCode || error?.code || 'BLE_ERROR';
// Ignore "Operation was cancelled" (code 2) - this is expected when we cleanup
// This happens when subscription is removed but BLE still tries to send callback
if (errorCode === 2 || errorCode === 'OperationCancelled' ||
(error?.message && error.message.includes('cancelled'))) {
console.log('[BLE] Ignoring cancelled operation (normal cleanup)');
return;
}
responseReceived = true;
cleanup();
// Ensure error has a valid message (fixes Android NullPointerException)
const errorMessage = error?.message || error?.reason || 'BLE operation failed';
reject(new Error(`[${errorCode}] ${errorMessage}`));
};
try {
// Subscribe to notifications
device.monitorCharacteristicForService(
// Subscribe to notifications with explicit transactionId
console.log('[BLE] Setting up notification listener with transactionId:', transactionId);
subscription = device.monitorCharacteristicForService(
BLE_CONFIG.SERVICE_UUID,
BLE_CONFIG.CHAR_UUID,
(error, characteristic) => {
if (error) {
if (!responseReceived) {
responseReceived = true;
reject(error);
// Wrap callback in try-catch to prevent crashes
try {
if (error) {
console.error('[BLE] Notification error:', {
message: error?.message || 'null',
errorCode: (error as any)?.errorCode || 'null',
reason: (error as any)?.reason || 'null',
});
safeReject(error);
return;
}
return;
}
if (characteristic?.value) {
const decoded = base64.decode(characteristic.value);
response = decoded;
responseReceived = true;
resolve(decoded);
if (characteristic?.value) {
const decoded = base64.decode(characteristic.value);
console.log('[BLE] Response received:', decoded.substring(0, 100));
if (!responseReceived) {
responseReceived = true;
cleanup();
resolve(decoded);
}
}
} catch (callbackError: any) {
console.error('[BLE] Callback exception:', callbackError?.message);
safeReject(callbackError);
}
}
},
transactionId // Explicit transaction ID prevents Android null pointer
);
// Send command
const encoded = base64.encode(command);
console.log('[BLE] Writing command to characteristic...');
await device.writeCharacteristicWithResponseForService(
BLE_CONFIG.SERVICE_UUID,
BLE_CONFIG.CHAR_UUID,
encoded
);
console.log('[BLE] Command written successfully');
// Timeout
setTimeout(() => {
timeoutId = setTimeout(() => {
if (!responseReceived) {
responseReceived = true;
reject(new Error('Command timeout'));
console.error('[BLE] Command timeout after', BLE_CONFIG.COMMAND_TIMEOUT, 'ms');
safeReject(new Error('Command timeout'));
}
}, BLE_CONFIG.COMMAND_TIMEOUT);
} catch (error) {
reject(error);
} catch (error: any) {
console.error('[BLE] sendCommand exception:', {
message: error?.message,
errorCode: error?.errorCode,
});
safeReject(error);
}
});
}
async getWiFiList(deviceId: string): Promise<WiFiNetwork[]> {
console.log('[BLE] getWiFiList started for:', deviceId);
// Step 1: Unlock device
console.log('[BLE] Step 1: Unlocking device...');
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
console.log('[BLE] Unlock response:', unlockResponse);
if (!unlockResponse.includes('ok')) {
console.error('[BLE] Unlock FAILED - response does not contain "ok"');
throw new Error('Failed to unlock device');
}
// Step 2: Get WiFi list
console.log('[BLE] Step 2: Getting WiFi list...');
const listResponse = await this.sendCommand(deviceId, BLE_COMMANDS.GET_WIFI_LIST);
console.log('[BLE] WiFi list response:', listResponse);
// Parse response: "mac,XXXXXX|w|COUNT|SSID1,RSSI1|SSID2,RSSI2|..."
const parts = listResponse.split('|');
@ -215,34 +377,66 @@ export class RealBLEManager implements IBLEManager {
}
}
const networks: WiFiNetwork[] = [];
// Use Map to deduplicate by SSID, keeping the strongest signal
const networksMap = new Map<string, WiFiNetwork>();
for (let i = 3; i < parts.length; i++) {
const [ssid, rssiStr] = parts[i].split(',');
if (ssid && rssiStr) {
networks.push({
ssid: ssid.trim(),
rssi: parseInt(rssiStr, 10),
});
const trimmedSsid = ssid.trim();
const rssi = parseInt(rssiStr, 10);
// Skip empty SSIDs
if (!trimmedSsid) continue;
// Keep the one with strongest signal if duplicate
const existing = networksMap.get(trimmedSsid);
if (!existing || rssi > existing.rssi) {
networksMap.set(trimmedSsid, {
ssid: trimmedSsid,
rssi: rssi,
});
}
}
}
// Sort by signal strength (strongest first)
return networks.sort((a, b) => b.rssi - a.rssi);
// Convert to array and sort by signal strength (strongest first)
return Array.from(networksMap.values()).sort((a, b) => b.rssi - a.rssi);
}
async setWiFi(deviceId: string, ssid: string, password: string): Promise<boolean> {
console.log('[BLE] setWiFi started:', { deviceId, ssid, passwordLength: password.length });
// Step 1: Unlock device
console.log('[BLE] Step 1: Unlocking device for WiFi config...');
const unlockResponse = await this.sendCommand(deviceId, BLE_COMMANDS.PIN_UNLOCK);
console.log('[BLE] Unlock response:', unlockResponse);
if (!unlockResponse.includes('ok')) {
throw new Error('Failed to unlock device');
throw new Error(`Device unlock failed: ${unlockResponse}`);
}
// Step 2: Set WiFi credentials
const command = `${BLE_COMMANDS.SET_WIFI}|${ssid},${password}`;
console.log('[BLE] Step 2: Sending WiFi credentials...');
const setResponse = await this.sendCommand(deviceId, command);
console.log('[BLE] WiFi config response:', setResponse);
// Parse response: "mac,XXXXXX|W|ok" or "mac,XXXXXX|W|fail"
return setResponse.includes('|W|ok');
// Parse response: "mac,XXXXXX|W|ok" or "mac,XXXXXX|W|fail" or other errors
if (setResponse.includes('|W|ok')) {
console.log('[BLE] WiFi configuration SUCCESS');
return true;
}
// WiFi config failed - throw detailed error
if (setResponse.includes('|W|fail')) {
throw new Error('WiFi credentials rejected by sensor. Check password.');
}
if (setResponse.includes('timeout') || setResponse.includes('Timeout')) {
throw new Error('Sensor did not respond to WiFi config. Try again.');
}
// Unknown error - include raw response for debugging
throw new Error(`WiFi config failed: ${setResponse.substring(0, 100)}`);
}
async getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null> {

View File

@ -1,17 +1,42 @@
// BLE Service entry point
import * as Device from 'expo-device';
import { RealBLEManager } from './BLEManager';
import { MockBLEManager } from './MockBLEManager';
import { IBLEManager } from './types';
// Determine if BLE is available (real device vs simulator)
export const isBLEAvailable = Device.isDevice;
// Export singleton instance
export const bleManager: IBLEManager = isBLEAvailable
? new RealBLEManager()
: new MockBLEManager();
// Lazy singleton - only create BLEManager when first accessed
let _bleManager: IBLEManager | null = null;
function getBLEManager(): IBLEManager {
if (!_bleManager) {
console.log('[BLE] Creating BLEManager instance (lazy)...');
// Dynamic import to prevent crash on Android startup
if (isBLEAvailable) {
const { RealBLEManager } = require('./BLEManager');
_bleManager = new RealBLEManager();
} else {
const { MockBLEManager } = require('./MockBLEManager');
_bleManager = new MockBLEManager();
}
}
return _bleManager!; // Non-null assertion - we just assigned it above
}
// Proxy object that lazily initializes the real manager
export const bleManager: IBLEManager = {
scanDevices: () => getBLEManager().scanDevices(),
stopScan: () => getBLEManager().stopScan(),
connectDevice: (deviceId: string) => getBLEManager().connectDevice(deviceId),
disconnectDevice: (deviceId: string) => getBLEManager().disconnectDevice(deviceId),
isDeviceConnected: (deviceId: string) => getBLEManager().isDeviceConnected(deviceId),
sendCommand: (deviceId: string, command: string) => getBLEManager().sendCommand(deviceId, command),
getWiFiList: (deviceId: string) => getBLEManager().getWiFiList(deviceId),
setWiFi: (deviceId: string, ssid: string, password: string) => getBLEManager().setWiFi(deviceId, ssid, password),
getCurrentWiFi: (deviceId: string) => getBLEManager().getCurrentWiFi(deviceId),
rebootDevice: (deviceId: string) => getBLEManager().rebootDevice(deviceId),
};
// Re-export types
export * from './types';

View File

@ -0,0 +1,247 @@
/**
* Push Notifications Service
*
* Handles:
* - Requesting push notification permissions
* - Getting Expo Push Token
* - Registering/unregistering token on server
* - Handling incoming notifications
*/
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
const WELLNUO_API_URL = 'https://wellnuo.smartlaunchhub.com/api';
const PUSH_TOKEN_KEY = 'expoPushToken';
// Configure notification handler for foreground notifications
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
/**
* Register for push notifications and get Expo Push Token
* Returns the token or null if not available
*/
export async function registerForPushNotificationsAsync(): Promise<string | null> {
let token: string | null = null;
// Must be a physical device for push notifications
if (!Device.isDevice) {
console.log('[Push] Must use physical device for push notifications');
// For simulator, return a fake token for testing
if (__DEV__) {
return 'ExponentPushToken[SIMULATOR_TEST_TOKEN]';
}
return null;
}
// Check/request permissions
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('[Push] Permission not granted');
return null;
}
// Get Expo Push Token
try {
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const pushTokenResponse = await Notifications.getExpoPushTokenAsync({
projectId: projectId,
});
token = pushTokenResponse.data;
console.log('[Push] Got Expo Push Token:', token);
// Store locally
await SecureStore.setItemAsync(PUSH_TOKEN_KEY, token);
} catch (error) {
console.error('[Push] Error getting push token:', error);
return null;
}
// Android needs notification channel
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
});
// Emergency alerts channel
await Notifications.setNotificationChannelAsync('emergency', {
name: 'Emergency Alerts',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 500, 200, 500],
lightColor: '#FF0000',
sound: 'default',
});
}
return token;
}
/**
* Register push token on WellNuo backend
*/
export async function registerTokenOnServer(token: string): Promise<boolean> {
try {
const accessToken = await SecureStore.getItemAsync('accessToken');
if (!accessToken) {
console.log('[Push] No access token, skipping server registration');
return false;
}
const response = await fetch(`${WELLNUO_API_URL}/push-tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
token,
platform: Platform.OS,
deviceName: Device.deviceName || `${Device.brand} ${Device.modelName}`,
}),
});
if (!response.ok) {
const error = await response.json();
console.error('[Push] Server registration failed:', error);
return false;
}
console.log('[Push] Token registered on server successfully');
return true;
} catch (error) {
console.error('[Push] Error registering token on server:', error);
return false;
}
}
/**
* Unregister push token from server (call on logout)
*/
export async function unregisterToken(): Promise<boolean> {
try {
const token = await SecureStore.getItemAsync(PUSH_TOKEN_KEY);
const accessToken = await SecureStore.getItemAsync('accessToken');
if (!token || !accessToken) {
return true; // Nothing to unregister
}
const response = await fetch(`${WELLNUO_API_URL}/push-tokens`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ token }),
});
// Clear local storage regardless of server response
await SecureStore.deleteItemAsync(PUSH_TOKEN_KEY);
if (!response.ok) {
console.error('[Push] Server unregistration failed');
return false;
}
console.log('[Push] Token unregistered successfully');
return true;
} catch (error) {
console.error('[Push] Error unregistering token:', error);
// Still clear local storage
await SecureStore.deleteItemAsync(PUSH_TOKEN_KEY);
return false;
}
}
/**
* Full registration flow: get token + register on server
* Call this after successful login
*/
export async function setupPushNotifications(): Promise<string | null> {
console.log('[Push] Setting up push notifications...');
const token = await registerForPushNotificationsAsync();
if (token) {
await registerTokenOnServer(token);
}
return token;
}
/**
* Get stored push token (if any)
*/
export async function getStoredPushToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(PUSH_TOKEN_KEY);
} catch {
return null;
}
}
/**
* Add listener for received notifications (while app is open)
*/
export function addNotificationReceivedListener(
callback: (notification: Notifications.Notification) => void
): Notifications.EventSubscription {
return Notifications.addNotificationReceivedListener(callback);
}
/**
* Add listener for notification response (user tapped notification)
*/
export function addNotificationResponseListener(
callback: (response: Notifications.NotificationResponse) => void
): Notifications.EventSubscription {
return Notifications.addNotificationResponseReceivedListener(callback);
}
/**
* Get last notification response (if app was opened from notification)
*/
export async function getLastNotificationResponse(): Promise<Notifications.NotificationResponse | null> {
return await Notifications.getLastNotificationResponseAsync();
}
/**
* Parse notification data to determine navigation target
*/
export function parseNotificationData(data: Record<string, unknown>): {
type: string;
deploymentId?: number;
beneficiaryId?: number;
alertId?: string;
} {
return {
type: (data.type as string) || 'unknown',
deploymentId: data.deploymentId as number | undefined,
beneficiaryId: data.beneficiaryId as number | undefined,
alertId: data.alertId as string | undefined,
};
}

View File

@ -179,6 +179,35 @@ export interface ChatResponse {
status: string;
}
// Notification Types
export type NotificationType = 'emergency' | 'activity' | 'low_battery' | 'daily_summary' | 'weekly_summary' | 'system';
export type NotificationChannel = 'push' | 'email' | 'sms';
export type NotificationStatus = 'pending' | 'sent' | 'delivered' | 'failed' | 'skipped';
// Notification History Item (from /api/notification-settings/history)
export interface NotificationHistoryItem {
id: number;
title: string;
body: string;
type: NotificationType;
channel: NotificationChannel;
status: NotificationStatus;
skipReason: string | null;
data: Record<string, unknown> | null;
beneficiaryId: number | null;
createdAt: string;
sentAt: string | null;
deliveredAt: string | null;
}
// Notification History Response
export interface NotificationHistoryResponse {
history: NotificationHistoryItem[];
total: number;
limit: number;
offset: number;
}
// Notification Settings
export interface NotificationSettings {
// Alert types