Files
ridebus-admin/docs/fcm-alert-notifications.md
Zakhar Timoshenko 6804ca5cdb feat: add AppLayout, BrandLogo, ProfileMenu, and RouteBadge components
- Implemented AppLayout component for application shell with navigation and sidebar.
- Created BrandLogo component to display the application logo.
- Developed ProfileMenu component for user profile management with dropdown functionality.
- Added RouteBadge component to visually represent route numbers with color coding.

style: add CSS styles for Dashboard and Routes pages

- Introduced Dashboard.css for styling the dashboard layout and components.
- Created RoutesPage.css for styling the routes page, including filters and table layout.
- Added SchedulesPage.css for styling the schedules layout and route options.
2026-09-15 22:11:10 +03:00

15 KiB
Raw Blame History

FCM-уведомления о новых алертах

Backend отправляет уведомление через Firebase Cloud Messaging (FCM) после создания нового активного алерта через POST /api/v1/alerts. Android-клиент RideBus должен быть подписан на тему route_alerts и заранее создать канал уведомлений с идентификатором route_alerts.

Firebase Admin SDK работает только на сервере. Приватный ключ или service account JSON нельзя добавлять в репозиторий, frontend-сборку или публичные артефакты админки.

Поток обработки

Админка
  -> 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:

    {
      "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:

psql "$DATABASE_URL" -f backend/migrations/007_alert_creation_idempotency.sql

Для запущенного Compose stack:

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 — безопасный режим по умолчанию. В нём приложение не пытается инициализировать Firebase и полностью работает без credentials.

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). Файл ключа в этом случае не нужен:

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 и укажите абсолютный путь:

cd backend
cp ../.env.example .env

В backend/.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:

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 проекта задайте:

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-файла больше не требуется:

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:

{
  "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"
  }
}

Для алерта без маршрута поля выглядят так:

{
  "routeId": "",
  "routeNumber": ""
}

Пример создания алерта

Endpoint принимает JWT администратора/оператора или API key согласно общей аутентификации /api/v1. Пример с API key:

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 сообщения.