Files
ridebus-admin/docs/fcm-alert-notifications.md
Zakhar Timoshenko ddd67f438b feat: Implement alert creation service with FCM notifications
- Add AlertCreationService to handle alert creation and notifications.
- Implement idempotency key support to prevent duplicate alerts.
- Create alert notification service for sending FCM messages.
- Add tests for alert creation and notification services.
- Document FCM notification flow and alert creation process.
2026-07-11 01:18:18 +03:00

14 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

Значение GOOGLE_APPLICATION_CREDENTIALS внутри контейнера должно указывать на путь внутри контейнера, поэтому одного host-пути в корневом .env недостаточно. Создайте локальный, не коммитящийся файл docker-compose.firebase.override.yml:

services:
  backend:
    environment:
      FCM_ENABLED: "true"
      FIREBASE_PROJECT_ID: ridebus-development
      GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/firebase-service-account.json
    volumes:
      - /absolute/private/path/firebase-service-account.json:/run/secrets/firebase-service-account.json:ro

Запуск:

docker compose \
  -f docker-compose.yml \
  -f docker-compose.firebase.override.yml \
  up -d --build backend

Основной docker-compose.yml передаёт все FCM-переменные, но не требует и не монтирует credential-файл. Поэтому обычный запуск с FCM_ENABLED=false остается рабочим без Firebase.

Формирование сообщения

FCM получает одновременно видимый notification payload и строковый data payload. Android priority всегда high. Все значения в data, включая ID и даты, преобразуются в строки; отсутствующие значения передаются как пустые строки.

notification.title берётся из alert.title. Если заголовок отсутствует или состоит только из пробелов, используется fallback по типу:

alert_type Fallback title
delay Задержка движения
cancellation Отмена рейса
detour Изменение маршрута
info Информация о маршруте

notification.body содержит alert.message. Для пустого сообщения используется безопасный текст с номером маршрута, а если маршрут не указан — общий текст об обновлении движения.

Пример сообщения для алерта с ID 42:

{
  "topic": "route_alerts",
  "notification": {
    "title": "Задержка движения",
    "body": "Автобусы задерживаются примерно на 15 минут"
  },
  "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"
  },
  "android": {
    "priority": "high",
    "notification": {
      "channelId": "route_alerts",
      "tag": "alert_42"
    }
  }
}

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

{
  "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 зависит от клиентской реализации, но серверный контракт всегда содержит и notification, и data payload.