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:
2026-10-05 12:07:09 +03:00
parent e511f74aea
commit c0d40f9b90
18 changed files with 462 additions and 150 deletions

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

View File

@@ -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]);

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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'] }));
});

View File

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

View File

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

View File

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

View File

@@ -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` сохранены для совместимости с одним номером; при нескольких они пустые. Мобильный клиент должен использовать массивы для привязки уведомления к маршрутам. Изменения мобильного клиента здесь не реализуются.

View File

@@ -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`; изменить соответствие.

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

View File

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

View File

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