From c0d40f9b90c1bd061ca13e42f21346d004194086 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Mon, 5 Oct 2026 12:07:09 +0300 Subject: [PATCH] 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. --- .../migrations/015_alert_route_numbers.sql | 20 ++ backend/src/controllers/syncController.js | 5 +- backend/src/routes/alerts.js | 39 ++-- backend/src/services/alertCreationService.js | 18 +- .../src/services/alertNotificationService.js | 4 + backend/src/services/alertSchedulerService.js | 8 +- backend/src/services/alertTargets.js | 47 +++++ .../services/telegramCancellationParser.js | 33 ++-- backend/src/services/telegramImportService.js | 59 +++--- backend/test/alertCreationService.test.js | 7 + backend/test/alertNotificationService.test.js | 9 + .../test/telegramCancellationParser.test.js | 32 ++- .../test/telegramImport.integration.test.js | 185 ++++++++++++++++-- docs/fcm-alert-notifications.md | 4 + docs/telegram-cancellation-import.md | 22 ++- frontend/src/components/RouteNumberPicker.jsx | 36 ++++ .../src/components/TelegramImportPanel.jsx | 37 ++-- frontend/src/pages/AlertsPage.jsx | 47 ++--- 18 files changed, 462 insertions(+), 150 deletions(-) create mode 100644 backend/migrations/015_alert_route_numbers.sql create mode 100644 backend/src/services/alertTargets.js create mode 100644 frontend/src/components/RouteNumberPicker.jsx diff --git a/backend/migrations/015_alert_route_numbers.sql b/backend/migrations/015_alert_route_numbers.sql new file mode 100644 index 0000000..7c7b6dd --- /dev/null +++ b/backend/migrations/015_alert_route_numbers.sql @@ -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; diff --git a/backend/src/controllers/syncController.js b/backend/src/controllers/syncController.js index a1e9e38..6bb8570 100644 --- a/backend/src/controllers/syncController.js +++ b/backend/src/controllers/syncController.js @@ -1,3 +1,4 @@ +const { alertSelectSql } = require('../services/alertTargets'); const pool = require('../config/database'); const logger = require('../config/logger'); @@ -61,7 +62,7 @@ class SyncController { // Get active alerts const alertsResult = await pool.query(` - SELECT * FROM alerts + SELECT ${alertSelectSql()} FROM alerts a WHERE city_id = $1 AND is_active = true AND start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time >= CURRENT_TIMESTAMP) @@ -157,7 +158,7 @@ class SyncController { // Get new alerts const alertsResult = await pool.query(` - SELECT * FROM alerts + SELECT ${alertSelectSql()} FROM alerts a WHERE city_id = $2 AND is_active = true AND created_at > $1 `, [lastSyncDate, req.city.id]); diff --git a/backend/src/routes/alerts.js b/backend/src/routes/alerts.js index c049c5f..652b493 100644 --- a/backend/src/routes/alerts.js +++ b/backend/src/routes/alerts.js @@ -4,7 +4,7 @@ const pool = require('../config/database'); const logger = require('../config/logger'); const { getAlertCreationService } = require('../services/alertCreationService'); const { listAlertTemplates } = require('../services/alertTemplates'); -const { getRouteInCity } = require('../middleware/city'); +const { resolveAlertTargets, alertSelectSql, alertRouteFilterSql } = require('../services/alertTargets'); function validateAlertTimes({ start_time, end_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; 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 LEFT JOIN routes r ON a.route_id = r.id WHERE a.city_id = $1 @@ -31,7 +31,7 @@ router.get('/', async (req, res) => { let paramCount = 2; if (route_id) { - query += ` AND a.route_id = $${paramCount}`; + query += ` AND ${alertRouteFilterSql(`$${paramCount}`)}`; params.push(route_id); paramCount++; } @@ -64,7 +64,7 @@ router.get('/active', async (req, res) => { const { route_id } = req.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 LEFT JOIN routes r ON a.route_id = r.id WHERE a.city_id = $1 @@ -75,7 +75,7 @@ router.get('/active', async (req, res) => { const params = [req.city.id]; if (route_id) { - query += ' AND a.route_id = $2'; + query += ` AND ${alertRouteFilterSql('$2')}`; params.push(route_id); } @@ -98,7 +98,7 @@ router.get('/:id', async (req, res) => { try { const { id } = req.params; 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 LEFT JOIN routes r ON a.route_id = r.id WHERE a.id = $1 AND a.city_id = $2`, @@ -119,7 +119,7 @@ router.get('/:id', async (req, res) => { function createAlertHandler({ alertCreationService = null } = {}) { return async (req, res) => { try { - const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body; + const { alert_type, title, message, start_time, end_time, is_active } = req.body; // Validation if (!alert_type || !title || !message || !start_time) { @@ -134,13 +134,11 @@ function createAlertHandler({ alertCreationService = null } = {}) { return res.status(400).json({ error: 'Invalid alert_type' }); } - if (route_id && !await getRouteInCity(route_id, req.city.id)) { - return res.status(400).json({ error: 'Маршрут не принадлежит выбранному городу.' }); - } + const targets = await resolveAlertTargets(pool, req.city.id, req.body); const service = alertCreationService || getAlertCreationService(); 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') } ); @@ -166,7 +164,7 @@ router.post('/', createAlertHandler()); router.put('/:id', async (req, res) => { try { 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 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 (route_id && !await getRouteInCity(route_id, req.city.id)) { - return res.status(400).json({ error: 'Маршрут не принадлежит выбранному городу.' }); - } + const targets = Object.hasOwn(req.body, 'route_numbers') || Object.hasOwn(req.body, 'route_id') + ? await resolveAlertTargets(pool, req.city.id, req.body) : null; - const result = await pool.query( + await pool.query( `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), title = COALESCE($3, title), message = COALESCE($4, message), @@ -206,11 +204,14 @@ router.put('/:id', async (req, res) => { is_active = COALESCE($7, is_active) WHERE id = $8 AND city_id = $9 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) { + if (error.status) return res.status(error.status).json({ error: error.message }); console.error('Error updating alert:', error); res.status(500).json({ error: 'Internal server error' }); } diff --git a/backend/src/services/alertCreationService.js b/backend/src/services/alertCreationService.js index bdd387f..9852393 100644 --- a/backend/src/services/alertCreationService.js +++ b/backend/src/services/alertCreationService.js @@ -1,14 +1,15 @@ const crypto = require('node:crypto'); +const { normalizeRouteNumbers, routeIdsSql } = require('./alertTargets'); const INSERT_ALERT_SQL = ` WITH inserted AS ( - INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active, city_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + 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, COALESCE($9::text[], ARRAY(SELECT upper(regexp_replace(route_number, '[[:space:]№]', '', 'g')) FROM routes WHERE id=$1))) 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 - LEFT JOIN routes ON routes.id = inserted.route_id `; function createHttpError(status, message) { @@ -38,6 +39,7 @@ function normalizeIdempotencyKey(value) { function normalizeAlertInput(payload) { return { route_id: payload.route_id || null, + ...(payload.route_numbers ? { route_numbers: normalizeRouteNumbers(payload.route_numbers) } : {}), alert_type: payload.alert_type, title: payload.title, message: payload.message, @@ -58,14 +60,15 @@ function normalizeHashValue(value) { function createAlertRequestHash(alert) { const canonicalPayload = { - route_id: normalizeHashValue(alert.route_id), + route_id: alert.route_numbers ? null : normalizeHashValue(alert.route_id), alert_type: normalizeHashValue(alert.alert_type), title: normalizeHashValue(alert.title), message: normalizeHashValue(alert.message), start_time: normalizeHashValue(alert.start_time), end_time: normalizeHashValue(alert.end_time), is_active: normalizeHashValue(alert.is_active), - city_id: normalizeHashValue(alert.city_id) + city_id: normalizeHashValue(alert.city_id), + ...(alert.route_numbers ? { route_numbers: normalizeRouteNumbers(alert.route_numbers) } : {}) }; return crypto @@ -149,7 +152,8 @@ class AlertCreationService { alertInput.start_time, alertInput.end_time, alertInput.is_active, - alertInput.city_id + alertInput.city_id, + alertInput.route_numbers ?? null ]); const row = result.rows[0]; diff --git a/backend/src/services/alertNotificationService.js b/backend/src/services/alertNotificationService.js index d68e4e9..021aa4e 100644 --- a/backend/src/services/alertNotificationService.js +++ b/backend/src/services/alertNotificationService.js @@ -101,6 +101,10 @@ function buildAlertCreatedMessage(alert, options = {}) { type: getAlertType(alert), routeId: getRouteId(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)), endTime, title: getNotificationTitle(alert), diff --git a/backend/src/services/alertSchedulerService.js b/backend/src/services/alertSchedulerService.js index ea566a2..fa36e55 100644 --- a/backend/src/services/alertSchedulerService.js +++ b/backend/src/services/alertSchedulerService.js @@ -1,3 +1,4 @@ +const { routeIdsSql } = require('./alertTargets'); const CLAIM_DUE_ALERTS_SQL = ` WITH due AS ( SELECT a.id @@ -21,11 +22,8 @@ const CLAIM_DUE_ALERTS_SQL = ` notification_attempts = a.notification_attempts + 1 FROM due WHERE a.id = due.id - RETURNING a.*, ( - SELECT r.route_number - FROM routes r - WHERE r.id = a.route_id - ) AS notification_route_number + RETURNING a.*, ${routeIdsSql()} AS route_ids, + CASE WHEN cardinality(a.route_numbers)=1 THEN a.route_numbers[1] END AS notification_route_number `; class AlertSchedulerService { diff --git a/backend/src/services/alertTargets.js b/backend/src/services/alertTargets.js new file mode 100644 index 0000000..779798d --- /dev/null +++ b/backend/src/services/alertTargets.js @@ -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 }; diff --git a/backend/src/services/telegramCancellationParser.js b/backend/src/services/telegramCancellationParser.js index 7163b92..22c2cd1 100644 --- a/backend/src/services/telegramCancellationParser.js +++ b/backend/src/services/telegramCancellationParser.js @@ -1,7 +1,8 @@ const { getLocalDateTime } = require('../utils/etaTime'); +const { normalizeRouteNumber } = require('./alertTargets'); 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 = /(? n + d.times.length, 0)) { 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 }; } @@ -125,7 +128,8 @@ function resolveCancellationStops(parsed, stops, aliases) { ? departure.stop.charAt(0).toUpperCase() + departure.stop.slice(1).toLowerCase() : departure.stop; return { ...departure, sourceStop, stop: stop?.name || readable, ...(stop ? { stopId: Number(stop.id) } : {}), - ...(alias && !stop ? { invalidAlias: true } : {}) }; + ...(alias && !stop ? { invalidAlias: true } : {}), + ...(!stop ? { unresolvedStop: true } : {}) }; }) })); return { ...parsed, groups }; } @@ -133,33 +137,36 @@ function resolveCancellationStops(parsed, stops, aliases) { function matchCancellationRoutes(parsed, routes) { const plans = new Map(); 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) { if (departure.invalidAlias) return { reasons: [`Остановка для «${departure.sourceStop}» отключена. Обновите словарь названий.`], plans: [] }; - const matches = candidates.length === 1 ? candidates : candidates.filter(r => departure.stopId - ? Number(r.origin_stop_id) === departure.stopId : normalizeStop(r.origin_stop || '') === normalizeStop(departure.stop)); - 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); + if (departure.unresolvedStop) return { reasons: [`Остановка «${departure.sourceStop}» не определена однозначно. Дополните словарь названий.`], plans: [] }; + plans.get(number).departures.push(departure); } } return { reasons: [], plans: [...plans.values()] }; } function buildCancellationDraft(plan, serviceDate) { + const plans = Array.isArray(plan) ? plan : [plan]; return { - title: `Отмена отдельных рейсов №${plan.routeNumber}`, - message: `${serviceDate.split('-').reverse().join('.')}: не выполняются рейсы:\n${plan.departures.map(d => `${d.stop} — ${d.times.join(', ')}`).join('\n')}` + title: `Отмена отдельных рейсов ${plans.map(p => `№${p.routeNumber}`).join(', ')}`, + 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) { + const plans = Array.isArray(plan) ? plan : [plan]; const bounds = dayBounds(serviceDate); const draft = buildCancellationDraft(plan, serviceDate); const start = new Date(Math.max(Date.parse(bounds.start), Date.parse(publishedAt))).toISOString(); 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, message: `${draft.message}\nИсточник: ${sourceUrl}`, start_time: start, end_time: bounds.end, is_active: true diff --git a/backend/src/services/telegramImportService.js b/backend/src/services/telegramImportService.js index 50fdf92..c3901bb 100644 --- a/backend/src/services/telegramImportService.js +++ b/backend/src/services/telegramImportService.js @@ -5,6 +5,7 @@ const { TelegramSource } = require('./telegramSource'); const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationDraft, buildCancellationAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser'); const { AlertCreationService } = require('./alertCreationService'); const { TelegramStopAliasService } = require('./telegramStopAliasService'); +const { resolveAlertTargets } = require('./alertTargets'); function httpError(status, message) { return Object.assign(new Error(message), { status }); } 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: [] }); groups.get(group.routeNumber).departures.push(...group.departures); } - return [ - ...(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) })) - ]; + return [...groups.values()].map(group => ({ routeId: null, routeNumber: group.routeNumber, ...buildCancellationDraft(group, parsed.serviceDate) })); } function publicError(error) { @@ -92,11 +90,7 @@ class TelegramImportService { } 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 - 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; + return (await db.query('SELECT id,route_number FROM routes WHERE city_id=$1 AND is_active=true ORDER BY id', [cityId])).rows; } async getStopContext(db, cityId) { @@ -133,7 +127,7 @@ class TelegramImportService { const context = await this.getStopContext(db, cityId); routes = await this.getRoutes(db, cityId); 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'); if (sameContent && row.parsed.resolutionHash === resolutionHash && Date.parse(dayBounds(parsed.serviceDate).end) > this.now().getTime()) { await db.query('COMMIT'); return { status: 'unchanged' }; @@ -154,7 +148,14 @@ class TelegramImportService { else if (!reasons.length) reasons = ['Автоматическая публикация выключена. Подтвердите уведомление.']; } 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, 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) { 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) { - 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) { - // 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, - 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 - 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; } 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('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) { - return (await this.pool.query(`SELECT m.*, 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), '[]') AS alert_ids + return (await this.pool.query(`SELECT m.*, CASE WHEN EXISTS(SELECT 1 FROM telegram_message_alerts t WHERE t.message_id=m.id) + 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 => ({ ...row, source_url: sourceUrl(row), drafts: messageDrafts(row.parsed), @@ -206,17 +220,16 @@ class TelegramImportService { if (input.action === 'publish') { if (!validDate(input.service_date)) throw httpError(400, 'Укажите корректную дату отмены.'); 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)); - if (!route) throw httpError(400, 'Выберите действующий маршрут выбранного города.'); + const targets = await resolveAlertTargets(db, cityId, input, { required: true }); const title = String(input.title || '').trim(); const message = String(input.message || '').trim(); if (!title || title.length > 200 || !message || message.length > 5000) throw httpError(400, 'Укажите заголовок (до 200 символов) и текст (до 5000 символов).'); await this.deactivate(db, row.id); 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 }); } 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('COMMIT'); return { id: row.id, status: input.action === 'publish' ? 'published' : 'dismissed' }; diff --git a/backend/test/alertCreationService.test.js b/backend/test/alertCreationService.test.js index 92fa712..8adf7fa 100644 --- a/backend/test/alertCreationService.test.js +++ b/backend/test/alertCreationService.test.js @@ -418,3 +418,10 @@ test('alert request hash is stable for the normalized creation payload', () => { 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'] })); +}); diff --git a/backend/test/alertNotificationService.test.js b/backend/test/alertNotificationService.test.js index d268173..b2f6149 100644 --- a/backend/test/alertNotificationService.test.js +++ b/backend/test/alertNotificationService.test.js @@ -257,3 +257,12 @@ test('reads disabled-by-default Firebase configuration and environment overrides 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')); +}); diff --git a/backend/test/telegramCancellationParser.test.js b/backend/test/telegramCancellationParser.test.js index 4ba0a46..ff3be38 100644 --- a/backend/test/telegramCancellationParser.test.js +++ b/backend/test/telegramCancellationParser.test.js @@ -91,16 +91,16 @@ test('different route numbers in one post are separate groups', () => { 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 = [ { id: 1, route_number: '27', origin_stop: 'Автовокзал' }, { id: 2, route_number: '27', origin_stop: 'Зодчего' }, { id: 3, route_number: '5', origin_stop: 'Центр' } ]; 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(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); }); @@ -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: 'Зодчего Иоанна' } ]; const matched = matchCancellationRoutes(parsed, routes); - assert.deepEqual(matched.plans.map(plan => plan.routeId), [1, 2]); - const alert = buildCancellationAlert(matched.plans[1], '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.deepEqual(matched.plans[0].routeIds, [1, 2, 3]); + 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:43, 16:43\nЗодчего Иоанна — 15:58, 16:58\nИсточник: https://t.me/avp2Polotsk/1166'); assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ/); 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: 'Зодчего Иоанна' }]); 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')); + } +}); diff --git a/backend/test/telegramImport.integration.test.js b/backend/test/telegramImport.integration.test.js index 18d6b8b..f125b75 100644 --- a/backend/test/telegramImport.integration.test.js +++ b/backend/test/telegramImport.integration.test.js @@ -31,13 +31,13 @@ test('fixed parser reprocesses a stored invalid-date review without a Telegram t const row = (await service.list(1))[0]; assert.equal(row.parsed.serviceDate, '2026-10-05'); 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; - assert.equal(alerts.length, 2); + 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, 1); 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.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(); } }); @@ -46,7 +46,7 @@ async function setup() { const db = new PGlite(); 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 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 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, @@ -64,6 +64,10 @@ async function setup() { const aliasesMigration = await fs.readFile(path.join(__dirname, '../migrations/014_telegram_stop_aliases.sql'), 'utf8'); 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. let tail = Promise.resolve(); 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'); const results = await Promise.all([service.processMessage(message(), 1), service.processMessage(message(), 1)]); 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; - assert.equal(alerts.length, 2); - assert.deepEqual(alerts.map(a => a.route_id), [1, 2]); + let alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY id')).rows; + assert.equal(alerts.length, 1); + assert.deepEqual(alerts[0].route_numbers, ['27']); assert.equal(alerts[0].end_time.toISOString(), '2026-10-02T21:00:00.000Z'); assert.match(alerts[0].message, /Автовокзал.*15:43, 16:43/); 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'); 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'); - alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY route_id')).rows; - assert.equal(alerts.length, 2); + alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY id')).rows; + assert.equal(alerts.length, 1); assert.ok(alerts.every(a => a.notification_sent_at !== null)); assert.match(alerts[0].message, /15:45/); 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. 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 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. clock('2026-10-02T21:00:00Z'); @@ -142,7 +146,7 @@ test('Telegram SQL lifecycle: atomic import, duplicates, edits, expiry, deletion } 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(); try { 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 query = client.query; 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 client; @@ -183,14 +187,14 @@ test('stop dictionary validates city and uniqueness, resolves waiting messages a assert.doesNotMatch(row.drafts[0].message, /Уважаемые|С УВАЖЕНИЕМ/); 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'); - 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; - assert.equal(alerts.length, 2); - assert.match(alerts[1].message, /Зодчего Иоанна — 15:58, 16:58/); + 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, 1); + assert.match(alerts[0].message, /Зодчего Иоанна — 15:58, 16:58/); 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); 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); // 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/); clock('2026-10-02T21:00:00Z'); 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(); } }); diff --git a/docs/fcm-alert-notifications.md b/docs/fcm-alert-notifications.md index 45e3b94..b1ca31c 100644 --- a/docs/fcm-alert-notifications.md +++ b/docs/fcm-alert-notifications.md @@ -295,3 +295,7 @@ curl -i -X POST http://localhost:3000/api/v1/alerts \ Поведение Android при foreground/background полностью определяется клиентской обработкой 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` сохранены для совместимости с одним номером; при нескольких они пустые. Мобильный клиент должен использовать массивы для привязки уведомления к маршрутам. Изменения мобильного клиента здесь не реализуются. diff --git a/docs/telegram-cancellation-import.md b/docs/telegram-cancellation-import.md index 83fd851..8ee16ab 100644 --- a/docs/telegram-cancellation-import.md +++ b/docs/telegram-cancellation-import.md @@ -6,13 +6,13 @@ Формат «Не будут выполняться рейсы: ПО МАРШРУТУ № 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. -Новые однозначные отмены публикуются автоматически. `TELEGRAM_AUTO_PUBLISH=false` переводит все распознанные отмены на проверку. В админке на странице **«Уведомления» → «Отмены из Telegram»** доступны состояние подключения, ручная проверка канала, исходные сообщения, ссылки на оригинал и история. При ручной публикации оператор проверяет маршрут, текст и день отмены; срок до полуночи назначается сервером. +Новые однозначные отмены публикуются автоматически. `TELEGRAM_AUTO_PUBLISH=false` переводит все распознанные отмены на проверку. В админке на странице **«Уведомления» → «Отмены из Telegram»** доступны состояние подключения, ручная проверка канала, исходные сообщения, ссылки на оригинал и история. При ручной публикации оператор выбирает один или несколько номеров маршрутов, проверяет текст и день отмены; срок до полуночи назначается сервером. ### Словарь остановок и текст уведомления @@ -31,7 +31,7 @@ Источник: 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 \ psql -v ON_ERROR_STOP=1 --single-transaction -U transit_admin -d polotsk_transit \ < 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 во время длительного простоя сервера, восстановить нельзя. +## Несколько маршрутов в 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 и проверка Все endpoint требуют JWT администратора и учитывают `X-City-Id`: - `GET /api/v1/telegram-import` — безопасный статус и до 100 сообщений, сначала требующие проверки. - `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` — словарь выбранного города. - `POST /api/v1/telegram-import/stop-aliases` — `alias`, `stop_id`; добавить соответствие. - `PUT /api/v1/telegram-import/stop-aliases/:id` — `alias`, `stop_id`; изменить соответствие. diff --git a/frontend/src/components/RouteNumberPicker.jsx b/frontend/src/components/RouteNumberPicker.jsx new file mode 100644 index 0000000..7b60420 --- /dev/null +++ b/frontend/src/components/RouteNumberPicker.jsx @@ -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 ( +
+ Номера маршрутов{required ? ' *' : ''} +
+ {numbers.map(number => ( + + ))} + {!numbers.length && Нет действующих маршрутов.} +
+

+ {selected.size ? `Выбрано: ${selected.size}. Уведомление относится ко всем направлениям этих номеров.` + : required ? 'Выберите хотя бы один номер. Можно выбрать несколько.' : 'Без выбранных номеров — общее уведомление для города.'} +

+ {selected.size > 0 && } +
+ ); +} diff --git a/frontend/src/components/TelegramImportPanel.jsx b/frontend/src/components/TelegramImportPanel.jsx index 5a0b629..5c05799 100644 --- a/frontend/src/components/TelegramImportPanel.jsx +++ b/frontend/src/components/TelegramImportPanel.jsx @@ -3,6 +3,7 @@ import { ExternalLink, RefreshCw, Send, X } from 'lucide-react'; import { telegramImportApi } from '../services/api'; import { useCity } from '../contexts/CityContext'; import TelegramStopAliases from './TelegramStopAliases'; +import RouteNumberPicker, { normalizeRouteNumber } from './RouteNumberPicker'; const connectionLabels = { disabled: 'Импорт выключен', configuration_required: 'Нужна настройка аккаунта', @@ -116,38 +117,42 @@ function TelegramReviewForm({ row, routes, busy, onSubmit, onCancel }) { const publicationDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(row.published_at)); - const initialRouteId = row.parsed?.plans?.length === 1 ? row.parsed.plans[0].routeId : ''; - const draftFor = routeId => { - const route = routes.find(r => Number(r.id) === Number(routeId)); - return row.drafts?.find(d => d.routeId !== null && Number(d.routeId) === Number(routeId)) - || row.drafts?.find(d => d.routeId === null && (!route || String(d.routeNumber).toLowerCase() === String(route.route_number).toLowerCase())); + const initialNumbers = [...new Set((row.parsed?.selectedRouteNumbers || row.parsed?.groups?.map(g => g.routeNumber) || []) + .map(normalizeRouteNumber))]; + const draftFor = (numbers, date) => { + const drafts = numbers.map(number => row.drafts?.find(d => normalizeRouteNumber(d.routeNumber) === number)); + 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({ - action: 'publish', service_date: row.parsed?.serviceDate || publicationDate, route_id: initialRouteId, - title: initialDraft?.title || 'Отмена отдельных рейсов', message: initialDraft?.message || row.raw_text.slice(0, 5000) + action: 'publish', service_date: initialDate, route_numbers: initialNumbers, + ...draftFor(initialNumbers, initialDate) }); const [editedText, setEditedText] = useState(false); const change = e => { const { name, value } = e.target; if (name === 'title' || name === 'message') setEditedText(true); - const draft = name === 'route_id' && !editedText ? draftFor(value) : null; - const message = name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(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 } : {}) }); + setForm(current => ({ ...current, [name]: value, + ...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value) : {}) })); }; + const changeRoutes = route_numbers => setForm(current => ({ ...current, route_numbers, + ...(!editedText ? draftFor(route_numbers, current.service_date) : {}) })); return (
{ e.preventDefault(); onSubmit(form); }}>

Проверка отмены

-
- + +