Compare commits
11 Commits
d453126c89
...
671374da9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
671374da9a | ||
|
|
c17292ea48 | ||
|
|
20911fe521 | ||
|
|
5483c8244c | ||
|
|
0da9ccf02d | ||
|
|
7cb29bd874 | ||
|
|
4a4fc5c077 | ||
|
|
a055e1b6f8 | ||
|
|
2f25940e0a | ||
|
|
e90518a629 | ||
|
|
a74d6d5e92 |
@ -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.
|
||||
|
||||
@ -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 файл после миграции.
|
||||
|
||||
1327
AUDIT_REPORT.md
1327
AUDIT_REPORT.md
File diff suppressed because it is too large
Load Diff
63
PARALLEL_REPORT.md
Normal file
63
PARALLEL_REPORT.md
Normal 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
76
PRD-SECURITY.md
Normal 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
263
PRD.md
@ -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
264
REVIEW_REPORT.md
Normal 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
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
294
backend/DOPPLER_SETUP.md
Normal 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.
|
||||
75
backend/migrations/010_create_notification_history.sql
Normal file
75
backend/migrations/010_create_notification_history.sql
Normal 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;
|
||||
702
backend/package-lock.json
generated
702
backend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
138
backend/src/routes/mqtt.js
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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']
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
// SECURITY: Always verify webhook signature
|
||||
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)');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err.message);
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||
|
||||
415
backend/src/services/mqtt.js
Normal file
415
backend/src/services/mqtt.js
Normal 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,
|
||||
};
|
||||
590
backend/src/services/notifications.js
Normal file
590
backend/src/services/notifications.js
Normal 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
|
||||
};
|
||||
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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
103
mqtt-test.js
Normal 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
664
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
1262
patches/react-native-ble-plx+3.5.0.patch
Normal file
1262
patches/react-native-ble-plx+3.5.0.patch
Normal file
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||
|
||||
@ -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);
|
||||
await device.discoverAllServicesAndCharacteristics();
|
||||
this.connectedDevices.set(deviceId, device);
|
||||
// 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;
|
||||
} catch (error) {
|
||||
}
|
||||
// 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: 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,78 +193,173 @@ export class RealBLEManager implements IBLEManager {
|
||||
async disconnectDevice(deviceId: string): Promise<void> {
|
||||
const device = this.connectedDevices.get(deviceId);
|
||||
if (device) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isDeviceConnected(deviceId: string): boolean {
|
||||
return this.connectedDevices.has(deviceId);
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Wrap callback in try-catch to prevent crashes
|
||||
try {
|
||||
if (error) {
|
||||
if (!responseReceived) {
|
||||
responseReceived = true;
|
||||
reject(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;
|
||||
}
|
||||
|
||||
if (characteristic?.value) {
|
||||
const decoded = base64.decode(characteristic.value);
|
||||
response = decoded;
|
||||
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> {
|
||||
|
||||
@ -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';
|
||||
|
||||
247
services/pushNotifications.ts
Normal file
247
services/pushNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user