feat: add support for multiple route numbers in alert notifications
- Updated FCM alert notifications documentation to include handling of multiple route numbers. - Enhanced Telegram cancellation import logic to support multiple route numbers, ensuring correct notifications are sent for all directions. - Modified frontend components to allow selection of multiple route numbers for alerts, improving user experience. - Implemented backend migration to add `route_numbers` column to alerts and adjusted related database constraints. - Introduced new utility functions for normalizing and validating route numbers in alert processing.
This commit is contained in:
20
backend/migrations/015_alert_route_numbers.sql
Normal file
20
backend/migrations/015_alert_route_numbers.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Numbers target every direction in the city. Keep route_id for older clients.
|
||||||
|
ALTER TABLE alerts ADD COLUMN IF NOT EXISTS route_numbers TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
UPDATE alerts a SET route_numbers = ARRAY[upper(regexp_replace(r.route_number, '[[:space:]№]', '', 'g'))]
|
||||||
|
FROM routes r WHERE r.id=a.route_id AND cardinality(a.route_numbers)=0;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_route_numbers ON alerts USING gin(route_numbers);
|
||||||
|
|
||||||
|
-- Removing one direction must not remove a notification for the whole number.
|
||||||
|
ALTER TABLE alerts DROP CONSTRAINT IF EXISTS alerts_route_id_fkey;
|
||||||
|
ALTER TABLE alerts ADD CONSTRAINT alerts_route_id_fkey FOREIGN KEY(route_id) REFERENCES routes(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- One source publication creates one alert. A NULL alert_id preserves deletion.
|
||||||
|
-- Legacy per-direction links stay intact until that publication is edited/reviewed.
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_message_alerts (
|
||||||
|
message_id INTEGER PRIMARY KEY REFERENCES telegram_import_messages(id) ON DELETE CASCADE,
|
||||||
|
alert_id INTEGER UNIQUE REFERENCES alerts(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { alertSelectSql } = require('../services/alertTargets');
|
||||||
const pool = require('../config/database');
|
const pool = require('../config/database');
|
||||||
const logger = require('../config/logger');
|
const logger = require('../config/logger');
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ class SyncController {
|
|||||||
|
|
||||||
// Get active alerts
|
// Get active alerts
|
||||||
const alertsResult = await pool.query(`
|
const alertsResult = await pool.query(`
|
||||||
SELECT * FROM alerts
|
SELECT ${alertSelectSql()} FROM alerts a
|
||||||
WHERE city_id = $1 AND is_active = true
|
WHERE city_id = $1 AND is_active = true
|
||||||
AND start_time <= CURRENT_TIMESTAMP
|
AND start_time <= CURRENT_TIMESTAMP
|
||||||
AND (end_time IS NULL OR end_time >= CURRENT_TIMESTAMP)
|
AND (end_time IS NULL OR end_time >= CURRENT_TIMESTAMP)
|
||||||
@@ -157,7 +158,7 @@ class SyncController {
|
|||||||
|
|
||||||
// Get new alerts
|
// Get new alerts
|
||||||
const alertsResult = await pool.query(`
|
const alertsResult = await pool.query(`
|
||||||
SELECT * FROM alerts
|
SELECT ${alertSelectSql()} FROM alerts a
|
||||||
WHERE city_id = $2 AND is_active = true
|
WHERE city_id = $2 AND is_active = true
|
||||||
AND created_at > $1
|
AND created_at > $1
|
||||||
`, [lastSyncDate, req.city.id]);
|
`, [lastSyncDate, req.city.id]);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const pool = require('../config/database');
|
|||||||
const logger = require('../config/logger');
|
const logger = require('../config/logger');
|
||||||
const { getAlertCreationService } = require('../services/alertCreationService');
|
const { getAlertCreationService } = require('../services/alertCreationService');
|
||||||
const { listAlertTemplates } = require('../services/alertTemplates');
|
const { listAlertTemplates } = require('../services/alertTemplates');
|
||||||
const { getRouteInCity } = require('../middleware/city');
|
const { resolveAlertTargets, alertSelectSql, alertRouteFilterSql } = require('../services/alertTargets');
|
||||||
|
|
||||||
function validateAlertTimes({ start_time, end_time }) {
|
function validateAlertTimes({ start_time, end_time }) {
|
||||||
const start = new Date(start_time);
|
const start = new Date(start_time);
|
||||||
@@ -22,7 +22,7 @@ router.get('/', async (req, res) => {
|
|||||||
const { route_id, is_active, alert_type } = req.query;
|
const { route_id, is_active, alert_type } = req.query;
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT a.*, r.name as route_name, r.route_number, r.color
|
SELECT ${alertSelectSql()}, r.name as route_name, r.color
|
||||||
FROM alerts a
|
FROM alerts a
|
||||||
LEFT JOIN routes r ON a.route_id = r.id
|
LEFT JOIN routes r ON a.route_id = r.id
|
||||||
WHERE a.city_id = $1
|
WHERE a.city_id = $1
|
||||||
@@ -31,7 +31,7 @@ router.get('/', async (req, res) => {
|
|||||||
let paramCount = 2;
|
let paramCount = 2;
|
||||||
|
|
||||||
if (route_id) {
|
if (route_id) {
|
||||||
query += ` AND a.route_id = $${paramCount}`;
|
query += ` AND ${alertRouteFilterSql(`$${paramCount}`)}`;
|
||||||
params.push(route_id);
|
params.push(route_id);
|
||||||
paramCount++;
|
paramCount++;
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ router.get('/active', async (req, res) => {
|
|||||||
const { route_id } = req.query;
|
const { route_id } = req.query;
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT a.*, r.name as route_name, r.route_number, r.color
|
SELECT ${alertSelectSql()}, r.name as route_name, r.color
|
||||||
FROM alerts a
|
FROM alerts a
|
||||||
LEFT JOIN routes r ON a.route_id = r.id
|
LEFT JOIN routes r ON a.route_id = r.id
|
||||||
WHERE a.city_id = $1
|
WHERE a.city_id = $1
|
||||||
@@ -75,7 +75,7 @@ router.get('/active', async (req, res) => {
|
|||||||
const params = [req.city.id];
|
const params = [req.city.id];
|
||||||
|
|
||||||
if (route_id) {
|
if (route_id) {
|
||||||
query += ' AND a.route_id = $2';
|
query += ` AND ${alertRouteFilterSql('$2')}`;
|
||||||
params.push(route_id);
|
params.push(route_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT a.*, r.name as route_name, r.route_number, r.color
|
`SELECT ${alertSelectSql()}, r.name as route_name, r.color
|
||||||
FROM alerts a
|
FROM alerts a
|
||||||
LEFT JOIN routes r ON a.route_id = r.id
|
LEFT JOIN routes r ON a.route_id = r.id
|
||||||
WHERE a.id = $1 AND a.city_id = $2`,
|
WHERE a.id = $1 AND a.city_id = $2`,
|
||||||
@@ -119,7 +119,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
function createAlertHandler({ alertCreationService = null } = {}) {
|
function createAlertHandler({ alertCreationService = null } = {}) {
|
||||||
return 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 { alert_type, title, message, start_time, end_time, is_active } = req.body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!alert_type || !title || !message || !start_time) {
|
if (!alert_type || !title || !message || !start_time) {
|
||||||
@@ -134,13 +134,11 @@ function createAlertHandler({ alertCreationService = null } = {}) {
|
|||||||
return res.status(400).json({ error: 'Invalid alert_type' });
|
return res.status(400).json({ error: 'Invalid alert_type' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route_id && !await getRouteInCity(route_id, req.city.id)) {
|
const targets = await resolveAlertTargets(pool, req.city.id, req.body);
|
||||||
return res.status(400).json({ error: 'Маршрут не принадлежит выбранному городу.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = alertCreationService || getAlertCreationService();
|
const service = alertCreationService || getAlertCreationService();
|
||||||
const result = await service.createAlert(
|
const result = await service.createAlert(
|
||||||
{ route_id, alert_type, title, message, start_time, end_time, is_active, city_id: req.city.id },
|
{ ...targets, ...(req.body.route_numbers === undefined ? { route_id: req.body.route_id || null, route_numbers: undefined } : {}), alert_type, title, message, start_time, end_time, is_active, city_id: req.city.id },
|
||||||
{ idempotencyKey: req.get('Idempotency-Key') }
|
{ idempotencyKey: req.get('Idempotency-Key') }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -166,7 +164,7 @@ router.post('/', createAlertHandler());
|
|||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
|
const { alert_type, title, message, start_time, end_time, is_active } = req.body;
|
||||||
|
|
||||||
// Check if alert exists
|
// Check if alert exists
|
||||||
const checkResult = await pool.query(
|
const checkResult = await pool.query(
|
||||||
@@ -191,13 +189,13 @@ router.put('/:id', async (req, res) => {
|
|||||||
});
|
});
|
||||||
if (timeError) return res.status(400).json({ error: timeError });
|
if (timeError) return res.status(400).json({ error: timeError });
|
||||||
|
|
||||||
if (route_id && !await getRouteInCity(route_id, req.city.id)) {
|
const targets = Object.hasOwn(req.body, 'route_numbers') || Object.hasOwn(req.body, 'route_id')
|
||||||
return res.status(400).json({ error: 'Маршрут не принадлежит выбранному городу.' });
|
? await resolveAlertTargets(pool, req.city.id, req.body) : null;
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
await pool.query(
|
||||||
`UPDATE alerts
|
`UPDATE alerts
|
||||||
SET route_id = COALESCE($1, route_id),
|
SET route_id = CASE WHEN $10::boolean THEN $1 ELSE route_id END,
|
||||||
|
route_numbers = CASE WHEN $10::boolean THEN $11::text[] ELSE route_numbers END,
|
||||||
alert_type = COALESCE($2, alert_type),
|
alert_type = COALESCE($2, alert_type),
|
||||||
title = COALESCE($3, title),
|
title = COALESCE($3, title),
|
||||||
message = COALESCE($4, message),
|
message = COALESCE($4, message),
|
||||||
@@ -206,11 +204,14 @@ router.put('/:id', async (req, res) => {
|
|||||||
is_active = COALESCE($7, is_active)
|
is_active = COALESCE($7, is_active)
|
||||||
WHERE id = $8 AND city_id = $9
|
WHERE id = $8 AND city_id = $9
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[route_id, alert_type, title, message, start_time, end_time, is_active, id, req.city.id]
|
[targets?.route_id, alert_type, title, message, start_time,
|
||||||
|
end_time === undefined ? checkResult.rows[0].end_time : end_time, is_active, id, req.city.id, Boolean(targets), targets?.route_numbers]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
const enriched = await pool.query(`SELECT ${alertSelectSql()} FROM alerts a WHERE a.id=$1 AND a.city_id=$2`, [id, req.city.id]);
|
||||||
|
res.json(enriched.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.status) return res.status(error.status).json({ error: error.message });
|
||||||
console.error('Error updating alert:', error);
|
console.error('Error updating alert:', error);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
|
const { normalizeRouteNumbers, routeIdsSql } = require('./alertTargets');
|
||||||
|
|
||||||
const INSERT_ALERT_SQL = `
|
const INSERT_ALERT_SQL = `
|
||||||
WITH inserted AS (
|
WITH inserted AS (
|
||||||
INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active, city_id)
|
INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active, city_id, route_numbers)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9::text[], ARRAY(SELECT upper(regexp_replace(route_number, '[[:space:]№]', '', 'g')) FROM routes WHERE id=$1)))
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT inserted.*, routes.route_number AS notification_route_number
|
SELECT inserted.*, ${routeIdsSql('inserted')} AS route_ids,
|
||||||
|
CASE WHEN cardinality(inserted.route_numbers)=1 THEN inserted.route_numbers[1] END AS notification_route_number
|
||||||
FROM inserted
|
FROM inserted
|
||||||
LEFT JOIN routes ON routes.id = inserted.route_id
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function createHttpError(status, message) {
|
function createHttpError(status, message) {
|
||||||
@@ -38,6 +39,7 @@ function normalizeIdempotencyKey(value) {
|
|||||||
function normalizeAlertInput(payload) {
|
function normalizeAlertInput(payload) {
|
||||||
return {
|
return {
|
||||||
route_id: payload.route_id || null,
|
route_id: payload.route_id || null,
|
||||||
|
...(payload.route_numbers ? { route_numbers: normalizeRouteNumbers(payload.route_numbers) } : {}),
|
||||||
alert_type: payload.alert_type,
|
alert_type: payload.alert_type,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
@@ -58,14 +60,15 @@ function normalizeHashValue(value) {
|
|||||||
|
|
||||||
function createAlertRequestHash(alert) {
|
function createAlertRequestHash(alert) {
|
||||||
const canonicalPayload = {
|
const canonicalPayload = {
|
||||||
route_id: normalizeHashValue(alert.route_id),
|
route_id: alert.route_numbers ? null : normalizeHashValue(alert.route_id),
|
||||||
alert_type: normalizeHashValue(alert.alert_type),
|
alert_type: normalizeHashValue(alert.alert_type),
|
||||||
title: normalizeHashValue(alert.title),
|
title: normalizeHashValue(alert.title),
|
||||||
message: normalizeHashValue(alert.message),
|
message: normalizeHashValue(alert.message),
|
||||||
start_time: normalizeHashValue(alert.start_time),
|
start_time: normalizeHashValue(alert.start_time),
|
||||||
end_time: normalizeHashValue(alert.end_time),
|
end_time: normalizeHashValue(alert.end_time),
|
||||||
is_active: normalizeHashValue(alert.is_active),
|
is_active: normalizeHashValue(alert.is_active),
|
||||||
city_id: normalizeHashValue(alert.city_id)
|
city_id: normalizeHashValue(alert.city_id),
|
||||||
|
...(alert.route_numbers ? { route_numbers: normalizeRouteNumbers(alert.route_numbers) } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
return crypto
|
return crypto
|
||||||
@@ -149,7 +152,8 @@ class AlertCreationService {
|
|||||||
alertInput.start_time,
|
alertInput.start_time,
|
||||||
alertInput.end_time,
|
alertInput.end_time,
|
||||||
alertInput.is_active,
|
alertInput.is_active,
|
||||||
alertInput.city_id
|
alertInput.city_id,
|
||||||
|
alertInput.route_numbers ?? null
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ function buildAlertCreatedMessage(alert, options = {}) {
|
|||||||
type: getAlertType(alert),
|
type: getAlertType(alert),
|
||||||
routeId: getRouteId(alert),
|
routeId: getRouteId(alert),
|
||||||
routeNumber: getRouteNumber(alert),
|
routeNumber: getRouteNumber(alert),
|
||||||
|
...(Array.isArray(alert.route_numbers) ? {
|
||||||
|
routeNumbers: JSON.stringify(alert.route_numbers),
|
||||||
|
routeIds: JSON.stringify(alert.route_ids || [])
|
||||||
|
} : {}),
|
||||||
startTime: toIsoString(firstDefined(alert.start_time, alert.startTime)),
|
startTime: toIsoString(firstDefined(alert.start_time, alert.startTime)),
|
||||||
endTime,
|
endTime,
|
||||||
title: getNotificationTitle(alert),
|
title: getNotificationTitle(alert),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { routeIdsSql } = require('./alertTargets');
|
||||||
const CLAIM_DUE_ALERTS_SQL = `
|
const CLAIM_DUE_ALERTS_SQL = `
|
||||||
WITH due AS (
|
WITH due AS (
|
||||||
SELECT a.id
|
SELECT a.id
|
||||||
@@ -21,11 +22,8 @@ const CLAIM_DUE_ALERTS_SQL = `
|
|||||||
notification_attempts = a.notification_attempts + 1
|
notification_attempts = a.notification_attempts + 1
|
||||||
FROM due
|
FROM due
|
||||||
WHERE a.id = due.id
|
WHERE a.id = due.id
|
||||||
RETURNING a.*, (
|
RETURNING a.*, ${routeIdsSql()} AS route_ids,
|
||||||
SELECT r.route_number
|
CASE WHEN cardinality(a.route_numbers)=1 THEN a.route_numbers[1] END AS notification_route_number
|
||||||
FROM routes r
|
|
||||||
WHERE r.id = a.route_id
|
|
||||||
) AS notification_route_number
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class AlertSchedulerService {
|
class AlertSchedulerService {
|
||||||
|
|||||||
47
backend/src/services/alertTargets.js
Normal file
47
backend/src/services/alertTargets.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
function normalizeRouteNumber(value) {
|
||||||
|
return String(value).replace(/[\s№]/g, '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouteNumbers(values) {
|
||||||
|
return [...new Set(values.map(normalizeRouteNumber))].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetError(message) { return Object.assign(new Error(message), { status: 400 }); }
|
||||||
|
|
||||||
|
async function resolveAlertTargets(db, cityId, input, { required = false } = {}) {
|
||||||
|
const hasNumbers = Object.hasOwn(input, 'route_numbers');
|
||||||
|
if (hasNumbers && (!Array.isArray(input.route_numbers) || input.route_numbers.length > 100
|
||||||
|
|| input.route_numbers.some(n => typeof n !== 'string' || !normalizeRouteNumber(n) || n.length > 30))) {
|
||||||
|
throw targetError('Укажите список номеров маршрутов (до 100).');
|
||||||
|
}
|
||||||
|
const routes = (await db.query('SELECT id, route_number FROM routes WHERE city_id=$1 AND is_active=true ORDER BY id', [cityId])).rows;
|
||||||
|
const legacy = input.route_id ? routes.find(r => Number(r.id) === Number(input.route_id)) : null;
|
||||||
|
if (input.route_id && !legacy) throw targetError('Выберите действующий маршрут выбранного города.');
|
||||||
|
const numbers = normalizeRouteNumbers(hasNumbers ? input.route_numbers : legacy ? [legacy.route_number] : []);
|
||||||
|
if (legacy && !numbers.includes(normalizeRouteNumber(legacy.route_number))) throw targetError('route_id не соответствует выбранным номерам маршрутов.');
|
||||||
|
if (required && !numbers.length) throw targetError('Выберите хотя бы один номер маршрута.');
|
||||||
|
const known = new Set(routes.map(r => normalizeRouteNumber(r.route_number)));
|
||||||
|
if (numbers.some(n => !known.has(n))) throw targetError('Один из номеров маршрутов отсутствует или отключён в выбранном городе.');
|
||||||
|
return {
|
||||||
|
route_numbers: numbers,
|
||||||
|
route_id: numbers.length === 1 ? (legacy?.id || routes.find(r => normalizeRouteNumber(r.route_number) === numbers[0]).id) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correlated expressions work for SELECT, INSERT CTEs and UPDATE RETURNING.
|
||||||
|
function routeIdsSql(alias = 'a') {
|
||||||
|
return `ARRAY(SELECT r.id FROM routes r WHERE r.city_id=${alias}.city_id AND r.is_active=true
|
||||||
|
AND upper(regexp_replace(r.route_number, '[[:space:]№]', '', 'g'))=ANY(${alias}.route_numbers) ORDER BY r.id)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertSelectSql(alias = 'a') {
|
||||||
|
return `${alias}.*, ${routeIdsSql(alias)} AS route_ids,
|
||||||
|
CASE WHEN cardinality(${alias}.route_numbers)=1 THEN ${alias}.route_numbers[1] END AS route_number`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertRouteFilterSql(parameter, alias = 'a') {
|
||||||
|
return `EXISTS(SELECT 1 FROM routes target WHERE target.id=${parameter} AND target.city_id=${alias}.city_id
|
||||||
|
AND upper(regexp_replace(target.route_number, '[[:space:]№]', '', 'g'))=ANY(${alias}.route_numbers))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeRouteNumber, normalizeRouteNumbers, resolveAlertTargets, routeIdsSql, alertSelectSql, alertRouteFilterSql };
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
const { getLocalDateTime } = require('../utils/etaTime');
|
const { getLocalDateTime } = require('../utils/etaTime');
|
||||||
|
const { normalizeRouteNumber } = require('./alertTargets');
|
||||||
|
|
||||||
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
|
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
|
||||||
const ROUTE = /маршрут(?:у|а|е|ом)?\s*(?:№|номер|no\.?)?\s*(\d{1,3}[а-яa-z]?)(?![\dа-яa-z])/giu;
|
const ROUTE = /маршрут(?:ам|ы|ов|у|а|е|ом)?\s*(?:№|номер|no\.?)?\s*(\d{1,3}[а-яa-z]?(?![\dа-яa-z])(?:\s*(?:[,;]|и\s+)\s*№?\s*\d{1,3}[а-яa-z]?(?![\dа-яa-z]))*)/giu;
|
||||||
const TIME = /(?<!\d)(\d{1,2})[:.](\d{2})(?!\d|[.:]\d)/g;
|
const TIME = /(?<!\d)(\d{1,2})[:.](\d{2})(?!\d|[.:]\d)/g;
|
||||||
|
|
||||||
function validDate(value) {
|
function validDate(value) {
|
||||||
@@ -107,7 +108,9 @@ function parseCancellation({ text = '', publishedAt, hasMedia = false }, { prese
|
|||||||
if (!departures.length || [...section.matchAll(TIME)].length !== departures.reduce((n, d) => n + d.times.length, 0)) {
|
if (!departures.length || [...section.matchAll(TIME)].length !== departures.reduce((n, d) => n + d.times.length, 0)) {
|
||||||
return review('Не удалось однозначно связать все времена с остановками.');
|
return review('Не удалось однозначно связать все времена с остановками.');
|
||||||
}
|
}
|
||||||
groups.push({ routeNumber: normalizeRoute(route[1]), departures });
|
for (const number of route[1].match(/\d{1,3}[а-яa-z]?/giu)) {
|
||||||
|
groups.push({ routeNumber: normalizeRoute(number), departures });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { status: 'ready', reasons: [], serviceDate: parsedDate.date, inferredDate: parsedDate.inferred, groups };
|
return { status: 'ready', reasons: [], serviceDate: parsedDate.date, inferredDate: parsedDate.inferred, groups };
|
||||||
}
|
}
|
||||||
@@ -125,7 +128,8 @@ function resolveCancellationStops(parsed, stops, aliases) {
|
|||||||
? departure.stop.charAt(0).toUpperCase() + departure.stop.slice(1).toLowerCase() : departure.stop;
|
? departure.stop.charAt(0).toUpperCase() + departure.stop.slice(1).toLowerCase() : departure.stop;
|
||||||
return { ...departure, sourceStop, stop: stop?.name || readable,
|
return { ...departure, sourceStop, stop: stop?.name || readable,
|
||||||
...(stop ? { stopId: Number(stop.id) } : {}),
|
...(stop ? { stopId: Number(stop.id) } : {}),
|
||||||
...(alias && !stop ? { invalidAlias: true } : {}) };
|
...(alias && !stop ? { invalidAlias: true } : {}),
|
||||||
|
...(!stop ? { unresolvedStop: true } : {}) };
|
||||||
}) }));
|
}) }));
|
||||||
return { ...parsed, groups };
|
return { ...parsed, groups };
|
||||||
}
|
}
|
||||||
@@ -133,33 +137,36 @@ function resolveCancellationStops(parsed, stops, aliases) {
|
|||||||
function matchCancellationRoutes(parsed, routes) {
|
function matchCancellationRoutes(parsed, routes) {
|
||||||
const plans = new Map();
|
const plans = new Map();
|
||||||
for (const group of parsed.groups) {
|
for (const group of parsed.groups) {
|
||||||
const candidates = routes.filter(r => normalizeRoute(r.route_number) === group.routeNumber);
|
const number = normalizeRouteNumber(group.routeNumber);
|
||||||
|
const candidates = routes.filter(r => normalizeRouteNumber(r.route_number) === number);
|
||||||
|
if (!candidates.length) return { reasons: [`Маршрут №${number} отсутствует или отключён в выбранном городе.`], plans: [] };
|
||||||
|
if (!plans.has(number)) plans.set(number, { routeId: candidates[0].id, routeIds: candidates.map(r => r.id), routeNumber: number, departures: [] });
|
||||||
for (const departure of group.departures) {
|
for (const departure of group.departures) {
|
||||||
if (departure.invalidAlias) return { reasons: [`Остановка для «${departure.sourceStop}» отключена. Обновите словарь названий.`], plans: [] };
|
if (departure.invalidAlias) return { reasons: [`Остановка для «${departure.sourceStop}» отключена. Обновите словарь названий.`], plans: [] };
|
||||||
const matches = candidates.length === 1 ? candidates : candidates.filter(r => departure.stopId
|
if (departure.unresolvedStop) return { reasons: [`Остановка «${departure.sourceStop}» не определена однозначно. Дополните словарь названий.`], plans: [] };
|
||||||
? Number(r.origin_stop_id) === departure.stopId : normalizeStop(r.origin_stop || '') === normalizeStop(departure.stop));
|
plans.get(number).departures.push(departure);
|
||||||
if (matches.length !== 1) return { reasons: [`Маршрут №${group.routeNumber}, остановка «${departure.stop}»: направление не определено однозначно.`], plans: [] };
|
|
||||||
const route = matches[0];
|
|
||||||
if (!plans.has(route.id)) plans.set(route.id, { routeId: route.id, routeNumber: route.route_number, departures: [] });
|
|
||||||
plans.get(route.id).departures.push(departure);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { reasons: [], plans: [...plans.values()] };
|
return { reasons: [], plans: [...plans.values()] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCancellationDraft(plan, serviceDate) {
|
function buildCancellationDraft(plan, serviceDate) {
|
||||||
|
const plans = Array.isArray(plan) ? plan : [plan];
|
||||||
return {
|
return {
|
||||||
title: `Отмена отдельных рейсов №${plan.routeNumber}`,
|
title: `Отмена отдельных рейсов ${plans.map(p => `№${p.routeNumber}`).join(', ')}`,
|
||||||
message: `${serviceDate.split('-').reverse().join('.')}: не выполняются рейсы:\n${plan.departures.map(d => `${d.stop} — ${d.times.join(', ')}`).join('\n')}`
|
message: `${serviceDate.split('-').reverse().join('.')}: не выполняются рейсы:\n${plans.map(p =>
|
||||||
|
`${plans.length > 1 ? `Маршрут №${p.routeNumber}:\n` : ''}${p.departures.map(d => `${d.stop} — ${d.times.join(', ')}`).join('\n')}`).join('\n\n')}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCancellationAlert(plan, serviceDate, sourceUrl, publishedAt, cityId) {
|
function buildCancellationAlert(plan, serviceDate, sourceUrl, publishedAt, cityId) {
|
||||||
|
const plans = Array.isArray(plan) ? plan : [plan];
|
||||||
const bounds = dayBounds(serviceDate);
|
const bounds = dayBounds(serviceDate);
|
||||||
const draft = buildCancellationDraft(plan, serviceDate);
|
const draft = buildCancellationDraft(plan, serviceDate);
|
||||||
const start = new Date(Math.max(Date.parse(bounds.start), Date.parse(publishedAt))).toISOString();
|
const start = new Date(Math.max(Date.parse(bounds.start), Date.parse(publishedAt))).toISOString();
|
||||||
return {
|
return {
|
||||||
city_id: cityId, route_id: plan.routeId, alert_type: 'cancellation',
|
city_id: cityId, route_id: plans.length === 1 ? plans[0].routeId : null,
|
||||||
|
route_numbers: plans.map(p => p.routeNumber), alert_type: 'cancellation',
|
||||||
...draft,
|
...draft,
|
||||||
message: `${draft.message}\nИсточник: ${sourceUrl}`,
|
message: `${draft.message}\nИсточник: ${sourceUrl}`,
|
||||||
start_time: start, end_time: bounds.end, is_active: true
|
start_time: start, end_time: bounds.end, is_active: true
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const { TelegramSource } = require('./telegramSource');
|
|||||||
const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationDraft, buildCancellationAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser');
|
const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationDraft, buildCancellationAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser');
|
||||||
const { AlertCreationService } = require('./alertCreationService');
|
const { AlertCreationService } = require('./alertCreationService');
|
||||||
const { TelegramStopAliasService } = require('./telegramStopAliasService');
|
const { TelegramStopAliasService } = require('./telegramStopAliasService');
|
||||||
|
const { resolveAlertTargets } = require('./alertTargets');
|
||||||
|
|
||||||
function httpError(status, message) { return Object.assign(new Error(message), { status }); }
|
function httpError(status, message) { return Object.assign(new Error(message), { status }); }
|
||||||
function sourceUrl(row) { return `https://t.me/${row.channel_username}/${row.message_id}`; }
|
function sourceUrl(row) { return `https://t.me/${row.channel_username}/${row.message_id}`; }
|
||||||
@@ -19,10 +20,7 @@ function messageDrafts(parsed) {
|
|||||||
if (!groups.has(group.routeNumber)) groups.set(group.routeNumber, { routeNumber: group.routeNumber, departures: [] });
|
if (!groups.has(group.routeNumber)) groups.set(group.routeNumber, { routeNumber: group.routeNumber, departures: [] });
|
||||||
groups.get(group.routeNumber).departures.push(...group.departures);
|
groups.get(group.routeNumber).departures.push(...group.departures);
|
||||||
}
|
}
|
||||||
return [
|
return [...groups.values()].map(group => ({ routeId: null, routeNumber: group.routeNumber, ...buildCancellationDraft(group, parsed.serviceDate) }));
|
||||||
...(parsed.plans || []).map(plan => ({ routeId: plan.routeId, routeNumber: plan.routeNumber, ...buildCancellationDraft(plan, parsed.serviceDate) })),
|
|
||||||
...[...groups.values()].map(group => ({ routeId: null, routeNumber: group.routeNumber, ...buildCancellationDraft(group, parsed.serviceDate) }))
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicError(error) {
|
function publicError(error) {
|
||||||
@@ -92,11 +90,7 @@ class TelegramImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRoutes(db, cityId) {
|
async getRoutes(db, cityId) {
|
||||||
return (await db.query(`SELECT r.id, r.route_number, r.name, origin.name AS origin_stop, origin.id AS origin_stop_id
|
return (await db.query('SELECT id,route_number FROM routes WHERE city_id=$1 AND is_active=true ORDER BY id', [cityId])).rows;
|
||||||
FROM routes r LEFT JOIN LATERAL (
|
|
||||||
SELECT s.id, s.name FROM route_stops rs JOIN stops s ON s.id = rs.stop_id AND s.city_id = r.city_id AND s.is_active=true
|
|
||||||
WHERE rs.route_id = r.id ORDER BY rs.sequence LIMIT 1
|
|
||||||
) origin ON true WHERE r.city_id = $1 AND r.is_active = true ORDER BY r.id`, [cityId])).rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStopContext(db, cityId) {
|
async getStopContext(db, cityId) {
|
||||||
@@ -133,7 +127,7 @@ class TelegramImportService {
|
|||||||
const context = await this.getStopContext(db, cityId);
|
const context = await this.getStopContext(db, cityId);
|
||||||
routes = await this.getRoutes(db, cityId);
|
routes = await this.getRoutes(db, cityId);
|
||||||
const resolutionHash = crypto.createHash('sha256').update(JSON.stringify([
|
const resolutionHash = crypto.createHash('sha256').update(JSON.stringify([
|
||||||
context.stops, context.aliases.map(a => [a.normalized_alias, a.stop_id]), routes, this.config.autoPublish
|
'route-number-alerts-v1', context.stops, context.aliases.map(a => [a.normalized_alias, a.stop_id]), routes, this.config.autoPublish
|
||||||
])).digest('hex');
|
])).digest('hex');
|
||||||
if (sameContent && row.parsed.resolutionHash === resolutionHash && Date.parse(dayBounds(parsed.serviceDate).end) > this.now().getTime()) {
|
if (sameContent && row.parsed.resolutionHash === resolutionHash && Date.parse(dayBounds(parsed.serviceDate).end) > this.now().getTime()) {
|
||||||
await db.query('COMMIT'); return { status: 'unchanged' };
|
await db.query('COMMIT'); return { status: 'unchanged' };
|
||||||
@@ -154,7 +148,14 @@ class TelegramImportService {
|
|||||||
else if (!reasons.length) reasons = ['Автоматическая публикация выключена. Подтвердите уведомление.'];
|
else if (!reasons.length) reasons = ['Автоматическая публикация выключена. Подтвердите уведомление.'];
|
||||||
}
|
}
|
||||||
if (status === 'published') {
|
if (status === 'published') {
|
||||||
for (const plan of plans) await this.saveAlert(db, row.id, buildCancellationAlert(plan, parsed.serviceDate, sourceUrl(row), message.publishedAt, cityId));
|
const draft = buildCancellationDraft(plans, parsed.serviceDate);
|
||||||
|
if (draft.title.length > 200 || draft.message.length > 5000 || plans.length > 100) {
|
||||||
|
status = 'review';
|
||||||
|
reasons = ['Слишком длинное уведомление. Сократите заголовок или текст перед публикацией.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status === 'published') {
|
||||||
|
await this.saveAlert(db, row.id, buildCancellationAlert(plans, parsed.serviceDate, sourceUrl(row), message.publishedAt, cityId));
|
||||||
}
|
}
|
||||||
await db.query(`UPDATE telegram_import_messages SET content_hash=$2, raw_text=$3, edited_at=$4,
|
await db.query(`UPDATE telegram_import_messages SET content_hash=$2, raw_text=$3, edited_at=$4,
|
||||||
has_media=$5, status=$6, reasons=$7::jsonb, parsed=$8::jsonb, reviewed_by=NULL, updated_at=now() WHERE id=$1`,
|
has_media=$5, status=$6, reasons=$7::jsonb, parsed=$8::jsonb, reviewed_by=NULL, updated_at=now() WHERE id=$1`,
|
||||||
@@ -167,27 +168,40 @@ class TelegramImportService {
|
|||||||
|
|
||||||
async deactivate(db, messageId) {
|
async deactivate(db, messageId) {
|
||||||
await db.query(`UPDATE alerts SET is_active=false, notification_pending=false
|
await db.query(`UPDATE alerts SET is_active=false, notification_pending=false
|
||||||
WHERE id IN (SELECT alert_id FROM telegram_import_alerts WHERE message_id=$1)`, [messageId]);
|
WHERE id IN (SELECT alert_id FROM telegram_import_alerts WHERE message_id=$1
|
||||||
|
UNION SELECT alert_id FROM telegram_message_alerts WHERE message_id=$1)`, [messageId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAlert(db, messageId, payload) {
|
async saveAlert(db, messageId, payload) {
|
||||||
const existing = (await db.query('SELECT alert_id FROM telegram_import_alerts WHERE message_id=$1 AND route_id=$2', [messageId, payload.route_id])).rows[0];
|
let existing = (await db.query('SELECT alert_id FROM telegram_message_alerts WHERE message_id=$1', [messageId])).rows[0];
|
||||||
|
if (!existing) {
|
||||||
|
const legacy = (await db.query('SELECT alert_id FROM telegram_import_alerts WHERE message_id=$1 ORDER BY route_id', [messageId])).rows;
|
||||||
|
if (legacy.length) {
|
||||||
|
// Consolidate only on an edit/review; keep tombstones and delivered pushes.
|
||||||
|
existing = { alert_id: legacy.some(link => link.alert_id === null) ? null : legacy[0].alert_id };
|
||||||
|
if (existing.alert_id && legacy.length > 1) await db.query(`UPDATE alerts SET
|
||||||
|
notification_sent_at=(SELECT max(notification_sent_at) FROM alerts WHERE id=ANY($2::int[])) WHERE id=$1`,
|
||||||
|
[existing.alert_id, legacy.map(link => link.alert_id)]);
|
||||||
|
await db.query('INSERT INTO telegram_message_alerts(message_id,alert_id) VALUES ($1,$2)', [messageId, existing.alert_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// A deleted alert stays deleted, and editing a publication doesn't resend a delivered push.
|
// Deletion stays deleted. Editing a delivered alert doesn't resend its push.
|
||||||
if (existing.alert_id) await db.query(`UPDATE alerts SET title=$2, message=$3, start_time=$4, end_time=$5,
|
if (existing.alert_id) await db.query(`UPDATE alerts SET title=$2, message=$3, start_time=$4, end_time=$5,
|
||||||
is_active=true, notification_pending=true,
|
route_id=$6, route_numbers=$7::text[], is_active=true, notification_pending=true,
|
||||||
notification_attempts=CASE WHEN notification_sent_at IS NULL THEN 0 ELSE notification_attempts END
|
notification_attempts=CASE WHEN notification_sent_at IS NULL THEN 0 ELSE notification_attempts END
|
||||||
WHERE id=$1`, [existing.alert_id, payload.title, payload.message, payload.start_time, payload.end_time]);
|
WHERE id=$1`, [existing.alert_id, payload.title, payload.message, payload.start_time, payload.end_time, payload.route_id, payload.route_numbers]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await this.alerts.insertAlert(db, payload);
|
const result = await this.alerts.insertAlert(db, payload);
|
||||||
await db.query('UPDATE alerts SET notification_pending=true WHERE id=$1', [result.alert.id]);
|
await db.query('UPDATE alerts SET notification_pending=true WHERE id=$1', [result.alert.id]);
|
||||||
await db.query('INSERT INTO telegram_import_alerts(message_id,route_id,alert_id) VALUES ($1,$2,$3)', [messageId, payload.route_id, result.alert.id]);
|
await db.query('INSERT INTO telegram_message_alerts(message_id,alert_id) VALUES ($1,$2)', [messageId, result.alert.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(cityId) {
|
async list(cityId) {
|
||||||
return (await this.pool.query(`SELECT m.*, COALESCE((SELECT json_agg(t.alert_id) FROM telegram_import_alerts t
|
return (await this.pool.query(`SELECT m.*, CASE WHEN EXISTS(SELECT 1 FROM telegram_message_alerts t WHERE t.message_id=m.id)
|
||||||
WHERE t.message_id=m.id AND t.alert_id IS NOT NULL), '[]') AS alert_ids
|
THEN COALESCE((SELECT json_agg(t.alert_id) FROM telegram_message_alerts t WHERE t.message_id=m.id AND t.alert_id IS NOT NULL), '[]')
|
||||||
|
ELSE COALESCE((SELECT json_agg(t.alert_id) FROM telegram_import_alerts t WHERE t.message_id=m.id AND t.alert_id IS NOT NULL), '[]') END AS alert_ids
|
||||||
FROM telegram_import_messages m WHERE m.city_id=$1 ORDER BY (m.status='review') DESC,m.published_at DESC,m.id DESC LIMIT 100`, [cityId])).rows.map(row => ({
|
FROM telegram_import_messages m WHERE m.city_id=$1 ORDER BY (m.status='review') DESC,m.published_at DESC,m.id DESC LIMIT 100`, [cityId])).rows.map(row => ({
|
||||||
...row, source_url: sourceUrl(row),
|
...row, source_url: sourceUrl(row),
|
||||||
drafts: messageDrafts(row.parsed),
|
drafts: messageDrafts(row.parsed),
|
||||||
@@ -206,17 +220,16 @@ class TelegramImportService {
|
|||||||
if (input.action === 'publish') {
|
if (input.action === 'publish') {
|
||||||
if (!validDate(input.service_date)) throw httpError(400, 'Укажите корректную дату отмены.');
|
if (!validDate(input.service_date)) throw httpError(400, 'Укажите корректную дату отмены.');
|
||||||
if (Date.parse(dayBounds(input.service_date).end) <= this.now().getTime()) throw httpError(410, 'День отмены уже закончился.');
|
if (Date.parse(dayBounds(input.service_date).end) <= this.now().getTime()) throw httpError(410, 'День отмены уже закончился.');
|
||||||
const route = (await this.getRoutes(db, cityId)).find(r => r.id === Number(input.route_id));
|
const targets = await resolveAlertTargets(db, cityId, input, { required: true });
|
||||||
if (!route) throw httpError(400, 'Выберите действующий маршрут выбранного города.');
|
|
||||||
const title = String(input.title || '').trim();
|
const title = String(input.title || '').trim();
|
||||||
const message = String(input.message || '').trim();
|
const message = String(input.message || '').trim();
|
||||||
if (!title || title.length > 200 || !message || message.length > 5000) throw httpError(400, 'Укажите заголовок (до 200 символов) и текст (до 5000 символов).');
|
if (!title || title.length > 200 || !message || message.length > 5000) throw httpError(400, 'Укажите заголовок (до 200 символов) и текст (до 5000 символов).');
|
||||||
await this.deactivate(db, row.id);
|
await this.deactivate(db, row.id);
|
||||||
const bounds = dayBounds(input.service_date);
|
const bounds = dayBounds(input.service_date);
|
||||||
await this.saveAlert(db, row.id, { city_id: cityId, route_id: route.id, alert_type: 'cancellation', title,
|
await this.saveAlert(db, row.id, { city_id: cityId, ...targets, alert_type: 'cancellation', title,
|
||||||
message: `${message}\nИсточник: ${sourceUrl(row)}`, start_time: new Date(Math.max(Date.parse(bounds.start), this.now().getTime())).toISOString(), end_time: bounds.end, is_active: true });
|
message: `${message}\nИсточник: ${sourceUrl(row)}`, start_time: new Date(Math.max(Date.parse(bounds.start), this.now().getTime())).toISOString(), end_time: bounds.end, is_active: true });
|
||||||
} else await this.deactivate(db, row.id);
|
} else await this.deactivate(db, row.id);
|
||||||
const parsed = input.action === 'publish' ? { ...row.parsed, serviceDate: input.service_date, manual: true } : row.parsed;
|
const parsed = input.action === 'publish' ? { ...row.parsed, serviceDate: input.service_date, manual: true, selectedRouteNumbers: input.route_numbers } : row.parsed;
|
||||||
await db.query(`UPDATE telegram_import_messages SET status=$2, reviewed_by=$3, parsed=$4::jsonb, updated_at=now() WHERE id=$1`, [row.id, input.action === 'publish' ? 'published' : 'dismissed', userId, JSON.stringify(parsed)]);
|
await db.query(`UPDATE telegram_import_messages SET status=$2, reviewed_by=$3, parsed=$4::jsonb, updated_at=now() WHERE id=$1`, [row.id, input.action === 'publish' ? 'published' : 'dismissed', userId, JSON.stringify(parsed)]);
|
||||||
await db.query('COMMIT');
|
await db.query('COMMIT');
|
||||||
return { id: row.id, status: input.action === 'publish' ? 'published' : 'dismissed' };
|
return { id: row.id, status: input.action === 'publish' ? 'published' : 'dismissed' };
|
||||||
|
|||||||
@@ -418,3 +418,10 @@ test('alert request hash is stable for the normalized creation payload', () => {
|
|||||||
createAlertRequestHash({ ...normalized, is_active: false })
|
createAlertRequestHash({ ...normalized, is_active: false })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('route-number request hashes ignore selection order, spacing and duplicates', () => {
|
||||||
|
const a = normalizeAlertInput({ ...ALERT_INPUT, route_id: null, route_numbers: ['27', '2', '5а'] });
|
||||||
|
const b = normalizeAlertInput({ ...ALERT_INPUT, route_id: null, route_numbers: [' №5А ', '2', '27', '2'] });
|
||||||
|
assert.equal(createAlertRequestHash(a), createAlertRequestHash(b));
|
||||||
|
assert.notEqual(createAlertRequestHash(a), createAlertRequestHash({ ...b, route_numbers: ['2', '27'] }));
|
||||||
|
});
|
||||||
|
|||||||
@@ -257,3 +257,12 @@ test('reads disabled-by-default Firebase configuration and environment overrides
|
|||||||
projectId: 'ridebus-test'
|
projectId: 'ridebus-test'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('multi-route alert sends one data message with all direction IDs and number strings', () => {
|
||||||
|
const message = buildAlertCreatedMessage({ id: 17, route_id: null, route_numbers: ['2', '27'], route_ids: [1, 2, 4, 5], title: 'Переезд закрыт', message: 'Объезд' });
|
||||||
|
assert.equal(message.data.routeId, '');
|
||||||
|
assert.equal(message.data.routeNumber, '');
|
||||||
|
assert.deepEqual(JSON.parse(message.data.routeNumbers), ['2', '27']);
|
||||||
|
assert.deepEqual(JSON.parse(message.data.routeIds), [1, 2, 4, 5]);
|
||||||
|
assert.ok(Object.values(message.data).every(value => typeof value === 'string'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,16 +91,16 @@ test('different route numbers in one post are separate groups', () => {
|
|||||||
assert.deepEqual(result.groups.map(g => g.routeNumber), ['27', '5а']);
|
assert.deepEqual(result.groups.map(g => g.routeNumber), ['27', '5а']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('route number with two directions is matched by origin stop, with no guess on missing origin', () => {
|
test('one route number targets all its directions without requiring an origin match', () => {
|
||||||
const routes = [
|
const routes = [
|
||||||
{ id: 1, route_number: '27', origin_stop: 'Автовокзал' },
|
{ id: 1, route_number: '27', origin_stop: 'Автовокзал' },
|
||||||
{ id: 2, route_number: '27', origin_stop: 'Зодчего' },
|
{ id: 2, route_number: '27', origin_stop: 'Зодчего' },
|
||||||
{ id: 3, route_number: '5', origin_stop: 'Центр' }
|
{ id: 3, route_number: '5', origin_stop: 'Центр' }
|
||||||
];
|
];
|
||||||
const matched = matchCancellationRoutes(parse(), routes);
|
const matched = matchCancellationRoutes(parse(), routes);
|
||||||
assert.deepEqual(matched.plans.map(p => p.routeId), [1, 2]);
|
assert.deepEqual(matched.plans[0].routeIds, [1, 2]);
|
||||||
assert.equal(matched.reasons.length, 0);
|
assert.equal(matched.reasons.length, 0);
|
||||||
assert.equal(matchCancellationRoutes(parse(), routes.map(r => ({ ...r, origin_stop: '' }))).plans.length, 0);
|
assert.equal(matchCancellationRoutes(parse(), routes.map(r => ({ ...r, origin_stop: '' }))).plans.length, 1);
|
||||||
assert.equal(matchCancellationRoutes(parse(), []).reasons.length, 1);
|
assert.equal(matchCancellationRoutes(parse(), []).reasons.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +126,9 @@ test('dictionary selects the actual stop ID and uses its canonical name in conci
|
|||||||
{ id: 3, route_number: '27', origin_stop_id: 23, origin_stop: 'Зодчего Иоанна' }
|
{ id: 3, route_number: '27', origin_stop_id: 23, origin_stop: 'Зодчего Иоанна' }
|
||||||
];
|
];
|
||||||
const matched = matchCancellationRoutes(parsed, routes);
|
const matched = matchCancellationRoutes(parsed, routes);
|
||||||
assert.deepEqual(matched.plans.map(plan => plan.routeId), [1, 2]);
|
assert.deepEqual(matched.plans[0].routeIds, [1, 2, 3]);
|
||||||
const alert = buildCancellationAlert(matched.plans[1], '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52Z', 1);
|
const alert = buildCancellationAlert(matched.plans[0], '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52Z', 1);
|
||||||
assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nЗодчего Иоанна — 15:58, 16:58\nИсточник: https://t.me/avp2Polotsk/1166');
|
assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nАвтовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58\nИсточник: https://t.me/avp2Polotsk/1166');
|
||||||
assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ/);
|
assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ/);
|
||||||
assert.equal(normalizeStopKey(' « ЗОД-ЧЕГО » '), normalizeStopKey('Зодчего'));
|
assert.equal(normalizeStopKey(' « ЗОД-ЧЕГО » '), normalizeStopKey('Зодчего'));
|
||||||
});
|
});
|
||||||
@@ -143,3 +143,23 @@ test('canonical names are taken from the catalog; inactive aliases and ambiguous
|
|||||||
const result = matchCancellationRoutes(ambiguous, [{ id: 1, route_number: '27', origin_stop: 'Зодчего Иоанна' }, { id: 2, route_number: '27', origin_stop: 'Зодчего Иоанна' }]);
|
const result = matchCancellationRoutes(ambiguous, [{ id: 1, route_number: '27', origin_stop: 'Зодчего Иоанна' }, { id: 2, route_number: '27', origin_stop: 'Зодчего Иоанна' }]);
|
||||||
assert.equal(result.plans.length, 0);
|
assert.equal(result.plans.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('several numbered sections produce one concise alert without losing departures', () => {
|
||||||
|
const parsed = parse(text + '\nПО МАРШРУТУ № 5А ОТ ОСТАНОВКИ ЦЕНТР НА 18:00.');
|
||||||
|
const matched = matchCancellationRoutes(parsed, [{ id: 1, route_number: '27' }, { id: 2, route_number: '27' }, { id: 3, route_number: '5А' }]);
|
||||||
|
const alert = buildCancellationAlert(matched.plans, parsed.serviceDate, 'https://t.me/avp2Polotsk/1', '2026-10-02T12:00Z', 1);
|
||||||
|
assert.deepEqual(alert.route_numbers, ['27', '5А']);
|
||||||
|
assert.equal(alert.route_id, null);
|
||||||
|
assert.equal(alert.title, 'Отмена отдельных рейсов №27, №5А');
|
||||||
|
assert.match(alert.message, /Маршрут №27:\nАвтовокзал — 15:43, 16:43\nЗОДЧЕГО — 15:58, 16:58\n\nМаршрут №5А:\nЦЕНТР — 18:00/);
|
||||||
|
assert.deepEqual(matchCancellationRoutes(parsed, [{ id: 1, route_number: '27' }]).plans, [], 'unknown number prevents partial automatic publication');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a shared explicit departure list may name several route numbers', () => {
|
||||||
|
for (const list of ['№2, №27', '№2 и №27', '№2; 27']) {
|
||||||
|
const parsed = parse(`Не будут выполняться рейсы по маршрутам ${list} с остановки ЦЕНТР на 18.00,19.00.`);
|
||||||
|
assert.equal(parsed.status, 'ready', list);
|
||||||
|
assert.deepEqual(parsed.groups.map(g => g.routeNumber), ['2', '27']);
|
||||||
|
assert.ok(parsed.groups.every(g => g.departures[0].times.join(',') === '18:00,19:00'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ test('fixed parser reprocesses a stored invalid-date review without a Telegram t
|
|||||||
const row = (await service.list(1))[0];
|
const row = (await service.list(1))[0];
|
||||||
assert.equal(row.parsed.serviceDate, '2026-10-05');
|
assert.equal(row.parsed.serviceDate, '2026-10-05');
|
||||||
assert.deepEqual(row.reasons, []);
|
assert.deepEqual(row.reasons, []);
|
||||||
const alerts = (await pool.query('SELECT a.* FROM alerts a JOIN telegram_import_alerts t ON t.alert_id=a.id WHERE t.message_id=$1 ORDER BY route_id', [row.id])).rows;
|
const alerts = (await pool.query('SELECT a.* FROM alerts a JOIN telegram_message_alerts t ON t.alert_id=a.id WHERE t.message_id=$1 ORDER BY id', [row.id])).rows;
|
||||||
assert.equal(alerts.length, 2);
|
assert.equal(alerts.length, 1);
|
||||||
assert.match(alerts[0].message, /Больница \(Ксты\) — 07:15, 08:45/);
|
assert.match(alerts[0].message, /Больница \(Ксты\) — 07:15, 08:45/);
|
||||||
assert.match(alerts[1].message, /Больница \(Новка\) — 08:00, 09:30/);
|
assert.match(alerts[0].message, /Больница \(Новка\) — 08:00, 09:30/);
|
||||||
assert.ok(alerts.every(alert => alert.end_time.toISOString() === '2026-10-05T21:00:00.000Z'));
|
assert.ok(alerts.every(alert => alert.end_time.toISOString() === '2026-10-05T21:00:00.000Z'));
|
||||||
assert.equal((await service.processMessage(input, 1)).status, 'unchanged');
|
assert.equal((await service.processMessage(input, 1)).status, 'unchanged');
|
||||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_import_alerts WHERE message_id=$1', [row.id])).rows[0].n, 2);
|
assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_message_alerts WHERE message_id=$1', [row.id])).rows[0].n, 1);
|
||||||
} finally { await db.close(); }
|
} finally { await db.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ async function setup() {
|
|||||||
const db = new PGlite();
|
const db = new PGlite();
|
||||||
await db.exec(`CREATE TABLE cities(id serial PRIMARY KEY,slug text,is_active boolean DEFAULT true);
|
await db.exec(`CREATE TABLE cities(id serial PRIMARY KEY,slug text,is_active boolean DEFAULT true);
|
||||||
CREATE TABLE users(id serial PRIMARY KEY);
|
CREATE TABLE users(id serial PRIMARY KEY);
|
||||||
CREATE TABLE routes(id serial PRIMARY KEY,route_number text,name text,city_id int REFERENCES cities(id),is_active boolean DEFAULT true);
|
CREATE TABLE routes(id serial PRIMARY KEY,route_number text,name text,color text,city_id int REFERENCES cities(id),is_active boolean DEFAULT true);
|
||||||
CREATE TABLE stops(id serial PRIMARY KEY,name text,address text,city_id int REFERENCES cities(id),is_active boolean DEFAULT true);
|
CREATE TABLE stops(id serial PRIMARY KEY,name text,address text,city_id int REFERENCES cities(id),is_active boolean DEFAULT true);
|
||||||
CREATE TABLE route_stops(route_id int,stop_id int,sequence int);
|
CREATE TABLE route_stops(route_id int,stop_id int,sequence int);
|
||||||
CREATE TABLE alerts(id serial PRIMARY KEY,route_id int REFERENCES routes(id),alert_type text,title text,message text,
|
CREATE TABLE alerts(id serial PRIMARY KEY,route_id int REFERENCES routes(id),alert_type text,title text,message text,
|
||||||
@@ -64,6 +64,10 @@ async function setup() {
|
|||||||
const aliasesMigration = await fs.readFile(path.join(__dirname, '../migrations/014_telegram_stop_aliases.sql'), 'utf8');
|
const aliasesMigration = await fs.readFile(path.join(__dirname, '../migrations/014_telegram_stop_aliases.sql'), 'utf8');
|
||||||
await db.exec(aliasesMigration);
|
await db.exec(aliasesMigration);
|
||||||
await db.exec(aliasesMigration);
|
await db.exec(aliasesMigration);
|
||||||
|
const targetsMigration = await fs.readFile(path.join(__dirname, '../migrations/015_alert_route_numbers.sql'), 'utf8');
|
||||||
|
await db.exec(targetsMigration);
|
||||||
|
await db.exec(targetsMigration);
|
||||||
|
await db.exec(await fs.readFile(path.join(__dirname, '../migrations/007_alert_creation_idempotency.sql'), 'utf8'));
|
||||||
// A session is leased for the whole transaction, just like a pg Pool client.
|
// A session is leased for the whole transaction, just like a pg Pool client.
|
||||||
let tail = Promise.resolve();
|
let tail = Promise.resolve();
|
||||||
async function lease() {
|
async function lease() {
|
||||||
@@ -88,9 +92,9 @@ test('Telegram SQL lifecycle: atomic import, duplicates, edits, expiry, deletion
|
|||||||
assert.equal((await pool.query('SELECT end_time FROM alerts WHERE id=1')).rows[0].end_time.toISOString(), '2026-10-02T21:00:00.000Z');
|
assert.equal((await pool.query('SELECT end_time FROM alerts WHERE id=1')).rows[0].end_time.toISOString(), '2026-10-02T21:00:00.000Z');
|
||||||
const results = await Promise.all([service.processMessage(message(), 1), service.processMessage(message(), 1)]);
|
const results = await Promise.all([service.processMessage(message(), 1), service.processMessage(message(), 1)]);
|
||||||
assert.deepEqual(results.map(r => r.status), ['published', 'unchanged']);
|
assert.deepEqual(results.map(r => r.status), ['published', 'unchanged']);
|
||||||
let alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY route_id')).rows;
|
let alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY id')).rows;
|
||||||
assert.equal(alerts.length, 2);
|
assert.equal(alerts.length, 1);
|
||||||
assert.deepEqual(alerts.map(a => a.route_id), [1, 2]);
|
assert.deepEqual(alerts[0].route_numbers, ['27']);
|
||||||
assert.equal(alerts[0].end_time.toISOString(), '2026-10-02T21:00:00.000Z');
|
assert.equal(alerts[0].end_time.toISOString(), '2026-10-02T21:00:00.000Z');
|
||||||
assert.match(alerts[0].message, /Автовокзал.*15:43, 16:43/);
|
assert.match(alerts[0].message, /Автовокзал.*15:43, 16:43/);
|
||||||
assert.ok(alerts.every(a => a.notification_sent_at === null));
|
assert.ok(alerts.every(a => a.notification_sent_at === null));
|
||||||
@@ -99,8 +103,8 @@ test('Telegram SQL lifecycle: atomic import, duplicates, edits, expiry, deletion
|
|||||||
await pool.query('UPDATE alerts SET notification_sent_at=now() WHERE notification_pending=true');
|
await pool.query('UPDATE alerts SET notification_sent_at=now() WHERE notification_pending=true');
|
||||||
const edited = { ...message(), text: carrierText.replace('15.43', '15.45'), editedAt: '2026-10-02T13:01:00Z' };
|
const edited = { ...message(), text: carrierText.replace('15.43', '15.45'), editedAt: '2026-10-02T13:01:00Z' };
|
||||||
assert.equal((await service.processMessage(edited, 1)).status, 'published');
|
assert.equal((await service.processMessage(edited, 1)).status, 'published');
|
||||||
alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY route_id')).rows;
|
alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY id')).rows;
|
||||||
assert.equal(alerts.length, 2);
|
assert.equal(alerts.length, 1);
|
||||||
assert.ok(alerts.every(a => a.notification_sent_at !== null));
|
assert.ok(alerts.every(a => a.notification_sent_at !== null));
|
||||||
assert.match(alerts[0].message, /15:45/);
|
assert.match(alerts[0].message, /15:45/);
|
||||||
assert.equal((await service.processMessage(message(), 1)).status, 'unchanged', 'stale Telegram snapshot cannot undo an edit');
|
assert.equal((await service.processMessage(message(), 1)).status, 'unchanged', 'stale Telegram snapshot cannot undo an edit');
|
||||||
@@ -120,10 +124,10 @@ test('Telegram SQL lifecycle: atomic import, duplicates, edits, expiry, deletion
|
|||||||
|
|
||||||
// Administrator deletion is respected on a subsequent source edit.
|
// Administrator deletion is respected on a subsequent source edit.
|
||||||
await service.processMessage(message(1167), 1);
|
await service.processMessage(message(1167), 1);
|
||||||
const deletedId = (await pool.query('SELECT alert_id FROM telegram_import_alerts WHERE message_id=(SELECT id FROM telegram_import_messages WHERE message_id=1167) AND route_id=1')).rows[0].alert_id;
|
const deletedId = (await pool.query('SELECT alert_id FROM telegram_message_alerts WHERE message_id=(SELECT id FROM telegram_import_messages WHERE message_id=1167)')).rows[0].alert_id;
|
||||||
await pool.query('DELETE FROM alerts WHERE id=$1', [deletedId]);
|
await pool.query('DELETE FROM alerts WHERE id=$1', [deletedId]);
|
||||||
await service.processMessage({ ...message(1167), text: carrierText.replace('15.43', '15.46'), editedAt: '2026-10-02T14:01:00Z' }, 1);
|
await service.processMessage({ ...message(1167), text: carrierText.replace('15.43', '15.46'), editedAt: '2026-10-02T14:01:00Z' }, 1);
|
||||||
assert.equal((await pool.query('SELECT alert_id FROM telegram_import_alerts WHERE message_id=(SELECT id FROM telegram_import_messages WHERE message_id=1167) AND route_id=1')).rows[0].alert_id, null);
|
assert.equal((await pool.query('SELECT alert_id FROM telegram_message_alerts WHERE message_id=(SELECT id FROM telegram_import_messages WHERE message_id=1167)')).rows[0].alert_id, null);
|
||||||
|
|
||||||
// At midnight, late imports and manual publication cannot reactivate yesterday's cancellation.
|
// At midnight, late imports and manual publication cannot reactivate yesterday's cancellation.
|
||||||
clock('2026-10-02T21:00:00Z');
|
clock('2026-10-02T21:00:00Z');
|
||||||
@@ -142,7 +146,7 @@ test('Telegram SQL lifecycle: atomic import, duplicates, edits, expiry, deletion
|
|||||||
} finally { await db.close(); }
|
} finally { await db.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Telegram SQL import rolls back all alerts if a later route insert fails', { skip: !modulePath }, async () => {
|
test('Telegram SQL import rolls back the alert if its source link insert fails', { skip: !modulePath }, async () => {
|
||||||
const { db, pool, service } = await setup();
|
const { db, pool, service } = await setup();
|
||||||
try {
|
try {
|
||||||
const connect = pool.connect;
|
const connect = pool.connect;
|
||||||
@@ -150,7 +154,7 @@ test('Telegram SQL import rolls back all alerts if a later route insert fails',
|
|||||||
const client = await connect();
|
const client = await connect();
|
||||||
const query = client.query;
|
const query = client.query;
|
||||||
client.query = (sql, args) => {
|
client.query = (sql, args) => {
|
||||||
if (/INSERT INTO alerts/.test(sql) && args[0] === 2) throw new Error('Injected failure');
|
if (/INSERT INTO telegram_message_alerts/.test(sql)) throw new Error('Injected failure');
|
||||||
return query(sql, args);
|
return query(sql, args);
|
||||||
};
|
};
|
||||||
return client;
|
return client;
|
||||||
@@ -183,14 +187,14 @@ test('stop dictionary validates city and uniqueness, resolves waiting messages a
|
|||||||
assert.doesNotMatch(row.drafts[0].message, /Уважаемые|С УВАЖЕНИЕМ/);
|
assert.doesNotMatch(row.drafts[0].message, /Уважаемые|С УВАЖЕНИЕМ/);
|
||||||
const saved = await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 2 });
|
const saved = await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 2 });
|
||||||
assert.equal((await service.processMessage(original, 1)).status, 'published', 'unchanged Telegram post can be resolved after adding an alias');
|
assert.equal((await service.processMessage(original, 1)).status, 'published', 'unchanged Telegram post can be resolved after adding an alias');
|
||||||
const alerts = (await pool.query('SELECT a.* FROM alerts a JOIN telegram_import_alerts t ON t.alert_id=a.id WHERE t.message_id=$1 ORDER BY route_id', [row.id])).rows;
|
const alerts = (await pool.query('SELECT a.* FROM alerts a JOIN telegram_message_alerts t ON t.alert_id=a.id WHERE t.message_id=$1 ORDER BY id', [row.id])).rows;
|
||||||
assert.equal(alerts.length, 2);
|
assert.equal(alerts.length, 1);
|
||||||
assert.match(alerts[1].message, /Зодчего Иоанна — 15:58, 16:58/);
|
assert.match(alerts[0].message, /Зодчего Иоанна — 15:58, 16:58/);
|
||||||
row = (await service.list(1)).find(m => m.message_id === 1200);
|
row = (await service.list(1)).find(m => m.message_id === 1200);
|
||||||
assert.ok(row.drafts.some(d => d.routeId === 2 && d.message.includes('Зодчего Иоанна')));
|
assert.ok(row.drafts.some(d => d.routeNumber === '27' && d.message.includes('Зодчего Иоанна')));
|
||||||
await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 1 }, saved.id);
|
await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 1 }, saved.id);
|
||||||
assert.equal((await service.processMessage(original, 1)).status, 'unchanged');
|
assert.equal((await service.processMessage(original, 1)).status, 'unchanged');
|
||||||
assert.equal((await pool.query('SELECT message FROM alerts WHERE id=$1', [alerts[1].id])).rows[0].message, alerts[1].message);
|
assert.equal((await pool.query('SELECT message FROM alerts WHERE id=$1', [alerts[0].id])).rows[0].message, alerts[0].message);
|
||||||
await assert.rejects(dictionary.save(2, { alias: 'Чужое', stop_id: 3 }, saved.id), error => error.status === 404);
|
await assert.rejects(dictionary.save(2, { alias: 'Чужое', stop_id: 3 }, saved.id), error => error.status === 404);
|
||||||
|
|
||||||
// Inactive mappings never auto-publish, even for a route with one direction.
|
// Inactive mappings never auto-publish, even for a route with one direction.
|
||||||
@@ -230,6 +234,147 @@ test('reviewed snapshots respect auto-publish configuration changes and midnight
|
|||||||
assert.match(draft[0].message, /Автовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58/);
|
assert.match(draft[0].message, /Автовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58/);
|
||||||
clock('2026-10-02T21:00:00Z');
|
clock('2026-10-02T21:00:00Z');
|
||||||
assert.equal((await service.processMessage(message(1301), 1)).status, 'expired');
|
assert.equal((await service.processMessage(message(1301), 1)).status, 'expired');
|
||||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_import_alerts t JOIN telegram_import_messages m ON m.id=t.message_id WHERE m.message_id=1301')).rows[0].n, 0);
|
assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_message_alerts t JOIN telegram_import_messages m ON m.id=t.message_id WHERE m.message_id=1301')).rows[0].n, 0);
|
||||||
|
} finally { await db.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('several Telegram route numbers share one alert through import, review and edits', { skip: !modulePath }, async () => {
|
||||||
|
const { db, pool, service } = await setup();
|
||||||
|
try {
|
||||||
|
await pool.query("INSERT INTO routes(route_number,name,city_id) VALUES ('2','Туда',1),('2','Обратно',1),('99','Другой город',2)");
|
||||||
|
const multi = message(1500, carrierText + '\nПО МАРШРУТУ №2 ОТ ОСТАНОВКИ АВ/ВОКЗАЛ НА 18.00.');
|
||||||
|
assert.equal((await service.processMessage(multi, 1)).status, 'published');
|
||||||
|
let row = (await service.list(1))[0];
|
||||||
|
assert.equal(row.alert_ids.length, 1);
|
||||||
|
const id = row.alert_ids[0];
|
||||||
|
let alert = (await pool.query('SELECT * FROM alerts WHERE id=$1', [id])).rows[0];
|
||||||
|
assert.deepEqual(alert.route_numbers, ['27', '2']);
|
||||||
|
assert.match(alert.message, /Маршрут №27:/);
|
||||||
|
assert.match(alert.message, /Маршрут №2:\nАвтовокзал — 18:00/);
|
||||||
|
await pool.query('UPDATE alerts SET notification_sent_at=now() WHERE id=$1', [id]);
|
||||||
|
const edited = { ...multi, text: multi.text.replace('18.00', '18.05'), editedAt: '2026-10-02T14:00:00Z' };
|
||||||
|
await service.processMessage(edited, 1);
|
||||||
|
row = (await service.list(1))[0];
|
||||||
|
assert.deepEqual(row.alert_ids, [id]);
|
||||||
|
assert.match((await pool.query('SELECT * FROM alerts WHERE id=$1', [id])).rows[0].message, /18:05/);
|
||||||
|
const review = { action: 'publish', content_hash: row.content_hash, service_date: '2026-10-02', route_numbers: ['2', '27'], title: 'Отмены', message: 'Проверенный текст' };
|
||||||
|
await assert.rejects(service.review(row.id, 1, { ...review, route_numbers: [] }, 1), /хотя бы один/);
|
||||||
|
await assert.rejects(service.review(row.id, 1, { ...review, route_numbers: ['27', '99'] }, 1), /выбранном городе/);
|
||||||
|
await service.review(row.id, 1, review, 1);
|
||||||
|
alert = (await pool.query('SELECT * FROM alerts WHERE id=$1', [id])).rows[0];
|
||||||
|
assert.deepEqual(alert.route_numbers, ['2', '27']);
|
||||||
|
assert.ok(alert.notification_sent_at);
|
||||||
|
assert.equal(alert.end_time.toISOString(), '2026-10-02T21:00:00.000Z');
|
||||||
|
// Removing a route section changes the targets on the same alert.
|
||||||
|
await service.processMessage({ ...edited, text: carrierText, editedAt: '2026-10-02T15:00:00Z' }, 1);
|
||||||
|
assert.deepEqual((await pool.query('SELECT route_numbers FROM alerts WHERE id=$1', [id])).rows[0].route_numbers, ['27']);
|
||||||
|
const unknown = message(1501, multi.text.replace('№2 ', '№99 '));
|
||||||
|
assert.equal((await service.processMessage(unknown, 1)).status, 'review');
|
||||||
|
assert.equal((await service.list(1)).find(m => m.message_id === 1501).alert_ids.length, 0);
|
||||||
|
// The scheduler includes both directions of every selected number.
|
||||||
|
await pool.query("UPDATE alerts SET start_time=now()-interval '1 minute',end_time=now()+interval '1 hour',notification_sent_at=NULL WHERE id=$1", [id]);
|
||||||
|
const claimed = (await pool.query(CLAIM_DUE_ALERTS_SQL, [50])).rows;
|
||||||
|
assert.deepEqual(claimed.find(a => a.id === id).route_ids, [1, 2]);
|
||||||
|
} finally { await db.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual alert API validates numbers, returns all directions, filters, edits and replays one push', { skip: !modulePath }, async () => {
|
||||||
|
const { db, pool } = await setup();
|
||||||
|
const express = require('express');
|
||||||
|
const databasePath = require.resolve('../src/config/database');
|
||||||
|
const previous = require.cache[databasePath];
|
||||||
|
require.cache[databasePath] = { exports: pool };
|
||||||
|
delete require.cache[require.resolve('../src/routes/alerts')];
|
||||||
|
const { AlertCreationService, setAlertCreationServiceForTesting } = require('../src/services/alertCreationService');
|
||||||
|
const pushes = [];
|
||||||
|
setAlertCreationServiceForTesting(new AlertCreationService({ pool, logger: { error() {} }, notificationService: { sendAlertCreated: async alert => { pushes.push(alert); } } }));
|
||||||
|
const app = express(); app.use(express.json());
|
||||||
|
app.use((req, res, next) => { req.city = { id: Number(req.get('X-City-Id') || 1) }; next(); });
|
||||||
|
app.use('/alerts', require('../src/routes/alerts'));
|
||||||
|
const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
||||||
|
const url = `http://127.0.0.1:${server.address().port}/alerts`;
|
||||||
|
const request = async (path = '', method = 'GET', body, extra = {}) => {
|
||||||
|
const response = await fetch(url + path, { method, headers: { 'Content-Type': 'application/json', ...extra }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
||||||
|
return { status: response.status, data: await response.json() };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await pool.query("INSERT INTO routes(route_number,name,city_id) VALUES ('2','Туда',1),('2','Обратно',1),('99','Чужой',2),('5','Отключён',1)");
|
||||||
|
await pool.query('UPDATE routes SET is_active=false WHERE route_number=$1', ['5']);
|
||||||
|
const input = { alert_type: 'detour', title: 'Переезд закрыт', message: 'Объезд для №2 и №27', start_time: new Date(Date.now() - 60000).toISOString(), end_time: new Date(Date.now() + 3600000).toISOString(), route_numbers: ['27', '2'] };
|
||||||
|
for (const numbers of [['99'], ['27', '5'], [2], null, ['']]) {
|
||||||
|
assert.equal((await request('', 'POST', { ...input, route_numbers: numbers })).status, 400);
|
||||||
|
}
|
||||||
|
assert.equal(pushes.length, 0);
|
||||||
|
const first = await request('', 'POST', input, { 'Idempotency-Key': 'multi-route' });
|
||||||
|
assert.equal(first.status, 201);
|
||||||
|
assert.deepEqual(first.data.route_numbers, ['2', '27']);
|
||||||
|
assert.deepEqual(first.data.route_ids, [1, 2, 4, 5]);
|
||||||
|
assert.equal(pushes.length, 1);
|
||||||
|
const replay = await request('', 'POST', { ...input, route_numbers: [' №2 ', '27', '2'] }, { 'Idempotency-Key': 'multi-route' });
|
||||||
|
assert.equal(replay.data.id, first.data.id);
|
||||||
|
assert.equal(pushes.length, 1);
|
||||||
|
assert.equal((await request('', 'POST', { ...input, route_numbers: ['27'] }, { 'Idempotency-Key': 'multi-route' })).status, 409);
|
||||||
|
for (const routeId of [1, 2, 4, 5]) {
|
||||||
|
const filtered = await request(`/active?route_id=${routeId}`);
|
||||||
|
assert.equal(filtered.status, 200);
|
||||||
|
assert.ok(filtered.data.some(a => a.id === first.data.id));
|
||||||
|
}
|
||||||
|
assert.equal((await request('/active?route_id=3')).data.length, 0, 'another city is excluded');
|
||||||
|
assert.equal((await request('/' + first.data.id, 'GET', null, { 'X-City-Id': '2' })).status, 404);
|
||||||
|
assert.equal((await request('/' + first.data.id, 'PUT', { route_numbers: ['99'] })).status, 400);
|
||||||
|
assert.deepEqual((await request('/' + first.data.id)).data.route_numbers, ['2', '27']);
|
||||||
|
const updated = await request('/' + first.data.id, 'PUT', { route_numbers: ['27'] });
|
||||||
|
assert.deepEqual(updated.data.route_ids, [1, 2]);
|
||||||
|
assert.equal(updated.data.end_time, input.end_time, 'partial target edits preserve expiry');
|
||||||
|
assert.equal((await request(`/active?route_id=4`)).data.length, 0);
|
||||||
|
const general = await request('/' + first.data.id, 'PUT', { route_numbers: [] });
|
||||||
|
assert.deepEqual(general.data.route_ids, []);
|
||||||
|
assert.equal(general.data.route_id, null);
|
||||||
|
const legacy = await request('', 'POST', { ...input, route_numbers: undefined, route_id: 2 });
|
||||||
|
assert.deepEqual(legacy.data.route_numbers, ['27']);
|
||||||
|
assert.deepEqual(legacy.data.route_ids, [1, 2]);
|
||||||
|
await pool.query("UPDATE alerts SET end_time=now()-interval '1 minute' WHERE id=1");
|
||||||
|
const cleanup = await request('/cleanup', 'POST', {});
|
||||||
|
assert.equal(cleanup.status, 200);
|
||||||
|
assert.ok(cleanup.data.deactivated_count >= 1);
|
||||||
|
const single = await request('', 'POST', { ...input, route_numbers: ['27'] }, { 'Idempotency-Key': 'single-number' });
|
||||||
|
assert.equal(single.status, 201);
|
||||||
|
// Backfill and FK changes keep notifications when one direction is removed.
|
||||||
|
assert.deepEqual((await pool.query('SELECT route_numbers FROM alerts WHERE id=1')).rows[0].route_numbers, ['27']);
|
||||||
|
await pool.query('DELETE FROM routes WHERE id=1');
|
||||||
|
assert.equal((await pool.query('SELECT route_id FROM alerts WHERE id=1')).rows[0].route_id, null);
|
||||||
|
assert.deepEqual((await request('/' + legacy.data.id)).data.route_ids, [2]);
|
||||||
|
const singleReplay = await request('', 'POST', { ...input, route_numbers: ['27'] }, { 'Idempotency-Key': 'single-number' });
|
||||||
|
assert.equal(singleReplay.data.id, single.data.id, 'changing the compatibility anchor cannot change the request hash');
|
||||||
|
const newDirection = (await pool.query("INSERT INTO routes(route_number,name,city_id) VALUES ('27','Новое направление',1) RETURNING id")).rows[0].id;
|
||||||
|
assert.deepEqual((await request('/' + legacy.data.id)).data.route_ids, [2, newDirection]);
|
||||||
|
assert.ok((await request('/active?route_id=' + newDirection)).data.some(a => a.id === legacy.data.id));
|
||||||
|
} finally {
|
||||||
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
setAlertCreationServiceForTesting(null);
|
||||||
|
if (previous) require.cache[databasePath] = previous; else delete require.cache[databasePath];
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy per-direction Telegram alerts consolidate on edit without repeating a delivered push', { skip: !modulePath }, async () => {
|
||||||
|
const { db, pool, service } = await setup();
|
||||||
|
try {
|
||||||
|
await service.processMessage(message(1600), 1);
|
||||||
|
const row = (await service.list(1))[0];
|
||||||
|
const id = row.alert_ids[0];
|
||||||
|
await pool.query('DELETE FROM telegram_message_alerts WHERE message_id=$1', [row.id]);
|
||||||
|
await pool.query('INSERT INTO telegram_import_alerts(message_id,route_id,alert_id) VALUES ($1,1,$2)', [row.id, id]);
|
||||||
|
const second = (await pool.query(`INSERT INTO alerts(route_id,city_id,alert_type,title,message,start_time,end_time,notification_sent_at)
|
||||||
|
VALUES (2,1,'cancellation','Old second','Old second',now(),now()+interval '1 hour',now()) RETURNING id`)).rows[0].id;
|
||||||
|
await pool.query('INSERT INTO telegram_import_alerts(message_id,route_id,alert_id) VALUES ($1,2,$2)', [row.id, second]);
|
||||||
|
await service.processMessage({ ...message(1600), text: carrierText.replace('15.43','15.44'), editedAt:'2026-10-02T14:00Z' }, 1);
|
||||||
|
assert.deepEqual((await service.list(1))[0].alert_ids, [id]);
|
||||||
|
const alerts = (await pool.query('SELECT * FROM alerts WHERE id=ANY($1::int[]) ORDER BY id', [[id, second]])).rows;
|
||||||
|
assert.equal(alerts[0].is_active, true);
|
||||||
|
assert.ok(alerts[0].notification_sent_at);
|
||||||
|
assert.equal(alerts[1].is_active, false);
|
||||||
|
assert.match(alerts[0].message, /Автовокзал.*15:44/);
|
||||||
|
assert.match(alerts[0].message, /Зодчего Иоанна/);
|
||||||
} finally { await db.close(); }
|
} finally { await db.close(); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -295,3 +295,7 @@ curl -i -X POST http://localhost:3000/api/v1/alerts \
|
|||||||
|
|
||||||
Поведение Android при foreground/background полностью определяется клиентской
|
Поведение Android при foreground/background полностью определяется клиентской
|
||||||
обработкой data-only сообщения.
|
обработкой data-only сообщения.
|
||||||
|
|
||||||
|
## Несколько номеров маршрутов
|
||||||
|
|
||||||
|
Миграция `015_alert_route_numbers.sql` добавляет `alerts.route_numbers`. API создания/редактирования принимает, например, `route_numbers: ["2", "27"]`. Все направления этих номеров в выбранном городе получают одно уведомление. В data payload дополнительно передаются `routeNumbers: '["2","27"]'` и `routeIds: '[1,2,4,5]'` — строки с JSON-массивами. Для общего уведомления оба массива пусты. `routeId` и `routeNumber` сохранены для совместимости с одним номером; при нескольких они пустые. Мобильный клиент должен использовать массивы для привязки уведомления к маршрутам. Изменения мобильного клиента здесь не реализуются.
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
Формат «Не будут выполняться рейсы: ПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43. С ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.» разбирается в четыре конкретных отправления. `АВ/ВОКЗАЛ` означает **Автовокзал**, точки в часах заменяются двоеточием. Отмена всего маршрута из такого объявления не выводится.
|
Формат «Не будут выполняться рейсы: ПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43. С ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.» разбирается в четыре конкретных отправления. `АВ/ВОКЗАЛ` означает **Автовокзал**, точки в часах заменяются двоеточием. Отмена всего маршрута из такого объявления не выводится.
|
||||||
|
|
||||||
Поддерживаются формулировки **«С ОСТАНОВКИ»** и **«ОТ ОСТАНОВКИ»**, включая их сочетание в одном сообщении. Значения вроде `на 7.15` и `на 8.00` после остановки считаются временем отправления. Если отдельная дата не указана, день отмены берётся из оригинальной публикации. После обновления backend ранее сохранённые сообщения с ошибкой «Некорректная дата отмены» будут обработаны повторно при очередном чтении канала в пределах трёхдневного окна; сообщения с неизвестными остановками или неоднозначными направлениями останутся на проверке с уточнённой причиной.
|
Поддерживаются формулировки **«С ОСТАНОВКИ»** и **«ОТ ОСТАНОВКИ»**, включая их сочетание в одном сообщении. Значения вроде `на 7.15` и `на 8.00` после остановки считаются временем отправления. Если отдельная дата не указана, день отмены берётся из оригинальной публикации. После обновления backend ранее сохранённые сообщения с ошибкой «Некорректная дата отмены» будут обработаны повторно при очередном чтении канала в пределах трёхдневного окна; сообщения с неизвестными остановками или номерами маршрутов останутся на проверке с уточнённой причиной.
|
||||||
|
|
||||||
Если у номера несколько направлений, они сопоставляются по начальной остановке в базе. Неоднозначный номер, неизвестное направление, разные даты, условия, периоды и изображения без текста требуют проверки администратором. Обычные новости не импортируются. Пока нет OCR, распознавания произвольных формулировок и автоматического редактирования расписаний.
|
Номер маршрута относится ко всем его действующим направлениям в выбранном городе. Один пост создаёт одно уведомление, в том числе при нескольких номерах. Поддерживаются отдельные блоки «по маршруту №…» и общий список «по маршрутам №2, №27» с явно указанными остановками и временами. Неизвестные номера и остановки, разные даты, условия, периоды и изображения без текста требуют проверки администратором. Неизвестный номер блокирует автоматическую публикацию всего поста. Обычные новости не импортируются. Пока нет OCR, распознавания произвольных формулировок и автоматического редактирования расписаний.
|
||||||
|
|
||||||
Явная дата, «сегодня» или «завтра» определяют день отмены. Если дата отсутствует, используется **день оригинальной публикации по Europe/Minsk**, а не день импорта или правки. Дата без года выбирается ближайшей к публикации. Конец действия — **00:00 следующего дня по Минску**, граница исключительная. Например, для 02.10.2026 это `2026-10-02T21:00:00Z`. Будущие отмены активируются в начале указанного дня и получают push тогда же. Просроченные объявления сохраняются как завершённые без push.
|
Явная дата, «сегодня» или «завтра» определяют день отмены. Если дата отсутствует, используется **день оригинальной публикации по Europe/Minsk**, а не день импорта или правки. Дата без года выбирается ближайшей к публикации. Конец действия — **00:00 следующего дня по Минску**, граница исключительная. Например, для 02.10.2026 это `2026-10-02T21:00:00Z`. Будущие отмены активируются в начале указанного дня и получают push тогда же. Просроченные объявления сохраняются как завершённые без push.
|
||||||
|
|
||||||
Новые однозначные отмены публикуются автоматически. `TELEGRAM_AUTO_PUBLISH=false` переводит все распознанные отмены на проверку. В админке на странице **«Уведомления» → «Отмены из Telegram»** доступны состояние подключения, ручная проверка канала, исходные сообщения, ссылки на оригинал и история. При ручной публикации оператор проверяет маршрут, текст и день отмены; срок до полуночи назначается сервером.
|
Новые однозначные отмены публикуются автоматически. `TELEGRAM_AUTO_PUBLISH=false` переводит все распознанные отмены на проверку. В админке на странице **«Уведомления» → «Отмены из Telegram»** доступны состояние подключения, ручная проверка канала, исходные сообщения, ссылки на оригинал и история. При ручной публикации оператор выбирает один или несколько номеров маршрутов, проверяет текст и день отмены; срок до полуночи назначается сервером.
|
||||||
|
|
||||||
### Словарь остановок и текст уведомления
|
### Словарь остановок и текст уведомления
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
Источник: https://t.me/avp2Polotsk/1166
|
Источник: https://t.me/avp2Polotsk/1166
|
||||||
```
|
```
|
||||||
|
|
||||||
При нескольких направлениях формируются отдельные уведомления с соответствующими отправлениями. Исходный текст остаётся в истории импорта. В ручной проверке распознанный текст также подставляется в удобочитаемом виде; выбор направления подставляет его отправления, пока оператор не изменил текст самостоятельно. Если сообщение не удалось разобрать, текст для ручной правки остаётся исходным.
|
При нескольких номерах создаётся одно уведомление с блоком отправлений для каждого номера. Исходный текст остаётся в истории импорта. В ручной проверке распознанный текст также подставляется в удобочитаемом виде; выбор номеров подставляет соответствующие отправления, пока оператор не изменил текст самостоятельно. Если сообщение не удалось разобрать, текст для ручной правки остаётся исходным.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -44,9 +44,13 @@ docker compose exec -T postgres psql -U transit_admin -d polotsk_transit \
|
|||||||
docker compose exec -T postgres \
|
docker compose exec -T postgres \
|
||||||
psql -v ON_ERROR_STOP=1 --single-transaction -U transit_admin -d polotsk_transit \
|
psql -v ON_ERROR_STOP=1 --single-transaction -U transit_admin -d polotsk_transit \
|
||||||
< backend/migrations/014_telegram_stop_aliases.sql
|
< backend/migrations/014_telegram_stop_aliases.sql
|
||||||
|
|
||||||
|
docker compose exec -T postgres \
|
||||||
|
psql -v ON_ERROR_STOP=1 -U transit_admin -d polotsk_transit \
|
||||||
|
< backend/migrations/015_alert_route_numbers.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
Если миграция 013 уже установлена, для обновления словаря достаточно миграции **014** и пересборки backend/frontend. На чистой базе Docker применяет обе миграции автоматически. **Не запускайте `npm run migrate` на существующей базе**: старые init-миграции проекта не поддерживают повторный запуск.
|
Если миграции 013 и 014 уже установлены, примените только **015** и пересоберите backend/frontend. На чистой базе Docker применяет все миграции автоматически. Миграция 015 заполняет номера у существующих уведомлений. Опубликованные Telegram-записи сразу не переписываются: при следующей правке/ручной проверке старые уведомления по направлениям объединяются, уже доставленные push повторно не отправляются. **Не запускайте `npm run migrate` на существующей базе**: старые init-миграции проекта не поддерживают повторный запуск.
|
||||||
|
|
||||||
### Авторизация аккаунта
|
### Авторизация аккаунта
|
||||||
|
|
||||||
@@ -103,13 +107,19 @@ FCM TTL ограничен остатком срока действия алер
|
|||||||
|
|
||||||
При FloodWait worker выдерживает паузу, не запускает параллельные циклы и отображает время следующей попытки. За один цикл лимит составляет 1000 публикаций; превышение отображается как ошибка, а не как успешное чтение всей истории. Сообщения, исчезнувшие из Telegram во время длительного простоя сервера, восстановить нельзя.
|
При FloodWait worker выдерживает паузу, не запускает параллельные циклы и отображает время следующей попытки. За один цикл лимит составляет 1000 публикаций; превышение отображается как ошибка, а не как успешное чтение всей истории. Сообщения, исчезнувшие из Telegram во время длительного простоя сервера, восстановить нельзя.
|
||||||
|
|
||||||
|
## Несколько маршрутов в API
|
||||||
|
|
||||||
|
Ручные `POST /api/v1/alerts` и `PUT /api/v1/alerts/:id` принимают `route_numbers: ["2", "27"]`. Пустой список создаёт общее городское уведомление; при публикации отмены из Telegram хотя бы один номер обязателен. Сервер проверяет существование действующих маршрутов выбранного города; неизвестный номер отклоняет весь запрос. Повторный выбор и порядок номеров не влияют на идемпотентность.
|
||||||
|
|
||||||
|
Ответы API и sync содержат `route_numbers` и `route_ids` (ID всех действующих направлений выбранных номеров). Фильтр `GET /alerts/active?route_id=…` и `GET /alerts?route_id=…` возвращает уведомление для любого направления выбранного номера. Старое поле `route_id` сохранено для запросов и ответа с одним номером; при нескольких номерах оно равно `null`. В FCM добавлены строки `routeNumbers` и `routeIds` с JSON-массивами; отправляется одно событие на уведомление. Для корректной привязки нескольких маршрутов в мобильном клиенте необходимо читать эти массивы; Android-код в этом репозитории отсутствует.
|
||||||
|
|
||||||
## API и проверка
|
## API и проверка
|
||||||
|
|
||||||
Все endpoint требуют JWT администратора и учитывают `X-City-Id`:
|
Все endpoint требуют JWT администратора и учитывают `X-City-Id`:
|
||||||
|
|
||||||
- `GET /api/v1/telegram-import` — безопасный статус и до 100 сообщений, сначала требующие проверки.
|
- `GET /api/v1/telegram-import` — безопасный статус и до 100 сообщений, сначала требующие проверки.
|
||||||
- `POST /api/v1/telegram-import/sync` — чтение настроенного канала; запрещено в другом городе.
|
- `POST /api/v1/telegram-import/sync` — чтение настроенного канала; запрещено в другом городе.
|
||||||
- `POST /api/v1/telegram-import/:id/review` — `content_hash`, `action: publish|dismiss`; для публикации также `service_date`, `route_id`, `title`, `message`.
|
- `POST /api/v1/telegram-import/:id/review` — `content_hash`, `action: publish|dismiss`; для публикации также `service_date`, `route_numbers: ["2", "27"]`, `title`, `message`; старое поле `route_id` также принимается.
|
||||||
- `GET /api/v1/telegram-import/stop-aliases` — словарь выбранного города.
|
- `GET /api/v1/telegram-import/stop-aliases` — словарь выбранного города.
|
||||||
- `POST /api/v1/telegram-import/stop-aliases` — `alias`, `stop_id`; добавить соответствие.
|
- `POST /api/v1/telegram-import/stop-aliases` — `alias`, `stop_id`; добавить соответствие.
|
||||||
- `PUT /api/v1/telegram-import/stop-aliases/:id` — `alias`, `stop_id`; изменить соответствие.
|
- `PUT /api/v1/telegram-import/stop-aliases/:id` — `alias`, `stop_id`; изменить соответствие.
|
||||||
|
|||||||
36
frontend/src/components/RouteNumberPicker.jsx
Normal file
36
frontend/src/components/RouteNumberPicker.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useId } from 'react';
|
||||||
|
|
||||||
|
export const normalizeRouteNumber = value => String(value).replace(/[\s№]/g, '').toUpperCase();
|
||||||
|
|
||||||
|
export function alertRouteNumbers(alert, routes = []) {
|
||||||
|
if (Array.isArray(alert?.route_numbers)) return alert.route_numbers;
|
||||||
|
const number = alert?.route_number || routes.find(r => Number(r.id) === Number(alert?.route_id))?.route_number;
|
||||||
|
return number ? [normalizeRouteNumber(number)] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RouteNumberPicker({ routes, value, onChange, disabled = false, required = false }) {
|
||||||
|
const id = useId();
|
||||||
|
const selected = new Set(value.map(normalizeRouteNumber));
|
||||||
|
const active = new Set(routes.filter(r => r.is_active).map(r => normalizeRouteNumber(r.route_number)));
|
||||||
|
const numbers = [...new Set([...active, ...selected])].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true }));
|
||||||
|
const toggle = number => onChange(selected.has(number) ? [...selected].filter(n => n !== number) : [...selected, number]);
|
||||||
|
return (
|
||||||
|
<fieldset disabled={disabled} aria-describedby={`${id}-help`}>
|
||||||
|
<legend className="text-sm font-medium text-gray-700 mb-2">Номера маршрутов{required ? ' *' : ''}</legend>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-44 overflow-y-auto p-1">
|
||||||
|
{numbers.map(number => (
|
||||||
|
<label key={number} className={`inline-flex items-center gap-2 px-3 py-2 border rounded-lg text-sm cursor-pointer ${selected.has(number) ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-gray-200 bg-white text-gray-700'}`}>
|
||||||
|
<input type="checkbox" checked={selected.has(number)} onChange={() => toggle(number)} className="h-4 w-4 rounded text-primary-600 focus:ring-primary-500" />
|
||||||
|
<span>№{number}{!active.has(number) ? ' (недоступен)' : ''}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{!numbers.length && <span className="text-sm text-gray-500">Нет действующих маршрутов.</span>}
|
||||||
|
</div>
|
||||||
|
<p id={`${id}-help`} className="text-xs text-gray-500 mt-2">
|
||||||
|
{selected.size ? `Выбрано: ${selected.size}. Уведомление относится ко всем направлениям этих номеров.`
|
||||||
|
: required ? 'Выберите хотя бы один номер. Можно выбрать несколько.' : 'Без выбранных номеров — общее уведомление для города.'}
|
||||||
|
</p>
|
||||||
|
{selected.size > 0 && <button type="button" className="text-xs text-primary-600 mt-2 hover:underline" onClick={() => onChange([])}>Снять выбор</button>}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { ExternalLink, RefreshCw, Send, X } from 'lucide-react';
|
|||||||
import { telegramImportApi } from '../services/api';
|
import { telegramImportApi } from '../services/api';
|
||||||
import { useCity } from '../contexts/CityContext';
|
import { useCity } from '../contexts/CityContext';
|
||||||
import TelegramStopAliases from './TelegramStopAliases';
|
import TelegramStopAliases from './TelegramStopAliases';
|
||||||
|
import RouteNumberPicker, { normalizeRouteNumber } from './RouteNumberPicker';
|
||||||
|
|
||||||
const connectionLabels = {
|
const connectionLabels = {
|
||||||
disabled: 'Импорт выключен', configuration_required: 'Нужна настройка аккаунта',
|
disabled: 'Импорт выключен', configuration_required: 'Нужна настройка аккаунта',
|
||||||
@@ -116,38 +117,42 @@ function TelegramReviewForm({ row, routes, busy, onSubmit, onCancel }) {
|
|||||||
const publicationDate = new Intl.DateTimeFormat('en-CA', {
|
const publicationDate = new Intl.DateTimeFormat('en-CA', {
|
||||||
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit'
|
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit'
|
||||||
}).format(new Date(row.published_at));
|
}).format(new Date(row.published_at));
|
||||||
const initialRouteId = row.parsed?.plans?.length === 1 ? row.parsed.plans[0].routeId : '';
|
const initialNumbers = [...new Set((row.parsed?.selectedRouteNumbers || row.parsed?.groups?.map(g => g.routeNumber) || [])
|
||||||
const draftFor = routeId => {
|
.map(normalizeRouteNumber))];
|
||||||
const route = routes.find(r => Number(r.id) === Number(routeId));
|
const draftFor = (numbers, date) => {
|
||||||
return row.drafts?.find(d => d.routeId !== null && Number(d.routeId) === Number(routeId))
|
const drafts = numbers.map(number => row.drafts?.find(d => normalizeRouteNumber(d.routeNumber) === number));
|
||||||
|| row.drafts?.find(d => d.routeId === null && (!route || String(d.routeNumber).toLowerCase() === String(route.route_number).toLowerCase()));
|
const complete = drafts.length && drafts.every(Boolean);
|
||||||
|
return {
|
||||||
|
title: `Отмена отдельных рейсов${numbers.length ? ` ${numbers.map(n => `№${n}`).join(', ')}` : ''}`,
|
||||||
|
message: complete ? `${date.split('-').reverse().join('.')}: не выполняются рейсы:\n${drafts.map((d, i) =>
|
||||||
|
`${drafts.length > 1 ? `Маршрут №${numbers[i]}:\n` : ''}${d.message.replace(/^[^\n]*\n/, '')}`).join('\n\n')}` : row.raw_text.slice(0, 5000)
|
||||||
|
};
|
||||||
};
|
};
|
||||||
const initialDraft = draftFor(initialRouteId);
|
const initialDate = row.parsed?.serviceDate || publicationDate;
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
action: 'publish', service_date: row.parsed?.serviceDate || publicationDate, route_id: initialRouteId,
|
action: 'publish', service_date: initialDate, route_numbers: initialNumbers,
|
||||||
title: initialDraft?.title || 'Отмена отдельных рейсов', message: initialDraft?.message || row.raw_text.slice(0, 5000)
|
...draftFor(initialNumbers, initialDate)
|
||||||
});
|
});
|
||||||
const [editedText, setEditedText] = useState(false);
|
const [editedText, setEditedText] = useState(false);
|
||||||
const change = e => {
|
const change = e => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
if (name === 'title' || name === 'message') setEditedText(true);
|
if (name === 'title' || name === 'message') setEditedText(true);
|
||||||
const draft = name === 'route_id' && !editedText ? draftFor(value) : null;
|
setForm(current => ({ ...current, [name]: value,
|
||||||
const message = name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value) : {}) }));
|
||||||
? form.message.replace(/^\d{2}\.\d{2}\.\d{4}:/, `${value.split('-').reverse().join('.')}:`) : form.message;
|
|
||||||
const draftMessage = draft?.message.replace(/^\d{2}\.\d{2}\.\d{4}:/, `${form.service_date.split('-').reverse().join('.')}:`);
|
|
||||||
setForm({ ...form, message, [name]: value, ...(draft ? { title: draft.title, message: draftMessage } : {}) });
|
|
||||||
};
|
};
|
||||||
|
const changeRoutes = route_numbers => setForm(current => ({ ...current, route_numbers,
|
||||||
|
...(!editedText ? draftFor(route_numbers, current.service_date) : {}) }));
|
||||||
return (
|
return (
|
||||||
<form className="mt-4 rounded-lg bg-gray-50 p-4 space-y-3" onSubmit={e => { e.preventDefault(); onSubmit(form); }}>
|
<form className="mt-4 rounded-lg bg-gray-50 p-4 space-y-3" onSubmit={e => { e.preventDefault(); onSubmit(form); }}>
|
||||||
<div className="flex items-center justify-between"><h3 className="font-medium">Проверка отмены</h3><button type="button" onClick={onCancel} aria-label="Закрыть проверку" disabled={busy}><X className="h-5 w-5" /></button></div>
|
<div className="flex items-center justify-between"><h3 className="font-medium">Проверка отмены</h3><button type="button" onClick={onCancel} aria-label="Закрыть проверку" disabled={busy}><X className="h-5 w-5" /></button></div>
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
<RouteNumberPicker routes={routes} value={form.route_numbers} onChange={changeRoutes} disabled={busy} required />
|
||||||
<label className="text-sm">Маршрут<select className="input mt-1 w-full" name="route_id" value={form.route_id} onChange={change} required><option value="">Выберите направление</option>{routes.filter(r => r.is_active).map(r => <option key={r.id} value={r.id}>№{r.route_number} · {r.name}</option>)}</select></label>
|
<div>
|
||||||
<label className="text-sm">День отмены<input className="input mt-1 w-full" type="date" name="service_date" value={form.service_date} onChange={change} required /></label>
|
<label className="text-sm">День отмены<input className="input mt-1 w-full" type="date" name="service_date" value={form.service_date} onChange={change} required /></label>
|
||||||
</div>
|
</div>
|
||||||
<label className="text-sm block">Заголовок<input className="input mt-1 w-full" name="title" value={form.title} maxLength={200} onChange={change} required /></label>
|
<label className="text-sm block">Заголовок<input className="input mt-1 w-full" name="title" value={form.title} maxLength={200} onChange={change} required /></label>
|
||||||
<label className="text-sm block">Текст уведомления<textarea className="input mt-1 w-full" rows={5} name="message" value={form.message} maxLength={5000} onChange={change} required /></label>
|
<label className="text-sm block">Текст уведомления<textarea className="input mt-1 w-full" rows={5} name="message" value={form.message} maxLength={5000} onChange={change} required /></label>
|
||||||
<p className="text-xs text-gray-500">Уточните остановки и времена. Уведомление будет действовать до 00:00 следующего дня по Минску. Ссылка на оригинал добавится автоматически.</p>
|
<p className="text-xs text-gray-500">Уточните остановки и времена. Уведомление будет действовать до 00:00 следующего дня по Минску. Ссылка на оригинал добавится автоматически.</p>
|
||||||
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy}>Опубликовать отмену</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
|
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy || !form.route_numbers.length}>Опубликовать отмену</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { alertsApi, routesApi } from '../services/api';
|
|||||||
import { AlertTypeBadge } from '../components/AlertComponents';
|
import { AlertTypeBadge } from '../components/AlertComponents';
|
||||||
import { PageLoader } from '../components/AnimatedComponents';
|
import { PageLoader } from '../components/AnimatedComponents';
|
||||||
import TelegramImportPanel from '../components/TelegramImportPanel';
|
import TelegramImportPanel from '../components/TelegramImportPanel';
|
||||||
|
import RouteNumberPicker, { alertRouteNumbers } from '../components/RouteNumberPicker';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
export default function AlertsPage() {
|
export default function AlertsPage() {
|
||||||
@@ -93,7 +94,7 @@ export default function AlertsPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving alert:', error);
|
console.error('Error saving alert:', error);
|
||||||
alert('Ошибка сохранения уведомления');
|
throw new Error(error.response?.data?.error || 'Ошибка сохранения уведомления');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -382,17 +383,10 @@ function AlertCard({ alert, index, now, onEdit, onDelete }) {
|
|||||||
<AlertTypeBadge type={alert.alert_type} />
|
<AlertTypeBadge type={alert.alert_type} />
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusLabel.className}`}>{statusLabel.label}</span>
|
<span className={`text-xs px-2 py-0.5 rounded-full ${statusLabel.className}`}>{statusLabel.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{alert.route_name && (
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
{alertRouteNumbers(alert).map(number => <span key={number} className="px-2 py-0.5 rounded text-xs font-bold bg-primary-100 text-primary-700">№{number}</span>)}
|
||||||
<span
|
<span className="text-xs text-gray-500">{alertRouteNumbers(alert).length ? 'Все направления' : 'Общее уведомление'}</span>
|
||||||
className="px-2 py-0.5 rounded text-xs font-bold text-white"
|
</div>
|
||||||
style={{ backgroundColor: alert.color || '#6B7280' }}
|
|
||||||
>
|
|
||||||
{alert.route_number}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-600">{alert.route_name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -466,9 +460,10 @@ function toMinskDate(value) {
|
|||||||
function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
|
function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
|
||||||
const [idempotencyKey] = useState(createAlertIdempotencyKey);
|
const [idempotencyKey] = useState(createAlertIdempotencyKey);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
|
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
route_id: alert?.route_id || '',
|
route_numbers: alertRouteNumbers(alert, routes),
|
||||||
alert_type: alert?.alert_type || 'info',
|
alert_type: alert?.alert_type || 'info',
|
||||||
title: alert?.title || '',
|
title: alert?.title || '',
|
||||||
message: alert?.message || '',
|
message: alert?.message || '',
|
||||||
@@ -482,14 +477,16 @@ function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
|
|||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
...formData,
|
...formData,
|
||||||
route_id: formData.route_id || null,
|
|
||||||
start_time: toApiDateTime(formData.start_time),
|
start_time: toApiDateTime(formData.start_time),
|
||||||
end_time: toApiDateTime(formData.end_time)
|
end_time: toApiDateTime(formData.end_time)
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
setError('');
|
||||||
await onSubmit(data, alert ? null : idempotencyKey);
|
await onSubmit(data, alert ? null : idempotencyKey);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -540,25 +537,9 @@ function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
|
|||||||
<p className="mt-2 text-xs text-gray-500">Шаблон заполнит тип, заголовок и текст — всё можно отредактировать перед сохранением.</p>
|
<p className="mt-2 text-xs text-gray-500">Шаблон заполнит тип, заголовок и текст — всё можно отредактировать перед сохранением.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Route */}
|
<RouteNumberPicker routes={routes} value={formData.route_numbers} disabled={submitting}
|
||||||
<div>
|
onChange={route_numbers => setFormData(current => ({ ...current, route_numbers }))} />
|
||||||
<label className="label">
|
{error && <p role="alert" className="text-sm text-red-700">{error}</p>}
|
||||||
Маршрут <span className="text-gray-500 text-xs">(необязательно)</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="route_id"
|
|
||||||
value={formData.route_id}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="input"
|
|
||||||
>
|
|
||||||
<option value="">Общее уведомление</option>
|
|
||||||
{routes.map(route => (
|
|
||||||
<option key={route.id} value={route.id}>
|
|
||||||
{route.route_number} — {route.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert Type */}
|
{/* Alert Type */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user