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

300 lines
14 KiB
Markdown
Raw 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` — безопасный режим по умолчанию. В нём приложение не
пытается инициализировать 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). Файл ключа в
этом случае не нужен:
```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
Значение `GOOGLE_APPLICATION_CREDENTIALS` внутри контейнера должно указывать на
путь внутри контейнера, поэтому одного host-пути в корневом `.env` недостаточно.
Создайте локальный, не коммитящийся файл
`docker-compose.firebase.override.yml`:
```yaml
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
```
Запуск:
```bash
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`:
```json
{
"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"
}
}
}
```
Для алерта без маршрута поля выглядят так:
```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 зависит от клиентской реализации,
но серверный контракт всегда содержит и `notification`, и `data` payload.