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:
2026-07-11 01:18:18 +03:00
parent 93b266f26d
commit ddd67f438b
18 changed files with 1662 additions and 46 deletions

View File

@@ -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

View 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.

View File

@@ -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':