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:
16
.env.example
16
.env.example
@@ -5,6 +5,22 @@ DB_PASSWORD=your_secure_password_here
|
|||||||
API_KEY_SALT=generate_random_salt_with_openssl
|
API_KEY_SALT=generate_random_salt_with_openssl
|
||||||
CORS_ORIGIN=http://localhost:3001
|
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
|
||||||
BOOTSTRAP_ADMIN=true
|
BOOTSTRAP_ADMIN=true
|
||||||
BOOTSTRAP_ADMIN_RESET_PASSWORD=false
|
BOOTSTRAP_ADMIN_RESET_PASSWORD=false
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ sync_log -- Лог изменений для offline синхрониза
|
|||||||
**Версия 3.0 (Продвинутые функции):**
|
**Версия 3.0 (Продвинутые функции):**
|
||||||
- [ ] IoT GPS-трекеры
|
- [ ] IoT GPS-трекеры
|
||||||
- [ ] ML для предсказания задержек
|
- [ ] ML для предсказания задержек
|
||||||
- [ ] Push уведомления
|
- [x] Push уведомления о новых активных алертах через FCM
|
||||||
- [ ] Детальная аналитика
|
- [ ] Детальная аналитика
|
||||||
|
|
||||||
## 💡 Советы по развертыванию
|
## 💡 Советы по развертыванию
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -13,6 +13,7 @@
|
|||||||
- ✅ Кеширование через Redis
|
- ✅ Кеширование через Redis
|
||||||
- ✅ Аутентификация по API ключам
|
- ✅ Аутентификация по API ключам
|
||||||
- ✅ Rate limiting
|
- ✅ Rate limiting
|
||||||
|
- ✅ Серверные push-уведомления об алертах через Firebase Cloud Messaging
|
||||||
|
|
||||||
### Технологический стек
|
### Технологический стек
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
## 📋 Требования
|
## 📋 Требования
|
||||||
|
|
||||||
- Docker и Docker Compose
|
- Docker и Docker Compose
|
||||||
- Node.js 18+ (для разработки без Docker)
|
- Node.js 22+ (для разработки без Docker и Firebase Admin SDK)
|
||||||
- Статический IP или динамический DNS
|
- Статический IP или динамический DNS
|
||||||
- ~2GB свободного места на диске
|
- ~2GB свободного места на диске
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@ ADMIN_PASSWORD=your_secure_admin_password_here
|
|||||||
# Для разработки/деплоя через встроенный proxy оставьте как есть
|
# Для разработки/деплоя через встроенный proxy оставьте как есть
|
||||||
CORS_ORIGIN=http://localhost:3001
|
CORS_ORIGIN=http://localhost:3001
|
||||||
API_URL=/api/v1
|
API_URL=/api/v1
|
||||||
|
|
||||||
|
# Push отключён по умолчанию и не требует Firebase credentials
|
||||||
|
FCM_ENABLED=false
|
||||||
```
|
```
|
||||||
|
|
||||||
Генерация безопасного salt:
|
Генерация безопасного salt:
|
||||||
@@ -103,7 +107,7 @@ docker exec -it polotsk-transit-db psql -U transit_admin -d polotsk_transit -c "
|
|||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env
|
cp ../.env.example .env
|
||||||
|
|
||||||
# Убедитесь, что PostgreSQL и Redis запущены локально
|
# Убедитесь, что PostgreSQL и Redis запущены локально
|
||||||
# Настройте DATABASE_URL и REDIS_URL в .env
|
# Настройте DATABASE_URL и REDIS_URL в .env
|
||||||
@@ -168,6 +172,15 @@ GET /api/v1/sync?lastSync=2025-11-19T10:00:00Z # Инкрементальная
|
|||||||
GET /api/v1/sync/status # Статус системы
|
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 - Продвинутые функции
|
### Версия 3.0 - Продвинутые функции
|
||||||
- [ ] IoT GPS-трекеры интеграция
|
- [ ] IoT GPS-трекеры интеграция
|
||||||
- [ ] Машинное обучение для предсказания задержек
|
- [ ] Машинное обучение для предсказания задержек
|
||||||
- [ ] Push-уведомления
|
- [x] Push-уведомления о новых активных алертах
|
||||||
- [ ] Аналитика и отчеты
|
- [ ] Аналитика и отчеты
|
||||||
|
|
||||||
## 🤝 Контакты и поддержка
|
## 🤝 Контакты и поддержка
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
10
backend/migrations/007_alert_creation_idempotency.sql
Normal file
10
backend/migrations/007_alert_creation_idempotency.sql
Normal file
@@ -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);
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"firebase-admin": "^14.1.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
|||||||
65
backend/src/config/firebase.js
Normal file
65
backend/src/config/firebase.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const pool = require('../config/database');
|
const pool = require('../config/database');
|
||||||
|
const logger = require('../config/logger');
|
||||||
|
const { getAlertCreationService } = require('../services/alertCreationService');
|
||||||
|
|
||||||
// Get all alerts (with optional filters)
|
// Get all alerts (with optional filters)
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
@@ -97,8 +99,8 @@ router.get('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create new alert
|
function createAlertHandler({ alertCreationService = null } = {}) {
|
||||||
router.post('/', async (req, res) => {
|
return async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
|
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
|
||||||
|
|
||||||
@@ -112,19 +114,29 @@ router.post('/', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid alert_type' });
|
return res.status(400).json({ error: 'Invalid alert_type' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const service = alertCreationService || getAlertCreationService();
|
||||||
`INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active)
|
const result = await service.createAlert(
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
{ route_id, alert_type, title, message, start_time, end_time, is_active },
|
||||||
RETURNING *`,
|
{ idempotencyKey: req.get('Idempotency-Key') }
|
||||||
[route_id || null, alert_type, title, message, start_time, end_time || null, is_active !== false]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json(result.rows[0]);
|
return res.status(201).json(result.alert);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating alert:', error);
|
if (error.status) {
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
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('/', createAlertHandler());
|
||||||
|
|
||||||
// Update alert
|
// Update alert
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
@@ -207,3 +219,4 @@ router.post('/cleanup', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
module.exports.createAlertHandler = createAlertHandler;
|
||||||
|
|||||||
267
backend/src/services/alertCreationService.js
Normal file
267
backend/src/services/alertCreationService.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
238
backend/src/services/alertNotificationService.js
Normal file
238
backend/src/services/alertNotificationService.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
403
backend/test/alertCreationService.test.js
Normal file
403
backend/test/alertCreationService.test.js
Normal file
@@ -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 })
|
||||||
|
);
|
||||||
|
});
|
||||||
243
backend/test/alertNotificationService.test.js
Normal file
243
backend/test/alertNotificationService.test.js
Normal file
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,11 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||||
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-Main administrator}
|
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-Main administrator}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@polotsk-transit.local}
|
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:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1153,20 +1153,22 @@
|
|||||||
|
|
||||||
### [POST] /api/v1/alerts
|
### [POST] /api/v1/alerts
|
||||||
|
|
||||||
- Описание: создает уведомление
|
- Описание: создает уведомление и после успешного сохранения отправляет FCM для активного алерта
|
||||||
- Авторизация: Bearer JWT или `X-API-Key`
|
- Авторизация: Bearer JWT или `X-API-Key`
|
||||||
- Параметры path: нет
|
- Параметры path: нет
|
||||||
- Query-параметры: нет
|
- 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`
|
- Request Body: `CreateAlertRequest`
|
||||||
- Response 200/201: raw `Alert` без join-полей
|
- Response 201: raw `Alert` без join-полей; повтор того же payload с тем же `Idempotency-Key` возвращает тот же алерт без нового push
|
||||||
- Ошибки:
|
- Ошибки:
|
||||||
- `400` — отсутствуют обязательные поля
|
- `400` — отсутствуют обязательные поля
|
||||||
- `400` — invalid `alert_type`
|
- `400` — invalid `alert_type`
|
||||||
|
- `409` — `Idempotency-Key` уже использован с другим payload
|
||||||
- `401`/`403`/`429`
|
- `401`/`403`/`429`
|
||||||
- `500` — `Internal server error`
|
- `500` — `Internal server error`
|
||||||
|
- Ошибка FCM логируется и не меняет успешный `201`, если алерт уже сохранён
|
||||||
- Связанные сущности: `Alert`, `Route`
|
- Связанные сущности: `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
|
### [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:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
- ApiKeyAuth: []
|
- 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:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -1379,6 +1388,12 @@ paths:
|
|||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/Forbidden'
|
$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':
|
'429':
|
||||||
$ref: '#/components/responses/TooManyRequests'
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
'500':
|
'500':
|
||||||
|
|||||||
@@ -66,12 +66,12 @@ export default function AlertsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = async (data) => {
|
const handleFormSubmit = async (data, idempotencyKey) => {
|
||||||
try {
|
try {
|
||||||
if (editingAlert) {
|
if (editingAlert) {
|
||||||
await alertsApi.update(editingAlert.id, data);
|
await alertsApi.update(editingAlert.id, data);
|
||||||
} else {
|
} else {
|
||||||
await alertsApi.create(data);
|
await alertsApi.create(data, idempotencyKey);
|
||||||
}
|
}
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
loadData();
|
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 }) {
|
function AlertForm({ alert, routes, onSubmit, onCancel }) {
|
||||||
|
const [idempotencyKey] = useState(createAlertIdempotencyKey);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
route_id: alert?.route_id || '',
|
route_id: alert?.route_id || '',
|
||||||
alert_type: alert?.alert_type || 'info',
|
alert_type: alert?.alert_type || 'info',
|
||||||
@@ -401,7 +411,7 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) {
|
|||||||
is_active: alert?.is_active !== false
|
is_active: alert?.is_active !== false
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
@@ -410,7 +420,12 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) {
|
|||||||
end_time: formData.end_time || null
|
end_time: formData.end_time || null
|
||||||
};
|
};
|
||||||
|
|
||||||
onSubmit(data);
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(data, alert ? null : idempotencyKey);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
@@ -546,11 +561,20 @@ function AlertForm({ alert, routes, onSubmit, onCancel }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 safe-bottom">
|
||||||
<button type="button" onClick={onCancel} className="btn-secondary w-full sm:w-auto">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="btn-secondary w-full sm:w-auto"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary w-full sm:w-auto">
|
<button
|
||||||
Сохранить
|
type="submit"
|
||||||
|
className="btn-primary w-full sm:w-auto"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Сохранение…' : 'Сохранить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ export const alertsApi = {
|
|||||||
getAll: (params) => api.get('/alerts', { params }),
|
getAll: (params) => api.get('/alerts', { params }),
|
||||||
getActive: (routeId) => api.get('/alerts/active', { params: { route_id: routeId } }),
|
getActive: (routeId) => api.get('/alerts/active', { params: { route_id: routeId } }),
|
||||||
getById: (id) => api.get(`/alerts/${id}`),
|
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),
|
update: (id, data) => api.put(`/alerts/${id}`, data),
|
||||||
delete: (id) => api.delete(`/alerts/${id}`),
|
delete: (id) => api.delete(`/alerts/${id}`),
|
||||||
cleanup: () => api.post('/alerts/cleanup')
|
cleanup: () => api.post('/alerts/cleanup')
|
||||||
|
|||||||
Reference in New Issue
Block a user