feat: Implement alert creation service with FCM notifications

- Add AlertCreationService to handle alert creation and notifications.
- Implement idempotency key support to prevent duplicate alerts.
- Create alert notification service for sending FCM messages.
- Add tests for alert creation and notification services.
- Document FCM notification flow and alert creation process.
This commit is contained in:
2026-07-11 01:18:18 +03:00
parent 93b266f26d
commit ddd67f438b
18 changed files with 1662 additions and 46 deletions

View File

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

View File

@@ -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;
module.exports = router;
module.exports.createAlertHandler = createAlertHandler;

View 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
};

View 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
};