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.
This commit is contained in:
@@ -1153,20 +1153,22 @@
|
||||
|
||||
### [POST] /api/v1/alerts
|
||||
|
||||
- Описание: создает уведомление
|
||||
- Описание: создает уведомление и после успешного сохранения отправляет FCM для активного алерта
|
||||
- Авторизация: Bearer JWT или `X-API-Key`
|
||||
- Параметры path: нет
|
||||
- Query-параметры: нет
|
||||
- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json`
|
||||
- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json`, опциональный `Idempotency-Key` (до 255 символов)
|
||||
- Request Body: `CreateAlertRequest`
|
||||
- Response 200/201: raw `Alert` без join-полей
|
||||
- Response 201: raw `Alert` без join-полей; повтор того же payload с тем же `Idempotency-Key` возвращает тот же алерт без нового push
|
||||
- Ошибки:
|
||||
- `400` — отсутствуют обязательные поля
|
||||
- `400` — invalid `alert_type`
|
||||
- `409` — `Idempotency-Key` уже использован с другим payload
|
||||
- `401`/`403`/`429`
|
||||
- `500` — `Internal server error`
|
||||
- Ошибка FCM логируется и не меняет успешный `201`, если алерт уже сохранён
|
||||
- Связанные сущности: `Alert`, `Route`
|
||||
- Где найдено в коде: `backend/src/routes/alerts.js` -> inline `router.post('/')`
|
||||
- Где найдено в коде: `backend/src/routes/alerts.js` -> `createAlertHandler`, `backend/src/services/alertCreationService.js`, `backend/src/services/alertNotificationService.js`
|
||||
|
||||
### [PUT] /api/v1/alerts/:id
|
||||
|
||||
|
||||
299
docs/fcm-alert-notifications.md
Normal file
299
docs/fcm-alert-notifications.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# 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.
|
||||
@@ -1360,6 +1360,15 @@ paths:
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ApiKeyAuth: []
|
||||
parameters:
|
||||
- in: header
|
||||
name: Idempotency-Key
|
||||
required: false
|
||||
description: Stable key for retrying one logical alert creation without duplicate alerts or pushes
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1379,6 +1388,12 @@ paths:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'409':
|
||||
description: Idempotency-Key was already used with a different request payload
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
$ref: '#/components/responses/TooManyRequests'
|
||||
'500':
|
||||
|
||||
Reference in New Issue
Block a user