# 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` сохранены для совместимости с одним номером; при нескольких они пустые. Мобильный клиент должен использовать массивы для привязки уведомления к маршрутам. Изменения мобильного клиента здесь не реализуются.