From ddd67f438b043126cc3c55d5b582ad1fc9e8610e Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Sat, 11 Jul 2026 01:18:18 +0300 Subject: [PATCH] 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. --- .env.example | 16 + PROJECT_STRUCTURE.md | 2 +- README.md | 19 +- backend/Dockerfile | 2 +- .../007_alert_creation_idempotency.sql | 10 + backend/package.json | 1 + backend/src/config/firebase.js | 65 +++ backend/src/routes/alerts.js | 69 +-- backend/src/services/alertCreationService.js | 267 ++++++++++++ .../src/services/alertNotificationService.js | 238 +++++++++++ backend/test/alertCreationService.test.js | 403 ++++++++++++++++++ backend/test/alertNotificationService.test.js | 243 +++++++++++ docker-compose.yml | 5 + docs/api-documentation.md | 10 +- docs/fcm-alert-notifications.md | 299 +++++++++++++ docs/openapi-draft.yaml | 15 + frontend/src/pages/AlertsPage.jsx | 40 +- frontend/src/services/api.js | 4 +- 18 files changed, 1662 insertions(+), 46 deletions(-) create mode 100644 backend/migrations/007_alert_creation_idempotency.sql create mode 100644 backend/src/config/firebase.js create mode 100644 backend/src/services/alertCreationService.js create mode 100644 backend/src/services/alertNotificationService.js create mode 100644 backend/test/alertCreationService.test.js create mode 100644 backend/test/alertNotificationService.test.js create mode 100644 docs/fcm-alert-notifications.md diff --git a/.env.example b/.env.example index 59cd440..075c1d5 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,22 @@ DB_PASSWORD=your_secure_password_here API_KEY_SALT=generate_random_salt_with_openssl CORS_ORIGIN=http://localhost:3001 +# Backend runtime (used when starting backend directly, outside Docker Compose) +PORT=3000 +DATABASE_URL=postgresql://transit_admin:your_secure_password_here@localhost:5432/polotsk_transit +REDIS_URL=redis://localhost:6379 +JWT_SECRET=replace_with_a_long_random_string + +# Firebase Cloud Messaging (server-side alert notifications) +# Keep disabled to run without Firebase credentials. +FCM_ENABLED=false +FCM_TOPIC=route_alerts +FCM_ANDROID_CHANNEL_ID=route_alerts +FIREBASE_PROJECT_ID= +# Standard Application Default Credentials path. Store the JSON file outside +# the repository and leave this empty while FCM_ENABLED=false. +GOOGLE_APPLICATION_CREDENTIALS= + # Bootstrap admin BOOTSTRAP_ADMIN=true BOOTSTRAP_ADMIN_RESET_PASSWORD=false diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 99b69a9..c951000 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -147,7 +147,7 @@ sync_log -- Лог изменений для offline синхрониза **Версия 3.0 (Продвинутые функции):** - [ ] IoT GPS-трекеры - [ ] ML для предсказания задержек -- [ ] Push уведомления +- [x] Push уведомления о новых активных алертах через FCM - [ ] Детальная аналитика ## 💡 Советы по развертыванию diff --git a/README.md b/README.md index 50ba590..f77d5ea 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - ✅ Кеширование через Redis - ✅ Аутентификация по API ключам - ✅ Rate limiting +- ✅ Серверные push-уведомления об алертах через Firebase Cloud Messaging ### Технологический стек @@ -31,7 +32,7 @@ ## 📋 Требования - Docker и Docker Compose -- Node.js 18+ (для разработки без Docker) +- Node.js 22+ (для разработки без Docker и Firebase Admin SDK) - Статический IP или динамический DNS - ~2GB свободного места на диске @@ -57,6 +58,9 @@ ADMIN_PASSWORD=your_secure_admin_password_here # Для разработки/деплоя через встроенный proxy оставьте как есть CORS_ORIGIN=http://localhost:3001 API_URL=/api/v1 + +# Push отключён по умолчанию и не требует Firebase credentials +FCM_ENABLED=false ``` Генерация безопасного salt: @@ -103,7 +107,7 @@ docker exec -it polotsk-transit-db psql -U transit_admin -d polotsk_transit -c " ```bash cd backend npm install -cp .env.example .env +cp ../.env.example .env # Убедитесь, что PostgreSQL и Redis запущены локально # Настройте DATABASE_URL и REDIS_URL в .env @@ -168,6 +172,15 @@ GET /api/v1/sync?lastSync=2025-11-19T10:00:00Z # Инкрементальная GET /api/v1/sync/status # Статус системы ``` +**Алерты:** +```bash +GET /api/v1/alerts/active # Активные алерты для клиентов +POST /api/v1/alerts # Создать алерт и, если он активен, отправить FCM +``` + +Настройка Firebase, контракт сообщения, идемпотентность и способы локальной +проверки описаны в [документации FCM-уведомлений](docs/fcm-alert-notifications.md). + ### Примеры запросов **Создание маршрута:** @@ -338,7 +351,7 @@ SELECT PostGIS_Version(); ### Версия 3.0 - Продвинутые функции - [ ] IoT GPS-трекеры интеграция - [ ] Машинное обучение для предсказания задержек -- [ ] Push-уведомления +- [x] Push-уведомления о новых активных алертах - [ ] Аналитика и отчеты ## 🤝 Контакты и поддержка diff --git a/backend/Dockerfile b/backend/Dockerfile index c295cc8..080185f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:22-alpine WORKDIR /app diff --git a/backend/migrations/007_alert_creation_idempotency.sql b/backend/migrations/007_alert_creation_idempotency.sql new file mode 100644 index 0000000..f4b9783 --- /dev/null +++ b/backend/migrations/007_alert_creation_idempotency.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS alert_creation_requests ( + idempotency_key VARCHAR(255) PRIMARY KEY, + request_hash CHAR(64) NOT NULL, + alert_id INTEGER REFERENCES alerts(id) ON DELETE SET NULL, + response_body JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_alert_creation_requests_created_at + ON alert_creation_requests(created_at); diff --git a/backend/package.json b/backend/package.json index aa9610c..e998cb5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", + "firebase-admin": "^14.1.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", diff --git a/backend/src/config/firebase.js b/backend/src/config/firebase.js new file mode 100644 index 0000000..ec4af36 --- /dev/null +++ b/backend/src/config/firebase.js @@ -0,0 +1,65 @@ +const DEFAULT_FCM_TOPIC = 'route_alerts'; +const DEFAULT_FCM_ANDROID_CHANNEL_ID = 'route_alerts'; + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || String(value).trim() === '') { + return fallback; + } + + return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase()); +} + +function getFirebaseConfig(env = process.env) { + const topic = String(env.FCM_TOPIC || '').trim(); + const androidChannelId = String(env.FCM_ANDROID_CHANNEL_ID || '').trim(); + const projectId = String(env.FIREBASE_PROJECT_ID || '').trim(); + + return { + enabled: parseBoolean(env.FCM_ENABLED, false), + topic: topic || DEFAULT_FCM_TOPIC, + androidChannelId: androidChannelId || DEFAULT_FCM_ANDROID_CHANNEL_ID, + projectId: projectId || undefined + }; +} + +let messagingSingleton; + +function createFirebaseMessaging(config = getFirebaseConfig()) { + if (!config.enabled) { + return null; + } + + // Keep Firebase optional for disabled/local environments. These modular + // imports are evaluated only when an enabled notification is actually sent. + const { applicationDefault, getApps, initializeApp } = require('firebase-admin/app'); + const { getMessaging } = require('firebase-admin/messaging'); + + const existingApp = getApps()[0]; + const app = existingApp || initializeApp({ + credential: applicationDefault(), + ...(config.projectId ? { projectId: config.projectId } : {}) + }); + + return getMessaging(app); +} + +function getFirebaseMessaging(config = getFirebaseConfig()) { + if (!config.enabled) { + return null; + } + + if (!messagingSingleton) { + messagingSingleton = createFirebaseMessaging(config); + } + + return messagingSingleton; +} + +module.exports = { + DEFAULT_FCM_TOPIC, + DEFAULT_FCM_ANDROID_CHANNEL_ID, + parseBoolean, + getFirebaseConfig, + createFirebaseMessaging, + getFirebaseMessaging +}; diff --git a/backend/src/routes/alerts.js b/backend/src/routes/alerts.js index a580562..6c74bff 100644 --- a/backend/src/routes/alerts.js +++ b/backend/src/routes/alerts.js @@ -1,6 +1,8 @@ const express = require('express'); const router = express.Router(); const pool = require('../config/database'); +const logger = require('../config/logger'); +const { getAlertCreationService } = require('../services/alertCreationService'); // Get all alerts (with optional filters) router.get('/', async (req, res) => { @@ -97,34 +99,44 @@ router.get('/:id', async (req, res) => { } }); +function createAlertHandler({ alertCreationService = null } = {}) { + return async (req, res) => { + try { + const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body; + + // Validation + if (!alert_type || !title || !message || !start_time) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const validTypes = ['delay', 'cancellation', 'detour', 'info']; + if (!validTypes.includes(alert_type)) { + return res.status(400).json({ error: 'Invalid alert_type' }); + } + + const service = alertCreationService || getAlertCreationService(); + const result = await service.createAlert( + { route_id, alert_type, title, message, start_time, end_time, is_active }, + { idempotencyKey: req.get('Idempotency-Key') } + ); + + return res.status(201).json(result.alert); + } catch (error) { + if (error.status) { + return res.status(error.status).json({ error: error.message }); + } + + logger.error('Error creating alert', { + reason: error.message, + code: error.code || 'unknown' + }); + return res.status(500).json({ error: 'Internal server error' }); + } + }; +} + // Create new alert -router.post('/', async (req, res) => { - try { - const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body; - - // Validation - if (!alert_type || !title || !message || !start_time) { - return res.status(400).json({ error: 'Missing required fields' }); - } - - const validTypes = ['delay', 'cancellation', 'detour', 'info']; - if (!validTypes.includes(alert_type)) { - return res.status(400).json({ error: 'Invalid alert_type' }); - } - - const result = await pool.query( - `INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *`, - [route_id || null, alert_type, title, message, start_time, end_time || null, is_active !== false] - ); - - res.status(201).json(result.rows[0]); - } catch (error) { - console.error('Error creating alert:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); +router.post('/', createAlertHandler()); // Update alert router.put('/:id', async (req, res) => { @@ -206,4 +218,5 @@ router.post('/cleanup', async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; +module.exports.createAlertHandler = createAlertHandler; diff --git a/backend/src/services/alertCreationService.js b/backend/src/services/alertCreationService.js new file mode 100644 index 0000000..5e60fa0 --- /dev/null +++ b/backend/src/services/alertCreationService.js @@ -0,0 +1,267 @@ +const crypto = require('node:crypto'); + +const INSERT_ALERT_SQL = ` + WITH inserted AS ( + INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + ) + SELECT inserted.*, routes.route_number AS notification_route_number + FROM inserted + LEFT JOIN routes ON routes.id = inserted.route_id +`; + +function createHttpError(status, message) { + const error = new Error(message); + error.status = status; + return error; +} + +function normalizeIdempotencyKey(value) { + if (value === undefined || value === null) { + return null; + } + + const key = String(value).trim(); + + if (!key) { + throw createHttpError(400, 'Idempotency-Key must not be empty'); + } + + if (key.length > 255) { + throw createHttpError(400, 'Idempotency-Key must not exceed 255 characters'); + } + + return key; +} + +function normalizeAlertInput(payload) { + return { + route_id: payload.route_id || null, + alert_type: payload.alert_type, + title: payload.title, + message: payload.message, + start_time: payload.start_time, + end_time: payload.end_time || null, + is_active: payload.is_active !== false + }; +} + +function normalizeHashValue(value) { + if (value instanceof Date) { + return value.toISOString(); + } + + return value; +} + +function createAlertRequestHash(alert) { + const canonicalPayload = { + route_id: normalizeHashValue(alert.route_id), + alert_type: normalizeHashValue(alert.alert_type), + title: normalizeHashValue(alert.title), + message: normalizeHashValue(alert.message), + start_time: normalizeHashValue(alert.start_time), + end_time: normalizeHashValue(alert.end_time), + is_active: normalizeHashValue(alert.is_active) + }; + + return crypto + .createHash('sha256') + .update(JSON.stringify(canonicalPayload)) + .digest('hex'); +} + +function splitNotificationRow(row) { + const { notification_route_number: routeNumber, ...alert } = row; + + return { + alert, + notificationAlert: { + ...alert, + route_number: routeNumber ?? '' + } + }; +} + +function parseAlertSnapshot(value) { + if (!value) { + return null; + } + + if (typeof value === 'string') { + return JSON.parse(value); + } + + return value; +} + +class AlertCreationService { + constructor({ pool, notificationService, logger }) { + this.pool = pool; + this.notificationService = notificationService; + this.logger = logger; + } + + async createAlert(payload, { idempotencyKey: rawIdempotencyKey } = {}) { + const alertInput = normalizeAlertInput(payload); + const idempotencyKey = normalizeIdempotencyKey(rawIdempotencyKey); + const result = idempotencyKey + ? await this.createIdempotentAlert(alertInput, idempotencyKey) + : await this.insertAlert(this.pool, alertInput); + + if (result.created && result.alert.is_active !== false) { + await this.sendNotification(result.notificationAlert); + } + + return { + alert: result.alert, + created: result.created + }; + } + + async insertAlert(queryable, alertInput) { + const result = await queryable.query(INSERT_ALERT_SQL, [ + alertInput.route_id, + alertInput.alert_type, + alertInput.title, + alertInput.message, + alertInput.start_time, + alertInput.end_time, + alertInput.is_active + ]); + + const row = result.rows[0]; + + if (!row) { + throw new Error('Alert insert did not return the created row'); + } + + return { + ...splitNotificationRow(row), + created: true + }; + } + + async createIdempotentAlert(alertInput, idempotencyKey) { + const requestHash = createAlertRequestHash(alertInput); + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + + const claimResult = await client.query( + `INSERT INTO alert_creation_requests (idempotency_key, request_hash) + VALUES ($1, $2) + ON CONFLICT (idempotency_key) DO NOTHING + RETURNING idempotency_key`, + [idempotencyKey, requestHash] + ); + + if (claimResult.rows.length === 0) { + const existingRequestResult = await client.query( + `SELECT request_hash, alert_id, response_body + FROM alert_creation_requests + WHERE idempotency_key = $1 + FOR UPDATE`, + [idempotencyKey] + ); + const existingRequest = existingRequestResult.rows[0]; + + if (!existingRequest) { + throw new Error('Idempotency record disappeared during alert creation'); + } + + if (existingRequest.request_hash !== requestHash) { + throw createHttpError( + 409, + 'Idempotency-Key was already used with a different request payload' + ); + } + + const existingAlert = parseAlertSnapshot(existingRequest.response_body); + + if (!existingAlert) { + throw new Error('Idempotency record is missing its alert response snapshot'); + } + + await client.query('COMMIT'); + + return { + alert: existingAlert, + notificationAlert: null, + created: false + }; + } + + const created = await this.insertAlert(client, alertInput); + + await client.query( + `UPDATE alert_creation_requests + SET alert_id = $2, + response_body = $3::jsonb + WHERE idempotency_key = $1`, + [idempotencyKey, created.alert.id, JSON.stringify(created.alert)] + ); + await client.query('COMMIT'); + + return created; + } catch (error) { + try { + await client.query('ROLLBACK'); + } catch (rollbackError) { + this.logger.error('Failed to roll back alert creation transaction', { + reason: rollbackError.message + }); + } + + throw error; + } finally { + client.release(); + } + } + + async sendNotification(alert) { + try { + await this.notificationService.sendAlertCreated(alert); + } catch (error) { + this.logger.error('Failed to dispatch alert-created notification', { + alertId: String(alert.id), + reason: error.message || 'Unknown Firebase error', + firebaseCode: error.code || 'unknown' + }); + } + } +} + +let singleton = null; + +function getAlertCreationService() { + if (!singleton) { + const pool = require('../config/database'); + const logger = require('../config/logger'); + const { getAlertNotificationService } = require('./alertNotificationService'); + + singleton = new AlertCreationService({ + pool, + logger, + notificationService: getAlertNotificationService() + }); + } + + return singleton; +} + +function setAlertCreationServiceForTesting(service) { + singleton = service; +} + +module.exports = { + AlertCreationService, + createAlertRequestHash, + getAlertCreationService, + normalizeAlertInput, + normalizeIdempotencyKey, + parseAlertSnapshot, + setAlertCreationServiceForTesting +}; diff --git a/backend/src/services/alertNotificationService.js b/backend/src/services/alertNotificationService.js new file mode 100644 index 0000000..5b14f8a --- /dev/null +++ b/backend/src/services/alertNotificationService.js @@ -0,0 +1,238 @@ +const { + DEFAULT_FCM_TOPIC, + DEFAULT_FCM_ANDROID_CHANNEL_ID, + getFirebaseConfig, + getFirebaseMessaging +} = require('../config/firebase'); + +function getDefaultLogger() { + return require('../config/logger'); +} + +const ALERT_TITLE_FALLBACKS = Object.freeze({ + delay: 'Задержка движения', + cancellation: 'Отмена рейса', + detour: 'Изменение маршрута', + info: 'Информация о маршруте' +}); + +function firstDefined(...values) { + return values.find((value) => value !== undefined && value !== null); +} + +function toDataString(value) { + return value === undefined || value === null ? '' : String(value); +} + +function toIsoString(value) { + if (value === undefined || value === null || value === '') { + return ''; + } + + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); +} + +function getAlertType(alert = {}) { + return toDataString(firstDefined(alert.alert_type, alert.alertType, alert.type)); +} + +function getAlertId(alert = {}) { + return toDataString(firstDefined(alert.id, alert.alert_id, alert.alertId)); +} + +function getRouteId(alert = {}) { + return toDataString(firstDefined( + alert.route_id, + alert.routeId, + alert.route && firstDefined(alert.route.id, alert.route.route_id, alert.route.routeId) + )); +} + +function getRouteNumber(alert = {}) { + return toDataString(firstDefined( + alert.route_number, + alert.routeNumber, + alert.route && firstDefined(alert.route.route_number, alert.route.routeNumber, alert.route.number) + )); +} + +function nonBlankString(value) { + const normalized = toDataString(value).trim(); + return normalized || ''; +} + +function getNotificationTitle(alert = {}) { + const title = nonBlankString(alert.title); + if (title) { + return title; + } + + return ALERT_TITLE_FALLBACKS[getAlertType(alert)] || ALERT_TITLE_FALLBACKS.info; +} + +function getNotificationBody(alert = {}) { + const message = nonBlankString(firstDefined(alert.message, alert.body)); + if (message) { + return message; + } + + const routeNumber = nonBlankString(getRouteNumber(alert)); + return routeNumber + ? `Актуальная информация по маршруту №${routeNumber}` + : 'Актуальная информация о движении транспорта'; +} + +function isAlertActive(alert = {}) { + const isActive = firstDefined(alert.is_active, alert.isActive); + return isActive !== false; +} + +function buildAlertCreatedMessage(alert, options = {}) { + const alertId = getAlertId(alert); + const topic = options.topic || DEFAULT_FCM_TOPIC; + const channelId = options.androidChannelId || options.channelId || DEFAULT_FCM_ANDROID_CHANNEL_ID; + + return { + topic, + notification: { + title: getNotificationTitle(alert), + body: getNotificationBody(alert) + }, + data: { + event: 'alert_created', + alertId, + type: getAlertType(alert), + routeId: getRouteId(alert), + routeNumber: getRouteNumber(alert), + startTime: toIsoString(firstDefined(alert.start_time, alert.startTime)), + endTime: toIsoString(firstDefined(alert.end_time, alert.endTime)) + }, + android: { + priority: 'high', + notification: { + channelId, + tag: `alert_${alertId}` + } + } + }; +} + +function getFirebaseErrorCode(error) { + return toDataString(firstDefined( + error && error.code, + error && error.errorInfo && error.errorInfo.code + )); +} + +class AlertNotificationService { + constructor(options = {}) { + const firebaseConfig = options.firebaseConfig || getFirebaseConfig(options.env); + + this.enabled = options.enabled === undefined ? firebaseConfig.enabled : options.enabled; + this.topic = options.topic || firebaseConfig.topic; + this.androidChannelId = options.androidChannelId || options.channelId || firebaseConfig.androidChannelId; + this.logger = options.logger || getDefaultLogger(); + this.getMessaging = options.getMessaging || options.messagingFactory || (() => ( + options.messaging || getFirebaseMessaging({ + ...firebaseConfig, + enabled: this.enabled, + topic: this.topic, + androidChannelId: this.androidChannelId + }) + )); + } + + async sendAlertCreated(alert) { + const alertId = getAlertId(alert); + + if (!this.enabled) { + return { sent: false, skipped: true, reason: 'disabled' }; + } + + if (!isAlertActive(alert)) { + return { sent: false, skipped: true, reason: 'inactive' }; + } + + try { + const messaging = await this.getMessaging(); + if (!messaging || typeof messaging.send !== 'function') { + throw new Error('Firebase Messaging is unavailable'); + } + + const message = buildAlertCreatedMessage(alert, { + topic: this.topic, + androidChannelId: this.androidChannelId + }); + const messageId = await messaging.send(message); + const normalizedMessageId = toDataString(messageId); + + try { + if (this.logger && typeof this.logger.info === 'function') { + this.logger.info('Sent alert-created FCM notification', { + alertId, + messageId: normalizedMessageId, + topic: this.topic + }); + } + } catch (loggingError) { + // A logging transport must not turn a successful push into a request failure. + } + + return { + sent: true, + messageId: normalizedMessageId + }; + } catch (error) { + const reason = error && error.message + ? String(error.message) + : 'Unknown Firebase Messaging error'; + const firebaseCode = getFirebaseErrorCode(error) || 'unknown'; + + try { + if (this.logger && typeof this.logger.error === 'function') { + this.logger.error('Failed to send alert-created FCM notification', { + alertId, + reason, + firebaseCode + }); + } + } catch (loggingError) { + // A logging transport must not turn a best-effort push into a request failure. + } + + return { + sent: false, + reason: 'fcm_error', + firebaseCode + }; + } + } +} + +let alertNotificationServiceSingleton; + +function getAlertNotificationService() { + if (!alertNotificationServiceSingleton) { + alertNotificationServiceSingleton = new AlertNotificationService(); + } + + return alertNotificationServiceSingleton; +} + +module.exports = { + ALERT_TITLE_FALLBACKS, + toDataString, + toIsoString, + getAlertType, + getAlertId, + getRouteId, + getRouteNumber, + getNotificationTitle, + getNotificationBody, + isAlertActive, + buildAlertCreatedMessage, + getFirebaseErrorCode, + AlertNotificationService, + getAlertNotificationService +}; diff --git a/backend/test/alertCreationService.test.js b/backend/test/alertCreationService.test.js new file mode 100644 index 0000000..acb22e9 --- /dev/null +++ b/backend/test/alertCreationService.test.js @@ -0,0 +1,403 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + AlertCreationService, + createAlertRequestHash, + normalizeAlertInput +} = require('../src/services/alertCreationService'); + +const ALERT_INPUT = { + route_id: 7, + alert_type: 'delay', + title: 'Задержка автобуса', + message: 'Автобус задерживается на 15 минут', + start_time: '2026-07-11T07:00:00.000Z', + end_time: null, + is_active: true +}; + +function createStoredAlert(overrides = {}) { + return { + id: 42, + route_id: 7, + alert_type: 'delay', + title: 'Задержка автобуса', + message: 'Автобус задерживается на 15 минут', + start_time: new Date('2026-07-11T07:00:00.000Z'), + end_time: null, + is_active: true, + created_at: new Date('2026-07-11T06:55:00.000Z'), + notification_route_number: '3', + ...overrides + }; +} + +function createLogger() { + const errors = []; + return { + errors, + error(message, metadata) { + errors.push({ message, metadata }); + } + }; +} + +test('successfully saved active alert dispatches one notification after the insert', async () => { + const order = []; + const pool = { + async query() { + order.push('saved'); + return { rows: [createStoredAlert()] }; + } + }; + const notifications = []; + const notificationService = { + async sendAlertCreated(alert) { + order.push('notified'); + notifications.push(alert); + } + }; + const service = new AlertCreationService({ + pool, + notificationService, + logger: createLogger() + }); + + const result = await service.createAlert(ALERT_INPUT); + + assert.deepEqual(order, ['saved', 'notified']); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].route_number, '3'); + assert.equal('notification_route_number' in result.alert, false); + assert.equal(result.alert.id, 42); + assert.equal(result.created, true); +}); + +test('inactive alert is saved without dispatching a notification', async () => { + let notificationCalls = 0; + const service = new AlertCreationService({ + pool: { + async query() { + return { rows: [createStoredAlert({ is_active: false })] }; + } + }, + notificationService: { + async sendAlertCreated() { + notificationCalls += 1; + } + }, + logger: createLogger() + }); + + const result = await service.createAlert({ ...ALERT_INPUT, is_active: false }); + + assert.equal(result.alert.is_active, false); + assert.equal(notificationCalls, 0); +}); + +test('database failure prevents notification dispatch', async () => { + const databaseError = new Error('database unavailable'); + let notificationCalls = 0; + const service = new AlertCreationService({ + pool: { + async query() { + throw databaseError; + } + }, + notificationService: { + async sendAlertCreated() { + notificationCalls += 1; + } + }, + logger: createLogger() + }); + + await assert.rejects(() => service.createAlert(ALERT_INPUT), databaseError); + assert.equal(notificationCalls, 0); +}); + +test('unexpected notification failure is logged without failing the saved alert', async () => { + const logger = createLogger(); + const firebaseError = Object.assign(new Error('permission denied'), { + code: 'messaging/authentication-error' + }); + const service = new AlertCreationService({ + pool: { + async query() { + return { rows: [createStoredAlert()] }; + } + }, + notificationService: { + async sendAlertCreated() { + throw firebaseError; + } + }, + logger + }); + + const result = await service.createAlert(ALERT_INPUT); + + assert.equal(result.alert.id, 42); + assert.equal(logger.errors.length, 1); + assert.deepEqual(logger.errors[0], { + message: 'Failed to dispatch alert-created notification', + metadata: { + alertId: '42', + reason: 'permission denied', + firebaseCode: 'messaging/authentication-error' + } + }); +}); + +function createIdempotentPool() { + const requests = new Map(); + const alerts = new Map(); + let nextAlertId = 1; + + const client = { + async query(sql, params = []) { + const normalizedSql = String(sql).replace(/\s+/g, ' ').trim(); + + if (['BEGIN', 'COMMIT', 'ROLLBACK'].includes(normalizedSql)) { + return { rows: [] }; + } + + if (normalizedSql.startsWith('INSERT INTO alert_creation_requests')) { + const [key, requestHash] = params; + + if (requests.has(key)) { + return { rows: [] }; + } + + requests.set(key, { request_hash: requestHash, alert_id: null }); + return { rows: [{ idempotency_key: key }] }; + } + + if (normalizedSql.startsWith('SELECT request_hash, alert_id')) { + return { rows: [requests.get(params[0])] }; + } + + if (normalizedSql.startsWith('WITH inserted AS')) { + const alert = createStoredAlert({ + id: nextAlertId, + route_id: params[0], + alert_type: params[1], + title: params[2], + message: params[3], + start_time: new Date(params[4]), + end_time: params[5] ? new Date(params[5]) : null, + is_active: params[6] + }); + nextAlertId += 1; + alerts.set(alert.id, alert); + return { rows: [alert] }; + } + + if (normalizedSql.startsWith('UPDATE alert_creation_requests')) { + const [key, alertId, responseBody] = params; + requests.get(key).alert_id = alertId; + requests.get(key).response_body = JSON.parse(responseBody); + return { rows: [] }; + } + + throw new Error(`Unexpected query in fake pool: ${normalizedSql}`); + }, + release() {} + }; + + return { + requests, + alerts, + deleteAlert(alertId) { + alerts.delete(alertId); + + for (const request of requests.values()) { + if (request.alert_id === alertId) { + request.alert_id = null; + } + } + }, + async connect() { + return client; + } + }; +} + +test('same Idempotency-Key and payload replays the alert without duplicate notification', async () => { + const pool = createIdempotentPool(); + let notificationCalls = 0; + const service = new AlertCreationService({ + pool, + notificationService: { + async sendAlertCreated() { + notificationCalls += 1; + } + }, + logger: createLogger() + }); + + const first = await service.createAlert(ALERT_INPUT, { idempotencyKey: 'alert-request-1' }); + const replay = await service.createAlert(ALERT_INPUT, { idempotencyKey: 'alert-request-1' }); + + assert.equal(first.alert.id, replay.alert.id); + assert.equal(first.created, true); + assert.equal(replay.created, false); + assert.equal(notificationCalls, 1); +}); + +test('idempotency snapshot prevents a duplicate even after the original alert is deleted', async () => { + const pool = createIdempotentPool(); + let notificationCalls = 0; + const service = new AlertCreationService({ + pool, + notificationService: { + async sendAlertCreated() { + notificationCalls += 1; + } + }, + logger: createLogger() + }); + + const first = await service.createAlert(ALERT_INPUT, { idempotencyKey: 'alert-request-deleted' }); + pool.deleteAlert(first.alert.id); + const replay = await service.createAlert(ALERT_INPUT, { + idempotencyKey: 'alert-request-deleted' + }); + + assert.equal(replay.alert.id, first.alert.id); + assert.equal(replay.created, false); + assert.equal(notificationCalls, 1); +}); + +function createConcurrentIdempotentPool() { + const requests = new Map(); + let alertInserts = 0; + + function createClient() { + return { + async query(sql, params = []) { + const normalizedSql = String(sql).replace(/\s+/g, ' ').trim(); + + if (normalizedSql === 'BEGIN' || normalizedSql === 'ROLLBACK') { + return { rows: [] }; + } + + if (normalizedSql === 'COMMIT') { + for (const request of requests.values()) { + if (!request.committed) { + request.committed = true; + request.resolveCommitted(); + } + } + return { rows: [] }; + } + + if (normalizedSql.startsWith('INSERT INTO alert_creation_requests')) { + const [key, requestHash] = params; + const existing = requests.get(key); + + if (existing) { + await existing.committedPromise; + return { rows: [] }; + } + + let resolveCommitted; + const committedPromise = new Promise((resolve) => { + resolveCommitted = resolve; + }); + requests.set(key, { + request_hash: requestHash, + alert_id: null, + response_body: null, + committed: false, + committedPromise, + resolveCommitted + }); + return { rows: [{ idempotency_key: key }] }; + } + + if (normalizedSql.startsWith('WITH inserted AS')) { + alertInserts += 1; + return { rows: [createStoredAlert({ id: 91 })] }; + } + + if (normalizedSql.startsWith('UPDATE alert_creation_requests')) { + const [key, alertId, responseBody] = params; + const request = requests.get(key); + request.alert_id = alertId; + request.response_body = JSON.parse(responseBody); + return { rows: [] }; + } + + if (normalizedSql.startsWith('SELECT request_hash, alert_id')) { + return { rows: [requests.get(params[0])] }; + } + + throw new Error(`Unexpected query in concurrent fake pool: ${normalizedSql}`); + }, + release() {} + }; + } + + return { + get alertInserts() { + return alertInserts; + }, + async connect() { + return createClient(); + } + }; +} + +test('concurrent requests with one Idempotency-Key create and notify only once', async () => { + const pool = createConcurrentIdempotentPool(); + let notificationCalls = 0; + const service = new AlertCreationService({ + pool, + notificationService: { + async sendAlertCreated() { + notificationCalls += 1; + } + }, + logger: createLogger() + }); + + const [first, second] = await Promise.all([ + service.createAlert(ALERT_INPUT, { idempotencyKey: 'concurrent-alert-request' }), + service.createAlert(ALERT_INPUT, { idempotencyKey: 'concurrent-alert-request' }) + ]); + + assert.equal(first.alert.id, second.alert.id); + assert.equal([first.created, second.created].filter(Boolean).length, 1); + assert.equal(pool.alertInserts, 1); + assert.equal(notificationCalls, 1); +}); + +test('reusing Idempotency-Key with a different payload is rejected', async () => { + const service = new AlertCreationService({ + pool: createIdempotentPool(), + notificationService: { async sendAlertCreated() {} }, + logger: createLogger() + }); + + await service.createAlert(ALERT_INPUT, { idempotencyKey: 'alert-request-2' }); + + await assert.rejects( + () => service.createAlert( + { ...ALERT_INPUT, message: 'Другое сообщение' }, + { idempotencyKey: 'alert-request-2' } + ), + (error) => error.status === 409 + ); +}); + +test('alert request hash is stable for the normalized creation payload', () => { + const normalized = normalizeAlertInput(ALERT_INPUT); + + assert.equal(createAlertRequestHash(normalized), createAlertRequestHash({ ...normalized })); + assert.notEqual( + createAlertRequestHash(normalized), + createAlertRequestHash({ ...normalized, is_active: false }) + ); +}); diff --git a/backend/test/alertNotificationService.test.js b/backend/test/alertNotificationService.test.js new file mode 100644 index 0000000..ed263b5 --- /dev/null +++ b/backend/test/alertNotificationService.test.js @@ -0,0 +1,243 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + ALERT_TITLE_FALLBACKS, + AlertNotificationService, + buildAlertCreatedMessage +} = require('../src/services/alertNotificationService'); +const { createFirebaseMessaging, getFirebaseConfig } = require('../src/config/firebase'); + +function createLogger() { + const errors = []; + return { + errors, + error(message, metadata) { + errors.push({ message, metadata }); + } + }; +} + +test('sends an exact alert-created notification and string-only data payload', async () => { + const messages = []; + const service = new AlertNotificationService({ + enabled: true, + topic: 'route_alerts', + androidChannelId: 'route_alerts', + messaging: { + async send(message) { + messages.push(message); + return 'projects/test/messages/42'; + } + }, + logger: createLogger() + }); + + const result = await service.sendAlertCreated({ + id: 42, + route_id: 7, + route_number: 12, + alert_type: 'delay', + title: 'Автобус задерживается', + message: 'Задержка около 15 минут', + start_time: new Date('2026-07-11T08:30:00.000Z'), + end_time: '2026-07-11T09:30:00Z', + is_active: true + }); + + assert.deepEqual(result, { + sent: true, + messageId: 'projects/test/messages/42' + }); + assert.equal(messages.length, 1); + assert.deepEqual(messages[0], { + topic: 'route_alerts', + notification: { + title: 'Автобус задерживается', + body: 'Задержка около 15 минут' + }, + data: { + event: 'alert_created', + alertId: '42', + type: 'delay', + routeId: '7', + routeNumber: '12', + startTime: '2026-07-11T08:30:00.000Z', + endTime: '2026-07-11T09:30:00.000Z' + }, + android: { + priority: 'high', + notification: { + channelId: 'route_alerts', + tag: 'alert_42' + } + } + }); + assert.ok(Object.values(messages[0].data).every((value) => typeof value === 'string')); +}); + +test('serializes a null route id and missing optional values as empty strings', () => { + const message = buildAlertCreatedMessage({ + id: 9, + route_id: null, + route_number: null, + alert_type: 'info', + title: 'Новость', + message: 'Важная информация', + start_time: null, + end_time: null + }); + + assert.equal(message.data.routeId, ''); + assert.equal(message.data.routeNumber, ''); + assert.equal(message.data.startTime, ''); + assert.equal(message.data.endTime, ''); +}); + +test('does not initialize messaging or send for an inactive alert', async () => { + let messagingInitializations = 0; + const service = new AlertNotificationService({ + enabled: true, + getMessaging() { + messagingInitializations += 1; + return { send: async () => 'unexpected' }; + }, + logger: createLogger() + }); + + const result = await service.sendAlertCreated({ id: 10, is_active: false }); + + assert.deepEqual(result, { sent: false, skipped: true, reason: 'inactive' }); + assert.equal(messagingInitializations, 0); +}); + +test('disabled mode works without initializing Firebase or credentials', async () => { + let messagingInitializations = 0; + const service = new AlertNotificationService({ + enabled: false, + getMessaging() { + messagingInitializations += 1; + throw new Error('credentials should not be read'); + }, + logger: createLogger() + }); + + const result = await service.sendAlertCreated({ id: 11, is_active: true }); + + assert.deepEqual(result, { sent: false, skipped: true, reason: 'disabled' }); + assert.equal(messagingInitializations, 0); +}); + +test('logs Firebase failures with safe structured metadata and does not throw', async () => { + const testLogger = createLogger(); + const firebaseError = Object.assign(new Error('Permission denied'), { + code: 'messaging/authentication-error' + }); + const service = new AlertNotificationService({ + enabled: true, + messaging: { + async send() { + throw firebaseError; + } + }, + logger: testLogger + }); + + const result = await service.sendAlertCreated({ + id: 57, + alert_type: 'detour', + message: 'Объезд', + is_active: true + }); + + assert.deepEqual(result, { + sent: false, + reason: 'fcm_error', + firebaseCode: 'messaging/authentication-error' + }); + assert.deepEqual(testLogger.errors, [{ + message: 'Failed to send alert-created FCM notification', + metadata: { + alertId: '57', + reason: 'Permission denied', + firebaseCode: 'messaging/authentication-error' + } + }]); +}); + +test('logs lazy Firebase initialization failures and does not throw', async () => { + const testLogger = createLogger(); + const initializationError = Object.assign(new Error('Application default credentials unavailable'), { + code: 'app/invalid-credential' + }); + const service = new AlertNotificationService({ + enabled: true, + getMessaging() { + throw initializationError; + }, + logger: testLogger + }); + + const result = await service.sendAlertCreated({ + id: 58, + alert_type: 'info', + message: 'Новость', + is_active: true + }); + + assert.deepEqual(result, { + sent: false, + reason: 'fcm_error', + firebaseCode: 'app/invalid-credential' + }); + assert.deepEqual(testLogger.errors[0].metadata, { + alertId: '58', + reason: 'Application default credentials unavailable', + firebaseCode: 'app/invalid-credential' + }); +}); + +test('uses the requested Russian title fallbacks and a route-aware body fallback', () => { + for (const [type, expectedTitle] of Object.entries(ALERT_TITLE_FALLBACKS)) { + const message = buildAlertCreatedMessage({ + id: 71, + alert_type: type, + title: ' ', + message: '', + route_number: '5' + }); + + assert.equal(message.notification.title, expectedTitle); + assert.equal(message.notification.body, 'Актуальная информация по маршруту №5'); + } + + const withoutRoute = buildAlertCreatedMessage({ + id: 72, + alert_type: 'info', + title: '', + message: '' + }); + assert.equal(withoutRoute.notification.body, 'Актуальная информация о движении транспорта'); +}); + +test('reads disabled-by-default Firebase configuration and environment overrides', () => { + assert.deepEqual(getFirebaseConfig({}), { + enabled: false, + topic: 'route_alerts', + androidChannelId: 'route_alerts', + projectId: undefined + }); + assert.equal(createFirebaseMessaging(getFirebaseConfig({})), null); + + assert.deepEqual(getFirebaseConfig({ + FCM_ENABLED: 'true', + FCM_TOPIC: 'custom_alerts', + FCM_ANDROID_CHANNEL_ID: 'custom_channel', + FIREBASE_PROJECT_ID: 'ridebus-test' + }), { + enabled: true, + topic: 'custom_alerts', + androidChannelId: 'custom_channel', + projectId: 'ridebus-test' + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 94a83b6..e52ba3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,11 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-Main administrator} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@polotsk-transit.local} + FCM_ENABLED: "${FCM_ENABLED:-false}" + FCM_TOPIC: "${FCM_TOPIC:-route_alerts}" + FCM_ANDROID_CHANNEL_ID: "${FCM_ANDROID_CHANNEL_ID:-route_alerts}" + FIREBASE_PROJECT_ID: "${FIREBASE_PROJECT_ID:-}" + GOOGLE_APPLICATION_CREDENTIALS: "${GOOGLE_APPLICATION_CREDENTIALS:-}" ports: - "3000:3000" depends_on: diff --git a/docs/api-documentation.md b/docs/api-documentation.md index e2e08e0..9099d57 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -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 diff --git a/docs/fcm-alert-notifications.md b/docs/fcm-alert-notifications.md new file mode 100644 index 0000000..22b7536 --- /dev/null +++ b/docs/fcm-alert-notifications.md @@ -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. diff --git a/docs/openapi-draft.yaml b/docs/openapi-draft.yaml index cb861f5..ccec855 100644 --- a/docs/openapi-draft.yaml +++ b/docs/openapi-draft.yaml @@ -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': diff --git a/frontend/src/pages/AlertsPage.jsx b/frontend/src/pages/AlertsPage.jsx index aa9c47b..362de47 100644 --- a/frontend/src/pages/AlertsPage.jsx +++ b/frontend/src/pages/AlertsPage.jsx @@ -66,12 +66,12 @@ export default function AlertsPage() { } }; - const handleFormSubmit = async (data) => { + const handleFormSubmit = async (data, idempotencyKey) => { try { if (editingAlert) { await alertsApi.update(editingAlert.id, data); } else { - await alertsApi.create(data); + await alertsApi.create(data, idempotencyKey); } setShowForm(false); loadData(); @@ -390,7 +390,17 @@ function AlertCard({ alert, index, onEdit, onDelete }) { ); } +function createAlertIdempotencyKey() { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + + return `alert-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + function AlertForm({ alert, routes, onSubmit, onCancel }) { + const [idempotencyKey] = useState(createAlertIdempotencyKey); + const [submitting, setSubmitting] = useState(false); const [formData, setFormData] = useState({ route_id: alert?.route_id || '', alert_type: alert?.alert_type || 'info', @@ -401,7 +411,7 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) { is_active: alert?.is_active !== false }); - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); const data = { @@ -410,7 +420,12 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) { end_time: formData.end_time || null }; - onSubmit(data); + setSubmitting(true); + try { + await onSubmit(data, alert ? null : idempotencyKey); + } finally { + setSubmitting(false); + } }; const handleChange = (e) => { @@ -546,15 +561,24 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) {
- -
); -} \ No newline at end of file +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index b8cfa9f..2ea2fec 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -155,7 +155,9 @@ export const alertsApi = { getAll: (params) => api.get('/alerts', { params }), getActive: (routeId) => api.get('/alerts/active', { params: { route_id: routeId } }), getById: (id) => api.get(`/alerts/${id}`), - create: (data) => api.post('/alerts', data), + create: (data, idempotencyKey) => api.post('/alerts', data, { + headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined + }), update: (id, data) => api.put(`/alerts/${id}`, data), delete: (id) => api.delete(`/alerts/${id}`), cleanup: () => api.post('/alerts/cleanup')