Files
ridebus-admin/docs/fcm-alert-notifications.md
Zakhar Timoshenko c0d40f9b90 feat: add support for multiple route numbers in alert notifications
- Updated FCM alert notifications documentation to include handling of multiple route numbers.
- Enhanced Telegram cancellation import logic to support multiple route numbers, ensuring correct notifications are sent for all directions.
- Modified frontend components to allow selection of multiple route numbers for alerts, improving user experience.
- Implemented backend migration to add `route_numbers` column to alerts and adjusted related database constraints.
- Introduced new utility functions for normalizing and validating route numbers in alert processing.
2026-10-05 12:07:09 +03:00

302 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FCM-уведомления о новых алертах
Backend отправляет уведомление через Firebase Cloud Messaging (FCM) после
создания нового активного алерта через `POST /api/v1/alerts`. Android-клиент
RideBus должен быть подписан на тему `route_alerts` и заранее создать канал
уведомлений с идентификатором `route_alerts`.
Firebase Admin SDK работает только на сервере. Приватный ключ или service
account JSON нельзя добавлять в репозиторий, frontend-сборку или публичные
артефакты админки.
## Поток обработки
```text
Админка
-> POST /api/v1/alerts
-> валидация запроса
-> сохранение алерта в PostgreSQL
-> прямая отправка FCM в тему route_alerts
-> HTTP 201 с сохранённым алертом
```
FCM вызывается только после подтверждённой успешной вставки в БД. Если вставка
завершилась ошибкой, push не отправляется. Создание алерта с
`is_active: false`, а также его последующее обновление, отключение, удаление или
автоматическая очистка не отправляют событие `alert_created`.
Отправка синхронная и best-effort: отдельной очереди, transactional outbox и
автоматических повторных попыток в проекте нет. Ошибка Firebase логируется с
`alertId`, кодом и причиной, но не меняет уже состоявшееся сохранение: endpoint
по-прежнему возвращает `201 Created`. Токены, credentials и другие секреты в
лог не записываются.
### Ограничения прямой отправки
- Между сохранением в PostgreSQL и вызовом FCM есть crash window: если процесс
завершится в этот момент, алерт останется в БД, но push может не уйти.
- Если FCM временно недоступен, автоматического retry нет.
- Нельзя атомарно подтвердить одной транзакцией и сохранение в PostgreSQL, и
приём сообщения инфраструктурой FCM.
- Ответ `201` означает, что алерт сохранён; он не гарантирует доставку push на
конкретное устройство.
Если понадобятся гарантированная доставка и управляемые retries, следующим
этапом следует добавить transactional outbox и отдельный worker. Текущая
реализация намеренно не вводит тяжёлую инфраструктуру.
## Защита от повторов
`POST /api/v1/alerts` поддерживает необязательный заголовок
`Idempotency-Key`. Ключ и нормализованный payload запроса сохраняются в БД.
- Первый запрос сохраняет алерт, отправляет один push и возвращает `201`.
- Повтор с тем же ключом и тем же нормализованным payload возвращает исходный
raw Alert body со статусом `201`, не создавая новый алерт и второй push.
- Тот же ключ с другим payload возвращает `409 Conflict`:
```json
{
"error": "Idempotency-Key was already used with a different request payload"
}
```
- Запрос без `Idempotency-Key` сохраняет прежнее, неидемпотентное поведение:
каждый успешный повтор создаёт новый алерт и может отправить новый push.
Клиенту админки следует генерировать новый стабильный ключ на одну логическую
попытку создания и повторно использовать его только при сетевом retry этого же
запроса.
Idempotency уменьшает дубли при HTTP-retry, но не устраняет crash window
прямой отправки. Например, авария после записи алерта, но до отправки FCM не
может быть автоматически восстановлена без outbox.
## Миграция базы данных
Новая база, созданная Docker Compose с нуля, автоматически применит
`backend/migrations/007_alert_creation_idempotency.sql` вместе с остальными
init-скриптами. Для уже существующей базы примените только новую миграцию до
деплоя backend:
```bash
psql "$DATABASE_URL" -f backend/migrations/007_alert_creation_idempotency.sql
```
Для запущенного Compose stack:
```bash
docker compose exec -T postgres \
psql -U transit_admin -d polotsk_transit \
-f /docker-entrypoint-initdb.d/007_alert_creation_idempotency.sql
```
Таблица хранит idempotency key, SHA-256-хэш нормализованного запроса,
неизменяемый снимок HTTP-ответа, ссылку на созданный алерт и время создания.
Снимок позволяет безопасно повторить исходный ответ даже после обновления или
удаления алерта. Firebase credentials и payload уведомления в таблицу не
записываются.
## Конфигурация
| Переменная | Значение по умолчанию | Назначение |
| --- | --- | --- |
| `FCM_ENABLED` | `false` | Включает инициализацию Firebase и отправку FCM. При `false` credentials не требуются. |
| `FCM_TOPIC` | `route_alerts` | Тема, на которую подписывается Android-клиент. |
| `FCM_ANDROID_CHANNEL_ID` | `route_alerts` | Идентификатор Android notification channel. |
| `FIREBASE_PROJECT_ID` | пусто | Явный Firebase/Google Cloud project ID; полезен, если ADC не определяет проект автоматически. |
| `GOOGLE_APPLICATION_CREDENTIALS` | пусто | Стандартный путь ADC к service account JSON в окружении запущенного backend-процесса. |
`FCM_ENABLED=false` — безопасный режим по умолчанию для push-уведомлений. Firebase
Admin всё равно потребуется backend, если включён пассажирский режим: endpoint
`/api/v1/passenger-telemetry/ingest` проверяет Firebase ID token независимо от FCM.
Firebase Admin SDK в этом проекте требует Node.js 22. Docker-образ backend
также должен использовать Node.js 22.
## Настройка Application Default Credentials
### Google Cloud runtime
Предпочтительный production-вариант — назначить runtime-сервису service
account с правами на отправку FCM и использовать credentials, предоставленные
окружением Google Cloud (metadata server / workload identity). Файл ключа в
этом случае не нужен:
```env
FCM_ENABLED=true
FCM_TOPIC=route_alerts
FCM_ANDROID_CHANNEL_ID=route_alerts
FIREBASE_PROJECT_ID=ridebus-production
GOOGLE_APPLICATION_CREDENTIALS=
```
Не выдавайте service account более широкие права, чем требуются backend для
Firebase Messaging.
### Локальный запуск backend без Docker
Храните service account JSON вне checkout и укажите абсолютный путь:
```bash
cd backend
cp ../.env.example .env
```
В `backend/.env`:
```env
FCM_ENABLED=true
FCM_TOPIC=route_alerts
FCM_ANDROID_CHANNEL_ID=route_alerts
FIREBASE_PROJECT_ID=ridebus-development
GOOGLE_APPLICATION_CREDENTIALS=/absolute/private/path/firebase-service-account.json
```
После этого запустите `npm run dev`. Не копируйте JSON в `backend/`, не
добавляйте его в `.env` в виде текста и не коммитьте файл.
### Запуск в Docker Compose на Proxmox
Docker должен работать внутри VM или LXC-контейнера Proxmox. На этом Docker-
хосте создайте закрытый каталог и положите в него service account JSON:
```bash
sudo install -d -m 700 /opt/polotsk-transit/secrets
sudo install -m 600 /private/path/firebase-service-account.json \
/opt/polotsk-transit/secrets/firebase-service-account.json
```
В корневом `.env` проекта задайте:
```env
FCM_ENABLED=true
FCM_TOPIC=route_alerts
FCM_ANDROID_CHANNEL_ID=route_alerts
FIREBASE_PROJECT_ID=ridebus-production
FIREBASE_SECRETS_DIR=/opt/polotsk-transit/secrets
GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.json
```
Основной `docker-compose.yml` монтирует `FIREBASE_SECRETS_DIR` в backend как
read-only `/run/secrets`. Запуск отдельного override-файла больше не требуется:
```bash
docker compose up -d --build backend
docker compose logs -f backend
```
При `FCM_ENABLED=false` Firebase не инициализируется, поэтому отсутствие JSON
не мешает работе приложения.
## Формирование сообщения
FCM получает только строковый `data` payload. Верхнеуровневого `notification`
и `android.notification` нет, поэтому Android-клиент сам создаёт уведомление и
может добавить локальные действия, включая кнопку «Прочитано». Android
priority всегда `high`. Все значения в `data`, включая ID, даты, заголовок и
текст, являются строками; отсутствующие значения передаются как пустые строки.
`data.title` берётся из `alert.title`. Если заголовок отсутствует или
состоит только из пробелов, используется fallback по типу:
| `alert_type` | Fallback title |
| --- | --- |
| `delay` | `Задержка движения` |
| `cancellation` | `Отмена рейса` |
| `detour` | `Изменение маршрута` |
| `info` | `Информация о маршруте` |
`data.body` содержит `alert.message`. Для пустого сообщения
используется безопасный текст с номером маршрута, а если маршрут не указан —
общий текст об обновлении движения.
Пример сообщения для алерта с ID `42`:
```json
{
"topic": "route_alerts",
"data": {
"event": "alert_created",
"alertId": "42",
"type": "delay",
"routeId": "7",
"routeNumber": "5",
"startTime": "2026-07-11T09:00:00.000Z",
"endTime": "2026-07-11T11:00:00.000Z",
"title": "Задержка движения",
"body": "Автобусы задерживаются примерно на 15 минут"
},
"android": {
"priority": "high"
}
}
```
Для алерта без маршрута поля выглядят так:
```json
{
"routeId": "",
"routeNumber": ""
}
```
## Пример создания алерта
Endpoint принимает JWT администратора/оператора или API key согласно общей
аутентификации `/api/v1`. Пример с API key:
```bash
curl -i -X POST http://localhost:3000/api/v1/alerts \
-H 'X-API-Key: dev_key_12345678' \
-H 'Idempotency-Key: alert-delay-route-5-20260711T120000Z' \
-H 'Content-Type: application/json' \
--data '{
"route_id": 7,
"alert_type": "delay",
"title": "Задержка движения",
"message": "Автобусы задерживаются примерно на 15 минут",
"start_time": "2026-07-11T12:00:00+03:00",
"end_time": "2026-07-11T14:00:00+03:00",
"is_active": true
}'
```
Успешный ответ — `201 Created` и сохранённый Alert. Активные алерты затем
доступны через `GET /api/v1/alerts/active`.
## Локальная проверка
### Без реальной отправки
1. Оставьте `FCM_ENABLED=false` в `.env`.
2. Запустите backend или весь Compose stack.
3. Выполните приведённый выше `curl` с валидным API key/JWT и существующим
`route_id` (либо передайте `null`).
4. Проверьте `201`, наличие записи в `GET /api/v1/alerts/active` и отсутствие
требования Firebase credentials.
5. Повторите запрос с тем же `Idempotency-Key`: ответ снова будет `201` с тем
же Alert body, без второй записи.
### С реальной отправкой
1. Подпишите тестовое Android-устройство на тему `route_alerts` и убедитесь,
что приложение создало notification channel `route_alerts`.
2. Настройте ADC одним из способов выше и задайте `FCM_ENABLED=true`.
3. Перезапустите backend и создайте новый активный алерт с уникальным
`Idempotency-Key`.
4. Проверьте `201`, server log с успешным Firebase message ID и уведомление на
устройстве.
5. Для проверки обработки ошибки временно используйте недоступный Firebase
project/отзовите право отправки: алерт всё равно должен сохраниться и
вернуть `201`, а лог должен содержать `alertId`, Firebase code и причину без
credentials или токенов.
Поведение Android при foreground/background полностью определяется клиентской
обработкой data-only сообщения.
## Несколько номеров маршрутов
Миграция `015_alert_route_numbers.sql` добавляет `alerts.route_numbers`. API создания/редактирования принимает, например, `route_numbers: ["2", "27"]`. Все направления этих номеров в выбранном городе получают одно уведомление. В data payload дополнительно передаются `routeNumbers: '["2","27"]'` и `routeIds: '[1,2,4,5]'` — строки с JSON-массивами. Для общего уведомления оба массива пусты. `routeId` и `routeNumber` сохранены для совместимости с одним номером; при нескольких они пустые. Мобильный клиент должен использовать массивы для привязки уведомления к маршрутам. Изменения мобильного клиента здесь не реализуются.