Compare commits

..

16 Commits

Author SHA1 Message Date
f57f1d7d22 feat: add passenger settings and fare configuration with validation 2026-10-12 20:35:33 +03:00
e959f55ea1 feat: add night mode settings and validation for board devices 2026-10-11 05:02:39 +03:00
360b684019 feat: implement board device service with CRUD operations and validation
- Add boardDeviceService for managing device pairing, status, claiming, and revocation.
- Introduce boardDeviceSettings for default settings and validation logic.
- Create integration tests for device lifecycle, concurrent claims, and updates.
- Add unit tests for device pairing, claiming, and settings validation.
- Implement API tests for device endpoints with JWT authentication.
- Develop in-memory repository for testing purposes.
- Create frontend components for managing devices, including a form for device settings.
- Style DevicesPage with CSS for better user experience.
2026-10-10 00:47:26 +03:00
249c338c2a feat: enhance Telegram import functionality to support delays and cancellations
- Updated telegramCancellationParser to include parsing for delay notices.
- Added new test cases for handling delay messages and stop-point cancellations.
- Modified telegramImport integration tests to validate new delay handling.
- Enhanced documentation to reflect the addition of delay notifications.
- Updated TelegramImportPanel component to accommodate new alert types.
- Introduced new fixture files for testing delay and cancellation messages.
2026-10-09 02:59:52 +03:00
c0d40f9b90 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.
2026-10-05 12:07:09 +03:00
e511f74aea feat: enhance cancellation parser to support mixed wording and add tests for hospital cancellations 2026-10-05 11:17:19 +03:00
666a0b268f feat: implement stop alias management for Telegram import
- Added TelegramStopAliasService for handling stop aliases in the database.
- Created API endpoints for managing stop aliases (list, create, update, delete).
- Introduced TelegramStopAliases component in the frontend for user interaction with stop aliases.
- Updated TelegramImportService to utilize stop aliases for message processing.
- Enhanced telegramCancellationParser to resolve stop names using aliases.
- Added migration script to create telegram_stop_aliases table and seed initial data.
- Updated documentation to include details about stop alias functionality.
2026-10-03 01:31:11 +03:00
f7f0b039dd Add Telegram cancellation import functionality with tests and documentation
- Implemented Telegram cancellation parsing logic in `telegramCancellationParser.js`.
- Created unit tests for cancellation parsing in `telegramCancellationParser.test.js`.
- Developed integration tests for Telegram import service in `telegramImport.integration.test.js`.
- Added API tests for Telegram import endpoints in `telegramImportApi.test.js`.
- Introduced service tests for Telegram import logic in `telegramImportService.test.js`.
- Documented the Telegram cancellation import process in `telegram-cancellation-import.md`.
- Built a React component for managing Telegram imports in `TelegramImportPanel.jsx`.
2026-10-03 00:45:41 +03:00
b5c6b1973e feat: implement Firebase ID token authentication for passenger telemetry and update related configurations 2026-09-18 04:04:43 +03:00
863ac603bc feat: add LiveMapPage for real-time vehicle tracking and ScheduleCenterPage for schedule management
- Implemented LiveMapPage with vehicle tracking using Leaflet and real-time updates.
- Added vehicle icons, status labels, and filtering options for routes.
- Created ScheduleCenterPage to display schedule versions, conflicts, and gaps.
- Introduced responsive design for both pages with dedicated CSS styles.
- Integrated API calls for fetching routes and vehicle data.
2026-09-18 02:58:20 +03:00
4552062b0a Add web app manifest for improved PWA support with icons 2026-09-17 21:40:13 +03:00
b9d2148cc0 fix: update brand logo dimensions and change background color for improved UI 2026-09-17 21:00:07 +03:00
def5c068b3 fix: adjust BrandLogo dimensions and remove unused login device card from LoginPage 2026-09-17 20:44:43 +03:00
f218f0123a Add SVG assets for login devices and passkey icons
- Created a new SVG file for login devices with detailed paths and styles.
- Added a new SVG file for the passkey icon with multiple vector paths and fill attributes.
2026-09-17 20:31:10 +03:00
acec6b4fc0 feat: add city avatar upload functionality and enhance city management features 2026-09-17 19:54:09 +03:00
d13cc92d82 feat: implement multi-city support with city management features
- Added a new cities table to the database to manage multiple cities independently.
- Created a middleware to resolve city context based on X-City-Id header.
- Updated routes and services to handle city-specific data for routes, stops, and alerts.
- Implemented a CityContext in the frontend to manage active city state and provide city data across the application.
- Added a CitySelector component for users to switch between cities.
- Created a CitiesPage for admins to manage cities, including adding new cities with validation.
- Updated various components and pages to reflect the active city context, ensuring data is displayed correctly based on the selected city.
- Enhanced the schedule import functionality to restrict imports based on the active city.
2026-09-17 19:41:17 +03:00
111 changed files with 15246 additions and 416 deletions

View File

@@ -11,6 +11,11 @@ DATABASE_URL=postgresql://transit_admin:your_secure_password_here@localhost:5432
REDIS_URL=redis://localhost:6379
JWT_SECRET=replace_with_a_long_random_string
# Passkeys (WebAuthn). HTTP works only on localhost; production requires HTTPS.
WEBAUTHN_RP_NAME=RideBus DE
WEBAUTHN_RP_ID=localhost
WEBAUTHN_ORIGIN=http://localhost,http://localhost:3001
# Firebase Cloud Messaging (server-side alert notifications)
# Keep disabled to run without Firebase credentials.
FCM_ENABLED=false
@@ -23,6 +28,20 @@ FIREBASE_SECRETS_DIR=/opt/polotsk-transit/secrets
# with the absolute host path to the JSON file.
GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.json
# Telegram cancellation import through a separate user account (MTProto).
# Get API credentials at my.telegram.org. Never put a session in the checkout.
TELEGRAM_IMPORT_ENABLED=false
TELEGRAM_AUTO_PUBLISH=true
TELEGRAM_API_ID=
TELEGRAM_API_HASH=
TELEGRAM_CHANNEL=avp2Polotsk
TELEGRAM_CITY_SLUG=polotsk
TELEGRAM_POLL_INTERVAL_MS=60000
# Host directory, mounted read-only into the backend container.
TELEGRAM_SECRETS_DIR=/opt/polotsk-transit/telegram-secrets
# For direct Node.js launches, replace this with the absolute host path.
TELEGRAM_SESSION_FILE=/run/telegram-secrets/account.session
# Bootstrap admin
BOOTSTRAP_ADMIN=true
BOOTSTRAP_ADMIN_RESET_PASSWORD=false
@@ -34,7 +53,14 @@ ADMIN_EMAIL=admin@polotsk-transit.local
# Frontend
API_URL=/api/v1
# Required by the passenger telemetry endpoint to verify Firebase ID tokens.
# Use the same Firebase project as the mobile app; keep the service account JSON outside the repo.
FIREBASE_PROJECT_ID=your-firebase-project-id
GOOGLE_APPLICATION_CREDENTIALS=/absolute/private/path/firebase-service-account.json
# Production settings (uncomment for production)
# NODE_ENV=production
# CORS_ORIGIN=https://admin.yourdomain.com
# API_URL=/api/v1
# WEBAUTHN_RP_ID=admin.yourdomain.com
# WEBAUTHN_ORIGIN=https://admin.yourdomain.com

4
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Dependencies
node_modules/
package-lock.json
yarn.lock
# Environment variables
@@ -40,6 +39,9 @@ nginx/ssl/
# Runtime secrets
secrets/
*.session
*.session-journal
telegram-session*
# Temp files
*.tmp

View File

@@ -1,4 +1,4 @@
# Структура проекта Polotsk Transit API
# Структура проекта RideBus DE API
## 📁 Обзор директорий

View File

@@ -1,4 +1,4 @@
# Polotsk Transit API
# RideBus DE API
Система управления информацией об общественном транспорте города Полоцка с REST API, веб-панелью администратора и поддержкой offline-синхронизации.
@@ -14,6 +14,7 @@
- ✅ Аутентификация по API ключам
- ✅ Rate limiting
- ✅ Серверные push-уведомления об алертах через Firebase Cloud Messaging
- ✅ Импорт отмен из Telegram через отдельный аккаунт, проверка в админке и срок действия до полуночи ([настройка](docs/telegram-cancellation-import.md))
### Технологический стек
@@ -59,10 +60,20 @@ ADMIN_PASSWORD=your_secure_admin_password_here
CORS_ORIGIN=http://localhost:3001
API_URL=/api/v1
# Push отключён по умолчанию и не требует Firebase credentials
# Push отключён по умолчанию
FCM_ENABLED=false
# Для пассажирского режима backend проверяет Firebase ID token.
# Укажите проект из google-services.json мобильного приложения и путь к service account JSON.
FIREBASE_PROJECT_ID=your-firebase-project-id
GOOGLE_APPLICATION_CREDENTIALS=/absolute/private/path/firebase-service-account.json
```
Для входа по passkey на публичном адресе укажите в `.env` точный домен панели
и используйте HTTPS: `WEBAUTHN_RP_ID=admin.example.by` и
`WEBAUTHN_ORIGIN=https://admin.example.by`. В `WEBAUTHN_ORIGIN` не применяются
wildcard-маски; при нескольких разрешённых адресах перечислите их через запятую.
Генерация безопасного salt:
```bash
openssl rand -base64 32
@@ -361,3 +372,46 @@ SELECT PostGIS_Version();
## 📄 Лицензия
MIT License - используйте свободно!
## Устройства RideBus Board
В админке появился раздел **Устройства** (только для администратора). Он работает с обновлённым проектом `ridebus-board`: одноразовый код из четырёх цифр на экране привязывает отдельный браузер телевизора. В форме выбираются площадка и маршрут, тема/палитры, профиль читаемости, число строк, интервалы, информационные блоки и рекламный предпросмотр. Город задаётся переключателем транспортной сети в админке; устройства и каталоги изолированы по выбранному городу.
Перед запуском примените **только новую** миграцию `backend/migrations/016_board_devices.sql` к существующей базе данных (обычным способом миграций при развёртывании). Не перезапускайте общий `npm run migrate` на уже созданной базе: существующий скрипт повторяет все старые SQL-файлы. Таблицы `board_pairings` и `board_devices` содержат одноразовые сессии и зарегистрированные устройства. Миграция 016 повторяемая.
В сервере Board включите `BOARD_REMOTE_SETTINGS=true`, укажите обновлённый API в `RIDEBUS_API_BASE_URL` и адрес этой админки в необязательном `BOARD_ADMIN_URL`. Серверный RideBus API-ключ остаётся на Board и не выдаётся телевизору. Используйте HTTPS с Secure cookie.
Новый узкий API монтируется до общей API-key авторизации:
- `POST /api/v1/board-devices/pairings` — создать сессию, TTL 5 минут, до 10 запросов за 10 минут с одного IP.
- `GET /api/v1/board-devices/self` и `POST /self/heartbeat` — только индивидуальный `X-Board-Token`.
- `GET /api/v1/board-devices/admin` и `/admin/routes?stopId=…` — администратор + контекст города.
- `POST /admin/claim` — код, название, настройки; до 5 попыток за 5 минут с одного IP.
- `PUT /admin/:id` — настройки с ожидаемой `revision`; конфликт даёт 409.
- `POST /admin/:id/revoke` — отозвать устройство в выбранном городе.
Короткий код не является токеном доступа. Секрет — 32 случайных байта, в базе только хеш; занятие кода и создание устройства атомарны в одной транзакции. Повторный запрос устройства после потерянного HTTP-ответа узнаёт уже созданную привязку. В списках и конфигурации секреты не возвращаются.
Табло опрашивает настройки раз в 15 секунд, отправляет сигнал работы раз в 30 и подтверждает применённую версию. «В сети» означает связь за последние 90 секунд. Офлайн-устройство продолжает работать со своим кешем, изменения остаются «Ожидают применения». Отзыв доступа действует после следующей связи с сервером. Команд перезагрузки ОС, удалённого shell или установки ПО нет.
Проверки: `npm test --prefix backend`, `npm run build --prefix frontend`. Отдельный PostgreSQL-тест устройств запускается с `BOARD_DEVICE_TEST_DATABASE_URL` (имя БД должно содержать `test`); он создаёт и удаляет изолированную схему, не использует рабочие таблицы. Совместный визуальный стенд находится в `ridebus-board/test/fixtures/device-flow-server.mjs` и использует явные тестовые данные.
### Ночной режим табло
В настройках устройства доступны `nightMode` (`auto` / `off`) и `nightWakeMinutes` (45 / 60). По умолчанию — автоматически, возврат за 60 минут. Обновлённый Board включает режим после завершения движения, если весь ночной перерыв до первого следующего рейса длиннее двух часов, только по полному действующему расписанию. Перезагрузка в середине перерыва сохраняет ночной экран до выбранного порога возврата 45/60 минут. Ночные продолжения рейсов, фильтр маршрута и отмены учитываются. В ночном экране остаются первый рейс, часы без секунд и сообщения о движении; цикл информации и рекламы приостанавливается. Дневная тема возвращается при выходе. Лимиты, привязка и подтверждение версии сохраняются.
Поля находятся в существующем JSONB настроек устройств; новая SQL-миграция не требуется. Старые сохранённые конфигурации нормализуются при чтении (`auto / 60`) и не изменяются в базе автоматически. Для редактирования этих полей разверните обновлённые API, frontend админки и Board вместе.
Блок информации «Дальше по маршруту» заменяет первый и последний рейс. Его переключатель сохраняет существующий ключ `infoServiceEnabled`: ранее отключённый блок остаётся отключённым, SQL-миграция и изменение JSON устройства не нужны. Схему следующих остановок и время пути строит обновлённый Board из полного кеша расписания; в админке изменено только название переключателя.
### Пассажирские блоки табло
В форме устройства добавлены переключатели «До важных мест», «Оплата поездки», «Напоминание о последнем рейсе» и «Вероятность осадков». До трёх важных мест задаются конкретной остановкой назначения и коротким названием. По умолчанию места не выбраны; незаполненный блок на табло скрыт. Отправление, путь и прибытие рассчитывает Board из полного действующего расписания с порядком остановок и конечной конкретного рейса. Отмены, направление и переход через полночь учитываются.
Тарифы по типу транспорта имеют поля `cityId`, `type` (`bus` / `minibus`), `price`, `payment`, `checkedAt`. Для Полоцка (город 1) пользователь подтвердил 11 октября 2026 года: автобус 1,05 BYN, маршрутка 2,10 BYN, оплата QR-кодом или наличными. Для других городов тарифы остаются незаполненными. Допустимы до 32 исключений по `routeId`; исключение имеет приоритет над тарифом типа. Цена и способы оплаты — подтверждённый оператором текст, дата проверки сохраняется и видна пассажиру. Будущий тариф не публикуется до указанной даты. Платёжный QR-код не генерируется.
Конфигурация: `infoPlacesEnabled`, `infoFareEnabled`, `lastTripNoticeEnabled`, `weatherHintEnabled` (по умолчанию `true`); `places: [{stopId, name}]`; `fareByType: [{cityId, type, price, payment, checkedAt}]`; `fareRules: [{routeId, price, payment, checkedAt}]`. API проверяет структуру, лимиты, дубликаты, действительность даты и принадлежность городу/площадке. Явные пустые массивы сохраняются. Старые конфигурации нормализуются без записи в БД; ранее отсутствовавший тариф типа подставляется только для города 1.
Новые поля хранятся в существующем JSONB; дополнительная SQL-миграция не нужна. Разверните обновлённые backend, frontend админки и RideBus Board вместе. Переключатели и содержимое передаются через существующие версии настроек и подтверждения устройством. Проверки пассажирских настроек входят в `test/boardDevices.test.js` и `test/boardDevicesApi.test.js`; визуальный стенд `ridebus-board/test/fixtures/passenger-server.mjs` использует отдельный локальный origin и тестовое расписание.

View File

@@ -0,0 +1,113 @@
-- Cities are independent transport networks. Existing Polotsk data is moved to
-- the default city so a second city can use the same route numbers and stop names.
CREATE TABLE IF NOT EXISTS cities (
id SERIAL PRIMARY KEY,
slug VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL UNIQUE,
emblem VARCHAR(64) NOT NULL DEFAULT '🏙️',
center_lat NUMERIC(9, 6),
center_lon NUMERIC(9, 6),
schedule_source_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT cities_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
CONSTRAINT cities_center_pair CHECK (
(center_lat IS NULL AND center_lon IS NULL)
OR (center_lat BETWEEN -90 AND 90 AND center_lon BETWEEN -180 AND 180)
)
);
INSERT INTO cities (slug, name, emblem, center_lat, center_lon, schedule_source_url)
VALUES
('polotsk', 'Полоцк', '🏛️', 55.4869, 28.7856, 'https://ap2polotsk.of.by/ap2/'),
('novopolotsk', 'Новополоцк', '🏙️', 55.5318, 28.5987, NULL)
ON CONFLICT (slug) DO NOTHING;
ALTER TABLE routes ADD COLUMN IF NOT EXISTS city_id INTEGER;
ALTER TABLE stops ADD COLUMN IF NOT EXISTS city_id INTEGER;
ALTER TABLE alerts ADD COLUMN IF NOT EXISTS city_id INTEGER;
UPDATE routes SET city_id = (SELECT id FROM cities WHERE slug = 'polotsk') WHERE city_id IS NULL;
UPDATE stops SET city_id = (SELECT id FROM cities WHERE slug = 'polotsk') WHERE city_id IS NULL;
UPDATE alerts SET city_id = (SELECT id FROM cities WHERE slug = 'polotsk') WHERE city_id IS NULL;
ALTER TABLE routes ALTER COLUMN city_id SET NOT NULL;
ALTER TABLE stops ALTER COLUMN city_id SET NOT NULL;
ALTER TABLE alerts ALTER COLUMN city_id SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'routes_city_id_fkey') THEN
ALTER TABLE routes ADD CONSTRAINT routes_city_id_fkey
FOREIGN KEY (city_id) REFERENCES cities(id) ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'stops_city_id_fkey') THEN
ALTER TABLE stops ADD CONSTRAINT stops_city_id_fkey
FOREIGN KEY (city_id) REFERENCES cities(id) ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'alerts_city_id_fkey') THEN
ALTER TABLE alerts ADD CONSTRAINT alerts_city_id_fkey
FOREIGN KEY (city_id) REFERENCES cities(id) ON DELETE RESTRICT;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_routes_city ON routes(city_id, is_active, route_number);
CREATE INDEX IF NOT EXISTS idx_stops_city ON stops(city_id, is_active, name);
CREATE INDEX IF NOT EXISTS idx_alerts_city_active ON alerts(city_id, is_active, start_time, end_time);
-- Incremental mobile sync must not leak even delete identifiers between cities.
ALTER TABLE sync_log ADD COLUMN IF NOT EXISTS city_id INTEGER REFERENCES cities(id) ON DELETE SET NULL;
UPDATE sync_log l SET city_id = r.city_id
FROM routes r
WHERE l.table_name = 'routes' AND l.record_id = r.id AND l.city_id IS NULL;
UPDATE sync_log l SET city_id = s.city_id
FROM stops s
WHERE l.table_name = 'stops' AND l.record_id = s.id AND l.city_id IS NULL;
UPDATE sync_log l SET city_id = r.city_id
FROM route_stops rs JOIN routes r ON r.id = rs.route_id
WHERE l.table_name = 'route_stops' AND l.record_id = rs.id AND l.city_id IS NULL;
UPDATE sync_log l SET city_id = r.city_id
FROM schedules s JOIN routes r ON r.id = s.route_id
WHERE l.table_name = 'schedules' AND l.record_id = s.id AND l.city_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_sync_log_city_timestamp ON sync_log(city_id, timestamp DESC);
CREATE OR REPLACE FUNCTION log_sync_changes()
RETURNS TRIGGER AS $$
DECLARE
sync_city_id INTEGER;
related_route_id INTEGER;
BEGIN
IF TG_TABLE_NAME IN ('routes', 'stops', 'alerts') THEN
sync_city_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.city_id ELSE NEW.city_id END;
ELSIF TG_TABLE_NAME IN ('route_stops', 'schedules') THEN
related_route_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.route_id ELSE NEW.route_id END;
SELECT city_id INTO sync_city_id FROM routes WHERE id = related_route_id;
END IF;
IF (TG_OP = 'DELETE') THEN
INSERT INTO sync_log (table_name, record_id, action, city_id)
VALUES (TG_TABLE_NAME, OLD.id, 'delete', sync_city_id);
RETURN OLD;
ELSIF (TG_OP = 'UPDATE') THEN
INSERT INTO sync_log (table_name, record_id, action, city_id)
VALUES (TG_TABLE_NAME, NEW.id, 'update', sync_city_id);
RETURN NEW;
ELSIF (TG_OP = 'INSERT') THEN
INSERT INTO sync_log (table_name, record_id, action, city_id)
VALUES (TG_TABLE_NAME, NEW.id, 'insert', sync_city_id);
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_cities_updated_at') THEN
CREATE TRIGGER update_cities_updated_at BEFORE UPDATE ON cities
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;

View File

@@ -0,0 +1,6 @@
-- A city may use a raster coat of arms or another custom image instead of the
-- text/emoji emblem. The file itself is stored under public/uploads/cities.
ALTER TABLE cities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(255);
CREATE INDEX IF NOT EXISTS idx_cities_avatar ON cities(avatar_url)
WHERE avatar_url IS NOT NULL;

View File

@@ -0,0 +1,31 @@
-- Discoverable WebAuthn credentials used for passwordless sign-in.
CREATE TABLE passkey_credentials (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id VARCHAR(512) UNIQUE NOT NULL,
public_key BYTEA NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
transports TEXT[] NOT NULL DEFAULT '{}',
device_type VARCHAR(32) NOT NULL,
backed_up BOOLEAN NOT NULL DEFAULT false,
label VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP
);
CREATE INDEX idx_passkey_credentials_user_id ON passkey_credentials(user_id);
-- Challenges are short-lived and single-use. Keeping them in PostgreSQL lets
-- verification work across backend restarts and multiple application instances.
CREATE TABLE passkey_challenges (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
purpose VARCHAR(32) NOT NULL CHECK (purpose IN ('registration', 'authentication')),
challenge VARCHAR(512) NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(purpose, challenge)
);
CREATE INDEX idx_passkey_challenges_lookup
ON passkey_challenges(purpose, challenge, expires_at);

View File

@@ -0,0 +1,45 @@
-- Admin alert forms already store UTC clock values. Give these columns an
-- explicit timezone so imports, API serialization and midnight expiry agree
-- regardless of the PostgreSQL or Node.js host timezone. Safe to rerun.
DO $$
DECLARE col text;
BEGIN
FOREACH col IN ARRAY ARRAY['start_time', 'end_time', 'created_at'] LOOP
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = 'alerts'
AND column_name = col AND data_type = 'timestamp without time zone') THEN
EXECUTE format('ALTER TABLE alerts ALTER COLUMN %I TYPE timestamptz USING %I AT TIME ZONE ''UTC''', col, col);
END IF;
END LOOP;
END $$;
ALTER TABLE alerts ADD COLUMN IF NOT EXISTS notification_pending BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS telegram_import_messages (
id SERIAL PRIMARY KEY,
city_id INTEGER NOT NULL REFERENCES cities(id),
channel_id TEXT NOT NULL,
channel_username VARCHAR(32) NOT NULL,
message_id INTEGER NOT NULL CHECK (message_id > 0),
published_at TIMESTAMPTZ NOT NULL,
edited_at TIMESTAMPTZ,
content_hash CHAR(64) NOT NULL,
raw_text TEXT NOT NULL,
has_media BOOLEAN NOT NULL DEFAULT false,
status VARCHAR(20) NOT NULL CHECK (status IN ('review', 'published', 'dismissed', 'expired')),
reasons JSONB NOT NULL DEFAULT '[]',
parsed JSONB NOT NULL DEFAULT '{}',
reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (city_id, channel_id, message_id)
);
CREATE TABLE IF NOT EXISTS telegram_import_alerts (
message_id INTEGER NOT NULL REFERENCES telegram_import_messages(id) ON DELETE CASCADE,
route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
alert_id INTEGER UNIQUE REFERENCES alerts(id) ON DELETE SET NULL,
PRIMARY KEY (message_id, route_id)
);
CREATE INDEX IF NOT EXISTS idx_telegram_import_review ON telegram_import_messages(city_id, status, published_at DESC);

View File

@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS telegram_stop_aliases (
id SERIAL PRIMARY KEY,
city_id INTEGER NOT NULL REFERENCES cities(id) ON DELETE CASCADE,
alias VARCHAR(200) NOT NULL,
normalized_alias VARCHAR(200) NOT NULL CHECK (length(normalized_alias) > 0),
stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (city_id, normalized_alias)
);
-- Known carrier abbreviations are seeded only when the Polotsk stop is unique.
-- Ambiguous platforms must be selected explicitly by an administrator.
WITH defaults(alias, normalized_alias, stop_name) AS (
VALUES ('АВ/ВОКЗАЛ', 'аввокзал', 'Автовокзал'),
('ЗОДЧЕГО', 'зодчего', 'Зодчего Иоанна')
), candidates AS (
SELECT c.id AS city_id, d.alias, d.normalized_alias, min(s.id) AS stop_id
FROM defaults d JOIN cities c ON c.slug = 'polotsk'
JOIN stops s ON s.city_id = c.id AND s.is_active = true
AND lower(trim(s.name)) = lower(d.stop_name)
GROUP BY c.id, d.alias, d.normalized_alias HAVING count(*) = 1
)
INSERT INTO telegram_stop_aliases(city_id, alias, normalized_alias, stop_id)
SELECT city_id, alias, normalized_alias, stop_id FROM candidates
ON CONFLICT (city_id, normalized_alias) DO NOTHING;

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

@@ -0,0 +1,28 @@
BEGIN;
CREATE TABLE IF NOT EXISTS board_pairings (
code CHAR(4) PRIMARY KEY CHECK (code ~ '^[0-9]{4}$'),
token_hash CHAR(64) NOT NULL UNIQUE,
device_info JSONB NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS board_pairings_expiry ON board_pairings(expires_at);
CREATE TABLE IF NOT EXISTS board_devices (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL,
city_id INTEGER NOT NULL REFERENCES cities(id) ON DELETE RESTRICT,
stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE RESTRICT,
token_hash CHAR(64) NOT NULL UNIQUE,
settings JSONB NOT NULL,
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
applied_revision INTEGER NOT NULL DEFAULT 0 CHECK (applied_revision >= 0 AND applied_revision <= revision),
last_seen_at TIMESTAMPTZ,
device_info JSONB NOT NULL DEFAULT '{}',
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS board_devices_city ON board_devices(city_id, created_at);
COMMIT;

5511
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"test": "node --test",
"migrate": "node src/utils/migrate.js",
"create-admin": "node scripts/create-admin.js",
"telegram:login": "node scripts/telegram-login.js",
"seed": "node src/utils/seed.js"
},
"keywords": [
@@ -19,6 +20,7 @@
"author": "Zakhar",
"license": "MIT",
"dependencies": {
"@simplewebauthn/server": "^14.0.2",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"cheerio": "^1.2.0",
@@ -34,6 +36,7 @@
"pg": "^8.11.3",
"redis": "^4.6.10",
"socket.io": "^4.7.2",
"teleproto": "1.229.1",
"uuid": "^9.0.1",
"winston": "^3.11.0",
"ws": "^8.20.0"

View File

@@ -0,0 +1,50 @@
require('dotenv').config();
const fs = require('node:fs/promises');
const readline = require('node:readline');
const { getTelegramConfig, missingTelegramConfig, assertExternalSessionFile } = require('../src/config/telegram');
const { publicError } = require('../src/services/telegramImportService');
function ask(prompt, hidden = false) {
return new Promise((resolve, reject) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
rl.question(prompt, answer => { rl.close(); process.stdout.write('\n'); resolve(answer.trim()); });
if (hidden) rl._writeToOutput = () => {};
rl.on('SIGINT', () => { rl.close(); reject(new Error('LOGIN_CANCELLED')); });
});
}
async function main() {
const config = getTelegramConfig();
if (!process.stdin.isTTY) throw new Error('LOGIN_REQUIRES_TERMINAL');
if (missingTelegramConfig(config).length) throw new Error('LOGIN_CONFIG_REQUIRED');
// The operator creates a private directory outside the checkout first.
const file = await assertExternalSessionFile(config.sessionFile);
let session = '';
try { session = (await fs.readFile(file, 'utf8')).trim(); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
const { TelegramClient } = require('teleproto');
const { StringSession } = require('teleproto/sessions');
const { Logger } = require('teleproto/extensions/Logger');
const client = new TelegramClient(new StringSession(session), config.apiId, config.apiHash, { baseLogger: new Logger('none'), connectionRetries: 2 });
try {
await client.start({
phoneNumber: () => ask('Номер отдельного Telegram-аккаунта: '),
phoneCode: () => ask('Код из Telegram: ', true),
password: () => ask('Пароль двухэтапной проверки: ', true),
// Do not register a new account or accept registration terms from this utility.
firstAndLastNames: () => { throw new Error('USE_EXISTING_ACCOUNT'); },
onError: () => true
});
if (!await client.checkAuthorization() || (await client.getMe()).bot) throw new Error('USER_ACCOUNT_REQUIRED');
const handle = await fs.open(file, session ? 'w' : 'wx', 0o600);
try { await handle.writeFile(client.session.save()); await handle.sync(); }
finally { await handle.close(); }
console.log('Авторизация сохранена в защищённый файл. Теперь можно включить TELEGRAM_IMPORT_ENABLED=true.');
} finally { await client.destroy(); }
}
main().catch(error => {
const local = ['LOGIN_CANCELLED', 'LOGIN_REQUIRES_TERMINAL', 'LOGIN_CONFIG_REQUIRED', 'USE_EXISTING_ACCOUNT'];
console.error(`Авторизация не завершена (${local.includes(error.message) ? error.message : publicError(error)}).`);
process.exitCode = 1;
});

View File

@@ -24,6 +24,18 @@ function getFirebaseConfig(env = process.env) {
let messagingSingleton;
function getFirebaseApp(config = getFirebaseConfig()) {
// Keep Firebase Admin lazy: deployments that do not receive passenger telemetry
// still run without local ADC credentials.
const { applicationDefault, getApps, initializeApp } = require('firebase-admin/app');
const existingApp = getApps()[0];
return existingApp || initializeApp({
credential: applicationDefault(),
...(config.projectId ? { projectId: config.projectId } : {})
});
}
function createFirebaseMessaging(config = getFirebaseConfig()) {
if (!config.enabled) {
return null;
@@ -31,16 +43,8 @@ function createFirebaseMessaging(config = getFirebaseConfig()) {
// Keep Firebase optional for disabled/local environments. These modular
// imports are evaluated only when an enabled notification is actually sent.
const { applicationDefault, getApps, initializeApp } = require('firebase-admin/app');
const { getMessaging } = require('firebase-admin/messaging');
const existingApp = getApps()[0];
const app = existingApp || initializeApp({
credential: applicationDefault(),
...(config.projectId ? { projectId: config.projectId } : {})
});
return getMessaging(app);
return getMessaging(getFirebaseApp(config));
}
function getFirebaseMessaging(config = getFirebaseConfig()) {
@@ -55,11 +59,18 @@ function getFirebaseMessaging(config = getFirebaseConfig()) {
return messagingSingleton;
}
function createFirebaseAuth(config = getFirebaseConfig()) {
const { getAuth } = require('firebase-admin/auth');
return getAuth(getFirebaseApp(config));
}
module.exports = {
DEFAULT_FCM_TOPIC,
DEFAULT_FCM_ANDROID_CHANNEL_ID,
parseBoolean,
getFirebaseConfig,
getFirebaseApp,
createFirebaseAuth,
createFirebaseMessaging,
getFirebaseMessaging
};

View File

@@ -0,0 +1,47 @@
const fs = require('node:fs/promises');
const path = require('node:path');
function getTelegramConfig(env = process.env) {
return {
enabled: env.TELEGRAM_IMPORT_ENABLED === 'true',
autoPublish: env.TELEGRAM_AUTO_PUBLISH !== 'false',
apiId: Number(env.TELEGRAM_API_ID),
apiHash: env.TELEGRAM_API_HASH || '',
sessionFile: env.TELEGRAM_SESSION_FILE || '',
channel: (env.TELEGRAM_CHANNEL || 'avp2Polotsk').replace(/^@/, ''),
citySlug: env.TELEGRAM_CITY_SLUG || 'polotsk',
intervalMs: Math.max(30_000, Number(env.TELEGRAM_POLL_INTERVAL_MS) || 60_000)
};
}
function missingTelegramConfig(config) {
const missing = [];
if (!Number.isSafeInteger(config.apiId) || config.apiId <= 0) missing.push('TELEGRAM_API_ID');
if (!/^[a-f0-9]{32}$/i.test(config.apiHash)) missing.push('TELEGRAM_API_HASH');
if (!path.isAbsolute(config.sessionFile)) missing.push('TELEGRAM_SESSION_FILE');
if (!/^[a-zA-Z][a-zA-Z0-9_]{4,31}$/.test(config.channel)) missing.push('TELEGRAM_CHANNEL');
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.citySlug)) missing.push('TELEGRAM_CITY_SLUG');
return missing;
}
async function assertExternalSessionFile(file) {
if (!path.isAbsolute(file)) throw new Error('SESSION_FILE_INVALID');
const root = await fs.realpath(path.resolve(__dirname, '../../..'));
// Resolve the parent as well, so a symlink cannot put the credential in the checkout.
const parent = await fs.realpath(path.dirname(file));
const resolved = path.join(parent, path.basename(file));
if (resolved === root || resolved.startsWith(`${root}${path.sep}`)) {
throw new Error('SESSION_FILE_IN_CHECKOUT');
}
try {
const stat = await fs.lstat(resolved);
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
throw new Error('SESSION_FILE_PERMISSIONS');
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
return resolved;
}
module.exports = { getTelegramConfig, missingTelegramConfig, assertExternalSessionFile };

View File

@@ -2,12 +2,71 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const pool = require('../config/database');
const logger = require('../config/logger');
const passkeyService = require('../services/passkeyService');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const JWT_EXPIRES_IN = '24h';
const REFRESH_TOKEN_EXPIRES_DAYS = 7;
class AuthController {
async createSessionResponse(user, req) {
const accessToken = jwt.sign(
{ id: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ id: user.id, type: 'refresh' },
JWT_SECRET,
{ expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d` }
);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);
await pool.query(
`INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5)`,
[user.id, refreshToken, expiresAt, req.ip, req.get('user-agent')]
);
await pool.query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]);
return {
user: {
id: user.id,
username: user.username,
full_name: user.full_name,
email: user.email,
role: user.role,
avatar_url: user.avatar_url,
is_active: user.is_active,
last_login: user.last_login,
created_at: user.created_at,
},
accessToken,
refreshToken,
};
}
sendPasskeyError(res, error, fallbackMessage) {
if (error.code === 'PASSKEY_CONFIGURATION_REQUIRED') {
return res.status(503).json({ error: 'Вход по passkey ещё не настроен для этого адреса сайта.' });
}
if (error.code === 'PASSKEY_CHALLENGE_EXPIRED') {
return res.status(400).json({ error: 'Время ожидания passkey истекло. Повторите попытку.' });
}
if (error.code === 'PASSKEY_NOT_FOUND' || error.code === 'PASSKEY_VERIFICATION_FAILED' || error.code === 'PASSKEY_RESPONSE_INVALID') {
return res.status(401).json({ error: 'Не удалось подтвердить passkey.' });
}
logger.error('Passkey error:', error);
return res.status(500).json({ error: fallbackMessage });
}
// Login
async login(req, res) {
try {
@@ -35,51 +94,105 @@ class AuthController {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const accessToken = jwt.sign(
{ id: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
const refreshToken = jwt.sign(
{ id: user.id, type: 'refresh' },
JWT_SECRET,
{ expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d` }
);
// Save refresh token
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);
await pool.query(
`INSERT INTO user_sessions (user_id, refresh_token, expires_at, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5)`,
[user.id, refreshToken, expiresAt, req.ip, req.get('user-agent')]
);
// Update last login
await pool.query(
'UPDATE users SET last_login = NOW() WHERE id = $1',
[user.id]
);
// Remove password hash from response
delete user.password_hash;
logger.info(`User logged in: ${username}`);
res.json({
user,
accessToken,
refreshToken
});
res.json(await this.createSessionResponse(user, req));
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async generatePasskeyRegistrationOptions(req, res) {
try {
const userResult = await pool.query(
`SELECT id, username, full_name
FROM users
WHERE id = $1 AND is_active = true`,
[req.user.id]
);
if (userResult.rows.length === 0) {
return res.status(404).json({ error: 'Пользователь не найден.' });
}
res.json(await passkeyService.generateRegistrationOptionsForUser(req, userResult.rows[0]));
} catch (error) {
this.sendPasskeyError(res, error, 'Не удалось начать регистрацию passkey.');
}
}
async verifyPasskeyRegistration(req, res) {
try {
const userResult = await pool.query(
`SELECT id, username, full_name
FROM users
WHERE id = $1 AND is_active = true`,
[req.user.id]
);
if (userResult.rows.length === 0) {
return res.status(404).json({ error: 'Пользователь не найден.' });
}
const passkey = await passkeyService.verifyRegistrationForUser(
req,
userResult.rows[0],
req.body.response,
req.body.label,
);
logger.info(`Passkey registered for user: ${userResult.rows[0].username}`);
res.status(201).json({ passkey });
} catch (error) {
this.sendPasskeyError(res, error, 'Не удалось сохранить passkey.');
}
}
async generatePasskeyAuthenticationOptions(req, res) {
try {
res.json(await passkeyService.generateAuthenticationOptionsForRequest(req));
} catch (error) {
this.sendPasskeyError(res, error, 'Не удалось начать вход по passkey.');
}
}
async verifyPasskeyAuthentication(req, res) {
try {
const user = await passkeyService.verifyAuthenticationForRequest(req, req.body.response);
logger.info(`User logged in with passkey: ${user.username}`);
res.json(await this.createSessionResponse(user, req));
} catch (error) {
this.sendPasskeyError(res, error, 'Не удалось выполнить вход по passkey.');
}
}
async listPasskeys(req, res) {
try {
res.json({ passkeys: await passkeyService.listUserPasskeys(req.user.id) });
} catch (error) {
logger.error('List passkeys error:', error);
res.status(500).json({ error: 'Не удалось получить список passkey.' });
}
}
async removePasskey(req, res) {
try {
const passkeyId = Number(req.params.passkeyId);
if (!Number.isSafeInteger(passkeyId) || passkeyId < 1) {
return res.status(400).json({ error: 'Некорректный идентификатор passkey.' });
}
const removed = await passkeyService.removeUserPasskey(req.user.id, passkeyId);
if (!removed) {
return res.status(404).json({ error: 'Passkey не найден.' });
}
res.json({ message: 'Passkey удалён.' });
} catch (error) {
logger.error('Remove passkey error:', error);
res.status(500).json({ error: 'Не удалось удалить passkey.' });
}
}
// Refresh token
async refresh(req, res) {
try {
@@ -229,4 +342,4 @@ class AuthController {
}
}
module.exports = new AuthController();
module.exports = new AuthController();

View File

@@ -0,0 +1,137 @@
const pool = require('../config/database');
const logger = require('../config/logger');
const fs = require('fs').promises;
const path = require('path');
const cityUploadDir = path.join(__dirname, '../../public/uploads/cities');
function normalizeSlug(value) {
return String(value || '').trim().toLowerCase();
}
function validateCity({ name, slug, emblem, center_lat, center_lon }) {
if (!String(name || '').trim()) return 'Укажите название города.';
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalizeSlug(slug))) {
return 'Код города должен состоять из латинских строчных букв, цифр и дефисов.';
}
if (emblem !== undefined && String(emblem).trim().length > 64) return 'Иконка или герб слишком длинные.';
const hasLat = center_lat !== undefined && center_lat !== '';
const hasLon = center_lon !== undefined && center_lon !== '';
if (hasLat !== hasLon) return 'Укажите обе координаты центра города.';
if (hasLat && (!Number.isFinite(Number(center_lat)) || !Number.isFinite(Number(center_lon))
|| Number(center_lat) < -90 || Number(center_lat) > 90 || Number(center_lon) < -180 || Number(center_lon) > 180)) {
return 'Укажите корректные координаты центра города.';
}
return null;
}
class CitiesController {
async getAllCities(req, res) {
try {
const result = await pool.query(`
SELECT id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
FROM cities
WHERE is_active = true
ORDER BY name COLLATE "ru-RU-x-icu"
`);
res.json(result.rows);
} catch (error) {
logger.error('Error fetching cities', { message: error.message });
res.status(500).json({ error: 'Не удалось загрузить города.' });
}
}
async createCity(req, res) {
const { name, slug, emblem, center_lat, center_lon, schedule_source_url } = req.body;
const validationError = validateCity({ name, slug, emblem, center_lat, center_lon });
if (validationError) return res.status(400).json({ error: validationError });
try {
const result = await pool.query(`
INSERT INTO cities (name, slug, emblem, center_lat, center_lon, schedule_source_url)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
`, [
String(name).trim(), normalizeSlug(slug), String(emblem || '🏙️').trim(),
center_lat === '' || center_lat === undefined ? null : Number(center_lat),
center_lon === '' || center_lon === undefined ? null : Number(center_lon),
String(schedule_source_url || '').trim() || null
]);
res.status(201).json(result.rows[0]);
} catch (error) {
if (error.code === '23505') return res.status(409).json({ error: 'Город с таким названием или кодом уже есть.' });
logger.error('Error creating city', { message: error.message });
res.status(500).json({ error: 'Не удалось добавить город.' });
}
}
async updateCity(req, res) {
const cityId = Number(req.params.cityId);
if (!Number.isInteger(cityId) || cityId <= 0) {
return res.status(400).json({ error: 'Некорректный идентификатор города.' });
}
const { name, slug, emblem, center_lat, center_lon } = req.body;
const validationError = validateCity({ name, slug, emblem, center_lat, center_lon });
if (validationError) return res.status(400).json({ error: validationError });
try {
const result = await pool.query(`
UPDATE cities
SET name = $1, slug = $2, emblem = $3, center_lat = $4, center_lon = $5
WHERE id = $6
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
`, [
String(name).trim(), normalizeSlug(slug), String(emblem || '🏙️').trim(),
center_lat === '' || center_lat === undefined ? null : Number(center_lat),
center_lon === '' || center_lon === undefined ? null : Number(center_lon),
cityId
]);
if (!result.rows.length) return res.status(404).json({ error: 'Город не найден.' });
res.json(result.rows[0]);
} catch (error) {
if (error.code === '23505') return res.status(409).json({ error: 'Город с таким названием или кодом уже есть.' });
logger.error('Error updating city', { cityId, message: error.message });
res.status(500).json({ error: 'Не удалось сохранить изменения города.' });
}
}
async uploadCityAvatar(req, res) {
const cityId = Number(req.params.cityId);
if (!Number.isInteger(cityId) || cityId <= 0) {
if (req.file) await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'Некорректный идентификатор города.' });
}
if (!req.file) return res.status(400).json({ error: 'Выберите изображение.' });
try {
const current = await pool.query('SELECT avatar_url FROM cities WHERE id = $1', [cityId]);
if (!current.rows.length) {
await fs.unlink(req.file.path).catch(() => {});
return res.status(404).json({ error: 'Город не найден.' });
}
const avatarUrl = `/uploads/cities/${req.file.filename}`;
const result = await pool.query(`
UPDATE cities SET avatar_url = $1 WHERE id = $2
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
`, [avatarUrl, cityId]);
const previous = current.rows[0].avatar_url;
if (previous?.startsWith('/uploads/cities/')) {
await fs.unlink(path.join(cityUploadDir, path.basename(previous))).catch(error => {
logger.warn('Failed to remove previous city avatar', { cityId, message: error.message });
});
}
res.json({ message: 'Изображение города сохранено.', city: result.rows[0] });
} catch (error) {
await fs.unlink(req.file.path).catch(() => {});
logger.error('Error uploading city avatar', { cityId, message: error.message });
res.status(500).json({ error: 'Не удалось сохранить изображение города.' });
}
}
}
module.exports = new CitiesController();

View File

@@ -1,6 +1,7 @@
const pool = require('../config/database');
const logger = require('../config/logger');
const { getLocalDateTime, getIsoWeekday } = require('../utils/etaTime');
const { getRouteInCity, getStopInCity } = require('../middleware/city');
class EtaController {
// Calculate ETA for a stop on a route
@@ -17,6 +18,13 @@ class EtaController {
}
try {
const [route, stop] = await Promise.all([
getRouteInCity(routeId, req.city.id),
getStopInCity(stopId, req.city.id)
]);
if (!route || !stop) {
return res.status(404).json({ error: 'Route or stop not found in the selected city' });
}
// Get schedule type for today (holiday-aware)
const typeResult = await pool.query(
'SELECT get_schedule_type($1::date) as schedule_type',
@@ -138,13 +146,16 @@ class EtaController {
const { stopId } = req.params;
try {
if (!await getStopInCity(stopId, req.city.id)) {
return res.status(404).json({ error: 'Stop not found' });
}
// Get all routes passing through this stop
const routesResult = await pool.query(`
SELECT DISTINCT r.id, r.route_number, r.name, r.type, r.color
FROM routes r
JOIN route_stops rs ON r.id = rs.route_id
WHERE rs.stop_id = $1 AND r.is_active = true
`, [stopId]);
WHERE rs.stop_id = $1 AND r.city_id = $2 AND r.is_active = true
`, [stopId, req.city.id]);
const etaPromises = routesResult.rows.map(route =>
this.calculateEtaForRoute(stopId, route.id)

View File

@@ -9,7 +9,7 @@ class QualityController {
async getReport(req, res) {
try {
res.json(await this.report(this.pool));
res.json(await this.report(this.pool, req.city.id));
} catch (error) {
logger.error('Error building quality report', { message: error.message });
res.status(500).json({ error: 'Не удалось проверить качество данных. Повторите попытку.' });

View File

@@ -1,6 +1,7 @@
const pool = require('../config/database');
const redisClient = require('../config/redis');
const logger = require('../config/logger');
const { getRouteInCity, getStopInCity } = require('../middleware/city');
class RouteStopsController {
// Get all stops for a route (ordered by sequence)
@@ -20,9 +21,10 @@ class RouteStopsController {
ST_X(s.location::geometry) as longitude
FROM route_stops rs
JOIN stops s ON rs.stop_id = s.id
WHERE rs.route_id = $1
JOIN routes r ON r.id = rs.route_id
WHERE rs.route_id = $1 AND r.city_id = $2 AND s.city_id = $2
ORDER BY rs.sequence
`, [routeId]);
`, [routeId, req.city.id]);
res.json(result.rows);
} catch (error) {
@@ -44,20 +46,14 @@ class RouteStopsController {
try {
// Check if route exists
const routeCheck = await pool.query(
'SELECT id FROM routes WHERE id = $1',
[routeId]
);
if (routeCheck.rows.length === 0) {
const route = await getRouteInCity(routeId, req.city.id);
if (!route) {
return res.status(404).json({ error: 'Route not found' });
}
// Check if stop exists
const stopCheck = await pool.query(
'SELECT id FROM stops WHERE id = $1',
[stopId]
);
if (stopCheck.rows.length === 0) {
const stop = await getStopInCity(stopId, req.city.id);
if (!stop) {
return res.status(404).json({ error: 'Stop not found' });
}
@@ -88,8 +84,8 @@ class RouteStopsController {
`, [routeId, stopId, actualSequence, timeOffsetMinutes]);
// Invalidate cache
await redisClient.del(`route:${routeId}`);
await redisClient.del('routes:all');
await redisClient.del(`route:${req.city.id}:${routeId}`);
await redisClient.del(`routes:all:${req.city.id}`);
logger.info(`Stop ${stopId} added to route ${routeId} at position ${actualSequence}`);
res.status(201).json(result.rows[0]);
@@ -105,6 +101,9 @@ class RouteStopsController {
const { sequence, timeOffsetMinutes } = req.body;
try {
if (!await getRouteInCity(routeId, req.city.id)) {
return res.status(404).json({ error: 'Route not found' });
}
// Get current route_stop
const current = await pool.query(
'SELECT * FROM route_stops WHERE id = $1 AND route_id = $2',
@@ -151,7 +150,7 @@ class RouteStopsController {
`, [sequence, timeOffsetMinutes, routeStopId, routeId]);
// Invalidate cache
await redisClient.del(`route:${routeId}`);
await redisClient.del(`route:${req.city.id}:${routeId}`);
logger.info(`Route stop ${routeStopId} updated`);
res.json(result.rows[0]);
@@ -166,6 +165,9 @@ class RouteStopsController {
const { routeId, routeStopId } = req.params;
try {
if (!await getRouteInCity(routeId, req.city.id)) {
return res.status(404).json({ error: 'Route not found' });
}
// Get the stop being removed to know its sequence
const current = await pool.query(
'SELECT sequence FROM route_stops WHERE id = $1 AND route_id = $2',
@@ -192,8 +194,8 @@ class RouteStopsController {
`, [routeId, removedSequence]);
// Invalidate cache
await redisClient.del(`route:${routeId}`);
await redisClient.del('routes:all');
await redisClient.del(`route:${req.city.id}:${routeId}`);
await redisClient.del(`routes:all:${req.city.id}`);
logger.info(`Stop removed from route ${routeId}`);
res.json({ message: 'Stop removed from route successfully' });
@@ -218,6 +220,17 @@ class RouteStopsController {
const client = await pool.connect();
try {
if (!await getRouteInCity(routeId, req.city.id, client)) {
return res.status(404).json({ error: 'Route not found' });
}
const stopIds = [...new Set(stops.map(stop => Number(stop.stopId)))];
const cityStops = await client.query(
'SELECT id FROM stops WHERE city_id = $1 AND id = ANY($2::int[])',
[req.city.id, stopIds]
);
if (cityStops.rows.length !== stopIds.length) {
return res.status(400).json({ error: 'All stops must belong to the selected city' });
}
await client.query('BEGIN');
// Delete all existing route_stops for this route
@@ -237,8 +250,8 @@ class RouteStopsController {
await client.query('COMMIT');
// Invalidate cache
await redisClient.del(`route:${routeId}`);
await redisClient.del('routes:all');
await redisClient.del(`route:${req.city.id}:${routeId}`);
await redisClient.del(`routes:all:${req.city.id}`);
logger.info(`Route ${routeId} stops reordered: ${stops.length} stops`);
res.json({ message: 'Route stops reordered successfully', count: stops.length });
@@ -264,12 +277,13 @@ class RouteStopsController {
ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude
FROM stops s
WHERE s.is_active = true
JOIN routes r ON r.id = $1 AND r.city_id = $2
WHERE s.is_active = true AND s.city_id = $2
AND s.id NOT IN (
SELECT stop_id FROM route_stops WHERE route_id = $1
)
ORDER BY s.name
`, [routeId]);
`, [routeId, req.city.id]);
res.json(result.rows);
} catch (error) {
@@ -279,4 +293,4 @@ class RouteStopsController {
}
}
module.exports = new RouteStopsController();
module.exports = new RouteStopsController();

View File

@@ -8,8 +8,10 @@ class RoutesController {
// Get all routes
async getAllRoutes(req, res) {
try {
const cityId = req.city.id;
// Try cache first
const cached = await redisClient.get('routes:all');
const cacheKey = `routes:all:${cityId}`;
const cached = await redisClient.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
@@ -20,15 +22,15 @@ class RoutesController {
COUNT(DISTINCT rs.stop_id) as stops_count
FROM routes r
LEFT JOIN route_stops rs ON r.id = rs.route_id
WHERE r.is_active = true
WHERE r.is_active = true AND r.city_id = $1
GROUP BY r.id
ORDER BY r.route_number
`);
`, [cityId]);
const routes = result.rows;
// Cache the result
await redisClient.setEx('routes:all', CACHE_TTL, JSON.stringify(routes));
await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(routes));
res.json(routes);
} catch (error) {
@@ -40,9 +42,10 @@ class RoutesController {
// Get route by ID with stops
async getRouteById(req, res) {
const { id } = req.params;
const cityId = req.city.id;
try {
const cacheKey = `route:${id}`;
const cacheKey = `route:${cityId}:${id}`;
const cached = await redisClient.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
@@ -50,8 +53,8 @@ class RoutesController {
// Get route info
const routeResult = await pool.query(
'SELECT * FROM routes WHERE id = $1 AND is_active = true',
[id]
'SELECT * FROM routes WHERE id = $1 AND city_id = $2 AND is_active = true',
[id, cityId]
);
if (routeResult.rows.length === 0) {
@@ -68,9 +71,9 @@ class RoutesController {
rs.time_offset_minutes
FROM stops s
JOIN route_stops rs ON s.id = rs.stop_id
WHERE rs.route_id = $1
WHERE rs.route_id = $1 AND s.city_id = $2
ORDER BY rs.sequence
`, [id]);
`, [id, cityId]);
const route = {
...routeResult.rows[0],
@@ -90,6 +93,7 @@ class RoutesController {
// Create new route
async createRoute(req, res) {
const { route_number, name, type, color, description } = req.body;
const cityId = req.city.id;
if (!route_number || !name || !type) {
return res.status(400).json({
@@ -100,13 +104,13 @@ class RoutesController {
try {
const result = await pool.query(`
INSERT INTO routes (route_number, name, type, color, description)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO routes (route_number, name, type, color, description, city_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [route_number, name, type, color || '#0066CC', description]);
`, [route_number, name, type, color || '#0066CC', description, cityId]);
// Invalidate cache
await redisClient.del('routes:all');
await redisClient.del(`routes:all:${cityId}`);
logger.info(`Route created: ${route_number} - ${name}`);
res.status(201).json(result.rows[0]);
@@ -120,6 +124,7 @@ class RoutesController {
async updateRoute(req, res) {
const { id } = req.params;
const { route_number, name, type, color, description, is_active } = req.body;
const cityId = req.city.id;
try {
const result = await pool.query(`
@@ -131,17 +136,17 @@ class RoutesController {
color = COALESCE($4, color),
description = COALESCE($5, description),
is_active = COALESCE($6, is_active)
WHERE id = $7
WHERE id = $7 AND city_id = $8
RETURNING *
`, [route_number, name, type, color, description, is_active, id]);
`, [route_number, name, type, color, description, is_active, id, cityId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Route not found' });
}
// Invalidate cache
await redisClient.del('routes:all');
await redisClient.del(`route:${id}`);
await redisClient.del(`routes:all:${cityId}`);
await redisClient.del(`route:${cityId}:${id}`);
logger.info(`Route updated: ${id}`);
res.json(result.rows[0]);
@@ -154,11 +159,12 @@ class RoutesController {
// Delete route
async deleteRoute(req, res) {
const { id } = req.params;
const cityId = req.city.id;
try {
const result = await pool.query(
'DELETE FROM routes WHERE id = $1 RETURNING *',
[id]
'DELETE FROM routes WHERE id = $1 AND city_id = $2 RETURNING *',
[id, cityId]
);
if (result.rows.length === 0) {
@@ -166,8 +172,8 @@ class RoutesController {
}
// Invalidate cache
await redisClient.del('routes:all');
await redisClient.del(`route:${id}`);
await redisClient.del(`routes:all:${cityId}`);
await redisClient.del(`route:${cityId}:${id}`);
logger.info(`Route deleted: ${id}`);
res.json({ message: 'Route deleted successfully' });

View File

@@ -0,0 +1,48 @@
const logger = require('../config/logger');
const { buildScheduleCenter, formatMinskDate, parseDate } = require('../services/scheduleCenterReport');
const SCHEDULE_CENTER_QUERY = `
SELECT
r.id AS route_id,
r.route_number,
r.name AS route_name,
r.color,
s.id AS schedule_id,
s.day_type,
s.valid_from,
s.valid_until,
GREATEST(
COALESCE(cardinality(s.departure_times), 0),
CASE WHEN jsonb_typeof(s.trips) = 'array' THEN jsonb_array_length(s.trips) ELSE 0 END
)::integer AS departure_count
FROM routes r
LEFT JOIN schedules s ON s.route_id = r.id
WHERE r.city_id = $1 AND r.is_active = true
ORDER BY r.route_number, r.name, s.day_type, s.valid_from NULLS FIRST, s.id
`;
class ScheduleCenterController {
constructor({ pool, report = buildScheduleCenter } = {}) {
this.pool = pool || require('../config/database');
this.report = report;
}
async getOverview(req, res) {
const targetDate = req.query.date || formatMinskDate();
if (!parseDate(targetDate)) {
return res.status(400).json({ error: 'Дата должна быть в формате ГГГГ-ММ-ДД.' });
}
try {
const { rows } = await this.pool.query(SCHEDULE_CENTER_QUERY, [req.city.id]);
return res.json(this.report(rows, targetDate));
} catch (error) {
logger.error('Error building schedule center report', { message: error.message, cityId: req.city?.id });
return res.status(500).json({ error: 'Не удалось загрузить центр расписаний. Повторите попытку.' });
}
}
}
module.exports = new ScheduleCenterController();
module.exports.ScheduleCenterController = ScheduleCenterController;
module.exports.SCHEDULE_CENTER_QUERY = SCHEDULE_CENTER_QUERY;

View File

@@ -1,6 +1,7 @@
const pool = require('../config/database');
const redisClient = require('../config/redis');
const logger = require('../config/logger');
const { getRouteInCity } = require('../middleware/city');
const VALID_DAY_TYPES = ['weekday', 'saturday', 'sunday', 'holiday'];
@@ -17,7 +18,7 @@ class SchedulesController {
r.name as route_name
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE s.route_id = $1
WHERE s.route_id = $1 AND r.city_id = $2
ORDER BY
CASE s.day_type
WHEN 'weekday' THEN 1
@@ -25,7 +26,7 @@ class SchedulesController {
WHEN 'sunday' THEN 3
WHEN 'holiday' THEN 4
END
`, [routeId]);
`, [routeId, req.city.id]);
res.json(result.rows.map(schedule => this.withScheduleTrips(schedule)));
} catch (error) {
@@ -46,8 +47,8 @@ class SchedulesController {
r.name as route_name
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE s.id = $1
`, [id]);
WHERE s.id = $1 AND r.city_id = $2
`, [id, req.city.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Schedule not found' });
@@ -88,6 +89,9 @@ class SchedulesController {
}
try {
if (!await getRouteInCity(routeId, req.city.id)) {
return res.status(404).json({ error: 'Route not found' });
}
// Check if schedule already exists for this route and day type
const existing = await pool.query(`
SELECT id FROM schedules
@@ -117,7 +121,7 @@ class SchedulesController {
]);
// Invalidate cache
await redisClient.del(`route:${routeId}`);
await redisClient.del(`route:${req.city.id}:${routeId}`);
logger.info(`Schedule created for route ${routeId}, day type: ${dayType}`);
res.status(201).json(this.withScheduleTrips(result.rows[0]));
@@ -134,7 +138,11 @@ class SchedulesController {
try {
// Get current schedule to find route_id
const current = await pool.query('SELECT route_id FROM schedules WHERE id = $1', [id]);
const current = await pool.query(`
SELECT s.route_id FROM schedules s
JOIN routes r ON r.id = s.route_id
WHERE s.id = $1 AND r.city_id = $2
`, [id, req.city.id]);
if (current.rows.length === 0) {
return res.status(404).json({ error: 'Schedule not found' });
}
@@ -169,7 +177,7 @@ class SchedulesController {
]);
// Invalidate cache
await redisClient.del(`route:${current.rows[0].route_id}`);
await redisClient.del(`route:${req.city.id}:${current.rows[0].route_id}`);
logger.info(`Schedule ${id} updated`);
res.json(this.withScheduleTrips(result.rows[0]));
@@ -185,8 +193,10 @@ class SchedulesController {
try {
const result = await pool.query(
'DELETE FROM schedules WHERE id = $1 RETURNING route_id',
[id]
`DELETE FROM schedules s USING routes r
WHERE s.id = $1 AND r.id = s.route_id AND r.city_id = $2
RETURNING s.route_id`,
[id, req.city.id]
);
if (result.rows.length === 0) {
@@ -194,7 +204,7 @@ class SchedulesController {
}
// Invalidate cache
await redisClient.del(`route:${result.rows[0].route_id}`);
await redisClient.del(`route:${req.city.id}:${result.rows[0].route_id}`);
logger.info(`Schedule ${id} deleted`);
res.json({ message: 'Schedule deleted successfully' });
@@ -226,12 +236,12 @@ class SchedulesController {
SELECT s.*, r.route_number, r.name as route_name
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE s.route_id = $1
WHERE s.route_id = $1 AND r.city_id = $4
AND s.day_type = $2
AND (s.valid_from IS NULL OR s.valid_from <= $3::date)
AND (s.valid_until IS NULL OR s.valid_until >= $3::date)
LIMIT 1
`, [routeId, scheduleType, targetDate]);
`, [routeId, scheduleType, targetDate, req.city.id]);
// If holiday and no specific holiday schedule, fall back to sunday
if (result.rows.length === 0 && scheduleType === 'holiday') {
@@ -239,12 +249,12 @@ class SchedulesController {
SELECT s.*, r.route_number, r.name as route_name
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE s.route_id = $1
WHERE s.route_id = $1 AND r.city_id = $3
AND s.day_type = 'sunday'
AND (s.valid_from IS NULL OR s.valid_from <= $2::date)
AND (s.valid_until IS NULL OR s.valid_until >= $2::date)
LIMIT 1
`, [routeId, targetDate]);
`, [routeId, targetDate, req.city.id]);
}
if (result.rows.length === 0) {
@@ -287,6 +297,9 @@ class SchedulesController {
}
try {
if (!await getRouteInCity(routeId, req.city.id)) {
return res.status(404).json({ error: 'Route not found' });
}
// Get source schedule
const source = await pool.query(`
SELECT departure_times, trips, valid_from, valid_until FROM schedules
@@ -352,7 +365,7 @@ class SchedulesController {
}
// Invalidate cache
await redisClient.del(`route:${routeId}`);
await redisClient.del(`route:${req.city.id}:${routeId}`);
logger.info(`Schedule copied from ${fromDayType} to ${toDayType} for route ${routeId}`);
} catch (error) {

View File

@@ -8,7 +8,9 @@ class StopsController {
// Get all stops
async getAllStops(req, res) {
try {
const cached = await redisClient.get('stops:all');
const cityId = req.city.id;
const cacheKey = `stops:all:${cityId}`;
const cached = await redisClient.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
@@ -21,13 +23,13 @@ class StopsController {
COUNT(DISTINCT rs.route_id) as routes_count
FROM stops s
LEFT JOIN route_stops rs ON s.id = rs.stop_id
WHERE s.is_active = true
WHERE s.is_active = true AND s.city_id = $1
GROUP BY s.id
ORDER BY s.name
`);
`, [cityId]);
const stops = result.rows;
await redisClient.setEx('stops:all', CACHE_TTL, JSON.stringify(stops));
await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(stops));
res.json(stops);
} catch (error) {
@@ -39,6 +41,7 @@ class StopsController {
// Get stop by ID
async getStopById(req, res) {
const { id } = req.params;
const cityId = req.city.id;
try {
const result = await pool.query(`
@@ -47,8 +50,8 @@ class StopsController {
ST_Y(s.location::geometry) as latitude,
ST_X(s.location::geometry) as longitude
FROM stops s
WHERE s.id = $1 AND s.is_active = true
`, [id]);
WHERE s.id = $1 AND s.city_id = $2 AND s.is_active = true
`, [id, cityId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Stop not found' });
@@ -62,9 +65,9 @@ class StopsController {
rs.time_offset_minutes
FROM routes r
JOIN route_stops rs ON r.id = rs.route_id
WHERE rs.stop_id = $1 AND r.is_active = true
WHERE rs.stop_id = $1 AND r.city_id = $2 AND r.is_active = true
ORDER BY r.route_number
`, [id]);
`, [id, cityId]);
const stop = {
...result.rows[0],
@@ -81,6 +84,7 @@ class StopsController {
// Get nearby stops (PostGIS query)
async getNearbyStops(req, res) {
const { lat, lon, radius = 500 } = req.query;
const cityId = req.city.id;
if (!lat || !lon) {
return res.status(400).json({
@@ -100,7 +104,7 @@ class StopsController {
ST_MakePoint($2, $1)::geography
) as distance_meters
FROM stops s
WHERE s.is_active = true
WHERE s.is_active = true AND s.city_id = $4
AND ST_DWithin(
s.location,
ST_MakePoint($2, $1)::geography,
@@ -108,7 +112,7 @@ class StopsController {
)
ORDER BY distance_meters
LIMIT 20
`, [parseFloat(lat), parseFloat(lon), parseFloat(radius)]);
`, [parseFloat(lat), parseFloat(lon), parseFloat(radius), cityId]);
res.json(result.rows);
} catch (error) {
@@ -120,6 +124,7 @@ class StopsController {
// Create stop
async createStop(req, res) {
const { name, latitude, longitude, address, description } = req.body;
const cityId = req.city.id;
if (!name || !latitude || !longitude) {
return res.status(400).json({
@@ -130,20 +135,21 @@ class StopsController {
try {
const result = await pool.query(`
INSERT INTO stops (name, location, address, description)
INSERT INTO stops (name, location, address, description, city_id)
VALUES (
$1,
ST_SetSRID(ST_MakePoint($3, $2), 4326)::geography,
$4,
$5
$5,
$6
)
RETURNING
*,
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude
`, [name, latitude, longitude, address, description]);
`, [name, latitude, longitude, address, description, cityId]);
await redisClient.del('stops:all');
await redisClient.del(`stops:all:${cityId}`);
logger.info(`Stop created: ${name}`);
res.status(201).json(result.rows[0]);
@@ -157,6 +163,7 @@ class StopsController {
async updateStop(req, res) {
const { id } = req.params;
const { name, latitude, longitude, address, description, is_active } = req.body;
const cityId = req.city.id;
try {
let query = 'UPDATE stops SET ';
@@ -190,8 +197,8 @@ class StopsController {
}
query = query.slice(0, -2); // Remove trailing comma
query += ` WHERE id = $${paramCount} RETURNING *, ST_Y(location::geometry) as latitude, ST_X(location::geometry) as longitude`;
values.push(id);
query += ` WHERE id = $${paramCount} AND city_id = $${paramCount + 1} RETURNING *, ST_Y(location::geometry) as latitude, ST_X(location::geometry) as longitude`;
values.push(id, cityId);
const result = await pool.query(query, values);
@@ -199,7 +206,7 @@ class StopsController {
return res.status(404).json({ error: 'Stop not found' });
}
await redisClient.del('stops:all');
await redisClient.del(`stops:all:${cityId}`);
logger.info(`Stop updated: ${id}`);
res.json(result.rows[0]);
@@ -212,18 +219,19 @@ class StopsController {
// Delete stop
async deleteStop(req, res) {
const { id } = req.params;
const cityId = req.city.id;
try {
const result = await pool.query(
'DELETE FROM stops WHERE id = $1 RETURNING *',
[id]
'DELETE FROM stops WHERE id = $1 AND city_id = $2 RETURNING *',
[id, cityId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Stop not found' });
}
await redisClient.del('stops:all');
await redisClient.del(`stops:all:${cityId}`);
logger.info(`Stop deleted: ${id}`);
res.json({ message: 'Stop deleted successfully' });

View File

@@ -1,3 +1,4 @@
const { alertSelectSql } = require('../services/alertTargets');
const pool = require('../config/database');
const logger = require('../config/logger');
@@ -23,10 +24,11 @@ class SyncController {
// Full synchronization (all data)
async fullSync(req, res) {
try {
const cityId = req.city.id;
// Get all active routes
const routesResult = await pool.query(`
SELECT * FROM routes WHERE is_active = true
`);
SELECT * FROM routes WHERE is_active = true AND city_id = $1
`, [cityId]);
// Get all active stops with coordinates
const stopsResult = await pool.query(`
@@ -35,8 +37,8 @@ class SyncController {
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude
FROM stops
WHERE is_active = true
`);
WHERE is_active = true AND city_id = $1
`, [cityId]);
// Get route-stop relationships
const routeStopsResult = await pool.query(`
@@ -45,25 +47,26 @@ class SyncController {
JOIN routes r ON rs.route_id = r.id
JOIN stops s ON rs.stop_id = s.id
WHERE r.is_active = true AND s.is_active = true
`);
AND r.city_id = $1 AND s.city_id = $1
`, [cityId]);
// Get schedules
const schedulesResult = await pool.query(`
SELECT s.*
FROM schedules s
JOIN routes r ON s.route_id = r.id
WHERE r.is_active = true
WHERE r.is_active = true AND r.city_id = $1
AND (s.valid_from IS NULL OR s.valid_from <= CURRENT_DATE)
AND (s.valid_until IS NULL OR s.valid_until >= CURRENT_DATE)
`);
`, [cityId]);
// Get active alerts
const alertsResult = await pool.query(`
SELECT * FROM alerts
WHERE is_active = true
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)
`);
`, [cityId]);
// Get holidays for current and next year
const holidaysResult = await pool.query(`
@@ -125,9 +128,9 @@ class SyncController {
action,
timestamp
FROM sync_log
WHERE timestamp > $1
WHERE timestamp > $1 AND city_id = $2
ORDER BY table_name, record_id, timestamp DESC
`, [lastSyncDate]);
`, [lastSyncDate, req.city.id]);
const changes = {
routes: { updated: [], deleted: [] },
@@ -146,7 +149,7 @@ class SyncController {
}
} else {
// Get updated record
const record = await this.getRecord(table_name, record_id);
const record = await this.getRecord(table_name, record_id, req.city.id);
if (record && changes[table_name]) {
changes[table_name].updated.push(record);
}
@@ -155,10 +158,10 @@ class SyncController {
// Get new alerts
const alertsResult = await pool.query(`
SELECT * FROM alerts
WHERE is_active = true
SELECT ${alertSelectSql()} FROM alerts a
WHERE city_id = $2 AND is_active = true
AND created_at > $1
`, [lastSyncDate]);
`, [lastSyncDate, req.city.id]);
const syncData = {
syncType: 'incremental',
@@ -181,13 +184,13 @@ class SyncController {
}
// Helper: Get record from table
async getRecord(tableName, recordId) {
async getRecord(tableName, recordId, cityId) {
try {
let query = '';
switch (tableName) {
case 'routes':
query = 'SELECT * FROM routes WHERE id = $1 AND is_active = true';
query = 'SELECT * FROM routes WHERE id = $1 AND city_id = $2 AND is_active = true';
break;
case 'stops':
query = `
@@ -196,16 +199,19 @@ class SyncController {
ST_Y(location::geometry) as latitude,
ST_X(location::geometry) as longitude
FROM stops
WHERE id = $1 AND is_active = true
WHERE id = $1 AND city_id = $2 AND is_active = true
`;
break;
case 'route_stops':
query = 'SELECT * FROM route_stops WHERE id = $1';
query = `SELECT rs.* FROM route_stops rs
JOIN routes r ON r.id = rs.route_id
WHERE rs.id = $1 AND r.city_id = $2`;
break;
case 'schedules':
query = `
SELECT * FROM schedules
WHERE id = $1
SELECT schedules.* FROM schedules
JOIN routes r ON r.id = schedules.route_id
WHERE schedules.id = $1 AND r.city_id = $2
AND (valid_from IS NULL OR valid_from <= CURRENT_DATE)
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)
`;
@@ -214,7 +220,7 @@ class SyncController {
return null;
}
const result = await pool.query(query, [recordId]);
const result = await pool.query(query, [recordId, cityId]);
return result.rows.length > 0 ? result.rows[0] : null;
} catch (error) {
@@ -226,13 +232,14 @@ class SyncController {
// Get sync status
async getSyncStatus(req, res) {
try {
const cityId = req.city.id;
const stats = await pool.query(`
SELECT
(SELECT COUNT(*) FROM routes WHERE is_active = true) as routes_count,
(SELECT COUNT(*) FROM stops WHERE is_active = true) as stops_count,
(SELECT COUNT(*) FROM schedules) as schedules_count,
(SELECT MAX(timestamp) FROM sync_log) as last_change
`);
(SELECT COUNT(*) FROM routes WHERE is_active = true AND city_id = $1) as routes_count,
(SELECT COUNT(*) FROM stops WHERE is_active = true AND city_id = $1) as stops_count,
(SELECT COUNT(*) FROM schedules s JOIN routes r ON r.id = s.route_id WHERE r.city_id = $1) as schedules_count,
(SELECT MAX(timestamp) FROM sync_log WHERE city_id = $1) as last_change
`, [cityId]);
res.json({
status: 'ready',

View File

@@ -13,11 +13,13 @@ const { ensureRealtimeSchema } = require('./services/realtimeSchema');
const { attachRealtimeServer, closeRealtimeServer } = require('./services/realtimeService');
const { ensureBootstrapAdmin } = require('./utils/bootstrapAdmin');
const { startAlertScheduler } = require('./services/alertSchedulerService');
const { startTelegramImporter } = require('./services/telegramImportService');
const app = express();
const PORT = process.env.PORT || 3000;
let server;
let stopAlertScheduler = () => {};
let stopTelegramImporter = () => Promise.resolve();
// Security middleware
app.use(helmet({
@@ -77,9 +79,21 @@ try {
logger.warn('Upload routes disabled - multer not installed. Run: npm install multer');
}
// API routes with API key authentication and rate limiting
// Pairing and per-device credentials have a separate narrow authentication contract.
const { createBoardDevicesRouter } = require('./routes/boardDevices');
const { createBoardDeviceRepository } = require('./services/boardDeviceRepository');
const { createBoardDeviceService } = require('./services/boardDeviceService');
app.use('/api/v1/board-devices', createBoardDevicesRouter(createBoardDeviceService(createBoardDeviceRepository(pool))));
// Passenger telemetry has its own Firebase ID-token authentication inside apiRoutes.
// All other API routes keep the existing API-key/custom-JWT contract.
app.use('/api/v1',
authenticateApiKey,
(req, res, next) => {
if (req.path === '/passenger-telemetry/ingest') {
return next();
}
return authenticateApiKey(req, res, next);
},
createRateLimiter(1000, 3600000), // 1000 requests per hour
apiRoutes
);
@@ -99,15 +113,13 @@ app.use((err, req, res, next) => {
// Multer errors
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
error: 'File too large',
message: 'Maximum file size is 5MB'
error: 'Размер файла не должен превышать 5 МБ.'
});
}
if (err.message && err.message.includes('Only image files are allowed')) {
if (err.message && (err.message.includes('Only image files are allowed') || err.message.includes('Only JPEG, PNG, and WEBP images are allowed'))) {
return res.status(400).json({
error: 'Invalid file type',
message: err.message
error: 'Поддерживаются только изображения JPG, PNG и WEBP.'
});
}
@@ -124,6 +136,7 @@ function shutdown(signal) {
closeRealtimeServer();
stopAlertScheduler();
stopTelegramImporter().catch(() => {});
if (!server) {
process.exit(0);
@@ -164,6 +177,7 @@ async function startServer() {
await ensureRealtimeSchema();
await ensureBootstrapAdmin();
stopAlertScheduler = startAlertScheduler();
stopTelegramImporter = startTelegramImporter();
server = app.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`);

View File

@@ -0,0 +1,46 @@
const pool = require('../config/database');
const logger = require('../config/logger');
const DEFAULT_CITY_SLUG = 'polotsk';
async function resolveCity(req, res, next) {
const rawCityId = req.get('X-City-Id');
if (rawCityId !== undefined && (!/^\d+$/.test(rawCityId) || Number(rawCityId) <= 0)) {
return res.status(400).json({ error: 'Invalid city identifier' });
}
try {
const result = rawCityId
? await pool.query('SELECT * FROM cities WHERE id = $1 AND is_active = true', [Number(rawCityId)])
: await pool.query('SELECT * FROM cities WHERE slug = $1 AND is_active = true', [DEFAULT_CITY_SLUG]);
if (!result.rows.length) {
return res.status(404).json({ error: 'City not found or inactive' });
}
req.city = result.rows[0];
return next();
} catch (error) {
logger.error('Unable to resolve city context', { message: error.message });
return res.status(500).json({ error: 'Unable to resolve city' });
}
}
async function getRouteInCity(routeId, cityId, db = pool) {
const result = await db.query(
'SELECT * FROM routes WHERE id = $1 AND city_id = $2',
[routeId, cityId]
);
return result.rows[0] || null;
}
async function getStopInCity(stopId, cityId, db = pool) {
const result = await db.query(
'SELECT * FROM stops WHERE id = $1 AND city_id = $2',
[stopId, cityId]
);
return result.rows[0] || null;
}
module.exports = { DEFAULT_CITY_SLUG, resolveCity, getRouteInCity, getStopInCity };

View File

@@ -0,0 +1,30 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const uploadDir = path.join(__dirname, '../../public/uploads/cities');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const allowedExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp']);
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'image/webp']);
const storage = multer.diskStorage({
destination: (_req, _file, callback) => callback(null, uploadDir),
filename: (req, file, callback) => {
const cityId = Number(req.params.cityId);
const extension = path.extname(file.originalname).toLowerCase();
callback(null, `city-${cityId}-${Date.now()}${extension}`);
}
});
function fileFilter(_req, file, callback) {
const extension = path.extname(file.originalname).toLowerCase();
if (allowedExtensions.has(extension) && allowedMimeTypes.has(file.mimetype)) return callback(null, true);
return callback(new Error('Only JPEG, PNG, and WEBP images are allowed'));
}
module.exports = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter
});

View File

@@ -0,0 +1,53 @@
const logger = require('../config/logger');
const { createFirebaseAuth } = require('../config/firebase');
function getBearerToken(req) {
const authorization = typeof req.get === 'function'
? req.get('Authorization')
: req.headers?.authorization;
const match = /^Bearer\s+([^\s]+)$/i.exec(authorization || '');
return match ? match[1] : null;
}
function createPassengerTelemetryAuthenticator({
getFirebaseAuth = createFirebaseAuth,
authLogger = logger
} = {}) {
return async (req, res, next) => {
const token = getBearerToken(req);
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
let firebaseAuth;
try {
firebaseAuth = getFirebaseAuth();
} catch (error) {
authLogger.error('Passenger telemetry authentication is unavailable', {
firebaseCode: error.code || 'unknown'
});
return res.status(503).json({ error: 'Passenger telemetry authentication is unavailable' });
}
try {
// Verify the Firebase ID token, including revocation. The decoded token and UID are
// deliberately discarded: telemetry identity is the installation-scoped sessionId only.
await firebaseAuth.verifyIdToken(token, true);
req.passengerTelemetryAuthenticated = true;
return next();
} catch (error) {
authLogger.warn('Passenger telemetry token was rejected', {
firebaseCode: error.code || 'unknown'
});
return res.status(403).json({ error: 'Invalid or expired token' });
}
};
}
const authenticatePassengerTelemetry = createPassengerTelemetryAuthenticator();
module.exports = {
createPassengerTelemetryAuthenticator,
authenticatePassengerTelemetry,
getBearerToken
};

View File

@@ -4,6 +4,7 @@ const pool = require('../config/database');
const logger = require('../config/logger');
const { getAlertCreationService } = require('../services/alertCreationService');
const { listAlertTemplates } = require('../services/alertTemplates');
const { resolveAlertTargets, alertSelectSql, alertRouteFilterSql } = require('../services/alertTargets');
function validateAlertTimes({ start_time, end_time }) {
const start = new Date(start_time);
@@ -21,16 +22,16 @@ 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 1=1
WHERE a.city_id = $1
`;
const params = [];
let paramCount = 1;
const params = [req.city.id];
let paramCount = 2;
if (route_id) {
query += ` AND a.route_id = $${paramCount}`;
query += ` AND ${alertRouteFilterSql(`$${paramCount}`)}`;
params.push(route_id);
paramCount++;
}
@@ -63,17 +64,18 @@ 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.is_active = true
WHERE a.city_id = $1
AND a.is_active = true
AND a.start_time <= NOW()
AND (a.end_time IS NULL OR a.end_time >= NOW())
AND (a.end_time IS NULL OR a.end_time > NOW())
`;
const params = [];
const params = [req.city.id];
if (route_id) {
query += ' AND a.route_id = $1';
query += ` AND ${alertRouteFilterSql('$2')}`;
params.push(route_id);
}
@@ -96,11 +98,11 @@ 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`,
[id]
WHERE a.id = $1 AND a.city_id = $2`,
[id, req.city.id]
);
if (result.rows.length === 0) {
@@ -117,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) {
@@ -132,9 +134,11 @@ function createAlertHandler({ alertCreationService = null } = {}) {
return res.status(400).json({ error: 'Invalid alert_type' });
}
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 },
{ ...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') }
);
@@ -160,10 +164,13 @@ 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('SELECT * FROM alerts WHERE id = $1', [id]);
const checkResult = await pool.query(
'SELECT * FROM alerts WHERE id = $1 AND city_id = $2',
[id, req.city.id]
);
if (checkResult.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
@@ -182,22 +189,29 @@ router.put('/:id', async (req, res) => {
});
if (timeError) return res.status(400).json({ error: timeError });
const result = await pool.query(
const targets = Object.hasOwn(req.body, 'route_numbers') || Object.hasOwn(req.body, 'route_id')
? await resolveAlertTargets(pool, req.city.id, req.body) : null;
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),
start_time = COALESCE($5, start_time),
end_time = $6,
is_active = COALESCE($7, is_active)
WHERE id = $8
WHERE id = $8 AND city_id = $9
RETURNING *`,
[route_id, alert_type, title, message, start_time, end_time, is_active, 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' });
}
@@ -207,7 +221,7 @@ router.put('/:id', async (req, res) => {
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query('DELETE FROM alerts WHERE id = $1 RETURNING id', [id]);
const result = await pool.query('DELETE FROM alerts WHERE id = $1 AND city_id = $2 RETURNING id', [id, req.city.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' });
@@ -226,11 +240,11 @@ router.post('/cleanup', async (req, res) => {
const result = await pool.query(
`UPDATE alerts
SET is_active = false
WHERE is_active = true
WHERE city_id = $1 AND is_active = true
AND end_time IS NOT NULL
AND end_time < NOW()
AND end_time <= NOW()
RETURNING id`
);
, [req.city.id]);
res.json({
message: 'Cleanup completed',

View File

@@ -12,11 +12,21 @@ const syncController = require('../controllers/syncController');
const telemetryController = require('../controllers/telemetryController');
const passengerTelemetryController = require('../controllers/passengerTelemetryController');
const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter');
const { authenticatePassengerTelemetry } = require('../middleware/passengerTelemetryAuth');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { resolveCity } = require('../middleware/city');
const qualityController = require('../controllers/qualityController');
const scheduleCenterController = require('../controllers/scheduleCenterController');
// City catalogue is global. Every transport-data endpoint below receives a
// verified city context from X-City-Id (or falls back to the legacy Polotsk city).
router.use('/cities', require('./cities'));
router.use(resolveCity);
router.use('/schedule-import', require('./scheduleImport'));
router.use('/telegram-import', require('./telegramImport'));
router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController));
router.get('/schedule-center', authenticateToken, requireAdmin, scheduleCenterController.getOverview.bind(scheduleCenterController));
// Routes
router.get('/routes', routesController.getAllRoutes.bind(routesController));
@@ -70,6 +80,7 @@ router.post('/telemetry/ingest', telemetryController.ingest.bind(telemetryContro
router.post(
'/passenger-telemetry/ingest',
createPassengerTelemetryRateLimiter(),
authenticatePassengerTelemetry,
passengerTelemetryController.ingest.bind(passengerTelemetryController)
);
router.get('/realtime/routes/:routeId/vehicles', telemetryController.getRouteLiveVehicles.bind(telemetryController));

View File

@@ -7,9 +7,15 @@ const { authenticateToken } = require('../middleware/auth');
router.post('/login', authController.login.bind(authController));
router.post('/refresh', authController.refresh.bind(authController));
router.post('/logout', authController.logout.bind(authController));
router.post('/passkeys/authentication/options', authController.generatePasskeyAuthenticationOptions.bind(authController));
router.post('/passkeys/authentication/verify', authController.verifyPasskeyAuthentication.bind(authController));
// Protected routes
router.get('/me', authenticateToken, authController.me.bind(authController));
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
router.get('/passkeys', authenticateToken, authController.listPasskeys.bind(authController));
router.post('/passkeys/registration/options', authenticateToken, authController.generatePasskeyRegistrationOptions.bind(authController));
router.post('/passkeys/registration/verify', authenticateToken, authController.verifyPasskeyRegistration.bind(authController));
router.delete('/passkeys/:passkeyId', authenticateToken, authController.removePasskey.bind(authController));
module.exports = router;
module.exports = router;

View File

@@ -0,0 +1,25 @@
const express = require('express');
const { createHash } = require('node:crypto');
const rateLimit = require('express-rate-limit');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { resolveCity } = require('../middleware/city');
function createBoardDevicesRouter(service, { cityMiddleware = resolveCity } = {}) {
const router = express.Router();
router.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const limited = (max, windowMs, keyGenerator) => rateLimit({ max, windowMs, ...(keyGenerator ? { keyGenerator } : {}), standardHeaders: true, legacyHeaders: false, message: { error: 'device_rate_limit', message: 'Слишком много попыток. Попробуйте позже' } });
const handle = fn => async (req, res) => { try { res.json(await fn(req)); } catch (error) { res.status(error.status || 503).json({ error: error.code || 'devices_unavailable', message: error.status ? error.message : 'Управление устройствами временно недоступно' }); } };
router.use(limited(20000, 600000));
const deviceKey = req => createHash('sha256').update(req.get('X-Board-Token') || req.ip).digest('hex');
router.post('/pairings', limited(10, 600000), handle(req => service.start(req.body)));
// Device routes use only individual device credentials, never an admin JWT/API key.
router.get('/self', limited(600, 600000, deviceKey), handle(req => service.status(req.get('X-Board-Token'))));
router.post('/self/heartbeat', limited(300, 600000, deviceKey), handle(req => service.heartbeat(req.get('X-Board-Token'), req.body)));
router.use('/admin', authenticateToken, requireAdmin, cityMiddleware);
router.get('/admin/routes', handle(req => service.routes(req.city.id, Number(req.query.stopId))));
router.get('/admin', handle(req => service.list(req.city.id)));
router.post('/admin/claim', limited(5, 300000), handle(req => service.claim(req.city.id, req.body, req.user.id)));
router.put('/admin/:id', handle(req => service.update(req.params.id, req.city.id, req.body, req.user.id)));
router.post('/admin/:id/revoke', handle(req => service.revoke(req.params.id, req.city.id, req.user.id)));
return router;
}
module.exports = { createBoardDevicesRouter };

View File

@@ -0,0 +1,13 @@
const express = require('express');
const citiesController = require('../controllers/citiesController');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const cityUpload = require('../middleware/cityUpload');
const router = express.Router();
router.get('/', citiesController.getAllCities.bind(citiesController));
router.post('/', authenticateToken, requireAdmin, citiesController.createCity.bind(citiesController));
router.put('/:cityId', authenticateToken, requireAdmin, citiesController.updateCity.bind(citiesController));
router.post('/:cityId/avatar', authenticateToken, requireAdmin, cityUpload.single('avatar'), citiesController.uploadCityAvatar.bind(citiesController));
module.exports = router;

View File

@@ -7,10 +7,20 @@ const { ScheduleImportService } = require('../services/scheduleImportService');
const service = new ScheduleImportService({ pool, invalidate: async routeId => {
if (!redis.isReady) throw new Error('Redis unavailable');
await redis.del([`route:${routeId}`, 'routes:all']);
const route = (await pool.query('SELECT city_id FROM routes WHERE id = $1', [routeId])).rows[0];
if (!route) return;
await redis.del([`route:${route.city_id}:${routeId}`, `routes:all:${route.city_id}`]);
} });
const router = express.Router();
router.use(authenticateToken, requireAdmin);
router.use((req, res, next) => {
if (req.city?.slug !== 'polotsk') {
return res.status(409).json({
error: 'Для выбранного города не настроен официальный источник импорта. Добавляйте маршруты, остановки и расписания вручную.'
});
}
return next();
});
const handle = action => async (req, res) => {
try { res.json(await action(req)); } catch (error) {
logger.error('Schedule import failed', { message: error.message });

View File

@@ -0,0 +1,46 @@
const express = require('express');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { getTelegramImportService } = require('../services/telegramImportService');
function createTelegramImportRouter(service = getTelegramImportService()) {
const router = express.Router();
router.use(authenticateToken, requireAdmin);
const handle = action => async (req, res) => {
try { res.json(await action(req)); }
catch (error) { res.status(error.status || 500).json({ error: error.status ? error.message : 'Не удалось обработать импорт Telegram. Проверьте миграции 013–014 и подключение базы.' }); }
};
const aliasId = req => {
const id = Number(req.params.id);
if (!/^\d+$/.test(req.params.id) || !Number.isSafeInteger(id) || id <= 0) {
const error = new Error('Некорректный идентификатор соответствия.');
error.status = 400;
throw error;
}
return id;
};
router.get('/', handle(async req => ({ status: service.getStatus(), messages: await service.list(req.city.id) })));
router.get('/stop-aliases', handle(req => service.stopAliases.list(req.city.id)));
router.post('/stop-aliases', handle(req => service.stopAliases.save(req.city.id, req.body)));
router.put('/stop-aliases/:id', handle(req => service.stopAliases.save(req.city.id, req.body, aliasId(req))));
router.delete('/stop-aliases/:id', handle(req => service.stopAliases.remove(aliasId(req), req.city.id)));
router.post('/sync', handle(req => {
if (req.city.slug !== service.config.citySlug) {
const error = new Error('Источник Telegram настроен для другого города.');
error.status = 409;
throw error;
}
return service.sync();
}));
router.post('/:id/review', handle(req => {
if (!/^\d+$/.test(req.params.id)) {
const error = new Error('Некорректный идентификатор сообщения.');
error.status = 400;
throw error;
}
return service.review(Number(req.params.id), req.city.id, req.body, req.user.id);
}));
return router;
}
module.exports = createTelegramImportRouter();
module.exports.createTelegramImportRouter = createTelegramImportRouter;

View File

@@ -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)
VALUES ($1, $2, $3, $4, $5, $6, $7)
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,12 +39,14 @@ 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,
start_time: payload.start_time,
end_time: payload.end_time || null,
is_active: payload.is_active !== false
is_active: payload.is_active !== false,
city_id: payload.city_id || null
};
}
@@ -57,13 +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)
is_active: normalizeHashValue(alert.is_active),
city_id: normalizeHashValue(alert.city_id),
...(alert.route_numbers ? { route_numbers: normalizeRouteNumbers(alert.route_numbers) } : {})
};
return crypto
@@ -111,7 +116,7 @@ function isAlertDue(alert, now = new Date()) {
}
const endTime = new Date(alert.end_time);
return !Number.isNaN(endTime.getTime()) && endTime >= now;
return !Number.isNaN(endTime.getTime()) && endTime > now;
}
class AlertCreationService {
@@ -146,7 +151,9 @@ class AlertCreationService {
alertInput.message,
alertInput.start_time,
alertInput.end_time,
alertInput.is_active
alertInput.is_active,
alertInput.city_id,
alertInput.route_numbers ?? null
]);
const row = result.rows[0];

View File

@@ -90,6 +90,8 @@ function isAlertActive(alert = {}) {
function buildAlertCreatedMessage(alert, options = {}) {
const alertId = getAlertId(alert);
const topic = options.topic || DEFAULT_FCM_TOPIC;
const endTime = toIsoString(firstDefined(alert.end_time, alert.endTime));
const now = options.now || new Date();
return {
topic,
@@ -99,13 +101,18 @@ 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: toIsoString(firstDefined(alert.end_time, alert.endTime)),
endTime,
title: getNotificationTitle(alert),
body: getNotificationBody(alert)
},
android: {
priority: 'high'
priority: 'high',
...(endTime ? { ttl: Math.max(0, Math.min(28 * 86_400_000, Date.parse(endTime) - new Date(now).getTime())) } : {})
}
};
}
@@ -124,6 +131,7 @@ class AlertNotificationService {
this.enabled = options.enabled === undefined ? firebaseConfig.enabled : options.enabled;
this.topic = options.topic || firebaseConfig.topic;
this.logger = options.logger || getDefaultLogger();
this.now = options.now || (() => new Date());
this.getMessaging = options.getMessaging || options.messagingFactory || (() => (
options.messaging || getFirebaseMessaging({
...firebaseConfig,
@@ -144,13 +152,18 @@ class AlertNotificationService {
return { sent: false, skipped: true, reason: 'inactive' };
}
const endTime = toIsoString(firstDefined(alert.end_time, alert.endTime));
if (endTime && Date.parse(endTime) <= this.now().getTime()) {
return { sent: false, skipped: true, reason: 'expired' };
}
try {
const messaging = await this.getMessaging();
if (!messaging || typeof messaging.send !== 'function') {
throw new Error('Firebase Messaging is unavailable');
}
const message = buildAlertCreatedMessage(alert, { topic: this.topic });
const message = buildAlertCreatedMessage(alert, { topic: this.topic, now: this.now() });
const messageId = await messaging.send(message);
const normalizedMessageId = toDataString(messageId);

View File

@@ -1,11 +1,12 @@
const { routeIdsSql } = require('./alertTargets');
const CLAIM_DUE_ALERTS_SQL = `
WITH due AS (
SELECT a.id
FROM alerts a
WHERE a.is_active = true
AND a.start_time > a.created_at
AND (a.start_time > a.created_at OR a.notification_pending = true)
AND a.start_time <= CURRENT_TIMESTAMP
AND (a.end_time IS NULL OR a.end_time >= CURRENT_TIMESTAMP)
AND (a.end_time IS NULL OR a.end_time > CURRENT_TIMESTAMP)
AND a.notification_sent_at IS NULL
AND a.notification_attempts < 5
AND (
@@ -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 {

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

@@ -0,0 +1,39 @@
// All claims are transactional. A lost HTTP reply can be retried using the same token.
function createBoardDeviceRepository(pool) {
const projection = 'd.*, c.name AS city_name, s.name AS stop_name, s.description AS stop_direction';
const joins = 'FROM board_devices d JOIN cities c ON c.id=d.city_id JOIN stops s ON s.id=d.stop_id';
return {
async createPairing(pairing) {
await pool.query('DELETE FROM board_pairings WHERE expires_at <= now()');
await pool.query('INSERT INTO board_pairings(code,token_hash,device_info,expires_at) VALUES($1,$2,$3,$4)', [pairing.code, pairing.token_hash, pairing.device_info, pairing.expires_at]);
return pairing;
},
async pairing(hash) { return (await pool.query('SELECT * FROM board_pairings WHERE token_hash=$1', [hash])).rows[0]; },
async device(hash) { return (await pool.query(`SELECT ${projection} ${joins} WHERE d.token_hash=$1`, [hash])).rows[0]; },
async routes(cityId, stopId) { return (await pool.query('SELECT DISTINCT r.id,r.route_number,r.name FROM routes r JOIN route_stops rs ON rs.route_id=r.id WHERE r.city_id=$1 AND rs.stop_id=$2 ORDER BY r.route_number,r.name', [cityId, stopId])).rows; },
async catalog(cityId, settings) {
const stop = (await pool.query('SELECT s.id FROM stops s JOIN cities c ON c.id=s.city_id WHERE s.id=$1 AND s.city_id=$2 AND c.is_active=true', [settings.stopId, cityId])).rows[0];
if (!stop) return false;
return !settings.routeFilter || Boolean((await pool.query('SELECT r.id FROM routes r JOIN route_stops rs ON rs.route_id=r.id WHERE r.id=$1 AND r.city_id=$2 AND rs.stop_id=$3', [Number(settings.routeFilter), cityId, settings.stopId])).rows[0]);
},
async claim(code, record) {
const db = await pool.connect();
try {
await db.query('BEGIN');
const pairing = (await db.query('DELETE FROM board_pairings WHERE code=$1 AND expires_at>now() RETURNING *', [code])).rows[0];
if (!pairing) { await db.query('ROLLBACK'); return null; }
const device = (await db.query('INSERT INTO board_devices(id,name,city_id,stop_id,token_hash,settings,device_info,created_by,updated_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$8) RETURNING *', [record.id, record.name, record.city_id, record.stop_id, pairing.token_hash, record.settings, pairing.device_info, record.created_by])).rows[0];
await db.query('COMMIT'); return device;
} catch (error) { await db.query('ROLLBACK'); throw error; } finally { db.release(); }
},
async list(cityId) { return (await pool.query(`SELECT ${projection} ${joins} WHERE d.city_id=$1 ORDER BY d.created_at DESC`, [cityId])).rows; },
async update(id, cityId, input) {
return (await pool.query('UPDATE board_devices SET name=$3,stop_id=$4,settings=$5,revision=revision+1,updated_at=now(),updated_by=$6 WHERE id=$1 AND city_id=$2 AND revision=$7 AND revoked_at IS NULL RETURNING *', [id, cityId, input.name, input.settings.stopId, input.settings, input.userId, input.revision])).rows[0];
},
async revoke(id, cityId, userId) { return (await pool.query('UPDATE board_devices SET revoked_at=COALESCE(revoked_at,now()),updated_by=$3,updated_at=now() WHERE id=$1 AND city_id=$2 RETURNING id', [id, cityId, userId])).rows[0]; },
async heartbeat(hash, revision, info) {
return (await pool.query('UPDATE board_devices SET last_seen_at=now(),applied_revision=GREATEST(applied_revision,LEAST(revision,$2)),device_info=$3 WHERE token_hash=$1 AND revoked_at IS NULL RETURNING id', [hash, revision, info])).rows[0];
},
};
}
module.exports = { createBoardDeviceRepository };

View File

@@ -0,0 +1,81 @@
const { randomBytes, randomInt, randomUUID, createHash } = require('node:crypto');
const { validSettings, deviceInfo, DeviceError } = require('./boardDeviceSettings');
const { cityFareDefaults } = require('./boardPassengerSettings');
const tokenHash = token => createHash('sha256').update(token).digest('hex');
function credential(token) {
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new DeviceError(401, 'invalid_device_token');
return tokenHash(token);
}
function name(value) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > 100) throw new DeviceError(400, 'invalid_name', 'Укажите название устройства до 100 символов');
return value.trim();
}
function deviceId(value) { if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new DeviceError(400, 'invalid_device_id'); return value; }
function publicDevice(row, now) {
const settings = validSettings(row.settings);
if (!Object.hasOwn(row.settings, 'fareByType')) settings.fareByType = cityFareDefaults(row.city_id);
return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings,
revision: row.revision, appliedRevision: row.applied_revision, lastSeenAt: row.last_seen_at, deviceInfo: row.device_info, revokedAt: row.revoked_at,
online: !row.revoked_at && Boolean(row.last_seen_at && now - Date.parse(row.last_seen_at) < 90000) };
}
function createBoardDeviceService(repository, { now = Date.now, makeCode = () => String(randomInt(10000)).padStart(4, '0') } = {}) {
async function checked(cityId, input) {
const settings = validSettings(input);
if (!Object.hasOwn(input, 'fareByType')) settings.fareByType = cityFareDefaults(cityId);
if (!await repository.catalog(cityId, settings)) throw new DeviceError(400, 'invalid_stop_or_route', 'Остановка или маршрут не принадлежат выбранному городу и площадке');
for (const place of settings.places) if (place.stopId === settings.stopId || !await repository.catalog(cityId, { stopId: place.stopId, routeFilter: '' })) throw new DeviceError(400, 'invalid_place', 'Выберите другую остановку назначения в этом городе');
for (const rule of settings.fareRules) if (!await repository.catalog(cityId, { stopId: settings.stopId, routeFilter: String(rule.routeId) })) throw new DeviceError(400, 'invalid_fare_route', 'Маршрут тарифа не проходит через эту площадку');
if (settings.fareByType.some(rule => rule.cityId !== cityId)) throw new DeviceError(400, 'invalid_fare_city', 'Тариф относится к другому городу');
return settings;
}
return {
async start(info) {
const token = randomBytes(32).toString('base64url');
for (let attempt = 0; attempt < 30; attempt++) {
const code = makeCode(), expiresAt = new Date(now() + 300000).toISOString();
try { await repository.createPairing({ code, token_hash: tokenHash(token), device_info: deviceInfo(info), expires_at: expiresAt }); return { token, state: 'pending', code, expiresAt, pollSeconds: 3 }; }
catch (error) { if (error.code !== '23505') throw error; }
}
throw new DeviceError(503, 'pairing_busy', 'Попробуйте подключить табло позже');
},
async status(token) {
const hash = credential(token), row = await repository.device(hash);
if (row) {
if (row.revoked_at) throw new DeviceError(410, 'device_revoked');
return { state: 'paired', device: publicDevice(row, now()), pollSeconds: 15 };
}
const pairing = await repository.pairing(hash);
if (!pairing) throw new DeviceError(401, 'invalid_device_token');
if (Date.parse(pairing.expires_at) <= now()) return { state: 'expired' };
return { state: 'pending', code: pairing.code, expiresAt: pairing.expires_at, pollSeconds: 3 };
},
async claim(cityId, input, userId) {
if (typeof input?.code !== 'string' || !/^\d{4}$/.test(input.code)) throw new DeviceError(400, 'invalid_code', 'Введите четыре цифры с экрана табло');
const settings = await checked(cityId, input.settings), label = name(input.name);
const row = await repository.claim(input.code, { id: randomUUID(), name: label, city_id: cityId, stop_id: settings.stopId, settings, created_by: userId });
if (!row) throw new DeviceError(409, 'code_expired', 'Код истёк или уже использован. Проверьте код на табло');
return { id: row.id };
},
async routes(cityId, stopId) { await checked(cityId, { stopId }); return repository.routes(cityId, stopId); },
async list(cityId) { return (await repository.list(cityId)).map(row => publicDevice(row, now())); },
async update(id, cityId, input, userId) {
deviceId(id);
if (!Number.isInteger(input?.revision) || input.revision < 1) throw new DeviceError(400, 'invalid_revision');
const settings = await checked(cityId, input.settings);
const row = await repository.update(id, cityId, { name: name(input.name), settings, revision: input.revision, userId });
if (!row) throw new DeviceError(409, 'revision_conflict', 'Устройство изменено или отвязано. Обновите список и откройте настройки заново');
return { id: row.id, revision: row.revision };
},
async revoke(id, cityId, userId) { if (!await repository.revoke(deviceId(id), cityId, userId)) throw new DeviceError(404, 'device_not_found'); return { revoked: true }; },
async heartbeat(token, input) {
const hash = credential(token);
if (!Number.isInteger(input?.appliedRevision) || input.appliedRevision < 0) throw new DeviceError(400, 'invalid_revision');
const row = await repository.device(hash);
if (!row || row.revoked_at) throw new DeviceError(410, 'device_revoked');
if (input.appliedRevision > row.revision) throw new DeviceError(400, 'invalid_revision');
if (!await repository.heartbeat(hash, input.appliedRevision, deviceInfo(input))) throw new DeviceError(410, 'device_revoked');
return { ok: true };
},
};
}
module.exports = { createBoardDeviceService, tokenHash, publicDevice };

View File

@@ -0,0 +1,34 @@
const { passengerDefaults, checkPassengerSettings } = require('./boardPassengerSettings');
const defaults = {
...passengerDefaults,
stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '',
theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
journeyTranslitEnabled: false,
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
nightMode: 'auto', nightWakeMinutes: 60,
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.',
};
class DeviceError extends Error {
constructor(status, code, message) { super(message || code); this.status = status; this.code = code; }
}
function validSettings(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new DeviceError(400, 'invalid_settings', 'Проверьте настройки табло');
const value = { ...defaults, ...input, demo: false };
const fail = key => { throw new DeviceError(400, 'invalid_settings', `Некорректное поле: ${key}`); };
for (const key of Object.keys(input)) if (!Object.hasOwn(defaults, key)) fail(key);
for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) {
if (!Number.isInteger(value[key]) || value[key] < min || value[key] > max) fail(key);
}
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'journeyTranslitEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof value[key] !== 'boolean') fail(key);
for (const [key, choices] of Object.entries({ theme: ['dark', 'light'], colorScheme: ['lime', 'amber', 'cyan', 'mint', 'lavender', 'white'], lightColorScheme: ['blue', 'forest', 'crimson', 'violet', 'teal', 'graphite'], displayProfile: ['tv', 'street'], viewingDistance: ['near', 'medium', 'far'], nightMode: ['off', 'auto'] })) if (!choices.includes(value[key])) fail(key);
if (![45, 60].includes(value.nightWakeMinutes)) fail('nightWakeMinutes');
checkPassengerSettings(value, fail);
if (typeof value.routeFilter !== 'string' || !/^(|[1-9]\d{0,8})$/.test(value.routeFilter)) fail('routeFilter');
for (const [key, max] of [['adTitle', 80], ['adText', 200]]) if (typeof value[key] !== 'string' || value[key].length > max) fail(key);
return value;
}
function deviceInfo(input = {}) {
return { width: Math.max(0, Math.min(16384, Math.round(Number(input.width) || 0))), height: Math.max(0, Math.min(16384, Math.round(Number(input.height) || 0))), version: typeof input.version === 'string' ? input.version.slice(0, 40) : '' };
}
module.exports = { defaults, validSettings, deviceInfo, DeviceError };

View File

@@ -0,0 +1,21 @@
const passengerDefaults = { infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true, places: [], fareRules: [], fareByType: [] };
const confirmedPolotskFares = [
{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
{ cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
];
const cityFareDefaults = cityId => cityId === 1 ? confirmedPolotskFares : [];
const object = value => value && typeof value === 'object' && !Array.isArray(value);
const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
const id = (value, max = 1000000) => Number.isSafeInteger(value) && value > 0 && value <= max;
const date = value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value + 'T12:00:00Z')) && new Date(value + 'T12:00:00Z').toISOString().slice(0, 10) === value;
const terms = value => text(value.price, 40) && text(value.payment, 100) && date(value.checkedAt);
function checkPassengerSettings(settings, fail) {
for (const key of ['infoPlacesEnabled', 'infoFareEnabled', 'lastTripNoticeEnabled', 'weatherHintEnabled']) if (typeof settings[key] !== 'boolean') fail(key);
if (!Array.isArray(settings.places) || settings.places.length > 3 || new Set(settings.places.map(p => p?.stopId)).size !== settings.places.length
|| !settings.places.every(p => object(p) && Object.keys(p).every(k => ['stopId', 'name'].includes(k)) && id(p.stopId) && text(p.name, 48))) fail('places');
if (!Array.isArray(settings.fareRules) || settings.fareRules.length > 32 || new Set(settings.fareRules.map(p => p?.routeId)).size !== settings.fareRules.length
|| !settings.fareRules.every(p => object(p) && Object.keys(p).every(k => ['routeId', 'price', 'payment', 'checkedAt'].includes(k)) && id(p.routeId, 999999999) && terms(p))) fail('fareRules');
if (!Array.isArray(settings.fareByType) || settings.fareByType.length > 2 || new Set(settings.fareByType.map(p => p?.type)).size !== settings.fareByType.length
|| !settings.fareByType.every(p => object(p) && Object.keys(p).every(k => ['cityId', 'type', 'price', 'payment', 'checkedAt'].includes(k)) && id(p.cityId) && ['bus', 'minibus'].includes(p.type) && terms(p))) fail('fareByType');
}
module.exports = { passengerDefaults, cityFareDefaults, checkPassengerSettings };

View File

@@ -0,0 +1,325 @@
const {
generateRegistrationOptions,
generateAuthenticationOptions,
verifyRegistrationResponse,
verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
const pool = require('../config/database');
const CHALLENGE_TTL_MINUTES = 5;
const PASSKEY_RP_NAME = process.env.WEBAUTHN_RP_NAME || 'RideBus DE';
function decodeClientDataChallenge(response) {
const encodedClientData = response?.response?.clientDataJSON;
if (!encodedClientData || typeof encodedClientData !== 'string') {
throw new Error('Passkey response does not contain client data');
}
let clientData;
try {
clientData = JSON.parse(Buffer.from(encodedClientData, 'base64url').toString('utf8'));
} catch {
throw new Error('Passkey response contains invalid client data');
}
if (!clientData.challenge || typeof clientData.challenge !== 'string') {
throw new Error('Passkey response does not contain a challenge');
}
return clientData.challenge;
}
function parseConfiguredOrigins() {
return (process.env.WEBAUTHN_ORIGIN || process.env.WEBAUTHN_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
}
function getPasskeyConfig(req) {
const configuredOrigins = parseConfiguredOrigins();
const configuredRpId = process.env.WEBAUTHN_RP_ID?.trim();
if (configuredRpId && configuredOrigins.length > 0) {
return {
rpID: configuredRpId,
origins: configuredOrigins,
};
}
// A safe local-development fallback. Production must name the public origin
// explicitly: the browser will otherwise reject passkeys on a real domain.
const requestOrigin = req.get('origin');
const localOrigin = requestOrigin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(requestOrigin);
if (process.env.NODE_ENV !== 'production' && localOrigin) {
return {
rpID: new URL(requestOrigin).hostname,
origins: [requestOrigin],
};
}
const error = new Error('Passkeys require WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN to be configured');
error.code = 'PASSKEY_CONFIGURATION_REQUIRED';
throw error;
}
async function storeChallenge({ userId = null, purpose, challenge }) {
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MINUTES * 60 * 1000);
await pool.query(
`INSERT INTO passkey_challenges (user_id, purpose, challenge, expires_at)
VALUES ($1, $2, $3, $4)`,
[userId, purpose, challenge, expiresAt],
);
// Keep this table bounded without making authentication depend on a scheduled job.
await pool.query('DELETE FROM passkey_challenges WHERE expires_at <= NOW()');
}
async function claimChallenge({ purpose, challenge, userId = null }) {
const params = [purpose, challenge];
let userFilter = '';
if (userId !== null) {
params.push(userId);
userFilter = ` AND user_id = $${params.length}`;
}
const result = await pool.query(
`DELETE FROM passkey_challenges
WHERE id = (
SELECT id
FROM passkey_challenges
WHERE purpose = $1 AND challenge = $2 AND expires_at > NOW()${userFilter}
ORDER BY id DESC
LIMIT 1
)
RETURNING id, user_id, challenge`,
params,
);
return result.rows[0] || null;
}
async function getUserCredentials(userId) {
const result = await pool.query(
`SELECT credential_id, transports
FROM passkey_credentials
WHERE user_id = $1
ORDER BY created_at ASC`,
[userId],
);
return result.rows;
}
async function generateRegistrationOptionsForUser(req, user) {
const config = getPasskeyConfig(req);
const credentials = await getUserCredentials(user.id);
const options = await generateRegistrationOptions({
rpName: PASSKEY_RP_NAME,
rpID: config.rpID,
userID: new TextEncoder().encode(String(user.id)),
userName: user.username,
userDisplayName: user.full_name || user.username,
attestationType: 'none',
excludeCredentials: credentials.map((credential) => ({
id: credential.credential_id,
transports: credential.transports || undefined,
})),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'required',
},
});
await storeChallenge({
userId: user.id,
purpose: 'registration',
challenge: options.challenge,
});
return options;
}
async function verifyRegistrationForUser(req, user, response, label) {
const config = getPasskeyConfig(req);
const challenge = decodeClientDataChallenge(response);
const pendingChallenge = await claimChallenge({
purpose: 'registration',
challenge,
userId: user.id,
});
if (!pendingChallenge) {
const error = new Error('Passkey registration has expired. Please try again.');
error.code = 'PASSKEY_CHALLENGE_EXPIRED';
throw error;
}
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: pendingChallenge.challenge,
expectedOrigin: config.origins,
expectedRPID: config.rpID,
requireUserVerification: true,
});
if (!verification.verified || !verification.registrationInfo) {
const error = new Error('Passkey could not be verified');
error.code = 'PASSKEY_VERIFICATION_FAILED';
throw error;
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
const normalizedLabel = typeof label === 'string'
? label.trim().slice(0, 100) || null
: null;
await pool.query(
`INSERT INTO passkey_credentials
(user_id, credential_id, public_key, counter, transports, device_type, backed_up, label)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
user.id,
credential.id,
Buffer.from(credential.publicKey),
credential.counter,
response.response.transports || [],
credentialDeviceType,
credentialBackedUp,
normalizedLabel,
],
);
return {
id: credential.id,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
};
}
async function generateAuthenticationOptionsForRequest(req) {
const config = getPasskeyConfig(req);
const options = await generateAuthenticationOptions({
rpID: config.rpID,
userVerification: 'required',
});
await storeChallenge({
purpose: 'authentication',
challenge: options.challenge,
});
return options;
}
async function verifyAuthenticationForRequest(req, response) {
const config = getPasskeyConfig(req);
const challenge = decodeClientDataChallenge(response);
const credentialId = response?.id;
if (!credentialId || typeof credentialId !== 'string') {
const error = new Error('Passkey response does not contain a credential');
error.code = 'PASSKEY_RESPONSE_INVALID';
throw error;
}
const pendingChallenge = await claimChallenge({
purpose: 'authentication',
challenge,
});
if (!pendingChallenge) {
const error = new Error('Passkey authentication has expired. Please try again.');
error.code = 'PASSKEY_CHALLENGE_EXPIRED';
throw error;
}
const credentialResult = await pool.query(
`SELECT pc.*, u.id AS user_id, u.username, u.full_name, u.email, u.role, u.avatar_url,
u.is_active, u.last_login, u.created_at AS user_created_at
FROM passkey_credentials pc
JOIN users u ON u.id = pc.user_id
WHERE pc.credential_id = $1 AND u.is_active = true
LIMIT 1`,
[credentialId],
);
const storedCredential = credentialResult.rows[0];
if (!storedCredential) {
const error = new Error('Passkey is not registered for an active account');
error.code = 'PASSKEY_NOT_FOUND';
throw error;
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: pendingChallenge.challenge,
expectedOrigin: config.origins,
expectedRPID: config.rpID,
requireUserVerification: true,
credential: {
id: storedCredential.credential_id,
publicKey: new Uint8Array(storedCredential.public_key),
counter: Number(storedCredential.counter),
transports: storedCredential.transports || undefined,
},
});
if (!verification.verified) {
const error = new Error('Passkey could not be verified');
error.code = 'PASSKEY_VERIFICATION_FAILED';
throw error;
}
await pool.query(
`UPDATE passkey_credentials
SET counter = $1, last_used_at = NOW(), backed_up = $2
WHERE id = $3`,
[
verification.authenticationInfo.newCounter,
verification.authenticationInfo.credentialBackedUp,
storedCredential.id,
],
);
return {
id: storedCredential.user_id,
username: storedCredential.username,
full_name: storedCredential.full_name,
email: storedCredential.email,
role: storedCredential.role,
avatar_url: storedCredential.avatar_url,
is_active: storedCredential.is_active,
last_login: storedCredential.last_login,
created_at: storedCredential.user_created_at,
};
}
async function listUserPasskeys(userId) {
const result = await pool.query(
`SELECT id, label, device_type, backed_up, created_at, last_used_at
FROM passkey_credentials
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId],
);
return result.rows;
}
async function removeUserPasskey(userId, passkeyId) {
const result = await pool.query(
'DELETE FROM passkey_credentials WHERE id = $1 AND user_id = $2 RETURNING id',
[passkeyId, userId],
);
return result.rows.length > 0;
}
module.exports = {
generateRegistrationOptionsForUser,
verifyRegistrationForUser,
generateAuthenticationOptionsForRequest,
verifyAuthenticationForRequest,
listUserPasskeys,
removeUserPasskey,
};

View File

@@ -277,9 +277,23 @@ function buildQualityReport(rowsByCheck, checkedAt = new Date().toISOString()) {
return { checked_at: checkedAt, summary, issues };
}
async function runQualityChecks(pool) {
const results = await Promise.all(qualityChecks.map(async ({ name, query }) => [name, (await pool.query(query)).rows]));
function withCityScope(query, cityId) {
if (!cityId) return { query, params: [] };
return {
query: query
.replaceAll('r.is_active = true', 'r.is_active = true AND r.city_id = $1')
.replace('WHERE s.is_active = true', 'WHERE s.is_active = true AND s.city_id = $1')
.replace('WHERE a.is_active = true', 'WHERE a.is_active = true AND a.city_id = $1'),
params: [cityId]
};
}
async function runQualityChecks(pool, cityId = null) {
const results = await Promise.all(qualityChecks.map(async ({ name, query }) => {
const scoped = withCityScope(query, cityId);
return [name, (await pool.query(scoped.query, scoped.params)).rows];
}));
return buildQualityReport(Object.fromEntries(results));
}
module.exports = { buildQualityReport, qualityChecks, runQualityChecks };
module.exports = { buildQualityReport, qualityChecks, runQualityChecks, withCityScope };

View File

@@ -0,0 +1,194 @@
const DAY_TYPES = [
{ value: 'weekday', label: 'Будние дни', shortLabel: 'Пн–Пт' },
{ value: 'saturday', label: 'Суббота', shortLabel: 'Сб' },
{ value: 'sunday', label: 'Воскресенье', shortLabel: 'Вс' },
];
const DAY_TYPE_BY_VALUE = new Map(DAY_TYPES.map((dayType) => [dayType.value, dayType]));
function toDateKey(value) {
if (!value) return null;
if (typeof value === 'string') return value.slice(0, 10);
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
return null;
}
function parseDate(date) {
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
const parsed = new Date(`${date}T00:00:00.000Z`);
return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date ? null : parsed;
}
function addDays(date, amount) {
const parsed = parseDate(date);
parsed.setUTCDate(parsed.getUTCDate() + amount);
return parsed.toISOString().slice(0, 10);
}
function formatMinskDate(date = new Date()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(date);
}
function versionState(version, targetDate) {
if (version.valid_from && version.valid_until && version.valid_until < version.valid_from) return 'invalid';
if ((!version.valid_from || version.valid_from <= targetDate)
&& (!version.valid_until || version.valid_until >= targetDate)) return 'active';
if (version.valid_from && version.valid_from > targetDate) return 'upcoming';
return 'past';
}
function overlaps(left, right) {
return (!left.valid_until || !right.valid_from || left.valid_until >= right.valid_from)
&& (!right.valid_until || !left.valid_from || right.valid_until >= left.valid_from);
}
function buildCoverageGaps(versions, targetDate, windowEnd) {
const periods = versions
.filter((version) => version.state !== 'invalid')
.filter((version) => !version.valid_until || version.valid_until >= targetDate)
.filter((version) => !version.valid_from || version.valid_from <= windowEnd)
.map((version) => ({
start: version.valid_from && version.valid_from > targetDate ? version.valid_from : targetDate,
end: version.valid_until && version.valid_until < windowEnd ? version.valid_until : windowEnd,
}))
.sort((left, right) => left.start.localeCompare(right.start));
const gaps = [];
let cursor = targetDate;
for (const period of periods) {
if (period.start > cursor) {
gaps.push({ starts_on: cursor, ends_on: addDays(period.start, -1) });
}
if (period.end >= cursor) cursor = addDays(period.end, 1);
if (cursor > windowEnd) break;
}
if (cursor <= windowEnd) gaps.push({ starts_on: cursor, ends_on: windowEnd });
return gaps;
}
function makeIssue({ code, severity, route, dayType, description, versionIds = [], range = null }) {
const idParts = [code, route.route_id, dayType?.value || 'all', ...versionIds, range?.starts_on || ''];
return {
id: idParts.join(':'),
code,
severity,
title: code === 'schedule_overlap' ? 'Пересекаются версии'
: code === 'invalid_period' ? 'Некорректный период версии'
: 'Нет действующей версии',
description,
route_id: route.route_id,
route_label: `№${route.route_number} · ${route.route_name}`,
day_type: dayType?.value || null,
day_type_label: dayType?.label || null,
version_ids: versionIds,
range,
};
}
function createRoute(row) {
return {
route_id: row.route_id,
route_number: row.route_number,
route_name: row.route_name,
color: row.color || '#525252',
day_types: DAY_TYPES.map((dayType) => ({
...dayType,
versions: [],
gaps: [],
status: 'gap',
})),
};
}
function buildScheduleCenter(rows, targetDate = formatMinskDate(), { windowDays = 90 } = {}) {
if (!parseDate(targetDate)) throw new Error('Invalid target date');
const windowEnd = addDays(targetDate, windowDays);
const routesById = new Map();
for (const row of rows) {
if (!routesById.has(row.route_id)) routesById.set(row.route_id, createRoute(row));
if (!row.schedule_id || !DAY_TYPE_BY_VALUE.has(row.day_type)) continue;
const route = routesById.get(row.route_id);
const dayType = route.day_types.find((item) => item.value === row.day_type);
const version = {
id: row.schedule_id,
valid_from: toDateKey(row.valid_from),
valid_until: toDateKey(row.valid_until),
departure_count: Number(row.departure_count || 0),
state: 'past',
has_conflict: false,
};
version.state = versionState(version, targetDate);
dayType.versions.push(version);
}
const routes = [...routesById.values()]
.sort((left, right) => String(left.route_number).localeCompare(String(right.route_number), 'ru', { numeric: true })
|| left.route_name.localeCompare(right.route_name, 'ru'));
const issues = [];
let active = 0;
let upcoming = 0;
let conflicts = 0;
let gaps = 0;
for (const route of routes) {
for (const dayType of route.day_types) {
dayType.versions.sort((left, right) => (left.valid_from || '').localeCompare(right.valid_from || '') || left.id - right.id);
for (const version of dayType.versions) {
if (version.state === 'active') active += 1;
if (version.state === 'upcoming') upcoming += 1;
if (version.state !== 'invalid') continue;
issues.push(makeIssue({
code: 'invalid_period', severity: 'critical', route, dayType, versionIds: [version.id],
description: `Версия №${version.id}: дата окончания раньше даты начала.`,
}));
}
for (let index = 0; index < dayType.versions.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < dayType.versions.length; otherIndex += 1) {
const left = dayType.versions[index];
const right = dayType.versions[otherIndex];
if (left.state === 'invalid' || right.state === 'invalid' || !overlaps(left, right)) continue;
left.has_conflict = true;
right.has_conflict = true;
conflicts += 1;
issues.push(makeIssue({
code: 'schedule_overlap', severity: 'critical', route, dayType, versionIds: [left.id, right.id],
description: `Версии №${left.id} и №${right.id} действуют одновременно.`,
}));
}
}
dayType.gaps = buildCoverageGaps(dayType.versions, targetDate, windowEnd);
if (dayType.versions.some((version) => version.state === 'active')) dayType.status = 'active';
else if (dayType.versions.some((version) => version.state === 'upcoming')) dayType.status = 'upcoming';
for (const gap of dayType.gaps) {
gaps += 1;
issues.push(makeIssue({
code: 'schedule_gap', severity: 'warning', route, dayType, range: gap,
description: gap.starts_on === gap.ends_on
? `На ${gap.starts_on} нет версии расписания.`
: `С ${gap.starts_on} по ${gap.ends_on} нет версии расписания.`,
}));
}
}
}
return {
target_date: targetDate,
window_end: windowEnd,
summary: { routes: routes.length, active, upcoming, conflicts, gaps },
routes,
issues: issues.sort((left, right) => left.severity.localeCompare(right.severity)
|| left.route_label.localeCompare(right.route_label, 'ru') || left.id.localeCompare(right.id)),
};
}
module.exports = { DAY_TYPES, addDays, buildScheduleCenter, formatMinskDate, parseDate };

View File

@@ -0,0 +1,214 @@
const { getLocalDateTime } = require('../utils/etaTime');
const { normalizeRouteNumber } = require('./alertTargets');
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
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 CANCELLATION = /(?:не\s+буд(?:ут|ет)\s+выполняться|не\s+выполняются|отмен(?:ены|яется|яются|а\s+рейс)|рейс[^\n]*отмен)/i;
const DELAY = /(?:(?:буд(?:ут|ет)|могут|может)\s+(?:выполняться|прибыть|прибывать|прийти|следовать)|выполняются|выполняется|прибывают|прибывает|следуют|следует)\s+(?:(?:следующие\s+)?рейсы?\s+)?с\s+(?:опозданием|задержкой)/i;
const STOP_INTRO = String.raw`(?:с|от)\s+(?:остановк[иа]|остановочного\s+пункта)\s+`;
const TIME_TOKEN = String.raw`(?<!\d)\d{1,2}[:.]\d{2}(?!\d|[.:]\d)`;
const TIME_LIST = `${TIME_TOKEN}(?:(?:\\s*[,;]\\s*|\\s+и\\s+|\\s+(?=\\d))${TIME_TOKEN})*`;
const QUOTED_STOP = String.raw`(?:"[^"\n]+"|«[^»\n]+»|“[^”\n]+”)`;
function departureMatches(text) {
const pattern = new RegExp(`(?:(${STOP_INTRO})(.+?)|^[ \\t]*(${QUOTED_STOP}))\\s+(?:на|в)\\s+(${TIME_LIST})`, 'gimu');
return [...text.matchAll(pattern)].filter(match => match[1]
|| new RegExp(STOP_INTRO, 'iu').test(text.slice(0, match.index)));
}
function validDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T12:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
function addDays(value, days) {
const date = new Date(`${value}T12:00:00Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function dayBounds(value) {
if (!validDate(value)) throw new Error('INVALID_SERVICE_DATE');
return {
start: new Date(`${value}T00:00:00+03:00`).toISOString(),
end: new Date(`${addDays(value, 1)}T00:00:00+03:00`).toISOString()
};
}
function inferYear(day, month, year, publicationDate) {
const format = y => `${y}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (year) return format(Number(year));
const base = Number(publicationDate.slice(0, 4));
return [base - 1, base, base + 1].map(format).filter(validDate)
.sort((a, b) => Math.abs(Date.parse(a) - Date.parse(publicationDate)) - Math.abs(Date.parse(b) - Date.parse(publicationDate)))[0] || format(base);
}
function parseDate(text, publishedAt) {
const publicationDate = getLocalDateTime(new Date(publishedAt)).date;
const dates = [];
let dateText = text;
// Stop names (e.g. "1 Мая") and departure times aren't service dates.
// Mask explicitly associated departures, keeping offsets intact.
// Quoted list entries inherit the stop heading, including across blank lines.
let dateScope = text;
for (const match of departureMatches(text)) {
dateScope = dateScope.slice(0, match.index) + ' '.repeat(match[0].length) + dateScope.slice(match.index + match[0].length);
}
const patterns = [
/(?<!\d)(\d{1,2})[./](\d{1,2})[./](\d{4})(?!\d)/g,
/(?:^|\n|(?:на|с|по|за|дата:?)\s+)(\d{1,2})[./](\d{2})(?![\d.:])/gi,
new RegExp(`(?<!\\d)(\\d{1,2})\\s+(${MONTHS.join('|')})(?:\\s+(\\d{4}))?`, 'gi')
];
for (const [i, pattern] of patterns.entries()) {
dateScope = dateScope.replace(pattern, (...args) => {
const [full, d, m] = args;
const y = i === 1 ? undefined : args[3];
const offset = args.at(-2);
dates.push(inferYear(d, i === 2 ? MONTHS.indexOf(m.toLowerCase()) + 1 : m, y, publicationDate));
dateText = dateText.slice(0, offset) + ' '.repeat(full.length) + dateText.slice(offset + full.length);
return ' '.repeat(full.length);
});
}
if (/сегодня/i.test(dateScope)) dates.push(publicationDate);
if (/завтра/i.test(dateScope)) dates.push(addDays(publicationDate, 1));
if (/послезавтра/i.test(dateScope)) return { reason: 'Дата «послезавтра» требует проверки.' };
if (dates.some(d => !validDate(d))) return { reason: 'Некорректная дата отмены.' };
if (new Set(dates).size > 1) return { reason: 'Указаны разные даты отмены.' };
return { date: dates[0] || publicationDate, dateText, inferred: dates.length === 0 };
}
function normalizeRoute(value) { return String(value).trim().toLowerCase(); }
function normalizeStopKey(value) {
return String(value || '').toLowerCase().replace(/ё/g, 'е').replace(/[^\p{L}\p{N}]/gu, '');
}
function normalizeStop(value) {
const key = normalizeStopKey(value);
return key === 'аввокзал' ? 'автовокзал' : key;
}
function canonicalStop(value) { return normalizeStop(value) === 'автовокзал' ? 'Автовокзал' : value; }
function parseTransitNotice({ text = '', publishedAt, hasMedia = false }, { preserveSourceStops = false } = {}) {
const normalized = text.replace(/\u00a0/g, ' ').replace(/\r/g, '').trim();
let serviceDate;
let alertType;
let delayPossible = false;
const review = reason => ({ status: 'review', reasons: [reason], groups: [],
...(alertType ? { alertType, ...(alertType === 'delay' ? { delayPossible } : {}) } : {}),
...(serviceDate ? { serviceDate } : {}) });
if (!normalized) return hasMedia ? review('Публикация содержит вложение без текста. Нужна проверка.') : { status: 'ignored', reasons: [], groups: [] };
const cancellation = CANCELLATION.test(normalized);
const delay = normalized.match(DELAY);
if (!cancellation && !delay) {
return { status: 'ignored', reasons: [], groups: [] };
}
if (cancellation && delay) return review('В сообщении есть отмены и опоздания. Проверьте тип и текст уведомления.');
alertType = delay ? 'delay' : 'cancellation';
delayPossible = Boolean(delay && /могут|может/i.test(delay[0]));
if (delay && /не\s*$/i.test(normalized.slice(0, delay.index))) return review('Формулировка об опоздании неоднозначна. Нужна проверка.');
if (hasMedia) return review('В публикации есть вложение. Проверьте сообщение по оригиналу.');
if (normalized.length > 20_000) return review('Слишком длинное сообщение. Нужна проверка.');
if (/возобнов|восстанов|кроме|только|ежеднев|еженедель|понедельник|вторник|сред[ау]|четверг|пятниц|суббот|воскресен|будн|выходн|период|дальнейш|неопредел|(?:с\s+\d[^\n]+\s+по\s+\d)|до\s+\d/i.test(normalized)) {
return review('Есть условия, период или восстановление движения. Нужна проверка.');
}
const otherExecution = normalized.replace(/не\s+буд(?:ут|ет)\s+выполняться/gi, '').replace(new RegExp(DELAY.source, 'gi'), '');
if (/буд(?:ут|ет)\s+выполняться/i.test(otherExecution) || /отмена\s+отменена/i.test(normalized)) {
return review('В сообщении есть сведения о выполнении рейсов. Нужна проверка.');
}
if (Number.isNaN(new Date(publishedAt).getTime())) return review('Неизвестно время публикации.');
const parsedDate = parseDate(normalized, publishedAt);
if (parsedDate.reason) return review(alertType === 'delay' ? parsedDate.reason.replace('отмены', 'уведомления') : parsedDate.reason);
serviceDate = parsedDate.date;
if (delay && /\d+\s*(?:минут|мин(?=[\s.,]|$)|час)/i.test(normalized)) return review('Указана величина опоздания. Сохраните её в тексте при ручной проверке.');
const routeMatches = [...parsedDate.dateText.matchAll(ROUTE)];
if (!routeMatches.length) return review('Не удалось определить номер маршрута.');
const groups = [];
for (const [i, route] of routeMatches.entries()) {
const section = parsedDate.dateText.slice(route.index + route[0].length, routeMatches[i + 1]?.index);
const departures = [];
let unmatched = section;
for (const stop of departureMatches(section)) {
const times = [...stop[4].matchAll(TIME)].map(t => `${t[1].padStart(2, '0')}:${t[2]}`);
if (times.some(t => Number(t.slice(0, 2)) > 23 || Number(t.slice(3)) > 59)) return review('Некорректное время отправления.');
const stopName = (stop[2] || stop[3]).replace(/^[\s«"“]+|[\s»"”.,:;]+$/g, '').trim();
if (!stopName || !times.length) return review('Не удалось определить остановку и время.');
departures.push({ stop: canonicalStop(stopName), times: [...new Set(times)], ...(preserveSourceStops ? { sourceStop: stopName } : {}) });
unmatched = unmatched.slice(0, stop.index) + ' '.repeat(stop[0].length) + unmatched.slice(stop.index + stop[0].length);
}
if (new RegExp(STOP_INTRO, 'iu').test(unmatched) || new RegExp(`^[ \\t]*${QUOTED_STOP}`, 'imu').test(unmatched)) {
return review('Не удалось определить время отправления для всех остановок.');
}
if (!departures.length || [...section.matchAll(TIME)].length !== departures.reduce((n, d) => n + d.times.length, 0)) {
return review('Не удалось однозначно связать все времена с остановками.');
}
for (const number of route[1].match(/\d{1,3}[а-яa-z]?/giu)) {
groups.push({ routeNumber: normalizeRoute(number), departures });
}
}
return { status: 'ready', reasons: [], alertType, ...(alertType === 'delay' ? { delayPossible } : {}),
serviceDate: parsedDate.date, inferredDate: parsedDate.inferred, groups };
}
function resolveCancellationStops(parsed, stops, aliases) {
const byId = new Map(stops.map(stop => [Number(stop.id), stop]));
const byAlias = new Map(aliases.map(alias => [alias.normalized_alias, alias]));
const groups = parsed.groups.map(group => ({ ...group, departures: group.departures.map(departure => {
const sourceStop = departure.sourceStop || departure.stop;
const alias = byAlias.get(normalizeStopKey(sourceStop));
const matches = alias ? [byId.get(Number(alias.stop_id))].filter(Boolean)
: stops.filter(stop => normalizeStop(stop.name) === normalizeStop(sourceStop));
const stop = matches.length === 1 ? matches[0] : null;
const readable = departure.stop === departure.stop.toUpperCase()
? 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 } : {}),
...(!stop ? { unresolvedStop: true } : {}) };
}) }));
return { ...parsed, groups };
}
function matchCancellationRoutes(parsed, routes) {
const plans = new Map();
for (const group of parsed.groups) {
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: [] };
if (departure.unresolvedStop) return { reasons: [`Остановка «${departure.sourceStop}» не определена однозначно. Дополните словарь названий.`], plans: [] };
plans.get(number).departures.push(departure);
}
}
return { reasons: [], plans: [...plans.values()] };
}
function buildTransitNoticeDraft(plan, serviceDate, { alertType = 'cancellation', delayPossible = false } = {}) {
const plans = Array.isArray(plan) ? plan : [plan];
const delay = alertType === 'delay';
return {
title: `${delay ? delayPossible ? 'Возможное опоздание' : 'Задержка' : 'Отмена'} отдельных рейсов ${plans.map(p => `№${p.routeNumber}`).join(', ')}`,
message: `${serviceDate.split('-').reverse().join('.')}: ${delay ? delayPossible ? 'возможны опоздания рейсов' : 'с опозданием выполняются рейсы' : 'не выполняются рейсы'}:\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 buildTransitNoticeAlert(plan, serviceDate, sourceUrl, publishedAt, cityId, options = {}) {
const plans = Array.isArray(plan) ? plan : [plan];
const bounds = dayBounds(serviceDate);
const draft = buildTransitNoticeDraft(plan, serviceDate, options);
const start = new Date(Math.max(Date.parse(bounds.start), Date.parse(publishedAt))).toISOString();
return {
city_id: cityId, route_id: plans.length === 1 ? plans[0].routeId : null,
route_numbers: plans.map(p => p.routeNumber), alert_type: options.alertType || 'cancellation',
...draft,
message: `${draft.message}`,
start_time: start, end_time: bounds.end, is_active: true
};
}
module.exports = { parseTransitNotice, buildTransitNoticeDraft, buildTransitNoticeAlert,
parseCancellation: parseTransitNotice, buildCancellationDraft: buildTransitNoticeDraft, buildCancellationAlert: buildTransitNoticeAlert,
resolveCancellationStops, matchCancellationRoutes, normalizeStopKey, dayBounds, validDate, addDays };

View File

@@ -0,0 +1,260 @@
const crypto = require('node:crypto');
const { getTelegramConfig, missingTelegramConfig } = require('../config/telegram');
const { getLocalDateTime } = require('../utils/etaTime');
const { TelegramSource } = require('./telegramSource');
const { parseTransitNotice, resolveCancellationStops, matchCancellationRoutes, buildTransitNoticeDraft, buildTransitNoticeAlert, dayBounds, validDate, addDays } = require('./telegramCancellationParser');
const { AlertCreationService } = require('./alertCreationService');
const { TelegramStopAliasService } = require('./telegramStopAliasService');
const { resolveAlertTargets } = require('./alertTargets');
const PARSER_VERSION = 'transit-notices-v2';
function noticeOptions(parsed) { return { alertType: parsed.alertType || 'cancellation', delayPossible: parsed.delayPossible }; }
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 contentHash(message) {
return crypto.createHash('sha256').update(JSON.stringify([message.text, message.hasMedia, message.publishedAt])).digest('hex');
}
function messageDrafts(parsed) {
if (!validDate(parsed.serviceDate)) return [];
const groups = new Map();
for (const group of parsed.groups || []) {
if (!groups.has(group.routeNumber)) groups.set(group.routeNumber, { routeNumber: group.routeNumber, departures: [] });
groups.get(group.routeNumber).departures.push(...group.departures);
}
return [...groups.values()].map(group => ({ routeId: null, routeNumber: group.routeNumber, ...buildTransitNoticeDraft(group, parsed.serviceDate, noticeOptions(parsed)) }));
}
function publicError(error) {
const known = ['AUTH_REQUIRED', 'USER_ACCOUNT_REQUIRED', 'SESSION_MISSING', 'SESSION_FILE_INVALID', 'SESSION_FILE_IN_CHECKOUT', 'SESSION_FILE_PERMISSIONS', 'CHANNEL_INVALID', 'CHANNEL_PRIVATE', 'USERNAME_NOT_OCCUPIED', 'HISTORY_LIMIT', 'AUTH_KEY_UNREGISTERED', 'SESSION_REVOKED'];
const value = error.errorMessage || error.message;
if (known.includes(value)) return value;
if (error.code === 'ENOENT') return 'SESSION_MISSING';
if (error.seconds) return 'FLOOD_WAIT';
return 'IMPORT_FAILED';
}
class TelegramImportService {
constructor({ pool, config = getTelegramConfig(), source, logger, now = () => new Date() } = {}) {
this.pool = pool || require('../config/database');
this.config = config;
this.source = source || new TelegramSource(config);
this.logger = logger || require('../config/logger');
this.now = now;
this.running = false;
this.stopped = false;
this.state = { connection: config.enabled ? 'waiting' : 'disabled', lastSyncAt: null, lastError: null, retryAt: null };
this.alerts = new AlertCreationService({ pool: this.pool, logger: this.logger });
this.stopAliases = new TelegramStopAliasService(this.pool);
}
getStatus() {
const missing = missingTelegramConfig(this.config);
return {
...this.state, enabled: this.config.enabled, autoPublish: this.config.autoPublish,
channel: this.config.channel, citySlug: this.config.citySlug,
connection: !this.config.enabled ? 'disabled' : missing.length ? 'configuration_required' : this.state.connection,
missing, syncing: this.running
};
}
async sync() {
if (!this.config.enabled) throw httpError(409, 'Импорт Telegram выключен в настройках сервера.');
if (missingTelegramConfig(this.config).length) throw httpError(409, 'Укажите Telegram API ID, API hash и файл авторизации на сервере.');
if (this.running || this.stopped) throw httpError(409, 'Импорт уже выполняется или остановлен.');
if (this.state.retryAt && Date.parse(this.state.retryAt) > this.now().getTime()) throw httpError(429, 'Telegram ограничил частоту запросов. Дождитесь следующей попытки.');
this.running = true;
this.state.connection = 'connecting';
try {
const city = (await this.pool.query('SELECT id FROM cities WHERE slug = $1 AND is_active = true', [this.config.citySlug])).rows[0];
if (!city) throw httpError(409, 'Город источника отсутствует или отключён.');
const today = getLocalDateTime(this.now()).date;
const messages = await this.source.fetchMessages(new Date(dayBounds(addDays(today, -2)).start));
const counts = { published: 0, review: 0, unchanged: 0, ignored: 0, expired: 0 };
for (const message of messages) {
if (this.stopped) break;
const result = await this.processMessage(message, city.id);
counts[result.status] = (counts[result.status] || 0) + 1;
}
this.state = { connection: 'ready', lastSyncAt: this.now().toISOString(), lastError: null, retryAt: null };
return counts;
} catch (error) {
const code = publicError(error);
const delay = error.seconds ? Math.min(86_400, Math.max(30, Number(error.seconds) || 60)) : 60;
this.state.connection = 'error';
this.state.lastError = code;
this.state.retryAt = new Date(this.now().getTime() + delay * 1000).toISOString();
this.logger.warn('Telegram channel import failed', { code });
await this.source.close().catch(() => {});
if (error.status) throw error;
throw httpError(503, `Не удалось прочитать канал Telegram (${code}). Проверьте подключение и авторизацию.`);
} finally { this.running = false; }
}
async getRoutes(db, cityId) {
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) {
const stops = (await db.query('SELECT id,name FROM stops WHERE city_id=$1 AND is_active=true ORDER BY id', [cityId])).rows;
const aliases = await this.stopAliases.list(cityId, db);
return { stops, aliases };
}
async processMessage(message, cityId) {
if (!Number.isSafeInteger(message.messageId) || message.messageId <= 0 || !/^\d+$/.test(message.channelId)
|| message.channelUsername.toLowerCase() !== this.config.channel.toLowerCase()) throw new Error('CHANNEL_INVALID');
let parsed = { ...parseTransitNotice(message, { preserveSourceStops: true }), parserVersion: PARSER_VERSION };
const hash = contentHash(message);
const db = await this.pool.connect();
try {
await db.query('BEGIN');
// The unique key and row lock serialize duplicate readers and concurrent reviews.
if (parsed.status !== 'ignored') {
await db.query(`INSERT INTO telegram_import_messages
(city_id, channel_id, channel_username, message_id, published_at, edited_at, content_hash, raw_text, has_media, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'review') ON CONFLICT DO NOTHING`,
[cityId, message.channelId, message.channelUsername, message.messageId, message.publishedAt, message.editedAt, hash, message.text.slice(0, 20_000), message.hasMedia]);
}
const row = (await db.query(`SELECT * FROM telegram_import_messages
WHERE city_id = $1 AND channel_id = $2 AND message_id = $3 FOR UPDATE`, [cityId, message.channelId, message.messageId])).rows[0];
if (!row) { await db.query('COMMIT'); return { status: 'ignored' }; }
const initialized = row.parsed && Object.keys(row.parsed).length > 0;
const sameContent = initialized && row.content_hash === hash;
if ((sameContent && (row.status !== 'review' || (parsed.status !== 'ready' && row.parsed.parserVersion === PARSER_VERSION))) || (row.edited_at && (!message.editedAt || Date.parse(message.editedAt) < new Date(row.edited_at).getTime()))) {
await db.query('COMMIT'); return { status: 'unchanged' };
}
let routes = [];
if (parsed.status === 'ready') {
const context = await this.getStopContext(db, cityId);
routes = await this.getRoutes(db, cityId);
const resolutionHash = crypto.createHash('sha256').update(JSON.stringify([
PARSER_VERSION, 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' };
}
parsed = { ...resolveCancellationStops(parsed, context.stops, context.aliases), resolutionHash };
}
if (initialized) await this.deactivate(db, row.id);
let status = row.status === 'dismissed' ? 'dismissed' : 'review';
let reasons = parsed.reasons;
let plans = [];
if (parsed.status === 'ignored') reasons = ['Публикация больше не распознаётся как отмена или опоздание рейсов. Проверьте оригинал.'];
if (parsed.status === 'ready') {
const matched = matchCancellationRoutes(parsed, routes);
plans = matched.plans;
reasons = matched.reasons;
if (Date.parse(dayBounds(parsed.serviceDate).end) <= this.now().getTime()) status = status === 'dismissed' ? status : 'expired';
else if (!reasons.length && this.config.autoPublish && status !== 'dismissed') status = 'published';
else if (!reasons.length) reasons = ['Автоматическая публикация выключена. Подтвердите уведомление.'];
}
if (status === 'published') {
const draft = buildTransitNoticeDraft(plans, parsed.serviceDate, noticeOptions(parsed));
if (draft.title.length > 200 || draft.message.length > 5000 || plans.length > 100) {
status = 'review';
reasons = ['Слишком длинное уведомление. Сократите заголовок или текст перед публикацией.'];
}
}
if (status === 'published') {
await this.saveAlert(db, row.id, buildTransitNoticeAlert(plans, parsed.serviceDate, sourceUrl(row), message.publishedAt, cityId, noticeOptions(parsed)));
}
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`,
[row.id, hash, message.text.slice(0, 20_000), message.editedAt, message.hasMedia, status, JSON.stringify(reasons), JSON.stringify({ ...parsed, plans })]);
await db.query('COMMIT');
return { status, id: row.id };
} catch (error) { await db.query('ROLLBACK'); throw error; }
finally { db.release(); }
}
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
UNION SELECT alert_id FROM telegram_message_alerts WHERE message_id=$1)`, [messageId]);
}
async saveAlert(db, messageId, payload) {
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) {
// 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,
route_id=$6, route_numbers=$7::text[], alert_type=$8, 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, payload.route_id, payload.route_numbers, payload.alert_type]);
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_message_alerts(message_id,alert_id) VALUES ($1,$2)', [messageId, result.alert.id]);
}
async list(cityId) {
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),
status: row.status === 'published' && validDate(row.parsed.serviceDate) && Date.parse(dayBounds(row.parsed.serviceDate).end) <= this.now().getTime() ? 'expired' : row.status
}));
}
async review(id, cityId, input, userId) {
const db = await this.pool.connect();
try {
await db.query('BEGIN');
const row = (await db.query('SELECT * FROM telegram_import_messages WHERE id=$1 AND city_id=$2 FOR UPDATE', [id, cityId])).rows[0];
if (!row) throw httpError(404, 'Сообщение не найдено.');
if (input.content_hash !== row.content_hash) throw httpError(409, 'Публикация изменилась. Обновите список и проверьте новый текст.');
if (!['publish', 'dismiss'].includes(input.action)) throw httpError(400, 'Неизвестное действие.');
let alertType = row.parsed.alertType || 'cancellation';
if (input.action === 'publish') {
alertType = input.alert_type === undefined ? alertType : input.alert_type;
if (!['cancellation', 'delay'].includes(alertType)) 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, 'День уведомления уже закончился.');
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, ...targets, alert_type: alertType, title,
message: `${message}`, 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, alertType, 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' };
} catch (error) { await db.query('ROLLBACK'); throw error; }
finally { db.release(); }
}
async stop() { this.stopped = true; await this.source.close(); }
}
let singleton;
function getTelegramImportService() { return singleton || (singleton = new TelegramImportService()); }
function startTelegramImporter(service = getTelegramImportService()) {
if (!service.config.enabled) return () => service.stop();
const run = () => service.sync().catch(() => {}); // sync records a sanitized error for the admin status.
run();
const timer = setInterval(run, service.config.intervalMs);
timer.unref?.();
return () => { clearInterval(timer); return service.stop(); };
}
module.exports = { TelegramImportService, getTelegramImportService, startTelegramImporter, contentHash, publicError };

View File

@@ -0,0 +1,57 @@
const fs = require('node:fs/promises');
const { assertExternalSessionFile } = require('../config/telegram');
class TelegramSource {
constructor(config) { this.config = config; this.client = null; this.channel = null; }
async connect() {
if (this.client?.connected && this.channel) return;
if (this.client) await this.close();
const file = await assertExternalSessionFile(this.config.sessionFile);
const session = (await fs.readFile(file, 'utf8')).trim();
if (!session) throw new Error('SESSION_MISSING');
const { TelegramClient } = require('teleproto');
const { StringSession } = require('teleproto/sessions');
const { Logger } = require('teleproto/extensions/Logger');
const client = new TelegramClient(new StringSession(session), this.config.apiId, this.config.apiHash, {
connectionRetries: 2, requestRetries: 2, floodSleepThreshold: 0, baseLogger: new Logger('none')
});
this.client = client;
await client.connect();
if (!await client.checkAuthorization()) throw new Error('AUTH_REQUIRED');
if ((await client.getMe()).bot) throw new Error('USER_ACCOUNT_REQUIRED');
const channel = await client.getEntity(this.config.channel);
if (channel.className !== 'Channel' || !channel.broadcast || channel.username?.toLowerCase() !== this.config.channel.toLowerCase()) {
throw new Error('CHANNEL_INVALID');
}
this.channel = channel;
}
async fetchMessages(since) {
await this.connect();
const messages = [];
// Pagination revisits recent messages so edits are detected after restarts.
// The bound prevents an accidental full-history import of a busy channel.
for await (const message of this.client.iterMessages(this.channel, { limit: 1000, waitTime: 1 })) {
if (message.date && message.date * 1000 < since.getTime()) return messages.reverse();
if (message.className !== 'Message' || !message.date) continue;
messages.push({
channelId: this.channel.id.toString(), channelUsername: this.channel.username,
messageId: message.id, text: message.message || '', hasMedia: Boolean(message.media && message.media.className !== 'MessageMediaWebPage'),
publishedAt: new Date(message.date * 1000).toISOString(),
editedAt: message.editDate ? new Date(message.editDate * 1000).toISOString() : null
});
}
if (messages.length >= 1000) throw new Error('HISTORY_LIMIT');
return messages.reverse();
}
async close() {
const client = this.client;
this.client = null;
this.channel = null;
if (client) await client.destroy();
}
}
module.exports = { TelegramSource };

View File

@@ -0,0 +1,46 @@
const { normalizeStopKey } = require('./telegramCancellationParser');
function httpError(status, message) { return Object.assign(new Error(message), { status }); }
class TelegramStopAliasService {
constructor(pool) { this.pool = pool || require('../config/database'); }
async list(cityId, db = this.pool) {
return (await db.query(`SELECT a.*, s.name AS stop_name, s.address AS stop_address, s.is_active AS stop_active
FROM telegram_stop_aliases a JOIN stops s ON s.id=a.stop_id AND s.city_id=a.city_id
WHERE a.city_id=$1 ORDER BY a.alias,a.id`, [cityId])).rows;
}
async save(cityId, input = {}, id = null) {
const alias = typeof input.alias === 'string' ? input.alias.trim() : '';
const key = normalizeStopKey(alias);
const stopId = Number(input.stop_id);
if (!alias || alias.length > 200 || !key || /[\r\n]/.test(alias)) throw httpError(400, 'Укажите название из канала (до 200 символов).');
if (!Number.isSafeInteger(stopId) || stopId <= 0) throw httpError(400, 'Выберите остановку.');
const db = await this.pool.connect();
try {
await db.query('BEGIN');
const stop = (await db.query('SELECT id FROM stops WHERE id=$1 AND city_id=$2 AND is_active=true FOR KEY SHARE', [stopId, cityId])).rows[0];
if (!stop) throw httpError(400, 'Выберите действующую остановку выбранного города.');
const result = id === null
? await db.query(`INSERT INTO telegram_stop_aliases(city_id,alias,normalized_alias,stop_id) VALUES ($1,$2,$3,$4) RETURNING id`, [cityId, alias, key, stopId])
: await db.query(`UPDATE telegram_stop_aliases SET alias=$3,normalized_alias=$4,stop_id=$5,updated_at=now()
WHERE id=$1 AND city_id=$2 RETURNING id`, [id, cityId, alias, key, stopId]);
if (!result.rows.length) throw httpError(404, 'Соответствие не найдено.');
await db.query('COMMIT');
return { id: result.rows[0].id };
} catch (error) {
await db.query('ROLLBACK');
if (error.code === '23505') throw httpError(409, 'Такое название уже есть в словаре. Измените существующее соответствие.');
throw error;
} finally { db.release(); }
}
async remove(id, cityId) {
const result = await this.pool.query('DELETE FROM telegram_stop_aliases WHERE id=$1 AND city_id=$2 RETURNING id', [id, cityId]);
if (!result.rows.length) throw httpError(404, 'Соответствие не найдено.');
return { id };
}
}
module.exports = { TelegramStopAliasService };

View File

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

View File

@@ -8,6 +8,17 @@ const {
} = require('../src/services/alertNotificationService');
const { createFirebaseMessaging, getFirebaseConfig } = require('../src/config/firebase');
test('expires a cancellation at Minsk midnight and does not send a stale push', async () => {
const now = new Date('2026-10-02T20:59:00Z');
const alert = { id: 1, alert_type: 'cancellation', end_time: '2026-10-02T21:00:00Z' };
assert.equal(buildAlertCreatedMessage(alert, { now }).android.ttl, 60_000);
let sends = 0;
const service = new AlertNotificationService({ enabled: true, now: () => new Date('2026-10-02T21:00:00Z'),
getMessaging: async () => { sends++; }, logger: createLogger() });
assert.deepEqual(await service.sendAlertCreated(alert), { sent: false, skipped: true, reason: 'expired' });
assert.equal(sends, 0);
});
function createLogger() {
const errors = [];
return {
@@ -23,6 +34,7 @@ test('sends an exact data-only alert-created message with string-only data', asy
const service = new AlertNotificationService({
enabled: true,
topic: 'route_alerts',
now: () => new Date('2026-07-11T08:30:00Z'),
androidChannelId: 'route_alerts',
messaging: {
async send(message) {
@@ -64,7 +76,8 @@ test('sends an exact data-only alert-created message with string-only data', asy
body: 'Задержка около 15 минут'
},
android: {
priority: 'high'
priority: 'high',
ttl: 3_600_000
}
});
assert.equal('notification' in messages[0], false);
@@ -244,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'));
});

View File

@@ -0,0 +1,24 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { Pool } = require('pg');
const { createBoardDeviceRepository } = require('../src/services/boardDeviceRepository');
const { createBoardDeviceService } = require('../src/services/boardDeviceService');
const { defaults } = require('../src/services/boardDeviceSettings');
test('PostgreSQL device lifecycle, concurrent claims/updates, city isolation and retry after a lost reply', { skip: !process.env.BOARD_DEVICE_TEST_DATABASE_URL }, async () => {
const url = process.env.BOARD_DEVICE_TEST_DATABASE_URL; assert.match(new URL(url).pathname, /test/i, 'Use a dedicated test database');
const admin = new Pool({ connectionString: url }); const schema = `board_device_test_${Date.now()}`; let pool;
try {
await admin.query(`CREATE SCHEMA ${schema}`); pool = new Pool({ connectionString: url, options: `-c search_path=${schema},public` });
await pool.query('CREATE TABLE cities(id INTEGER PRIMARY KEY,name TEXT,is_active BOOLEAN); CREATE TABLE users(id INTEGER PRIMARY KEY); CREATE TABLE stops(id INTEGER PRIMARY KEY,city_id INTEGER REFERENCES cities(id),name TEXT,description TEXT); CREATE TABLE routes(id INTEGER PRIMARY KEY,city_id INTEGER,route_number TEXT,name TEXT); CREATE TABLE route_stops(route_id INTEGER,stop_id INTEGER); INSERT INTO cities VALUES(1,\'Полоцк\',true),(2,\'Новополоцк\',true); INSERT INTO users VALUES(1); INSERT INTO stops VALUES(1,1,\'Швейная фабрика\',\'на центр\'),(2,2,\'Другая\',\'конечная\');');
const sql = await fs.readFile(path.join(__dirname, '../migrations/016_board_devices.sql'), 'utf8'); await pool.query(sql); await pool.query(sql);
const service = createBoardDeviceService(createBoardDeviceRepository(pool)); const p = await service.start({ width: 1920 });
const claims = await Promise.allSettled([service.claim(1,{code:p.code,name:'A',settings:defaults},1),service.claim(1,{code:p.code,name:'B',settings:defaults},1)]);
assert.equal(claims.filter(r=>r.status==='fulfilled').length,1); const row=(await service.status(p.token)).device;
assert.equal((await service.status(p.token)).device.id,row.id); assert.equal((await service.list(2)).length,0);
const updates=await Promise.allSettled([service.update(row.id,1,{name:'C',settings:defaults,revision:1},1),service.update(row.id,1,{name:'D',settings:defaults,revision:1},1)]);assert.equal(updates.filter(r=>r.status==='fulfilled').length,1);
await service.heartbeat(p.token,{appliedRevision:2});assert.equal((await service.list(1))[0].appliedRevision,2);
await assert.rejects(service.revoke(row.id,2,1),e=>e.status===404);await service.revoke(row.id,1,1);await assert.rejects(service.status(p.token),e=>e.status===410);
} finally { if(pool) await pool.end();await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);await admin.end(); }
});

View File

@@ -0,0 +1,104 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createBoardDeviceService, tokenHash } = require('../src/services/boardDeviceService');
const { defaults, validSettings } = require('../src/services/boardDeviceSettings');
const { memoryRepository } = require('./helpers/boardDeviceMemoryRepository');
test('device pairing expires, preserves leading zeroes, retries collisions and has a secret distinct from the display code', async () => {
let now = Date.now(), codes = ['0847', '0847', '0009']; const repo = memoryRepository({ now: () => now });
const service = createBoardDeviceService(repo, { now: () => now, makeCode: () => codes.shift() });
const a = await service.start({ width: 879, height: 593, version: 'test' });
assert.equal(a.code, '0847'); assert.match(a.token, /^[A-Za-z0-9_-]{43}$/); assert.equal(repo.pairings.get('0847').token_hash, tokenHash(a.token));
assert.equal((await service.start({})).code, '0009');
await assert.rejects(service.status('0847'), e => e.status === 401);
now += 300001; assert.equal((await service.status(a.token)).state, 'expired');
await assert.rejects(service.claim(1, { code: '0847', name: 'A', settings: defaults }, 1), e => e.status === 409);
});
test('only one concurrent claim succeeds and the same device credential survives retry/restart', async () => {
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({});
const input = { code: p.code, name: 'Улица', settings: defaults };
const claims = await Promise.allSettled([service.claim(1, input, 1), service.claim(1, input, 2)]);
assert.equal(claims.filter(c => c.status === 'fulfilled').length, 1);
assert.equal((await service.status(p.token)).state, 'paired'); assert.equal((await service.status(p.token)).device.name, 'Улица');
const json = JSON.stringify(await service.list(1)); assert.ok(!json.includes(p.token)); assert.ok(!json.includes(tokenHash(p.token)));
});
test('city/stop/route validation happens before consuming a pairing code', async () => {
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({});
for (const settings of [{ ...defaults, stopId: 3 }, { ...defaults, routeFilter: '2' }, { ...defaults, rowCount: 7 }, { ...defaults, colorScheme: 'sky' }]) await assert.rejects(service.claim(1, { code: p.code, name: 'A', settings }, 1), e => e.status === 400);
assert.equal((await service.status(p.token)).state, 'pending'); await service.claim(2, { code: p.code, name: 'B', settings: { ...defaults, stopId: 3, routeFilter: '2' } }, 1);
assert.equal((await service.list(1)).length, 0); assert.equal((await service.list(2)).length, 1);
assert.deepEqual(await service.routes(2, 3), [{ id: 2, city_id: 2, route_number: '1', name: 'Новополоцк', stops: [3] }]);
});
test('configuration revisions are optimistic and applied/online state comes from device acknowledgement', async () => {
let now = Date.now(); const service = createBoardDeviceService(memoryRepository({ now: () => now }), { now: () => now }); const p = await service.start({}); const d = await service.claim(1, { code: p.code, name: 'A', settings: defaults }, 1);
const input = { name: 'A', revision: 1, settings: { ...defaults, theme: 'light', lightColorScheme: 'forest', infoQrEnabled: false } };
await assert.rejects(service.update(d.id, 2, { ...input, settings: { ...defaults, stopId: 3 } }, 1), e => e.status === 409);
const r = await Promise.allSettled([service.update(d.id, 1, input, 1), service.update(d.id, 1, input, 1)]); assert.equal(r.filter(x => x.status === 'fulfilled').length, 1);
let row = (await service.list(1))[0]; assert.equal(row.revision, 2); assert.equal(row.appliedRevision, 0); assert.equal(row.online, false);
await service.heartbeat(p.token, { appliedRevision: 2, width: 1920, height: 1080 }); row = (await service.list(1))[0]; assert.equal(row.appliedRevision, 2); assert.equal(row.online, true);
await service.heartbeat(p.token, { appliedRevision: 1 }); assert.equal((await service.list(1))[0].appliedRevision, 2);
await assert.rejects(service.heartbeat(p.token, { appliedRevision: 999 }), e => e.status === 400);
now += 90001; assert.equal((await service.list(1))[0].online, false);
});
test('revocation rejects device access and is scoped to the selected city', async () => {
const service = createBoardDeviceService(memoryRepository()); const p = await service.start({}); const d = await service.claim(1, { code: p.code, name: 'A', settings: defaults }, 1);
await assert.rejects(service.revoke(d.id, 2, 1), e => e.status === 404); await service.revoke(d.id, 1, 1);
await assert.rejects(service.status(p.token), e => e.status === 410); await assert.rejects(service.heartbeat(p.token, { appliedRevision: 1 }), e => e.status === 410);
});
test('remote settings are bounded, preserve supported palettes and cannot enable demo or accept unknown properties', () => {
assert.equal(validSettings({ ...defaults, demo: true }).demo, false);
for (const bad of [{ ...defaults, holdNear: 'yes' }, { ...defaults, adText: 'x'.repeat(201) }, { ...defaults, toString: 'bad' }, { ...defaults, stopId: NaN }, { ...defaults, routeFilter: '-1' }]) assert.throws(() => validSettings(bad), e => e.status === 400);
assert.equal(validSettings({ ...defaults, colorScheme: 'cyan' }).colorScheme, 'cyan');
});
test('night preferences validate, survive updates and migrate old stored device settings without a SQL migration', async () => {
const repo=memoryRepository(), service=createBoardDeviceService(repo);const p=await service.start({});const d=await service.claim(1,{code:p.code,name:'Night',settings:defaults},1);
delete repo.devices.get(d.id).settings.nightMode;delete repo.devices.get(d.id).settings.nightWakeMinutes;
assert.equal((await service.status(p.token)).device.settings.nightMode,'auto');assert.equal((await service.list(1))[0].settings.nightWakeMinutes,60);
assert.equal(repo.devices.get(d.id).settings.nightMode,undefined);
for(const settings of [{...defaults,nightMode:'always'},{...defaults,nightWakeMinutes:5}]) assert.throws(()=>validSettings(settings),e=>e.status===400);
await service.update(d.id,1,{name:'Night',revision:1,settings:{...defaults,nightMode:'off',nightWakeMinutes:45}},1);
const saved=(await service.status(p.token)).device;assert.equal(saved.settings.nightMode,'off');assert.equal(saved.settings.nightWakeMinutes,45);assert.equal(saved.revision,2);
});
test('transliteration migrates old devices, validates booleans and survives a remote update', async () => {
const repo = memoryRepository(), service = createBoardDeviceService(repo);
const pairing = await service.start({});
const device = await service.claim(1, { code: pairing.code, name: 'Latin', settings: defaults }, 1);
delete repo.devices.get(device.id).settings.journeyTranslitEnabled;
assert.equal((await service.status(pairing.token)).device.settings.journeyTranslitEnabled, false);
assert.equal((await service.list(1))[0].settings.journeyTranslitEnabled, false);
for (const flag of ['true', 1, null]) assert.throws(() => validSettings({ ...defaults, journeyTranslitEnabled: flag }), e => e.status === 400);
await service.update(device.id, 1, { name: 'Latin', revision: 1, settings: { ...defaults, journeyTranslitEnabled: true } }, 1);
const saved = (await service.status(pairing.token)).device;
assert.equal(saved.settings.journeyTranslitEnabled, true);
assert.equal(saved.revision, 2);
});
test('passenger configuration validates shape and city/stop/route identity before consuming a code', async () => {
const service = createBoardDeviceService(memoryRepository()), p = await service.start({});
const rule = { routeId: 1, price: 'Особый тариф', payment: 'Подтверждённый способ', checkedAt: '2026-10-11' };
for (const patch of [{ places: [{ stopId: 3, name: 'Другой город' }] }, { places: [{ stopId: 1, name: 'Та же остановка' }] },
{ fareRules: [{ ...rule, routeId: 2 }] }, { fareByType: [{ cityId: 2, type: 'bus', ...rule }] }, { places: null }, { infoFareEnabled: 'true' }]) {
await assert.rejects(service.claim(1, { code: p.code, name: 'Guide', settings: { ...defaults, ...patch } }, 1), e => e.status === 400);
}
await assert.rejects(service.claim(1, { code: p.code, name: 'Guide', settings: null }, 1), e => e.status === 400);
await assert.rejects(service.claim(1, { code:p.code, name:'Guide', settings:{...defaults, fareByType:[{cityId:2,type:'bus',price:'1 BYN',payment:'Наличные',checkedAt:'2026-10-11'}]} }, 1), e => e.code === 'invalid_fare_city');
assert.equal((await service.status(p.token)).state, 'pending');
const d = await service.claim(1, { code: p.code, name: 'Guide', settings: { ...defaults, places: [{ stopId: 2, name: 'Вокзал' }], fareRules: [rule] } }, 1);
const saved = (await service.status(p.token)).device.settings;
assert.equal(saved.places[0].name, 'Вокзал');assert.equal(saved.fareRules[0].price, 'Особый тариф');
await service.update(d.id,1,{name:'Guide',revision:1,settings:{...saved,weatherHintEnabled:false,lastTripNoticeEnabled:false}},1);
assert.equal((await service.status(p.token)).device.settings.weatherHintEnabled,false);
});
test('legacy Polotsk tariffs migrate using the operator confirmation; other cities stay unconfigured', async () => {
for(const cityId of [1,2]) {
const repo=memoryRepository(),service=createBoardDeviceService(repo),p=await service.start({});
const d=await service.claim(cityId,{code:p.code,name:'Legacy',settings:{...defaults,stopId:cityId===1?1:3}},1);
for(const key of ['infoPlacesEnabled','infoFareEnabled','lastTripNoticeEnabled','weatherHintEnabled','places','fareRules','fareByType']) delete repo.devices.get(d.id).settings[key];
const saved=(await service.status(p.token)).device.settings;
assert.equal(saved.lastTripNoticeEnabled,true);assert.deepEqual(saved.places,[]);
assert.equal(saved.fareByType.length,cityId===1?2:0);
if(cityId===1) {assert.equal(saved.fareByType[0].price,'1,05 BYN');assert.equal(saved.fareByType[1].price,'2,10 BYN');assert.equal(saved.fareByType[0].checkedAt,'2026-10-11');}
assert.equal(repo.devices.get(d.id).settings.fareByType,undefined);
}
});

View File

@@ -0,0 +1,28 @@
const test = require('node:test');
const assert = require('node:assert/strict');
process.env.JWT_SECRET = 'board-device-test-secret-only';
require.cache[require.resolve('../src/config/database')] = { exports: { query: async () => ({ rows: [] }) } };
const express = require('express');
const jwt = require('jsonwebtoken');
const { createBoardDevicesRouter } = require('../src/routes/boardDevices');
const { createBoardDeviceService } = require('../src/services/boardDeviceService');
const { defaults } = require('../src/services/boardDeviceSettings');
const { memoryRepository } = require('./helpers/boardDeviceMemoryRepository');
test('public pairing and device credentials cannot access admin endpoints; JWT administrator and city are required', async t => {
const service = createBoardDeviceService(memoryRepository()); const app = express(); app.use(express.json());
app.use('/api/v1/board-devices', createBoardDevicesRouter(service, { cityMiddleware: (req, _res, next) => { req.city = { id: Number(req.get('X-City-Id') || 1) }; next(); } }));
const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); }); t.after(() => new Promise(resolve => server.close(resolve)));
const base = `http://127.0.0.1:${server.address().port}/api/v1/board-devices`;
const post = (path, body, headers = {}) => fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
const admin = { Authorization: `Bearer ${jwt.sign({ id: 7, role: 'admin' }, process.env.JWT_SECRET)}`, 'X-City-Id': '1' };
const pair = await (await post('/pairings', { width: 879, height: 593 })).json();
assert.match(pair.code, /^\d{4}$/); assert.equal((await fetch(base + '/admin')).status, 401);
assert.equal((await fetch(base + '/admin', { headers: { 'X-Board-Token': pair.token } })).status, 401);
assert.equal((await fetch(base + '/admin', { headers: { Authorization: `Bearer ${jwt.sign({ id: 7, role: 'user' }, process.env.JWT_SECRET)}` } })).status, 403);
assert.equal((await fetch(base + '/self', { headers: admin })).status, 401);
assert.equal((await post('/admin/claim', { code: pair.code, name: 'A', settings: defaults }, admin)).status, 200);
const status = await fetch(base + '/self', { headers: { 'X-Board-Token': pair.token } }); assert.equal(status.headers.get('cache-control'), 'no-store'); assert.equal((await status.json()).state, 'paired');
assert.equal((await post('/admin/claim', { code: pair.code, name: 'A', settings: defaults }, admin)).status, 409);
const list = await (await fetch(base + '/admin', { headers: admin })).json(); assert.equal(list.length, 1); assert.equal(list[0].token_hash, undefined);
assert.equal((await (await fetch(base + '/admin', { headers: { ...admin, 'X-City-Id': '2' } })).json()).length, 0);
});

View File

@@ -0,0 +1,5 @@
Уважаемые пассажиры!
будут выполняться рейсы с опозданием:
ПО МАРШРУТУ № 4 С ОСТАНОВКИ МАРИНЕНКО НА 19.05
С ОСТАНОВКИ БОРОВУХА НА 20.00

View File

@@ -0,0 +1,4 @@
Уважаемые пассажиры!
НЕ БУДУТ ВЫПОЛНЯТЬСЯ рейсы по маршруту №2 ОТ ОСТАНОВКИ больница ксты на 7.15,8.45.
ОТ ОСТАНОВКИ больница(новка) на 8.00,9.30.
Филиал "Автобусный парк №2 г.Полоцка" ОАО "Витебскоблавтотранс"

View File

@@ -0,0 +1,7 @@
Уважаемые пассажиры!
Не будут выполняться следующие рейсы:
По маршруту № 4 с остановочного пункта
"Мариненко" на 6.00, 7.30
"Боровуха" на 6.40, 8.15

View File

@@ -0,0 +1,21 @@
// Explicit test-only repository; production always uses PostgreSQL.
function memoryRepository({ now = Date.now } = {}) {
const pairings = new Map(), devices = new Map();
const stops = [{ id: 1, city_id: 1, name: 'Швейная фабрика', description: 'на Богатырскую' }, { id: 2, city_id: 1, name: 'Автовокзал', description: 'в центр' }, { id: 3, city_id: 2, name: 'Новополоцк', description: 'конечная' }];
const routes = [{ id: 1, city_id: 1, route_number: '1', name: 'Швейная фабрика — Богатырская', stops: [1, 2] }, { id: 2, city_id: 2, route_number: '1', name: 'Новополоцк', stops: [3] }];
const join = row => ({ ...row, city_name: row.city_id === 1 ? 'Полоцк' : 'Новополоцк', stop_name: stops.find(s => s.id === row.stop_id)?.name, stop_direction: stops.find(s => s.id === row.stop_id)?.description });
return {
stops, routesData: routes, devices, pairings,
async createPairing(p) { for (const [code, item] of pairings) if (Date.parse(item.expires_at) <= now()) pairings.delete(code); if (pairings.has(p.code)) { const e = new Error('collision'); e.code = '23505'; throw e; } pairings.set(p.code, p); return p; },
async pairing(hash) { return [...pairings.values()].find(p => p.token_hash === hash); },
async device(hash) { const row = [...devices.values()].find(d => d.token_hash === hash); return row && join(row); },
async routes(cityId, stopId) { return routes.filter(r => r.city_id === cityId && r.stops.includes(stopId)); },
async catalog(cityId, s) { return stops.some(stop => stop.id === s.stopId && stop.city_id === cityId) && (!s.routeFilter || routes.some(r => r.id === Number(s.routeFilter) && r.city_id === cityId && r.stops.includes(s.stopId))); },
async claim(code, record) { const p = pairings.get(code); if (!p || Date.parse(p.expires_at) <= now()) return null; pairings.delete(code); const row = { ...record, token_hash: p.token_hash, device_info: p.device_info, revision: 1, applied_revision: 0, last_seen_at: null, revoked_at: null }; devices.set(row.id, row); return row; },
async list(cityId) { return [...devices.values()].filter(d => d.city_id === cityId).map(join); },
async update(id, cityId, input) { const d = devices.get(id); if (!d || d.city_id !== cityId || d.revision !== input.revision || d.revoked_at) return null; Object.assign(d, { name: input.name, stop_id: input.settings.stopId, settings: input.settings, revision: d.revision + 1 }); return d; },
async revoke(id, cityId) { const d = devices.get(id); if (!d || d.city_id !== cityId) return null; d.revoked_at ||= new Date(now()).toISOString(); return d; },
async heartbeat(hash, revision, info) { const d = [...devices.values()].find(d => d.token_hash === hash && !d.revoked_at); if (!d) return null; d.applied_revision = Math.max(d.applied_revision, Math.min(d.revision, revision)); d.last_seen_at = new Date(now()).toISOString(); d.device_info = info; return d; },
};
}
module.exports = { memoryRepository };

View File

@@ -0,0 +1,109 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const {
createPassengerTelemetryAuthenticator,
getBearerToken
} = require('../src/middleware/passengerTelemetryAuth');
function createResponse() {
return {
statusCode: null,
body: null,
status(statusCode) {
this.statusCode = statusCode;
return this;
},
json(body) {
this.body = body;
return this;
}
};
}
function createRequest(authorization) {
return {
headers: { authorization },
get(name) {
return name.toLowerCase() === 'authorization' ? authorization : undefined;
}
};
}
test('requires a Bearer token for passenger telemetry', async () => {
const middleware = createPassengerTelemetryAuthenticator({
getFirebaseAuth() {
throw new Error('should not initialize Firebase without a token');
}
});
const res = createResponse();
await middleware(createRequest(), res, () => assert.fail('next should not run'));
assert.equal(res.statusCode, 401);
assert.deepEqual(res.body, { error: 'Access token required' });
});
test('accepts a verified Firebase ID token without retaining its UID', async () => {
let verifiedToken;
const middleware = createPassengerTelemetryAuthenticator({
getFirebaseAuth() {
return {
async verifyIdToken(token, checkRevoked) {
verifiedToken = token;
assert.equal(checkRevoked, true);
return { uid: 'must-not-be-copied-to-request' };
}
};
}
});
const req = createRequest('Bearer firebase-id-token');
const res = createResponse();
let advanced = false;
await middleware(req, res, () => {
advanced = true;
});
assert.equal(verifiedToken, 'firebase-id-token');
assert.equal(advanced, true);
assert.equal(req.passengerTelemetryAuthenticated, true);
assert.equal('user' in req, false);
assert.equal('firebaseUid' in req, false);
});
test('rejects invalid Firebase ID tokens without logging their value', async () => {
const warnings = [];
const middleware = createPassengerTelemetryAuthenticator({
getFirebaseAuth() {
return {
async verifyIdToken() {
const error = new Error('invalid token should not be logged');
error.code = 'auth/argument-error';
throw error;
}
};
},
authLogger: {
warn(message, metadata) {
warnings.push({ message, metadata });
},
error() {}
}
});
const res = createResponse();
await middleware(createRequest('Bearer private-token-value'), res, () => assert.fail('next should not run'));
assert.equal(res.statusCode, 403);
assert.deepEqual(warnings, [{
message: 'Passenger telemetry token was rejected',
metadata: { firebaseCode: 'auth/argument-error' }
}]);
});
test('parses only a complete Bearer authorization value', () => {
assert.equal(getBearerToken(createRequest('Bearer token')), 'token');
assert.equal(getBearerToken(createRequest('Basic token')), null);
assert.equal(getBearerToken(createRequest('Bearer token extra')), null);
});

View File

@@ -0,0 +1,36 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { buildScheduleCenter, parseDate } = require('../src/services/scheduleCenterReport');
test('schedule center separates active and future versions and detects gaps and overlaps', () => {
const report = buildScheduleCenter([
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 1, day_type: 'weekday', valid_from: null, valid_until: null, departure_count: 12 },
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 2, day_type: 'saturday', valid_from: '2026-10-01', valid_until: null, departure_count: 6 },
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 3, day_type: 'sunday', valid_from: null, valid_until: '2026-09-30', departure_count: 5 },
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 4, day_type: 'sunday', valid_from: '2026-09-25', valid_until: null, departure_count: 5 },
], '2026-09-18');
assert.deepEqual(report.summary, { routes: 1, active: 2, upcoming: 2, conflicts: 1, gaps: 1 });
assert.equal(report.window_end, '2026-12-17');
const saturday = report.routes[0].day_types.find((dayType) => dayType.value === 'saturday');
assert.equal(saturday.status, 'upcoming');
assert.deepEqual(saturday.gaps, [{ starts_on: '2026-09-18', ends_on: '2026-09-30' }]);
assert.equal(report.issues.find((issue) => issue.code === 'schedule_overlap').route_id, 7);
assert.deepEqual(report.issues.find((issue) => issue.code === 'schedule_overlap').version_ids, [3, 4]);
});
test('schedule center reports uncovered day types for routes without versions', () => {
const report = buildScheduleCenter([
{ route_id: 3, route_number: '2', route_name: 'Рынок — Больница', color: '#dc2626', schedule_id: null },
], '2026-09-18', { windowDays: 7 });
assert.equal(report.summary.gaps, 3);
assert.equal(report.issues.filter((issue) => issue.code === 'schedule_gap').length, 3);
assert.equal(report.routes[0].day_types.every((dayType) => dayType.status === 'gap'), true);
});
test('date validation accepts calendar dates only', () => {
assert.ok(parseDate('2026-02-28'));
assert.equal(parseDate('2026-02-29'), null);
assert.equal(parseDate('18.09.2026'), null);
});

View File

@@ -0,0 +1,279 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { parseTransitNotice, buildTransitNoticeAlert, parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationAlert, normalizeStopKey, dayBounds } = require('../src/services/telegramCancellationParser');
const hospitalText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-hospital-cancellation.txt'), 'utf8');
const text = `Уважаемые пассажиры!
Не будут выполняться рейсы: 
ПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43.
                С ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.
С УВАЖЕНИЕМ
Филиал "Автобусный парк №2 г.Полоцка" ОАО "Витебскоблавтотранс"`;
const parse = (value = text, publishedAt = '2026-10-02T12:52:00Z') => parseCancellation({ text: value, publishedAt });
test('carrier post preserves four individual departures and canonicalizes АВ/ВОКЗАЛ', () => {
const result = parse();
assert.equal(result.status, 'ready');
assert.equal(result.serviceDate, '2026-10-02');
assert.deepEqual(result.groups, [{ routeNumber: '27', departures: [
{ stop: 'Автовокзал', times: ['15:43', '16:43'] }, { stop: 'ЗОДЧЕГО', times: ['15:58', '16:58'] }
] }]);
});
test('implicit date comes from publication in Minsk, never from import day or edit date', () => {
assert.equal(parse(text, '2026-10-02T22:30:00Z').serviceDate, '2026-10-03');
});
test('tomorrow, numeric and Russian dates take precedence over publication day', () => {
for (const prefix of ['Завтра\n', '03.10.2026\n', 'На 03.10\n', '3 октября 2026 года\n']) {
assert.equal(parse(prefix + text).serviceDate, '2026-10-03', prefix);
}
assert.equal(parse('Завтра\n' + text, '2026-12-31T17:00:00Z').serviceDate, '2027-01-01');
assert.equal(parse('На 01.01\n' + text, '2026-12-31T17:00:00Z').serviceDate, '2027-01-01');
});
test('09.10 in a departure list remains a time', () => {
assert.deepEqual(parse(text.replace('15.43', '09.10')).groups[0].departures[0].times, ['09:10', '16:43']);
});
test('hospital post with ОТ ОСТАНОВКИ preserves all four times and uses the publication date', () => {
const result = parse(hospitalText, '2026-10-05T04:16:53Z');
assert.equal(result.status, 'ready');
assert.equal(result.serviceDate, '2026-10-05');
assert.equal(result.inferredDate, true);
assert.deepEqual(result.groups, [{ routeNumber: '2', departures: [
{ stop: 'больница ксты', times: ['07:15', '08:45'] },
{ stop: 'больница(новка)', times: ['08:00', '09:30'] }
] }]);
assert.equal(dayBounds(result.serviceDate).end, '2026-10-05T21:00:00.000Z');
});
test('с/от wording may be mixed; real dates and invalid times remain validated', () => {
for (const prefix of ['На 06.10\n', '06.10.2026\n', 'Завтра\n']) {
const result = parse(prefix + hospitalText.replace('ОТ ОСТАНОВКИ', 'с остановки'), '2026-10-05T04:16:53Z');
assert.equal(result.status, 'ready', prefix);
assert.equal(result.serviceDate, '2026-10-06', prefix);
}
const invalidDate = parse('На 31.02.2026\n' + hospitalText, '2026-10-05T04:16:53Z');
assert.deepEqual(invalidDate.reasons, ['Некорректная дата отмены.']);
const invalidTime = parse(hospitalText.replace('7.15', '25.15'), '2026-10-05T04:16:53Z');
assert.deepEqual(invalidTime.reasons, ['Некорректное время отправления.']);
assert.deepEqual(parse(text.replace('15.43', '09.10').replace(/С ОСТАНОВКИ/g, 'ОТ ОСТАНОВКИ')).groups[0].departures[0].times, ['09:10', '16:43']);
});
test('ambiguous dates, conditions, invalid times and unspecified departures require review', () => {
for (const value of [
'Сегодня и завтра\n' + text, 'На 31.02.2026\n' + text,
'С 02.10 по 05.10\n' + text, 'По пятницам\n' + text,
text.replace('15.43', '25.43'), text.replace('15.43', '15.99'),
'Не будут выполняться рейсы по маршруту №27.', text + '\nКроме отправления на 18:00.',
'Послезавтра\n' + text
]) assert.equal(parse(value).status, 'review', value);
});
test('news is ignored; a picture without text stays on review', () => {
assert.equal(parse('С праздником, уважаемые пассажиры!').status, 'ignored');
assert.equal(parseCancellation({ text: '', hasMedia: true, publishedAt: '2026-10-02T10:00:00Z' }).status, 'review');
});
test('review retains a recognized future date; attached media cannot be silently auto-published', () => {
const result = parse('Завтра\nНе будут выполняться рейсы по маршруту №27.');
assert.equal(result.status, 'review');
assert.equal(result.serviceDate, '2026-10-03');
assert.equal(parseCancellation({ text, hasMedia: true, publishedAt: '2026-10-02T12:00:00Z' }).status, 'review');
});
test('different route numbers in one post are separate groups', () => {
const result = parse(text + '\nПО МАРШРУТУ № 5А С ОСТАНОВКИ ЦЕНТР НА 18:00.');
assert.equal(result.status, 'ready');
assert.deepEqual(result.groups.map(g => g.routeNumber), ['27', '5а']);
});
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[0].routeIds, [1, 2]);
assert.equal(matched.reasons.length, 0);
assert.equal(matchCancellationRoutes(parse(), routes.map(r => ({ ...r, origin_stop: '' }))).plans.length, 1);
assert.equal(matchCancellationRoutes(parse(), []).reasons.length, 1);
});
test('midnight expiry is an explicit UTC instant, including year boundary', () => {
assert.deepEqual(dayBounds('2026-10-02'), { start: '2026-10-01T21:00:00.000Z', end: '2026-10-02T21:00:00.000Z' });
assert.equal(dayBounds('2026-12-31').end, '2026-12-31T21:00:00.000Z');
const plan = matchCancellationRoutes(parse(), [{ id: 1, route_number: '27' }]).plans[0];
const alert = buildCancellationAlert(plan, '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52:00Z', 1);
assert.equal(alert.end_time, '2026-10-02T21:00:00.000Z');
assert.match(alert.message, /Автовокзал.*15:43, 16:43/);
assert.match(alert.title, /отдельных рейсов/);
});
module.exports = { carrierText: text };
test('dictionary selects the actual stop ID and uses its canonical name in concise alerts', () => {
const stops = [{ id: 11, name: 'Автовокзал' }, { id: 22, name: 'Зодчего Иоанна' }, { id: 23, name: 'Зодчего Иоанна' }];
const aliases = [{ normalized_alias: normalizeStopKey('ЗОДЧЕГО'), stop_id: 22 }];
const parsed = resolveCancellationStops(parse(), stops, aliases);
const routes = [
{ id: 1, route_number: '27', origin_stop_id: 11, origin_stop: 'Автовокзал' },
{ id: 2, route_number: '27', origin_stop_id: 22, origin_stop: 'Зодчего Иоанна' },
{ id: 3, route_number: '27', origin_stop_id: 23, origin_stop: 'Зодчего Иоанна' }
];
const matched = matchCancellationRoutes(parsed, routes);
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');
assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ|Источник:/);
assert.equal(normalizeStopKey(' « ЗОД-ЧЕГО » '), normalizeStopKey('Зодчего'));
});
test('canonical names are taken from the catalog; inactive aliases and ambiguous platforms require review', () => {
const parsed = parse(text.replace('ЗОДЧЕГО', 'ЗОДЧЕГО ИОАННА'));
const stops = [{ id: 11, name: 'Автовокзал' }, { id: 22, name: 'Зодчего Иоанна' }];
assert.equal(resolveCancellationStops(parsed, stops, []).groups[0].departures[1].stop, 'Зодчего Иоанна');
const inactive = resolveCancellationStops(parse(), [stops[0]], [{ normalized_alias: 'зодчего', stop_id: 22 }]);
assert.match(matchCancellationRoutes(inactive, [{ id: 1, route_number: '27' }]).reasons[0], /отключена/);
const ambiguous = resolveCancellationStops(parsed, [...stops, { id: 23, name: 'Зодчего Иоанна' }], []);
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'));
}
});
const delayText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-delay-route-4.txt'), 'utf8');
const stopPointText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-stop-point-cancellation.txt'), 'utf8');
const route4Stops = [{ id: 41, name: 'Мариненко' }, { id: 42, name: 'Боровуха' }];
const route4 = [{ id: 4, route_number: '4' }, { id: 40, route_number: '4' }];
function route4Alert(parsed, publishedAt) {
const resolved = resolveCancellationStops(parsed, route4Stops, []);
const matched = matchCancellationRoutes(resolved, route4);
assert.deepEqual(matched.reasons, []);
return buildTransitNoticeAlert(matched.plans, parsed.serviceDate, 'https://t.me/avp2Polotsk/1700', publishedAt, 1, parsed);
}
test('screenshot delay is a delay notice with both departures and Minsk publication-day expiry', () => {
const publishedAt = '2026-10-08T16:14:01Z';
const parsed = parseTransitNotice({ text: delayText, publishedAt });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.alertType, 'delay');
assert.equal(parsed.serviceDate, '2026-10-08');
assert.deepEqual(parsed.groups[0].departures.map(d => d.times), [['19:05'], ['20:00']]);
const alert = route4Alert(parsed, publishedAt);
assert.equal(alert.alert_type, 'delay');
assert.deepEqual(alert.route_numbers, ['4']);
assert.equal(alert.title, 'Задержка отдельных рейсов №4');
assert.equal(alert.message, '08.10.2026: с опозданием выполняются рейсы:\nМариненко — 19:05\nБоровуха — 20:00');
assert.equal(alert.start_time, '2026-10-08T16:14:01.000Z');
assert.equal(alert.end_time, '2026-10-08T21:00:00.000Z');
});
test('screenshot stop-point heading and quoted continuation preserve four cancelled departures', () => {
const publishedAt = '2026-10-08T02:03:13Z';
const parsed = parseTransitNotice({ text: stopPointText, publishedAt, editedAt: '2026-10-09T01:00Z' }, { preserveSourceStops: true });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.alertType, 'cancellation');
assert.equal(parsed.serviceDate, '2026-10-08');
assert.deepEqual(parsed.groups, [{ routeNumber: '4', departures: [
{ stop: 'Мариненко', sourceStop: 'Мариненко', times: ['06:00', '07:30'] },
{ stop: 'Боровуха', sourceStop: 'Боровуха', times: ['06:40', '08:15'] }
] }]);
const alert = route4Alert(parsed, publishedAt);
assert.equal(alert.alert_type, 'cancellation');
assert.equal(alert.message, '08.10.2026: не выполняются рейсы:\nМариненко — 06:00, 07:30\nБоровуха — 06:40, 08:15');
assert.equal(alert.end_time, '2026-10-08T21:00:00.000Z');
});
test('stop-point heading supports quote variants, mixed prefixes and real dates independently from times', () => {
for (const [opening, closing] of [['"', '"'], ['«', '»'], ['“', '”']]) {
const text = stopPointText.replace(/"(Мариненко|Боровуха)"/g, (_, name) => opening + name + closing).replace('6.00', '09.10');
const parsed = parseTransitNotice({ text: 'На 09.10\n' + text, publishedAt: '2026-10-08T02:03:13Z' });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.serviceDate, '2026-10-09');
assert.deepEqual(parsed.groups[0].departures[0].times, ['09:10', '07:30']);
}
const mixed = stopPointText.replace('"Боровуха"', 'ОТ ОСТАНОВКИ «Боровуха»');
assert.equal(parseTransitNotice({ text: mixed, publishedAt: '2026-10-08T02:03:13Z' }).status, 'ready');
for (const text of [stopPointText.replace('6.00', '25.00'), 'На 31.02.2026\n' + stopPointText,
stopPointText.replace('с остановочного пункта', ''), stopPointText + '\nДополнительно в 09:00']) {
assert.equal(parseTransitNotice({ text, publishedAt: '2026-10-08T02:03:13Z' }).status, 'review');
}
});
test('possible delay stays possible and unspecified duration is never invented or silently dropped', () => {
const publishedAt = '2026-10-08T16:14:01Z';
for (const wording of ['могут выполняться рейсы с опозданием', 'рейсы могут прибыть с опозданием', 'рейсы могут прийти с опозданием']) {
const parsed = parseTransitNotice({ text: delayText.replace('будут выполняться рейсы с опозданием', wording), publishedAt });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.delayPossible, true);
const alert = route4Alert(parsed, publishedAt);
assert.match(alert.title, /Возможное опоздание/);
assert.match(alert.message, /возможны опоздания рейсов/);
assert.doesNotMatch(alert.message, /минут|час/);
}
for (const duration of [' на 20 минут', ' на 20 мин.', ' на 1 час']) {
const parsed = parseTransitNotice({ text: delayText.replace('с опозданием:', 'с опозданием' + duration + ':'), publishedAt });
assert.equal(parsed.status, 'review');
assert.equal(parsed.alertType, 'delay');
assert.match(parsed.reasons[0], /величина опоздания/);
}
});
test('mixed types, negated delay, conditions and incomplete departures stay on review; unrelated execution is ignored', () => {
const publishedAt = '2026-10-08T16:14:01Z';
for (const text of [delayText + '\n' + stopPointText,
delayText.replace('будут выполняться', 'не могут выполняться'),
delayText + '\nДвижение восстановлено.',
delayText.replace(' НА 20.00', ''),
delayText + '\nОстальные рейсы будут выполняться.']) {
assert.equal(parseTransitNotice({ text, publishedAt }).status, 'review', text);
}
assert.equal(parseTransitNotice({ text: 'Будут выполняться рейсы по маршруту №4.', publishedAt }).status, 'ignored');
assert.equal(parseTransitNotice({ text: 'С опозданием поздравляем с праздником!', publishedAt }).status, 'ignored');
const withMedia = parseTransitNotice({ text: delayText, publishedAt, hasMedia: true });
assert.equal(withMedia.status, 'review');
assert.equal(withMedia.alertType, 'delay');
});
test('multiple delayed routes are combined while every stop and departure remains visible', () => {
const publishedAt = '2026-10-08T16:14:01Z';
const parsed = parseTransitNotice({ text: delayText + '\nПО МАРШРУТУ №27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 20.15,21.00.', publishedAt });
const resolved = resolveCancellationStops(parsed, [...route4Stops, { id: 1, name: 'Автовокзал' }], []);
const matched = matchCancellationRoutes(resolved, [...route4, { id: 27, route_number: '27' }]);
const alert = buildTransitNoticeAlert(matched.plans, parsed.serviceDate, '', publishedAt, 1, parsed);
assert.equal(alert.alert_type, 'delay');
assert.deepEqual(alert.route_numbers, ['4', '27']);
assert.match(alert.message, /Маршрут №4:\nМариненко — 19:05\nБоровуха — 20:00/);
assert.match(alert.message, /Маршрут №27:\nАвтовокзал — 20:15, 21:00/);
});
test('calendar words in a stop name are not mistaken for a service date', () => {
const parsed = parseTransitNotice({ text: stopPointText.replace('Мариненко', '1 Мая'), publishedAt: '2026-10-08T02:03:13Z' });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.serviceDate, '2026-10-08');
assert.equal(parsed.groups[0].departures[0].stop, '1 Мая');
});

View File

@@ -0,0 +1,505 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { TelegramImportService, contentHash } = require('../src/services/telegramImportService');
const { CLAIM_DUE_ALERTS_SQL } = require('../src/services/alertSchedulerService');
// Run against an isolated in-memory PostgreSQL engine, never the working database.
// TELEGRAM_TEST_PGLITE_MODULE can point to a temporary npm installation.
const modulePath = process.env.TELEGRAM_TEST_PGLITE_MODULE;
const carrierText = 'Не будут выполняться рейсы:\nПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43.\nС ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.';
const config = { enabled: true, autoPublish: true, apiId: 123, apiHash: 'a'.repeat(32), sessionFile: '/tmp/private/account.session', channel: 'avp2Polotsk', citySlug: 'polotsk', intervalMs: 60_000 };
const message = (id = 1166, text = carrierText) => ({ channelId: '1234567', channelUsername: 'avp2Polotsk', messageId: id, publishedAt: '2026-10-02T12:52:00Z', editedAt: null, hasMedia: false, text });
test('fixed parser reprocesses a stored invalid-date review without a Telegram text edit or duplicate alerts', { skip: !modulePath }, async () => {
const { db, pool, service, clock } = await setup();
try {
clock('2026-10-05T04:20:00Z');
await pool.query("UPDATE routes SET route_number='2' WHERE city_id=1");
await pool.query("UPDATE stops SET name=CASE id WHEN 1 THEN 'Больница (Ксты)' ELSE 'Больница (Новка)' END WHERE city_id=1");
await service.stopAliases.save(1, { alias: 'больница ксты', stop_id: 1 });
await service.stopAliases.save(1, { alias: 'больница(новка)', stop_id: 2 });
const text = await fs.readFile(path.join(__dirname, 'fixtures/telegram-hospital-cancellation.txt'), 'utf8');
const input = { ...message(1400, text), publishedAt: '2026-10-05T04:16:53Z' };
const oldParsed = { status: 'review', reasons: ['Некорректная дата отмены.'], groups: [], plans: [] };
await pool.query(`INSERT INTO telegram_import_messages
(city_id,channel_id,channel_username,message_id,published_at,content_hash,raw_text,status,reasons,parsed)
VALUES (1,$1,$2,$3,$4,$5,$6,'review',$7::jsonb,$8::jsonb)`,
[input.channelId,input.channelUsername,input.messageId,input.publishedAt,contentHash(input),input.text,JSON.stringify(oldParsed.reasons),JSON.stringify(oldParsed)]);
assert.equal((await service.processMessage(input, 1)).status, 'published');
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_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[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_message_alerts WHERE message_id=$1', [row.id])).rows[0].n, 1);
} finally { await db.close(); }
});
async function setup() {
const { PGlite } = require(modulePath);
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,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,
city_id int,start_time timestamp,end_time timestamp,is_active boolean DEFAULT true,created_at timestamp DEFAULT now(),
notification_sent_at timestamptz,notification_claimed_at timestamptz,notification_attempts int DEFAULT 0);
INSERT INTO cities(slug) VALUES ('polotsk'),('novopolotsk'); INSERT INTO users DEFAULT VALUES;
INSERT INTO routes(route_number,name,city_id) VALUES ('27','Автовокзал → Зодчего',1),('27','Зодчего → Автовокзал',1),('27','Другой город',2);
INSERT INTO stops(name,city_id) VALUES ('Автовокзал',1),('Зодчего Иоанна',1),('Остановка другого города',2);
INSERT INTO route_stops VALUES (1,1,1),(2,2,1);
INSERT INTO alerts(route_id,city_id,start_time,end_time,title,message,alert_type) VALUES (1,1,'2026-10-02 12:00','2026-10-02 21:00','Old','Old','info');
SET TIME ZONE 'Europe/Minsk';`);
const migration = await fs.readFile(path.join(__dirname, '../migrations/013_telegram_alert_import.sql'), 'utf8');
await db.exec(migration);
await db.exec(migration); // idempotent upgrades
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() {
const previous = tail;
let release;
tail = new Promise(resolve => { release = resolve; });
await previous;
return { query: (sql, args) => db.query(sql, args), release };
}
const pool = { connect: lease, query: async (sql, args) => {
const client = await lease();
try { return await client.query(sql, args); } finally { client.release(); }
} };
let now = new Date('2026-10-02T13:00:00Z');
const service = new TelegramImportService({ pool, config, source: { close: async () => {} }, logger: { warn() {}, error() {} }, now: () => now });
return { db, pool, service, clock: value => { now = new Date(value); } };
}
test('Telegram SQL lifecycle: atomic import, duplicates, edits, expiry, deletion and city isolation', { skip: !modulePath }, async () => {
const { db, pool, service, clock } = await setup();
try {
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 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));
// Delivered pushes aren't repeated on text edits, while the stored alert changes.
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 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');
// Missing text after an edit withdraws the old alerts and requires a review.
assert.equal((await service.processMessage({ ...edited, text: 'Движение восстановлено.', editedAt: '2026-10-02T13:02:00Z' }, 1)).status, 'review');
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts WHERE notification_pending=true')).rows[0].n, 0);
const row = (await service.list(1))[0];
const input = { action: 'publish', content_hash: row.content_hash, service_date: '2026-10-02', route_id: 1, title: 'Отмена рейса', message: 'Автовокзал, 15:45' };
await assert.rejects(service.review(row.id, 2, input, 1), /не найдено/);
await assert.rejects(service.review(row.id, 1, { ...input, route_id: 3 }, 1), /выбранного города/);
await assert.rejects(service.review(row.id, 1, { ...input, content_hash: 'stale' }, 1), /изменилась/);
await service.review(row.id, 1, input, 1);
await service.review(row.id, 1, { action: 'dismiss', content_hash: row.content_hash }, 1);
assert.equal((await service.processMessage({ ...edited, editedAt: '2026-10-02T14:00:00Z' }, 1)).status, 'dismissed');
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts WHERE notification_pending=true')).rows[0].n, 0);
// 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_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_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');
assert.equal((await service.processMessage(message(1168), 1)).status, 'expired');
const expired = (await service.list(1)).find(m => m.message_id === 1168);
await assert.rejects(service.review(expired.id, 1, { ...input, content_hash: expired.content_hash }, 1), /уже закончился/);
assert.equal((await service.list(2)).length, 0);
// Pending imported alerts are claimed even when start_time is before created_at.
await pool.query(`UPDATE alerts SET is_active=true, notification_pending=true, start_time=now()-interval '1 minute',
end_time=now()+interval '1 hour', notification_sent_at=NULL WHERE id<>1`);
const claimed = await pool.query(CLAIM_DUE_ALERTS_SQL, [50]);
assert.ok(claimed.rows.length > 0);
assert.ok(claimed.rows.every(a => a.notification_attempts === 1));
assert.equal((await pool.query(CLAIM_DUE_ALERTS_SQL, [50])).rows.length, 0);
} finally { await db.close(); }
});
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;
pool.connect = async () => {
const client = await connect();
const query = client.query;
client.query = (sql, args) => {
if (/INSERT INTO telegram_message_alerts/.test(sql)) throw new Error('Injected failure');
return query(sql, args);
};
return client;
};
await assert.rejects(service.processMessage(message(), 1), /Injected failure/);
assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_import_messages')).rows[0].n, 0);
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts')).rows[0].n, 1);
} finally { await db.close(); }
});
test('stop dictionary validates city and uniqueness, resolves waiting messages and preserves published text', { skip: !modulePath }, async () => {
const { db, pool, service, clock } = await setup();
try {
const dictionary = service.stopAliases;
let aliases = await dictionary.list(1);
assert.equal(aliases.length, 2, 'migration seeds known abbreviations once');
const zodchego = aliases.find(a => a.alias === 'ЗОДЧЕГО');
assert.equal(zodchego.stop_name, 'Зодчего Иоанна');
await assert.rejects(dictionary.save(1, { alias: ' зод-чего ', stop_id: 2 }), error => error.status === 409);
await assert.rejects(dictionary.save(1, { alias: 'Другой город', stop_id: 3 }), error => error.status === 400);
await assert.rejects(dictionary.save(1, { alias: '?!', stop_id: 2 }), error => error.status === 400);
await assert.rejects(dictionary.remove(zodchego.id, 2), error => error.status === 404);
await dictionary.remove(zodchego.id, 1);
const original = message(1200);
assert.equal((await service.processMessage(original, 1)).status, 'review');
assert.equal((await service.processMessage(original, 1)).status, 'unchanged');
let row = (await service.list(1)).find(m => m.message_id === 1200);
assert.match(row.drafts.find(d => d.routeId === null).message, /Автовокзал — 15:43, 16:43/);
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_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.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[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.
await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 2 }, saved.id);
await pool.query('UPDATE stops SET is_active=false WHERE id=2');
assert.equal((await service.processMessage(message(1201), 1)).status, 'review');
const inactive = (await service.list(1)).find(m => m.message_id === 1201);
assert.match(inactive.reasons[0], /отключена/);
await assert.rejects(dictionary.save(1, { alias: 'Новое', stop_id: 2 }), error => error.status === 400);
await pool.query('UPDATE stops SET is_active=true WHERE id=2');
clock('2026-10-02T21:00:00Z');
assert.equal((await service.processMessage(message(1201), 1)).status, 'expired', 'dictionary changes cannot publish yesterday after midnight');
assert.equal((await dictionary.list(2)).length, 0);
// Re-running the migration never picks one of two identically named platforms.
await dictionary.remove(saved.id, 1);
await pool.query("INSERT INTO stops(name,city_id) VALUES ('Зодчего Иоанна',1)");
await db.exec(await fs.readFile(path.join(__dirname, '../migrations/014_telegram_stop_aliases.sql'), 'utf8'));
assert.equal((await dictionary.list(1)).some(a => a.alias === 'ЗОДЧЕГО'), false);
} finally { await db.close(); }
});
test('reviewed snapshots respect auto-publish configuration changes and midnight without requiring a source edit', { skip: !modulePath }, async () => {
const { db, pool, service, clock } = await setup();
// This test uses its own configuration so it cannot change other tests.
service.config = { ...config, autoPublish: false };
try {
assert.equal((await service.processMessage(message(1300), 1)).status, 'review');
assert.equal((await service.processMessage(message(1300), 1)).status, 'unchanged');
service.config.autoPublish = true;
assert.equal((await service.processMessage(message(1300), 1)).status, 'published');
service.config.autoPublish = false;
assert.equal((await service.processMessage(message(1301), 1)).status, 'review');
await service.processMessage(message(1302, carrierText.replace('\nС ОСТАНОВКИ ЗОДЧЕГО', '\nПО МАРШРУТУ № 27 С ОСТАНОВКИ ЗОДЧЕГО')), 1);
const draft = (await service.list(1)).find(m => m.message_id === 1302).drafts.filter(d => d.routeId === null);
assert.equal(draft.length, 1);
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_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(); }
});
async function setupRoute4() {
const context = await setup();
await context.pool.query("UPDATE routes SET route_number='4' WHERE city_id=1");
await context.pool.query("UPDATE stops SET name=CASE id WHEN 1 THEN 'Мариненко' ELSE 'Боровуха' END WHERE city_id=1");
context.clock('2026-10-08T16:20:00Z');
return context;
}
async function route4Message(id, fixture = 'telegram-delay-route-4.txt') {
const text = await fs.readFile(path.join(__dirname, 'fixtures', fixture), 'utf8');
return { ...message(id, text), publishedAt: fixture.includes('delay') ? '2026-10-08T16:14:01Z' : '2026-10-08T02:03:13Z' };
}
async function seedLegacyReview(pool, input, parsed, reasons) {
return (await pool.query(`INSERT INTO telegram_import_messages
(city_id,channel_id,channel_username,message_id,published_at,content_hash,raw_text,status,reasons,parsed)
VALUES (1,$1,$2,$3,$4,$5,$6,'review',$7::jsonb,$8::jsonb) RETURNING id`,
[input.channelId,input.channelUsername,input.messageId,input.publishedAt,contentHash(input),input.text,JSON.stringify(reasons),JSON.stringify(parsed)])).rows[0].id;
}
test('screenshot reviews reprocess unchanged text into correctly typed alerts with no duplicates', { skip: !modulePath }, async () => {
const { db, pool, service } = await setupRoute4();
try {
const delayed = await route4Message(1800);
const cancelled = await route4Message(1801, 'telegram-stop-point-cancellation.txt');
await seedLegacyReview(pool, delayed, { status: 'ignored', groups: [], plans: [] }, ['Текст исходной публикации изменился: отмена больше не распознаётся.']);
await seedLegacyReview(pool, cancelled, { status: 'review', groups: [], plans: [] }, ['Некорректная дата отмены.']);
for (const input of [delayed, cancelled]) {
assert.equal((await service.processMessage(input, 1)).status, 'published');
assert.equal((await service.processMessage(input, 1)).status, 'unchanged');
}
const rows = await service.list(1);
const delay = rows.find(row => row.message_id === delayed.messageId);
const cancellation = rows.find(row => row.message_id === cancelled.messageId);
assert.deepEqual(delay.reasons, []);
assert.deepEqual(cancellation.reasons, []);
assert.equal(delay.parsed.alertType, 'delay');
assert.equal(cancellation.parsed.alertType, 'cancellation');
assert.equal(delay.alert_ids.length, 1);
assert.equal(cancellation.alert_ids.length, 1);
assert.match(delay.drafts[0].title, /Задержка/);
assert.match(delay.drafts[0].message, /Мариненко — 19:05\nБоровуха — 20:00/);
assert.match(cancellation.drafts[0].message, /Мариненко — 06:00, 07:30\nБоровуха — 06:40, 08:15/);
const alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY id')).rows;
assert.deepEqual(alerts.map(alert => alert.alert_type), ['delay', 'cancellation']);
assert.ok(alerts.every(alert => alert.end_time.toISOString() === '2026-10-08T21:00:00.000Z'));
assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_message_alerts')).rows[0].n, 2);
} finally { await db.close(); }
});
test('editing a cancellation into a delay updates its type and content on the same delivered alert', { skip: !modulePath }, async () => {
const { db, pool, service } = await setupRoute4();
try {
const input = await route4Message(1802, 'telegram-stop-point-cancellation.txt');
await service.processMessage(input, 1);
const row = (await service.list(1))[0];
const id = row.alert_ids[0];
await pool.query('UPDATE alerts SET notification_sent_at=now() WHERE id=$1', [id]);
const delay = await route4Message(1802);
await service.processMessage({ ...input, text: delay.text, editedAt: '2026-10-08T16:15:00Z' }, 1);
const updated = (await pool.query('SELECT * FROM alerts WHERE id=$1', [id])).rows[0];
assert.equal(updated.alert_type, 'delay');
assert.match(updated.title, /Задержка/);
assert.match(updated.message, /Мариненко — 19:05\nБоровуха — 20:00/);
assert.ok(updated.notification_sent_at, 'the delivered push is not repeated');
assert.deepEqual((await service.list(1))[0].alert_ids, [id]);
assert.equal((await service.processMessage(input, 1)).status, 'unchanged', 'older source cannot restore the cancellation');
} finally { await db.close(); }
});
test('manual Telegram review defaults to delay, permits an explicit type choice, and rejects unrelated types', { skip: !modulePath }, async () => {
const { db, pool, service } = await setupRoute4();
service.config = { ...config, autoPublish: false };
try {
const input = await route4Message(1803);
assert.equal((await service.processMessage(input, 1)).status, 'review');
const row = (await service.list(1))[0];
const values = { action: 'publish', content_hash: row.content_hash, service_date: '2026-10-08', route_numbers: ['4'], ...row.drafts[0] };
await assert.rejects(service.review(row.id, 1, { ...values, alert_type: 'info' }, 1), error => error.status === 400);
await service.review(row.id, 1, values, 1);
const saved = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true')).rows[0];
assert.equal(saved.alert_type, 'delay');
assert.equal(saved.end_time.toISOString(), '2026-10-08T21:00:00.000Z');
await service.review(row.id, 1, { ...values, alert_type: 'cancellation', title: 'Уточнённая отмена', message: 'Уточнённый текст' }, 1);
assert.equal((await pool.query('SELECT alert_type FROM alerts WHERE id=$1', [saved.id])).rows[0].alert_type, 'cancellation');
assert.equal((await service.list(1))[0].parsed.alertType, 'cancellation');
} finally { await db.close(); }
});
test('past screenshot messages become expired on parser upgrade and cannot send a new push after midnight', { skip: !modulePath }, async () => {
const { db, pool, service, clock } = await setupRoute4();
clock('2026-10-08T21:00:00Z');
try {
for (const [id, fixture] of [[1804, 'telegram-delay-route-4.txt'], [1805, 'telegram-stop-point-cancellation.txt']]) {
const input = await route4Message(id, fixture);
await seedLegacyReview(pool, input, { status: 'review', groups: [] }, ['Старая ошибка']);
assert.equal((await service.processMessage(input, 1)).status, 'expired');
assert.equal((await service.processMessage(input, 1)).status, 'unchanged');
}
const rows = await service.list(1);
assert.ok(rows.every(row => row.status === 'expired' && row.alert_ids.length === 0));
assert.ok(rows.every(row => row.parsed.serviceDate === '2026-10-08'));
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts WHERE notification_pending=true')).rows[0].n, 0);
const delay = rows.find(row => row.parsed.alertType === 'delay');
await assert.rejects(service.review(delay.id, 1, { action: 'publish', content_hash: delay.content_hash, service_date: '2026-10-08', route_numbers: ['4'], title: 'Опоздание', message: 'Текст' }, 1), error => error.status === 410);
} finally { await db.close(); }
});
test('parser upgrade refreshes ambiguous delay review once and does not auto-publish attachments', { skip: !modulePath }, async () => {
const { db, pool, service } = await setupRoute4();
try {
const input = { ...await route4Message(1806), hasMedia: true };
await seedLegacyReview(pool, input, { status: 'ignored', groups: [] }, ['Отмена не распознана']);
assert.equal((await service.processMessage(input, 1)).status, 'review');
const row = (await service.list(1))[0];
assert.equal(row.parsed.alertType, 'delay');
assert.match(row.reasons[0], /вложение/);
assert.equal(row.alert_ids.length, 0);
assert.equal((await service.processMessage(input, 1)).status, 'unchanged');
await service.review(row.id, 1, { action: 'dismiss', content_hash: row.content_hash }, 1);
assert.equal((await service.processMessage({ ...input, hasMedia: false, editedAt: '2026-10-08T16:16:00Z' }, 1)).status, 'dismissed');
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts WHERE notification_pending=true')).rows[0].n, 0);
} finally { await db.close(); }
});

View File

@@ -0,0 +1,57 @@
const test = require('node:test');
const assert = require('node:assert/strict');
process.env.JWT_SECRET = 'telegram-api-tests-only-secret';
process.env.NODE_ENV = 'production';
// This router test never connects to a real database.
require.cache[require.resolve('../src/config/database')] = { exports: { query: async () => ({ rows: [] }) } };
const express = require('express');
const jwt = require('jsonwebtoken');
const { createTelegramImportRouter } = require('../src/routes/telegramImport');
test('Telegram admin API rejects anonymous/non-admin access and preserves selected-city scope', async () => {
const calls = [];
const service = {
config: { citySlug: 'polotsk' },
getStatus: () => ({ enabled: false }),
list: async cityId => { calls.push(['list', cityId]); return []; },
sync: async () => { calls.push(['sync']); return {}; },
review: async (...args) => { calls.push(['review', ...args]); return { status: 'dismissed' }; },
stopAliases: {
list: async (...args) => { calls.push(['aliases', ...args]); return []; },
save: async (...args) => { calls.push(['saveAlias', ...args]); return { id: 9 }; },
remove: async (...args) => { calls.push(['removeAlias', ...args]); return { id: 9 }; }
}
};
const app = express();
app.use(express.json());
app.use((req, res, next) => { req.city = { id: Number(req.get('X-City-Id') || 1), slug: req.get('X-City-Id') === '2' ? 'novopolotsk' : 'polotsk' }; next(); });
app.use('/telegram-import', createTelegramImportRouter(service));
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}/telegram-import`;
const token = role => jwt.sign({ id: 7, role }, process.env.JWT_SECRET);
try {
assert.equal((await fetch(url)).status, 401);
assert.equal((await fetch(url, { headers: { Authorization: `Bearer ${token('user')}` } })).status, 403);
assert.equal(calls.length, 0);
const headers = { Authorization: `Bearer ${token('admin')}`, 'X-City-Id': '2', 'Content-Type': 'application/json' };
assert.equal((await fetch(url, { headers })).status, 200);
assert.deepEqual(calls[0], ['list', 2]);
assert.equal((await fetch(`${url}/sync`, { method: 'POST', headers })).status, 409);
assert.equal(calls.length, 1);
assert.equal((await fetch(`${url}/4/review`, { method: 'POST', headers, body: JSON.stringify({ action: 'dismiss', content_hash: 'hash' }) })).status, 200);
assert.deepEqual(calls[1], ['review', 4, 2, { action: 'dismiss', content_hash: 'hash' }, 7]);
assert.equal((await fetch(`${url}/not-an-id/review`, { method: 'POST', headers, body: '{}' })).status, 400);
for (const method of ['GET', 'POST', 'PUT', 'DELETE']) {
const endpoint = `${url}/stop-aliases${['PUT', 'DELETE'].includes(method) ? '/9' : ''}`;
assert.equal((await fetch(endpoint, { method, headers: { Authorization: `Bearer ${token('user')}` } })).status, 403);
const result = await fetch(endpoint, { method, headers, ...(method === 'POST' || method === 'PUT' ? { body: JSON.stringify({ alias: 'ЗОДЧЕГО', stop_id: 22 }) } : {}) });
assert.equal(result.status, 200);
}
assert.deepEqual(calls.slice(-4), [
['aliases', 2], ['saveAlias', 2, { alias: 'ЗОДЧЕГО', stop_id: 22 }],
['saveAlias', 2, { alias: 'ЗОДЧЕГО', stop_id: 22 }, 9], ['removeAlias', 9, 2]
]);
assert.equal((await fetch(`${url}/stop-aliases/0`, { method: 'DELETE', headers })).status, 400);
assert.equal((await fetch(`${url}/stop-aliases/999999999999999999`, { method: 'PUT', headers, body: '{}' })).status, 400);
} finally { await new Promise(resolve => server.close(resolve)); }
});

View File

@@ -0,0 +1,82 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { TelegramImportService, publicError } = require('../src/services/telegramImportService');
const { TelegramSource } = require('../src/services/telegramSource');
const { getTelegramConfig } = require('../src/config/telegram');
const config = { enabled: true, autoPublish: true, apiId: 123, apiHash: 'a'.repeat(32), sessionFile: '/tmp/private/account.session', channel: 'avp2Polotsk', citySlug: 'polotsk', intervalMs: 60_000 };
function setup(source, overrides = {}) {
return new TelegramImportService({ config: { ...config, ...overrides }, source,
pool: { query: async () => ({ rows: [{ id: 1 }] }) }, logger: { warn() {} }, now: () => new Date('2026-10-02T12:00:00Z') });
}
test('disabled or incomplete setup never connects to Telegram and never reveals credentials in status', async () => {
let calls = 0;
const source = { fetchMessages: async () => { calls++; } };
const disabled = setup(source, { enabled: false });
await assert.rejects(disabled.sync(), /выключен/);
const missing = setup(source, { apiHash: '' });
assert.equal(missing.getStatus().connection, 'configuration_required');
await assert.rejects(missing.sync(), /Укажите/);
assert.equal(calls, 0);
assert.equal(JSON.stringify(disabled.getStatus()).includes(config.apiHash), false);
assert.equal(JSON.stringify(disabled.getStatus()).includes(config.sessionFile), false);
assert.equal(getTelegramConfig({}).enabled, false);
});
test('worker polls a bounded three-day window, processes sequentially, and reports success', async () => {
const processed = [];
const source = { async fetchMessages(since) {
assert.equal(since.toISOString(), '2026-09-29T21:00:00.000Z');
return [{ id: 1 }, { id: 2 }];
} };
const service = setup(source);
service.processMessage = async (message, cityId) => { processed.push([message.id, cityId]); return { status: 'published' }; };
const counts = await service.sync();
assert.equal(counts.published, 2);
assert.deepEqual(processed, [[1, 1], [2, 1]]);
assert.equal(service.getStatus().connection, 'ready');
});
test('overlapping worker requests do not start two channel readers', async () => {
let finish;
const service = setup({ fetchMessages: () => new Promise(resolve => { finish = resolve; }) });
const running = service.sync();
await new Promise(resolve => setImmediate(resolve));
await assert.rejects(service.sync(), /уже выполняется/);
finish([]);
await running;
});
test('FloodWait backs off and only a sanitized error code is exposed or logged', async () => {
const logs = [];
let attempts = 0;
const service = setup({ async fetchMessages() { attempts++; throw Object.assign(new Error('private-session-secret'), { seconds: 120 }); }, async close() {} });
service.logger = { warn: (...args) => logs.push(args) };
await assert.rejects(service.sync(), /FLOOD_WAIT/);
assert.equal(service.getStatus().retryAt, '2026-10-02T12:02:00.000Z');
await assert.rejects(service.sync(), /частоту запросов/);
assert.equal(attempts, 1);
assert.equal(JSON.stringify([logs, service.getStatus()]).includes('private-session-secret'), false);
assert.equal(publicError(new Error('some sensitive content')), 'IMPORT_FAILED');
});
test('source stops reading old history and keeps original publication and edit timestamps', async () => {
const source = new TelegramSource(config);
source.channel = { id: { toString: () => '1234567' }, username: config.channel };
source.connect = async () => {};
let iterations = 0;
source.client = { async *iterMessages() {
for (const entry of [
{ className: 'Message', id: 7, date: Date.parse('2026-10-02T10:00Z') / 1000, editDate: Date.parse('2026-10-02T11:00Z') / 1000, message: 'Отмена', media: null },
{ className: 'MessageService', id: 6, date: Date.parse('2026-10-02T09:00Z') / 1000 },
{ className: 'Message', id: 5, date: Date.parse('2026-09-01T10:00Z') / 1000 },
{ className: 'Message', id: 4, date: Date.parse('2026-08-01T10:00Z') / 1000 }
]) { iterations++; yield entry; }
} };
const messages = await source.fetchMessages(new Date('2026-10-01T21:00Z'));
assert.equal(iterations, 3);
assert.equal(messages.length, 1);
assert.equal(messages[0].publishedAt, '2026-10-02T10:00:00.000Z');
assert.equal(messages[0].editedAt, '2026-10-02T11:00:00.000Z');
});

View File

@@ -55,11 +55,22 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
ADMIN_FULL_NAME: ${ADMIN_FULL_NAME:-Main administrator}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@polotsk-transit.local}
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-RideBus DE}
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-http://localhost,http://localhost:3001}
FCM_ENABLED: "${FCM_ENABLED:-false}"
FCM_TOPIC: "${FCM_TOPIC:-route_alerts}"
FCM_ANDROID_CHANNEL_ID: "${FCM_ANDROID_CHANNEL_ID:-route_alerts}"
FIREBASE_PROJECT_ID: "${FIREBASE_PROJECT_ID:-}"
GOOGLE_APPLICATION_CREDENTIALS: "${GOOGLE_APPLICATION_CREDENTIALS:-/run/secrets/firebase-service-account.json}"
TELEGRAM_IMPORT_ENABLED: "${TELEGRAM_IMPORT_ENABLED:-false}"
TELEGRAM_AUTO_PUBLISH: "${TELEGRAM_AUTO_PUBLISH:-true}"
TELEGRAM_API_ID: "${TELEGRAM_API_ID:-}"
TELEGRAM_API_HASH: "${TELEGRAM_API_HASH:-}"
TELEGRAM_CHANNEL: "${TELEGRAM_CHANNEL:-avp2Polotsk}"
TELEGRAM_CITY_SLUG: "${TELEGRAM_CITY_SLUG:-polotsk}"
TELEGRAM_POLL_INTERVAL_MS: "${TELEGRAM_POLL_INTERVAL_MS:-60000}"
TELEGRAM_SESSION_FILE: "${TELEGRAM_SESSION_FILE:-/run/telegram-secrets/account.session}"
ports:
- "3000:3000"
depends_on:
@@ -73,7 +84,12 @@ services:
volumes:
- ./backend/logs:/app/logs
- avatars_data:/app/public/uploads/avatars
- city_images_data:/app/public/uploads/cities
- ${FIREBASE_SECRETS_DIR:-./secrets}:/run/secrets:ro
- type: bind
source: ${TELEGRAM_SECRETS_DIR:-./secrets}
target: /run/telegram-secrets
read_only: true
frontend:
build:
@@ -112,6 +128,7 @@ volumes:
postgres_data:
redis_data:
avatars_data:
city_images_data:
networks:
transit-network:

View File

@@ -107,8 +107,9 @@ docker compose exec -T postgres \
| `FIREBASE_PROJECT_ID` | пусто | Явный Firebase/Google Cloud project ID; полезен, если ADC не определяет проект автоматически. |
| `GOOGLE_APPLICATION_CREDENTIALS` | пусто | Стандартный путь ADC к service account JSON в окружении запущенного backend-процесса. |
`FCM_ENABLED=false` — безопасный режим по умолчанию. В нём приложение не
пытается инициализировать Firebase и полностью работает без credentials.
`FCM_ENABLED=false` — безопасный режим по умолчанию для push-уведомлений. Firebase
Admin всё равно потребуется backend, если включён пассажирский режим: endpoint
`/api/v1/passenger-telemetry/ingest` проверяет Firebase ID token независимо от FCM.
Firebase Admin SDK в этом проекте требует Node.js 22. Docker-образ backend
также должен использовать Node.js 22.
@@ -294,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` сохранены для совместимости с одним номером; при нескольких они пустые. Мобильный клиент должен использовать массивы для привязки уведомления к маршрутам. Изменения мобильного клиента здесь не реализуются.

View File

@@ -1,6 +1,6 @@
openapi: 3.0.3
info:
title: Polotsk Transit API
title: RideBus DE API
version: 1.0.0-draft
description: |
Черновик OpenAPI 3.0, восстановленный по реальному коду проекта.

View File

@@ -0,0 +1,153 @@
# Отмены и опоздания рейсов из @avp2Polotsk
Первый этап читает публичный канал через **отдельный существующий Telegram-аккаунт** и MTProto-клиент `teleproto`. Права управления каналом и Bot API не нужны. Получение выполняется раз в минуту; просматриваются публикации за текущий и два предыдущих дня, включая правки. Вложения не скачиваются. Расписания рейсов не изменяются: импорт создаёт предупреждения существующих типов `cancellation` (отмена) и `delay` (опоздание).
## Распознавание и срок действия
Формат «Не будут выполняться рейсы: ПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43. С ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.» разбирается в четыре конкретных отправления. `АВ/ВОКЗАЛ` означает **Автовокзал**, точки в часах заменяются двоеточием. Отмена всего маршрута из такого объявления не выводится.
Поддерживаются формулировки **«С ОСТАНОВКИ»**, **«ОТ ОСТАНОВКИ»** и **«С/ОТ ОСТАНОВОЧНОГО ПУНКТА»**, включая их сочетание в одном сообщении. После общего заголовка можно перечислять остановки в кавычках на отдельных строках, например `«Мариненко» на 6.00, 7.30` и `«Боровуха» на 6.40, 8.15`. Заголовок может отделяться от списка пустыми строками. Значения вроде `на 7.15` и `на 8.00` после остановки считаются временем отправления. Если отдельная дата не указана, день отмены берётся из оригинальной публикации. После обновления backend ранее сохранённые сообщения с ошибкой «Некорректная дата отмены» будут обработаны повторно при очередном чтении канала в пределах трёхдневного окна; сообщения с неизвестными остановками или номерами маршрутов останутся на проверке с уточнённой причиной.
Номер маршрута относится ко всем его действующим направлениям в выбранном городе. Один пост создаёт одно уведомление, в том числе при нескольких номерах. Поддерживаются отдельные блоки «по маршруту №…» и общий список «по маршрутам №2, №27» с явно указанными остановками и временами. Неизвестные номера и остановки, разные даты, условия, периоды и изображения без текста требуют проверки администратором. Неизвестный номер блокирует автоматическую публикацию всего поста. Обычные новости не импортируются. Пока нет OCR, распознавания произвольных формулировок и автоматического редактирования расписаний.
Явная дата, «сегодня» или «завтра» определяют день отмены. Если дата отсутствует, используется **день оригинальной публикации по Europe/Minsk**, а не день импорта или правки. Дата без года выбирается ближайшей к публикации. Конец действия — **00:00 следующего дня по Минску**, граница исключительная. Например, для 02.10.2026 это `2026-10-02T21:00:00Z`. Будущие отмены активируются в начале указанного дня и получают push тогда же. Просроченные объявления сохраняются как завершённые без push.
Новые однозначные отмены публикуются автоматически. `TELEGRAM_AUTO_PUBLISH=false` переводит все распознанные отмены на проверку. В админке на странице **«Уведомления» → «Уведомления из Telegram»** доступны состояние подключения, ручная проверка канала, исходные сообщения, ссылки на оригинал и история. При ручной публикации оператор выбирает один или несколько номеров маршрутов, проверяет текст и день отмены; срок до полуночи назначается сервером.
### Словарь остановок и текст уведомления
В блоке **«Уведомления из Telegram» → «Словарь названий остановок»** можно добавлять, изменять и удалять соответствия: название из канала → конкретная остановка из каталога выбранного города. Например, `ЗОДЧЕГО` → `Зодчего Иоанна`. Для разных сокращений одной остановки создайте несколько записей. Регистр, пробелы, знаки препинания и различие `ё/е` не учитываются; два эквивалентных сокращения в одном городе запрещены. Если есть несколько платформ с одним названием, выберите нужную остановку по адресу/ID. Отключённая остановка требует исправления соответствия и ручной проверки.
Миграция **014** добавляет словарь и начальные соответствия `АВ/ВОКЗАЛ` → `Автовокзал`, `ЗОДЧЕГО` → `Зодчего Иоанна` только для Полоцка и только при единственной действующей остановке с таким названием. При неоднозначности соответствие задаёт администратор. Уже сохранённые соответствия при повторном запуске миграции не перезаписываются.
Изменения словаря учитываются при следующем чтении канала, включая сообщения **на проверке с неизменившимся исходным текстом** в трёхдневном окне. Уже опубликованные или отклонённые сообщения повторно из-за изменения словаря не публикуются. Для распознанных отмен backend формирует лаконичный текст без приветствия и подписи перевозчика. Например:
```text
Отмена отдельных рейсов №27
02.10.2026: не выполняются рейсы:
Автовокзал — 15:43, 16:43
Зодчего Иоанна — 15:58, 16:58
```
При нескольких номерах создаётся одно уведомление с блоком отправлений для каждого номера. Исходный текст остаётся в истории импорта. В ручной проверке можно выбрать тип «Отмена рейсов» или «Опоздание рейсов». Распознанный текст также подставляется в удобочитаемом виде; выбор номеров подставляет соответствующие отправления, пока оператор не изменил текст самостоятельно. Если сообщение не удалось разобрать, текст для ручной правки остаётся исходным.
## Установка
Для существующей базы применить миграцию **013** один раз, до запуска новой версии backend. Она также переводит UTC-значения времени существующих алертов из `timestamp` в `timestamptz`. Это соответствует принятому формату хранения UTC в админке. При наличии вручную внесённых локальных времен сначала проверить их и сделать резервную копию базы.
```bash
docker compose exec -T postgres psql -U transit_admin -d polotsk_transit \
-f /docker-entrypoint-initdb.d/013_telegram_alert_import.sql
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 уже установлены, примените только **015** и пересоберите backend/frontend. На чистой базе Docker применяет все миграции автоматически. Миграция 015 заполняет номера у существующих уведомлений. Опубликованные Telegram-записи сразу не переписываются: при следующей правке/ручной проверке старые уведомления по направлениям объединяются, уже доставленные push повторно не отправляются. **Не запускайте `npm run migrate` на существующей базе**: старые init-миграции проекта не поддерживают повторный запуск.
### Авторизация аккаунта
1. Создать API-приложение на [my.telegram.org](https://my.telegram.org) и получить `api_id` и `api_hash`.
2. На компьютере/сервере оператора создать закрытый каталог **вне checkout**. Пример для локального запуска:
```bash
mkdir -p "$HOME/.config/polotsk-transit-telegram"
chmod 700 "$HOME/.config/polotsk-transit-telegram"
```
3. В `backend/.env` указать:
```dotenv
TELEGRAM_IMPORT_ENABLED=false
TELEGRAM_API_ID=your_api_id
TELEGRAM_API_HASH=your_api_hash
TELEGRAM_SESSION_FILE=/absolute/private/directory/account.session
TELEGRAM_CHANNEL=avp2Polotsk
TELEGRAM_CITY_SLUG=polotsk
TELEGRAM_AUTO_PUBLISH=true
TELEGRAM_POLL_INTERVAL_MS=60000
```
4. Из каталога `backend` выполнить `npm ci`, затем `npm run telegram:login`. Номер, код и пароль вводятся оператором локально. Код и пароль скрыты; session string не печатается. Утилита сохраняет файл с правами `600` и работает только с существующим пользовательским аккаунтом. Если Telegram требует регистрацию/дополнительный неподдерживаемый шаг, завершить его в официальном клиенте и повторить авторизацию.
5. Включить `TELEGRAM_IMPORT_ENABLED=true` и перезапустить backend. Для push также должны быть настроены Firebase и `FCM_ENABLED=true`.
Файл сессии даёт доступ к аккаунту. Его нельзя коммитить, передавать frontend или присылать в чат. Backend не выполняет интерактивную авторизацию; отсутствующая/отозванная сессия отображается как ошибка подключения. В статус и логи не попадают API hash, сессия, телефон, коды или сырые ошибки клиента.
### Docker / Proxmox
Авторизацию выполнить на доверенном хосте. Поместить созданный `account.session` в закрытый каталог на Docker-хосте, например `/opt/polotsk-transit/telegram-secrets`, с правами `600`. В корневом `.env` настроить:
```dotenv
TELEGRAM_SECRETS_DIR=/opt/polotsk-transit/telegram-secrets
TELEGRAM_SESSION_FILE=/run/telegram-secrets/account.session
TELEGRAM_API_ID=your_api_id
TELEGRAM_API_HASH=your_api_hash
TELEGRAM_IMPORT_ENABLED=true
TELEGRAM_AUTO_PUBLISH=true
```
Compose монтирует каталог только для чтения, отдельно от Firebase. После миграции выполнить `docker compose up -d --build backend frontend`.
## Повторы, правки и доставка
Идентичность публикации: город + числовой ID канала + ID сообщения. Снимок текста, результат распознавания и связи с алертами сохраняются одной транзакцией; повторный импорт и одновременные читатели не создают второй алерт. Более старая правка не заменяет свежую. Администратор публикует только проверенную версию по `content_hash`; изменившийся текст даёт `409` и требует нового просмотра.
Правка публикации обновляет связанные алерты. Если новая версия неоднозначна или больше не распознаётся как отмена/опоздание, прежние алерты выключаются и сообщение возвращается на проверку. Отклонение сохраняется при последующих правках. Удалённый администратором алерт не воссоздаётся при правке исходного сообщения. Автоудаление сообщения в Telegram не отменяет предупреждение раньше полуночи; удаление не трактуется как восстановление движения.
Импорт помечает алерты как `notification_pending` в той же транзакции. Существующий scheduler отправляет их после начала действия, использует блокировки и до пяти попыток; Firebase отключён — ожидание сохраняется. Уже доставленный push при правке того же алерта не отправляется повторно. Остаётся обычное окно между приёмом FCM и записью результата в БД: при аварии в этот момент возможен повтор доставки. `alertId` позволяет клиенту показывать один экземпляр уведомления.
FCM TTL ограничен остатком срока действия алерта; истёкший алерт не отправляется. Backend передаёт абсолютный `endTime`. **Удаление уже показанного уведомления из системной шторки требует поддержки Android-клиента**: проверить `endTime` при получении, не показывать просроченный push, поставить `setTimeoutAfter(endTime-now)` и исключать истёкшие алерты из локального списка. Изменения Android-клиента не входят в этот этап backend/админки. Правки и отключение алерта не отзывают уже показанный push.
При 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_numbers: ["2", "27"]`, `title`, `message`; необязательное `alert_type: cancellation|delay` (по умолчанию распознанный тип); старое поле `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`; изменить соответствие.
- `DELETE /api/v1/telegram-import/stop-aliases/:id` — удалить соответствие.
Обычные unit-тесты: `cd backend && npm test`. SQL-тесты используют временный PostgreSQL runtime PGlite и не обращаются к рабочей базе:
```bash
npm install --prefix /tmp/polotsk-telegram-test-runtime @electric-sql/pglite --ignore-scripts
TELEGRAM_TEST_PGLITE_MODULE=/tmp/polotsk-telegram-test-runtime/node_modules/@electric-sql/pglite \
node --test test/telegramImport.integration.test.js
```
Реальное чтение Telegram и доставка на устройство проверяются после локальной авторизации аккаунта, применения миграции и включения Firebase.
## Опоздания и обновление распознавания
Сообщения «будут выполняться рейсы с опозданием/с задержкой» распознаются как `delay`. Формулировки «могут выполняться/прибыть/прийти с опозданием» сохраняют неопределённость в тексте: «возможны опоздания рейсов». Время после названия остановки — время соответствующего рейса, а не обещанное время прибытия. Длительность опоздания автоматически не придумывается. Если в исходном тексте указана величина задержки, сообщение отправляется на ручную проверку, чтобы она не потерялась. Сообщения с отменами и опозданиями одновременно также требуют проверки.
Пример автоматически подготовленного уведомления:
```text
Задержка отдельных рейсов №4
08.10.2026: с опозданием выполняются рейсы:
Мариненко — 19:05
Боровуха — 20:00
```
Срок действия для опозданий совпадает с отменами: до 00:00 следующего дня по Минску. Истёкшие сообщения не создают новые алерты и не попадают в push-рассылку. Правка источника с отмены на опоздание обновляет тип существующего алерта; уже отправленный push не повторяется.
Для этого обновления новые миграции не нужны, если ранее применены 013–015. Пересоберите `backend frontend`: `docker compose up -d --build backend frontend`. При следующем чтении канала сообщения на проверке будут пересчитаны в пределах трёхдневного окна, даже без правки исходного текста. Сообщения за 8 октября при повторной обработке 9 октября получат статус «День уведомления завершён» и не вызовут новую рассылку. Уже опубликованные и отклонённые сообщения с неизменившимся исходным текстом не переписываются. Неизвестные названия остановок по-прежнему требуют пополнения словаря.

View File

@@ -2,10 +2,12 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/bus-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Polotsk Transit Admin Panel" />
<title>Polotsk Transit Admin</title>
<meta name="description" content="RideBus DE Admin Panel" />
<title>RideBus DE Admin</title>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />

3383
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
{
"name": "polotsk-transit-admin",
"version": "1.0.0",
"description": "Admin panel for Polotsk Transit API",
"description": "Admin panel for RideBus DE API",
"private": true,
"type": "module",
"dependencies": {
"@simplewebauthn/browser": "^14.0.0",
"@tanstack/react-table": "^8.10.7",
"axios": "^1.6.2",
"date-fns": "^2.30.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@@ -0,0 +1,9 @@
{
"icons": [
{ "src": "/favicon.ico", "type": "image/x-icon", "sizes": "16x16 32x32" },
{ "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
{ "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" },
{ "src": "/icon-192-maskable.png", "type": "image/png", "sizes": "192x192", "purpose": "maskable" },
{ "src": "/icon-512-maskable.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
]
}

View File

@@ -3,6 +3,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import AppLayout from './components/AppLayout';
import { PageLoader } from './components/AnimatedComponents';
import { CityProvider, useCity } from './contexts/CityContext';
// Pages
import LoginPage from './pages/LoginPage';
@@ -12,11 +13,15 @@ import RouteEditorPage from './pages/RouteEditorPage';
import StopsPage from './pages/StopsPage';
import SchedulesPage from './pages/SchedulesPage';
import ScheduleImportPage from './pages/ScheduleImportPage';
import ScheduleCenterPage from './pages/ScheduleCenterPage';
import LiveMapPage from './pages/LiveMapPage';
import QualityPage from './pages/QualityPage';
import HolidaysPage from './pages/HolidaysPage';
import AlertsPage from './pages/AlertsPage';
import UsersPage from './pages/UsersPage';
import ProfilePage from './pages/ProfilePage';
import CitiesPage from './pages/CitiesPage';
import DevicesPage from './pages/DevicesPage';
function App() {
return (
@@ -42,30 +47,41 @@ function AppContent() {
);
}
return (
<AppLayout>
<AnimatedRoutes />
</AppLayout>
);
return <CityProvider><CityWorkspace /></CityProvider>;
}
function AnimatedRoutes() {
function CityWorkspace() {
const { loading, error, activeCity } = useCity();
if (loading) return <PageLoader />;
if (error || !activeCity) return <main className="login-surface"><div className="login-card max-w-md"><h1 className="text-xl font-semibold">Города недоступны</h1><p className="mt-2 text-gray-600">{error || 'В системе нет активных городов.'}</p></div></main>;
return <AppLayout>
<AnimatedRoutes cityId={activeCity.id} />
</AppLayout>;
}
function AnimatedRoutes({ cityId }) {
const location = useLocation();
const { isAdmin } = useAuth();
return (
<div key={location.pathname} className="page-transition">
<div key={`${cityId}:${location.pathname}`} className="page-transition">
<Routes location={location}>
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/routes" element={<ProtectedRoute><RoutesPage /></ProtectedRoute>} />
<Route path="/routes/:id/edit" element={<ProtectedRoute><RouteEditorPage /></ProtectedRoute>} />
<Route path="/stops" element={<ProtectedRoute><StopsPage /></ProtectedRoute>} />
<Route path="/schedules" element={<ProtectedRoute><SchedulesPage /></ProtectedRoute>} />
<Route path="/live-map" element={<ProtectedRoute><LiveMapPage /></ProtectedRoute>} />
<Route path="/schedule-center" element={<ProtectedRoute requireAdmin><ScheduleCenterPage /></ProtectedRoute>} />
<Route path="/schedule-import" element={<ProtectedRoute requireAdmin><ScheduleImportPage /></ProtectedRoute>} />
<Route path="/devices" element={<ProtectedRoute requireAdmin><DevicesPage /></ProtectedRoute>} />
<Route path="/quality" element={<ProtectedRoute requireAdmin><QualityPage /></ProtectedRoute>} />
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />
<Route path="/alerts" element={<ProtectedRoute><AlertsPage /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
{isAdmin && <Route path="/cities" element={<ProtectedRoute requireAdmin><CitiesPage /></ProtectedRoute>} />}
{isAdmin && (
<Route path="/users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} />
)}

View File

@@ -0,0 +1,13 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="71.9698" height="44.59" viewBox="0 0 71.9698 44.59" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Component 1" clip-path="url(#clip0_0_38)">
<path id="Vector" d="M13.1939 37.3605C12.2313 37.3605 11.4509 36.5784 11.4509 35.6138V32.1203H13.1939V34.789C13.1939 35.2177 13.5407 35.5652 13.9686 35.5652H20.6499V37.3605H13.1939Z" fill="black"/>
<path id="Vector_2" d="M13.1939 14.9927H11.4509V1.74672C11.4509 0.782035 12.2313 0 13.1939 0H70.2269C71.1895 0 71.9698 0.782034 71.9698 1.74672V35.6138C71.9698 36.5784 71.1895 37.3605 70.2269 37.3605H43.8407V35.5652H69.4522C69.88 35.5652 70.2269 35.2176 70.2269 34.789V2.52304C70.2269 2.0943 69.88 1.74672 69.4522 1.74672H13.9686C13.5407 1.74672 13.1939 2.0943 13.1939 2.52305V14.9927Z" fill="black"/>
<path id="Vector_3" d="M3.9632 13.6099C2.93218 13.6099 2.06433 14.3832 1.94373 15.4094L0.0121967 31.8432C-0.109861 32.8817 0.699761 33.7943 1.74317 33.7943H11.4146C11.4146 33.7943 11.1878 36.8948 8.76968 39.2469C8.42297 39.584 8.64475 40.2717 9.12781 40.2717H20.6499V35.4686C20.3395 34.4883 20.2867 33.7943 20.2867 33.7943H20.6499V32.0718H2.68799C2.10817 32.0718 1.65833 31.5646 1.72639 30.9875L3.50481 15.9091C3.55665 15.4695 3.92852 15.1383 4.37025 15.1383H27.5694C28.0509 15.1383 28.4592 15.4928 28.5278 15.9704L29.9001 25.5216H31.6475L30.0464 15.3307C29.8907 14.3399 29.0387 13.6099 28.0377 13.6099H3.9632Z" fill="black"/>
<path id="Vector_4" fill-rule="evenodd" clip-rule="evenodd" d="M20.7951 44.59C19.8325 44.59 19.0521 43.808 19.0521 42.8432V25.6672C19.0521 24.7025 19.8325 23.9204 20.7951 23.9204H43.6954C44.658 23.9204 45.4384 24.7025 45.4384 25.6672V42.8432C45.4384 43.808 44.658 44.59 43.6954 44.59H20.7951ZM21.6182 25.5216C21.0833 25.5216 20.6499 25.9561 20.6499 26.492V42.0184C20.6499 42.5543 21.0833 42.9888 21.6182 42.9888H42.8723C43.4071 42.9888 43.8407 42.5543 43.8407 42.0184V26.492C43.8407 25.9561 43.4071 25.5216 42.8723 25.5216H21.6182Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_0_38">
<rect width="71.9698" height="44.59" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,26 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 4.3 -->
<svg viewBox="0 0 168 34" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 120 29" xmlns="http://www.w3.org/2000/svg">
<g id="g1">
<g id="g2">
<path id="path1" fill="#262626" stroke="none" d="M 37.050182 25.721401 L 37.050182 13.416975 L 39.763466 13.416975 L 39.763466 16.445757 L 39.447968 16.06716 C 39.637268 15.625462 39.826565 15.246862 40.078964 14.931366 C 40.331364 14.552767 40.646858 14.300369 41.025459 14.04797 C 41.340958 13.858671 41.656456 13.732471 41.971954 13.606272 C 42.350552 13.480074 42.66605 13.416975 43.044647 13.416975 C 43.423244 13.416975 43.801842 13.416975 44.180439 13.416975 L 44.180439 16.319557 C 43.864944 16.193357 43.423244 16.193357 42.981548 16.193357 C 42.539848 16.256458 42.098152 16.319557 41.782654 16.508856 C 41.404057 16.698156 41.088558 16.887453 40.836159 17.202951 C 40.583763 17.455351 40.394463 17.833948 40.268265 18.149446 C 40.142063 18.528046 40.078964 18.969742 40.078964 19.411438 L 40.078964 25.658302 L 37.050182 25.658302 Z"/>
<path id="path2" fill="#262626" stroke="none" d="M 46.641327 11.713284 L 46.641327 9 L 49.733208 9 L 49.733208 11.713284 L 46.641327 11.713284 Z M 46.641327 25.721401 L 46.641327 13.416975 L 49.733208 13.416975 L 49.733208 25.658302 L 46.641327 25.658302 Z"/>
<path id="path3" fill="#262626" stroke="none" d="M 58.062359 26.0369 C 56.926563 26.0369 55.916973 25.721401 55.096676 25.216606 C 54.276382 24.648708 53.582283 23.891514 53.140587 22.88192 C 52.635792 21.935425 52.446491 20.799631 52.446491 19.600739 C 52.446491 18.338745 52.698891 17.266052 53.140587 16.256458 C 53.582283 15.246862 54.276382 14.489668 55.159779 13.984871 C 56.043167 13.480074 57.052765 13.164576 58.251656 13.164576 C 59.387451 13.164576 60.397045 13.480074 61.217342 14.04797 C 62.037632 14.615868 62.60553 15.373062 63.047234 16.382658 C 63.425827 17.329151 63.678226 18.464945 63.678226 19.663837 C 63.678226 20.86273 63.488926 21.998524 63.047234 22.945019 C 62.60553 23.891514 62.037632 24.711807 61.217342 25.279705 C 60.333946 25.784502 59.324352 26.0369 58.062359 26.0369 Z M 58.567154 23.323616 C 59.261253 23.323616 59.829147 23.197418 60.207748 22.88192 C 60.649445 22.566422 60.901844 22.124722 61.091141 21.556828 C 61.280441 20.98893 61.34354 20.357933 61.34354 19.600739 C 61.34354 18.843544 61.280441 18.212547 61.091141 17.64465 C 60.901844 17.076752 60.649445 16.635056 60.207748 16.319557 C 59.829147 16.004059 59.261253 15.877859 58.630257 15.877859 C 57.936157 15.877859 57.368263 16.06716 56.926563 16.382658 C 56.484871 16.698156 56.169373 17.139853 55.916973 17.707748 C 55.727669 18.275646 55.601475 18.906643 55.601475 19.600739 C 55.601475 20.294834 55.727669 20.925831 55.916973 21.493727 C 56.106274 22.061625 56.421772 22.503321 56.863464 22.818819 C 57.368263 23.134317 57.936157 23.323616 58.567154 23.323616 Z M 61.406639 25.721401 L 61.406639 17.329151 L 61.028042 17.329151 L 61.028042 9.378597 L 64.119926 9.378597 L 64.119926 25.721401 L 61.406639 25.721401 Z"/>
<path id="path4" fill="#262626" stroke="none" d="M 73.016968 26.0369 C 71.754974 26.0369 70.682289 25.784502 69.672691 25.216606 C 68.726196 24.648708 67.969002 23.954613 67.4011 23.008118 C 66.833206 22.061625 66.580811 20.98893 66.580811 19.726936 C 66.580811 18.401844 66.833206 17.202951 67.4011 16.256458 C 67.905899 15.246862 68.663094 14.489668 69.609589 13.92177 C 70.556084 13.353874 71.628784 13.101477 72.827675 13.101477 C 74.089668 13.101477 75.225456 13.416975 76.108856 13.984871 C 76.992249 14.615868 77.686348 15.436163 78.128036 16.508856 C 78.56974 17.581551 78.695938 18.906643 78.56974 20.357933 L 75.540955 20.357933 L 75.540955 19.222141 C 75.540955 17.960148 75.351662 17.076752 74.973061 16.508856 C 74.59446 15.94096 73.900368 15.68856 73.016968 15.68856 C 71.944283 15.68856 71.18708 16.004059 70.682289 16.635056 C 70.17749 17.266052 69.925087 18.212547 69.925087 19.474539 C 69.925087 20.610332 70.17749 21.493727 70.682289 22.124722 C 71.18708 22.755718 71.88118 23.071217 72.89077 23.071217 C 73.521767 23.071217 74.026566 22.945019 74.468262 22.692619 C 74.909958 22.44022 75.225456 22.061625 75.477859 21.556828 L 78.56974 22.44022 C 78.128036 23.576015 77.37085 24.45941 76.361252 25.027306 C 75.288559 25.721401 74.215866 26.0369 73.016968 26.0369 Z M 68.915497 20.421034 L 68.915497 18.149446 L 77.055351 18.149446 L 77.055351 20.421034 L 68.915497 20.421034 Z"/>
<path id="path5" fill="#262626" stroke="none" d="M 81.093727 25.721401 L 81.093727 9.378597 L 84.185608 9.378597 L 84.185608 17.329151 L 83.807007 17.329151 L 83.807007 25.721401 L 81.093727 25.721401 Z M 87.151291 26.0369 C 85.952393 26.0369 84.942802 25.721401 84.122505 25.216606 C 83.302216 24.648708 82.671219 23.891514 82.292618 22.88192 C 81.914017 21.872326 81.661621 20.799631 81.661621 19.600739 C 81.661621 18.401844 81.850922 17.266052 82.292618 16.319557 C 82.671219 15.373062 83.302216 14.552767 84.122505 13.984871 C 84.942802 13.416975 85.889297 13.101477 87.088188 13.101477 C 88.287086 13.101477 89.296677 13.353874 90.180077 13.92177 C 91.063461 14.489668 91.757561 15.246862 92.199257 16.193357 C 92.640953 17.139853 92.893356 18.275646 92.893356 19.53764 C 92.893356 20.73653 92.640953 21.872326 92.199257 22.818819 C 91.694458 23.765312 91.063461 24.585609 90.243172 25.153507 C 89.233582 25.784502 88.287086 26.0369 87.151291 26.0369 Z M 86.646492 23.323616 C 87.340591 23.323616 87.845383 23.134317 88.287086 22.818819 C 88.728783 22.503321 89.044281 22.061625 89.233582 21.493727 C 89.422874 20.925831 89.54908 20.294834 89.54908 19.600739 C 89.54908 18.906643 89.422874 18.275646 89.233582 17.707748 C 89.044281 17.139853 88.66568 16.698156 88.223984 16.382658 C 87.782288 16.06716 87.214386 15.877859 86.520294 15.877859 C 85.889297 15.877859 85.384499 16.004059 84.942802 16.319557 C 84.564201 16.635056 84.248711 17.076752 84.059402 17.64465 C 83.87011 18.212547 83.807007 18.843544 83.807007 19.600739 C 83.807007 20.357933 83.87011 20.98893 84.059402 21.556828 C 84.248711 22.124722 84.564201 22.566422 84.942802 22.88192 C 85.384499 23.134317 85.952393 23.323616 86.646492 23.323616 Z"/>
<path id="path6" fill="#262626" stroke="none" d="M 99.83432 26.1 C 98.887825 26.1 98.067528 25.910702 97.499626 25.595203 C 96.868629 25.279705 96.426933 24.838009 96.111435 24.33321 C 95.795937 23.828413 95.543541 23.323616 95.417343 22.755718 C 95.291138 22.187822 95.16494 21.746126 95.16494 21.304428 C 95.16494 20.86273 95.101845 20.547232 95.101845 20.357933 L 95.101845 13.416975 L 98.256828 13.416975 L 98.256828 19.222141 C 98.256828 19.474539 98.256828 19.853138 98.319923 20.294834 C 98.383026 20.73653 98.446121 21.178228 98.635422 21.619926 C 98.824722 22.061625 99.077118 22.44022 99.392616 22.692619 C 99.771217 23.008118 100.276016 23.134317 100.907013 23.134317 C 101.159409 23.134317 101.474907 23.071217 101.790405 23.008118 C 102.105896 22.945019 102.358307 22.755718 102.610703 22.503321 C 102.863091 22.250923 103.115501 21.935425 103.241692 21.430628 C 103.430992 20.98893 103.494087 20.357933 103.494087 19.600739 L 105.26088 20.421034 C 105.26088 21.367527 105.071579 22.314022 104.692986 23.134317 C 104.314384 23.954613 103.683388 24.648708 102.926193 25.216606 C 102.169006 25.784502 101.033211 26.1 99.83432 26.1 Z M 103.809586 25.721401 L 103.809586 21.619926 L 103.430992 21.619926 L 103.430992 13.416975 L 106.522873 13.416975 L 106.522873 25.658302 L 103.809586 25.658302 Z"/>
<path id="path7" fill="#262626" stroke="none" d="M 114.536522 26.0369 C 113.022141 26.0369 111.760147 25.658302 110.813652 24.964207 C 109.867157 24.270111 109.299263 23.260517 109.109962 21.998524 L 112.264946 21.493727 C 112.391144 22.061625 112.706642 22.503321 113.148338 22.818819 C 113.590034 23.134317 114.157936 23.323616 114.85202 23.323616 C 115.41993 23.323616 115.861618 23.197418 116.177116 23.008118 C 116.492615 22.818819 116.618813 22.503321 116.618813 22.124722 C 116.618813 21.872326 116.555717 21.683025 116.429512 21.556828 C 116.303314 21.430628 116.050919 21.241329 115.60923 21.115129 C 115.167519 20.98893 114.536522 20.799631 113.653137 20.547232 C 112.643539 20.294834 111.82325 19.979336 111.255348 19.663837 C 110.687454 19.348339 110.245758 18.969742 109.93026 18.528046 C 109.614761 18.086346 109.551659 17.51845 109.551659 16.950554 C 109.551659 16.193357 109.740959 15.499262 110.182655 14.931366 C 110.624352 14.363468 111.12915 13.92177 111.886345 13.606272 C 112.643539 13.290775 113.463837 13.164576 114.473434 13.164576 C 115.41993 13.164576 116.303314 13.290775 116.997414 13.606272 C 117.754608 13.92177 118.322502 14.300369 118.827301 14.868265 C 119.268997 15.436163 119.584496 16.06716 119.647598 16.824354 L 116.492615 17.39225 C 116.429512 16.950554 116.240227 16.571957 115.924728 16.319557 C 115.60923 16.06716 115.104431 15.877859 114.536522 15.877859 C 113.968636 15.81476 113.526939 15.94096 113.148338 16.130259 C 112.83284 16.319557 112.643539 16.571957 112.643539 16.950554 C 112.643539 17.139853 112.706642 17.329151 112.895943 17.455351 C 113.022141 17.581551 113.337639 17.770849 113.842438 17.897049 C 114.284134 18.023247 115.041321 18.275646 115.987816 18.464945 C 116.934311 18.717342 117.691505 18.969742 118.259407 19.28524 C 118.827301 19.600739 119.205902 19.979336 119.5214 20.421034 C 119.773796 20.86273 119.899994 21.430628 119.899994 22.061625 C 119.899994 23.323616 119.458298 24.33321 118.511803 25.027306 C 117.376007 25.658302 116.114014 26.0369 114.536522 26.0369 Z"/>
<path id="path1" fill="#262626" stroke="none" d="M 37.050182 20.721401 L 37.050182 8.416975 L 39.763466 8.416975 L 39.763466 11.445757 L 39.447968 11.06716 C 39.637268 10.625462 39.826565 10.246862 40.078964 9.931366 C 40.331364 9.552767 40.646858 9.300369 41.025459 9.04797 C 41.340958 8.858671 41.656456 8.732471 41.971954 8.606272 C 42.350552 8.480074 42.66605 8.416975 43.044647 8.416975 C 43.423244 8.416975 43.801842 8.416975 44.180439 8.416975 L 44.180439 11.319557 C 43.864944 11.193357 43.423244 11.193357 42.981548 11.193357 C 42.539848 11.256458 42.098152 11.319557 41.782654 11.508856 C 41.404057 11.698156 41.088558 11.887453 40.836159 12.202951 C 40.583763 12.455351 40.394463 12.833948 40.268265 13.149446 C 40.142063 13.528045 40.078964 13.969741 40.078964 14.411439 L 40.078964 20.658302 L 37.050182 20.658302 Z"/>
<path id="path2" fill="#262626" stroke="none" d="M 46.641327 6.713284 L 46.641327 4 L 49.733208 4 L 49.733208 6.713284 L 46.641327 6.713284 Z M 46.641327 20.721401 L 46.641327 8.416975 L 49.733208 8.416975 L 49.733208 20.658302 L 46.641327 20.658302 Z"/>
<path id="path3" fill="#262626" stroke="none" d="M 58.062359 21.0369 C 56.926563 21.0369 55.916973 20.721401 55.096676 20.216606 C 54.276382 19.648708 53.582283 18.891514 53.140587 17.88192 C 52.635792 16.935425 52.446491 15.799631 52.446491 14.600738 C 52.446491 13.338745 52.698891 12.266052 53.140587 11.256458 C 53.582283 10.246862 54.276382 9.489668 55.159779 8.984871 C 56.043167 8.480074 57.052765 8.164576 58.251656 8.164576 C 59.387451 8.164576 60.397045 8.480074 61.217342 9.04797 C 62.037632 9.615868 62.60553 10.373062 63.047234 11.382658 C 63.425827 12.329151 63.678226 13.464945 63.678226 14.663837 C 63.678226 15.86273 63.488926 16.998524 63.047234 17.945019 C 62.60553 18.891514 62.037632 19.711807 61.217342 20.279705 C 60.333946 20.784502 59.324352 21.0369 58.062359 21.0369 Z M 58.567154 18.323616 C 59.261253 18.323616 59.829147 18.197418 60.207748 17.88192 C 60.649445 17.566422 60.901844 17.124722 61.091141 16.556828 C 61.280441 15.98893 61.34354 15.357934 61.34354 14.600738 C 61.34354 13.843543 61.280441 13.212546 61.091141 12.64465 C 60.901844 12.076752 60.649445 11.635056 60.207748 11.319557 C 59.829147 11.004059 59.261253 10.877859 58.630257 10.877859 C 57.936157 10.877859 57.368263 11.06716 56.926563 11.382658 C 56.484871 11.698156 56.169373 12.139853 55.916973 12.707748 C 55.727669 13.275646 55.601475 13.906643 55.601475 14.600738 C 55.601475 15.294834 55.727669 15.92583 55.916973 16.493727 C 56.106274 17.061625 56.421772 17.503321 56.863464 17.818819 C 57.368263 18.134317 57.936157 18.323616 58.567154 18.323616 Z M 61.406639 20.721401 L 61.406639 12.329151 L 61.028042 12.329151 L 61.028042 4.378597 L 64.119926 4.378597 L 64.119926 20.721401 L 61.406639 20.721401 Z"/>
<path id="path4" fill="#262626" stroke="none" d="M 73.016968 21.0369 C 71.754974 21.0369 70.682289 20.784502 69.672691 20.216606 C 68.726196 19.648708 67.969002 18.954613 67.4011 18.008118 C 66.833206 17.061625 66.580811 15.98893 66.580811 14.726937 C 66.580811 13.401845 66.833206 12.202951 67.4011 11.256458 C 67.905899 10.246862 68.663094 9.489668 69.609589 8.92177 C 70.556084 8.353874 71.628784 8.101477 72.827675 8.101477 C 74.089668 8.101477 75.225456 8.416975 76.108856 8.984871 C 76.992249 9.615868 77.686348 10.436163 78.128036 11.508856 C 78.56974 12.581551 78.695938 13.906643 78.56974 15.357934 L 75.540955 15.357934 L 75.540955 14.22214 C 75.540955 12.960148 75.351662 12.076752 74.973061 11.508856 C 74.59446 10.94096 73.900368 10.68856 73.016968 10.68856 C 71.944283 10.68856 71.18708 11.004059 70.682289 11.635056 C 70.17749 12.266052 69.925087 13.212546 69.925087 14.474539 C 69.925087 15.610332 70.17749 16.493727 70.682289 17.124722 C 71.18708 17.755718 71.88118 18.071217 72.89077 18.071217 C 73.521767 18.071217 74.026566 17.945019 74.468262 17.692619 C 74.909958 17.44022 75.225456 17.061625 75.477859 16.556828 L 78.56974 17.44022 C 78.128036 18.576015 77.37085 19.45941 76.361252 20.027306 C 75.288559 20.721401 74.215866 21.0369 73.016968 21.0369 Z M 68.915497 15.421034 L 68.915497 13.149446 L 77.055351 13.149446 L 77.055351 15.421034 L 68.915497 15.421034 Z"/>
<path id="path5" fill="#262626" stroke="none" d="M 81.093727 20.721401 L 81.093727 4.378597 L 84.185608 4.378597 L 84.185608 12.329151 L 83.807007 12.329151 L 83.807007 20.721401 L 81.093727 20.721401 Z M 87.151291 21.0369 C 85.952393 21.0369 84.942802 20.721401 84.122505 20.216606 C 83.302216 19.648708 82.671219 18.891514 82.292618 17.88192 C 81.914017 16.872326 81.661621 15.799631 81.661621 14.600738 C 81.661621 13.401845 81.850922 12.266052 82.292618 11.319557 C 82.671219 10.373062 83.302216 9.552767 84.122505 8.984871 C 84.942802 8.416975 85.889297 8.101477 87.088188 8.101477 C 88.287086 8.101477 89.296677 8.353874 90.180077 8.92177 C 91.063461 9.489668 91.757561 10.246862 92.199257 11.193357 C 92.640953 12.139853 92.893356 13.275646 92.893356 14.537639 C 92.893356 15.736531 92.640953 16.872326 92.199257 17.818819 C 91.694458 18.765312 91.063461 19.585609 90.243172 20.153507 C 89.233582 20.784502 88.287086 21.0369 87.151291 21.0369 Z M 86.646492 18.323616 C 87.340591 18.323616 87.845383 18.134317 88.287086 17.818819 C 88.728783 17.503321 89.044281 17.061625 89.233582 16.493727 C 89.422874 15.92583 89.54908 15.294834 89.54908 14.600738 C 89.54908 13.906643 89.422874 13.275646 89.233582 12.707748 C 89.044281 12.139853 88.66568 11.698156 88.223984 11.382658 C 87.782288 11.06716 87.214386 10.877859 86.520294 10.877859 C 85.889297 10.877859 85.384499 11.004059 84.942802 11.319557 C 84.564201 11.635056 84.248711 12.076752 84.059402 12.64465 C 83.87011 13.212546 83.807007 13.843543 83.807007 14.600738 C 83.807007 15.357934 83.87011 15.98893 84.059402 16.556828 C 84.248711 17.124722 84.564201 17.566422 84.942802 17.88192 C 85.384499 18.134317 85.952393 18.323616 86.646492 18.323616 Z"/>
<path id="path6" fill="#262626" stroke="none" d="M 99.83432 21.1 C 98.887825 21.1 98.067528 20.910702 97.499626 20.595203 C 96.868629 20.279705 96.426933 19.838009 96.111435 19.33321 C 95.795937 18.828413 95.543541 18.323616 95.417343 17.755718 C 95.291138 17.187822 95.16494 16.746126 95.16494 16.304428 C 95.16494 15.86273 95.101845 15.547232 95.101845 15.357934 L 95.101845 8.416975 L 98.256828 8.416975 L 98.256828 14.22214 C 98.256828 14.474539 98.256828 14.853137 98.319923 15.294834 C 98.383026 15.736531 98.446121 16.178228 98.635422 16.619926 C 98.824722 17.061625 99.077118 17.44022 99.392616 17.692619 C 99.771217 18.008118 100.276016 18.134317 100.907013 18.134317 C 101.159409 18.134317 101.474907 18.071217 101.790405 18.008118 C 102.105896 17.945019 102.358307 17.755718 102.610703 17.503321 C 102.863091 17.250923 103.115501 16.935425 103.241692 16.430628 C 103.430992 15.98893 103.494087 15.357934 103.494087 14.600738 L 105.26088 15.421034 C 105.26088 16.367527 105.071579 17.314022 104.692986 18.134317 C 104.314384 18.954613 103.683388 19.648708 102.926193 20.216606 C 102.169006 20.784502 101.033211 21.1 99.83432 21.1 Z M 103.809586 20.721401 L 103.809586 16.619926 L 103.430992 16.619926 L 103.430992 8.416975 L 106.522873 8.416975 L 106.522873 20.658302 L 103.809586 20.658302 Z"/>
<path id="path7" fill="#262626" stroke="none" d="M 114.536522 21.0369 C 113.022141 21.0369 111.760147 20.658302 110.813652 19.964207 C 109.867157 19.270111 109.299263 18.260517 109.109962 16.998524 L 112.264946 16.493727 C 112.391144 17.061625 112.706642 17.503321 113.148338 17.818819 C 113.590034 18.134317 114.157936 18.323616 114.85202 18.323616 C 115.41993 18.323616 115.861618 18.197418 116.177116 18.008118 C 116.492615 17.818819 116.618813 17.503321 116.618813 17.124722 C 116.618813 16.872326 116.555717 16.683025 116.429512 16.556828 C 116.303314 16.430628 116.050919 16.241329 115.60923 16.115129 C 115.167519 15.98893 114.536522 15.799631 113.653137 15.547232 C 112.643539 15.294834 111.82325 14.979336 111.255348 14.663837 C 110.687454 14.348339 110.245758 13.969741 109.93026 13.528045 C 109.614761 13.086347 109.551659 12.51845 109.551659 11.950554 C 109.551659 11.193357 109.740959 10.499262 110.182655 9.931366 C 110.624352 9.363468 111.12915 8.92177 111.886345 8.606272 C 112.643539 8.290775 113.463837 8.164576 114.473434 8.164576 C 115.41993 8.164576 116.303314 8.290775 116.997414 8.606272 C 117.754608 8.92177 118.322502 9.300369 118.827301 9.868265 C 119.268997 10.436163 119.584496 11.06716 119.647598 11.824354 L 116.492615 12.39225 C 116.429512 11.950554 116.240227 11.571957 115.924728 11.319557 C 115.60923 11.06716 115.104431 10.877859 114.536522 10.877859 C 113.968636 10.81476 113.526939 10.94096 113.148338 11.130259 C 112.83284 11.319557 112.643539 11.571957 112.643539 11.950554 C 112.643539 12.139853 112.706642 12.329151 112.895943 12.455351 C 113.022141 12.581551 113.337639 12.770849 113.842438 12.897049 C 114.284134 13.023247 115.041321 13.275646 115.987816 13.464945 C 116.934311 13.717343 117.691505 13.969741 118.259407 14.285239 C 118.827301 14.600738 119.205902 14.979336 119.5214 15.421034 C 119.773796 15.86273 119.899994 16.430628 119.899994 17.061625 C 119.899994 18.323616 119.458298 19.33321 118.511803 20.027306 C 117.376007 20.658302 116.114014 21.0369 114.536522 21.0369 Z"/>
</g>
</g>
<g id="g3">
<g id="g4">
<clipPath id="clipPath1">
<path d="M -0.1 5 L 28.9 5 L 28.9 34 L -0.1 34 Z"/>
<path d="M -0.1 0 L 28.9 0 L 28.9 29 L -0.1 29 Z"/>
</clipPath>
<g id="g5" clip-path="url(#clipPath1)">
<path id="path8" fill="#2082c4" stroke="none" d="M 10.6 5.700001 L 20.5 6.5 C 25.200001 6.799999 28.700001 10.9 28.299999 15.6 L 27.5 25.5 C 27.1 30.200001 23.1 33.700001 18.4 33.299999 L 8.5 32.5 C 3.9 32.200001 0.4 28 0.7 23.299999 L 1.5 13.4 C 1.9 8.799999 6 5.299999 10.6 5.700001 Z"/>
<path id="path8" fill="#2082c4" stroke="none" d="M 10.6 0.700001 L 20.5 1.5 C 25.200001 1.799999 28.700001 5.9 28.299999 10.6 L 27.5 20.5 C 27.1 25.200001 23.1 28.700001 18.4 28.299999 L 8.5 27.5 C 3.9 27.200001 0.4 23 0.7 18.299999 L 1.5 8.4 C 1.9 3.799999 6 0.299999 10.6 0.700001 Z"/>
</g>
</g>
</g>
<path id="path9" fill="#ffffff" stroke="none" d="M 9.8 27.200001 C 9 27.200001 8.7 26.9 8.7 26.299999 C 8.7 25.9 8.7 25.5 8.7 25.1 C 8.7 25 8.6 25 8.6 24.799999 C 8.1 24.200001 7.8 23.4 7.9 22.700001 C 8 20.200001 7.9 17.700001 7.9 15.1 C 7.9 14.700001 8 14.299999 8 14 C 8.3 13.1 9 12.5 9.9 12.200001 C 11 11.9 12 11.700001 13.2 11.799999 C 14.6 11.799999 16 11.799999 17.299999 11.9 C 18.299999 12 19.4 12.200001 20.200001 13 C 20.299999 13.1 20.5 13.299999 20.5 13.4 C 20.9 14 21.1 14.9 21 15.700001 C 21 16.9 21 18 21 19.1 L 21 19.1 C 21 20.4 21 21.6 21 23 C 21 23.6 20.700001 24.4 20.299999 24.9 C 20 25 20 25.200001 20 25.4 C 20 25.799999 20 26.200001 20 26.4 C 20 26.700001 19.9 27 19.6 27.200001 C 19.5 27.299999 19.299999 27.299999 19.200001 27.299999 C 18.9 27.299999 18.6 27.299999 18.200001 27.299999 C 17.700001 27.299999 17.4 26.9 17.299999 26.5 C 17.299999 26.299999 17.299999 26.1 17.299999 25.9 C 17.299999 25.799999 17.200001 25.799999 17.200001 25.700001 L 17.200001 25.700001 L 11.3 25.700001 C 11.2 25.700001 11.1 25.700001 11.2 25.9 C 11.2 26 11.2 26.299999 11.2 26.4 C 11.2 26.9 10.8 27.299999 10.4 27.299999 M 14.4 15.1 L 14.4 15.1 C 12.8 15.1 11.3 15.1 9.8 15.1 C 9.7 15.1 9.5 15.200001 9.5 15.4 C 9.5 16.5 9.5 17.700001 9.5 18.9 C 9.5 19 9.6 19.200001 9.8 19.200001 C 12.9 19.200001 15.9 19.200001 19 19.200001 C 19.200001 19.200001 19.299999 19.1 19.299999 18.9 C 19.299999 17.799999 19.299999 16.6 19.299999 15.5 C 19.299999 15.4 19.200001 15.200001 19 15.200001 L 19 15.200001 C 17.4 15.1 15.9 15.1 14.4 15.1 Z M 18.200001 24 C 18.799999 23.9 19.299999 23.4 19.200001 22.700001 C 19.1 22 18.6 21.6 17.9 21.700001 C 17.799999 21.700001 17.799999 21.700001 17.6 21.700001 C 17 21.9 16.6 22.6 16.799999 23.200001 C 17.1 23.6 17.6 24 18.200001 24 L 18.200001 24 Z M 10.5 24 C 11.1 24.1 11.8 23.700001 11.9 23 C 12 22.4 11.6 21.700001 11 21.6 C 10.4 21.5 9.7 21.9 9.6 22.6 C 9.5 22.700001 9.5 22.700001 9.5 22.9 C 9.6 23.4 10 23.9 10.5 24 L 10.5 24 Z M 13 23.1 C 12.9 23.6 13.3 24.1 13.7 24.299999 C 14.1 24.6 14.8 24.4 15.2 24.200001 C 15.6 23.9 15.8 23.6 15.7 23.1 L 15.1 23.1 C 15 23.6 14.7 23.9 14.3 23.9 C 13.9 23.9 13.7 23.700001 13.5 23.1 C 13.6 23.1 13 23.1 13 23.1 Z"/>
<path id="path9" fill="#ffffff" stroke="none" d="M 9.8 22.200001 C 9 22.200001 8.7 21.9 8.7 21.299999 C 8.7 20.9 8.7 20.5 8.7 20.1 C 8.7 20 8.6 20 8.6 19.799999 C 8.1 19.200001 7.8 18.4 7.9 17.700001 C 8 15.200001 7.9 12.700001 7.9 10.1 C 7.9 9.700001 8 9.299999 8 9 C 8.3 8.1 9 7.5 9.9 7.200001 C 11 6.9 12 6.700001 13.2 6.799999 C 14.6 6.799999 16 6.799999 17.299999 6.9 C 18.299999 7 19.4 7.200001 20.200001 8 C 20.299999 8.1 20.5 8.299999 20.5 8.4 C 20.9 9 21.1 9.9 21 10.700001 C 21 11.9 21 13 21 14.1 L 21 14.1 C 21 15.4 21 16.6 21 18 C 21 18.6 20.700001 19.4 20.299999 19.9 C 20 20 20 20.200001 20 20.4 C 20 20.799999 20 21.200001 20 21.4 C 20 21.700001 19.9 22 19.6 22.200001 C 19.5 22.299999 19.299999 22.299999 19.200001 22.299999 C 18.9 22.299999 18.6 22.299999 18.200001 22.299999 C 17.700001 22.299999 17.4 21.9 17.299999 21.5 C 17.299999 21.299999 17.299999 21.1 17.299999 20.9 C 17.299999 20.799999 17.200001 20.799999 17.200001 20.700001 L 17.200001 20.700001 L 11.3 20.700001 C 11.2 20.700001 11.1 20.700001 11.2 20.9 C 11.2 21 11.2 21.299999 11.2 21.4 C 11.2 21.9 10.8 22.299999 10.4 22.299999 M 14.4 10.1 L 14.4 10.1 C 12.8 10.1 11.3 10.1 9.8 10.1 C 9.7 10.1 9.5 10.200001 9.5 10.4 C 9.5 11.5 9.5 12.700001 9.5 13.9 C 9.5 14 9.6 14.200001 9.8 14.200001 C 12.9 14.200001 15.9 14.200001 19 14.200001 C 19.200001 14.200001 19.299999 14.1 19.299999 13.9 C 19.299999 12.799999 19.299999 11.6 19.299999 10.5 C 19.299999 10.4 19.200001 10.200001 19 10.200001 L 19 10.200001 C 17.4 10.1 15.9 10.1 14.4 10.1 Z M 18.200001 19 C 18.799999 18.9 19.299999 18.4 19.200001 17.700001 C 19.1 17 18.6 16.6 17.9 16.700001 C 17.799999 16.700001 17.799999 16.700001 17.6 16.700001 C 17 16.9 16.6 17.6 16.799999 18.200001 C 17.1 18.6 17.6 19 18.200001 19 L 18.200001 19 Z M 10.5 19 C 11.1 19.1 11.8 18.700001 11.9 18 C 12 17.4 11.6 16.700001 11 16.6 C 10.4 16.5 9.7 16.9 9.6 17.6 C 9.5 17.700001 9.5 17.700001 9.5 17.9 C 9.6 18.4 10 18.9 10.5 19 L 10.5 19 Z M 13 18.1 C 12.9 18.6 13.3 19.1 13.7 19.299999 C 14.1 19.6 14.8 19.4 15.2 19.200001 C 15.6 18.9 15.8 18.6 15.7 18.1 L 15.1 18.1 C 15 18.6 14.7 18.9 14.3 18.9 C 13.9 18.9 13.7 18.700001 13.5 18.1 C 13.6 18.1 13 18.1 13 18.1 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,8 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="15.7167" height="16.8839" viewBox="0 0 15.7167 16.8839" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group">
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M15.7067 6.84388C15.7067 8.37499 14.7644 9.67721 13.4522 10.1528L14.2456 11.4672L13.0711 12.9117L14.2456 14.3239L12.3522 16.865L11.0178 15.4405V12.5628V10.0561C9.83111 9.51721 9.00111 8.28166 9.00111 6.84388C9.00111 4.91055 10.5022 3.34277 12.3544 3.34277C14.2056 3.34277 15.7067 4.91055 15.7067 6.84388ZM12.3533 7.37944C12.8 7.37944 13.1622 7.00166 13.1622 6.53499C13.1622 6.06833 12.8 5.68944 12.3533 5.68944C11.9067 5.68944 11.5444 6.06721 11.5444 6.53499C11.5433 7.00166 11.9067 7.37944 12.3533 7.37944Z" fill="white"/>
<path id="Vector_2" fill-rule="evenodd" clip-rule="evenodd" d="M15.7167 6.85388C15.7167 8.36721 14.8 9.65721 13.5133 10.1505L14.2444 11.4661L13.1633 12.9105L14.2444 14.3228L12.3522 16.8839V14.0061V10.365V7.37944C12.7989 7.37944 13.1611 7.00055 13.1611 6.53499C13.1611 6.06833 12.7989 5.68944 12.3522 5.68944V3.34277C14.2111 3.34277 15.7167 4.91499 15.7167 6.85388Z" fill="#DAD9D9"/>
<path id="Vector_3" fill-rule="evenodd" clip-rule="evenodd" d="M9.92 10.6928C8.83667 9.80388 8.10889 8.43721 8.00889 6.88499H2.20444C0.986667 6.88499 7.94729e-08 7.8861 7.94729e-08 9.12166V11.9183C7.94729e-08 12.5361 0.493334 13.0372 1.10222 13.0372H8.81778C9.42667 13.0372 9.92 12.5361 9.92 11.9183V10.6928Z" fill="white"/>
<path id="Vector_4" d="M4.68889 6.21499C4.42 6.16388 4.15333 6.1161 3.89889 6.00833C2.93778 5.60499 2.37778 4.86166 2.19667 3.81166C2.07222 3.09277 2.13111 2.38166 2.42222 1.70944C2.83556 0.753881 3.57667 0.236104 4.55 0.0605481C5.13222 -0.0438963 5.71222 -0.0205632 6.27222 0.188326C7.11556 0.501659 7.68111 1.1061 7.94222 1.99277C8.20667 2.88721 8.16778 3.78166 7.76889 4.62833C7.35556 5.51277 6.63444 5.98721 5.72222 6.17277C5.64667 6.18833 5.57 6.20277 5.49444 6.21833C5.22667 6.21499 4.95778 6.21499 4.68889 6.21499Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,22 +1,27 @@
import { useEffect, useRef, useState } from 'react';
import { Link, NavLink, useLocation } from 'react-router-dom';
import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck } from 'lucide-react';
import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck, CalendarRange, Radio, Monitor } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import BrandLogo from './BrandLogo';
import ProfileMenu from './ProfileMenu';
import CitySelector from './CitySelector';
import { useCity } from '../contexts/CityContext';
const navigationGroups = [
{ label: 'Обзор', items: [{ to: '/', label: 'Главная', icon: LayoutDashboard }] },
{ label: 'Транспорт', items: [
{ to: '/routes', label: 'Маршруты', icon: Bus },
{ to: '/stops', label: 'Остановки', icon: MapPin },
{ to: '/live-map', label: 'Живая карта', icon: Radio },
{ to: '/schedules', label: 'Расписания', icon: CalendarDays },
{ to: '/schedule-center', label: 'Центр расписаний', icon: CalendarRange, adminOnly: true },
{ to: '/schedule-import', label: 'Импорт расписаний', icon: Download, adminOnly: true },
{ to: '/holidays', label: 'Праздники', icon: Gift },
] },
{ label: 'Управление', items: [
{ to: '/quality', label: 'Контроль качества', icon: ShieldCheck, adminOnly: true },
{ to: '/alerts', label: 'Уведомления', icon: Bell },
{ to: '/devices', label: 'Устройства', icon: Monitor, adminOnly: true },
{ to: '/users', label: 'Пользователи', icon: Users, adminOnly: true },
{ to: '/profile', label: 'Профиль', icon: User },
] },
@@ -24,6 +29,7 @@ const navigationGroups = [
export default function AppLayout({ children }) {
const { isAdmin } = useAuth();
const { activeCity } = useCity();
const { pathname } = useLocation();
const [menuOpen, setMenuOpen] = useState(false);
const menuButton = useRef(null);
@@ -55,6 +61,7 @@ export default function AppLayout({ children }) {
<div id="sidebar-content" className={`sidebar-content ${menuOpen ? 'is-open' : ''}`}>
<ProfileMenu />
<CitySelector />
<nav aria-label="Основная навигация" className="sidebar-navigation">
{navigationGroups.map(group => (
<section key={group.label}>
@@ -72,11 +79,11 @@ export default function AppLayout({ children }) {
))}
</nav>
<div className="sidebar-footer">
<p className="px-3 pt-4 text-xs text-gray-500">Общественный транспорт Полоцка</p>
<p className="px-3 pt-4 text-xs text-gray-500">Общественный транспорт {activeCity?.name}</p>
</div>
</div>
</aside>
<main id="main-content" tabIndex={-1} className={`app-main ${pathname === '/' ? 'app-main-dashboard' : ['/routes', '/schedules'].includes(pathname) ? 'app-main-wide' : ''}`}>
<main id="main-content" tabIndex={-1} className={`app-main ${pathname === '/' ? 'app-main-dashboard' : ['/routes', '/schedules', '/schedule-center', '/live-map'].includes(pathname) ? 'app-main-wide' : ''}`}>
<div className="app-content">{children}</div>
</main>
</div>

View File

@@ -0,0 +1,40 @@
export const passengerDefaults = { infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true, places: [], fareRules: [], fareByType: [] };
export const cityFareDefaults = cityId => cityId === 1 ? [
{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
{ cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
] : [];
const today = () => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk' }).format(Date.now());
export function BoardPassengerSettings({ settings, stops, routes, cityId, set }) {
const editPlace = (index, patch) => set('places', settings.places.map((value, i) => i === index ? { ...value, ...patch } : value));
const editRule = (index, patch) => set('fareRules', settings.fareRules.map((value, i) => i === index ? { ...value, ...patch } : value));
return <div className="device-passenger-settings">
<h3>Важные места</h3><p className="device-night-hint">До трёх остановок назначения. Укажите короткое название для пассажиров; табло найдёт ближайший прямой рейс с учётом его конечной и направления.</p>
{settings.places.map((place, index) => <fieldset className="device-passenger-editor" key={index}><legend>Место {index + 1}</legend>
<label>Остановка назначения<select aria-label={`Остановка места ${index + 1}`} required value={place.stopId || ''} onChange={e => { const stop = stops.find(s => s.id === Number(e.target.value)); editPlace(index, { stopId: Number(e.target.value), name: stop?.name.slice(0, 48) || '' }); }}>
<option value="">Выберите остановку</option>{stops.filter(s => s.id !== settings.stopId).map(stop => <option key={stop.id} value={stop.id} disabled={settings.places.some((p, i) => i !== index && p.stopId === stop.id)}>{stop.name}{stop.description ? ` · ${stop.description}` : ''}</option>)}
</select></label><label>Название на табло<input aria-label={`Название места ${index + 1}`} required maxLength={48} value={place.name} onChange={e => editPlace(index, { name: e.target.value })} /></label>
<button type="button" onClick={() => set('places', settings.places.filter((_, i) => i !== index))}>Удалить место {index + 1}</button>
</fieldset>)}
<button type="button" disabled={settings.places.length >= 3 || !stops.length} onClick={() => set('places', [...settings.places, { stopId: 0, name: '' }])}>Добавить место</button>
<h3>Оплата поездки</h3><p className="device-night-hint">Основные тарифы по типу транспорта. Только подтверждённые цена с валютой, способы оплаты и дата проверки. Без подходящего тарифа карточка скрыта.</p>
{['bus', 'minibus'].map(type => {
const rule = settings.fareByType.find(r => r.type === type && r.cityId === cityId), label = type === 'bus' ? 'автобуса' : 'маршрутки';
const edit = patch => set('fareByType', settings.fareByType.map(r => r.type === type && r.cityId === cityId ? { ...r, ...patch } : r));
return <fieldset className="device-passenger-editor" key={type}><legend>Тариф {label}</legend>
<label className="device-check"><input type="checkbox" checked={!!rule} onChange={e => set('fareByType', [...settings.fareByType.filter(r => r.type !== type), ...(e.target.checked ? [{ cityId, type, price: '', payment: '', checkedAt: today() }] : [])])} />Использовать тариф {label}</label>
{rule && <><div className="device-form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость ${label}`} required maxLength={40} value={rule.price} onChange={e => edit({ price: e.target.value })} /></label><label>Проверено<input aria-label={`Дата проверки ${label}`} required type="date" max={today()} value={rule.checkedAt} onChange={e => edit({ checkedAt: e.target.value })} /></label></div>
<label>Способы оплаты<input aria-label={`Способы оплаты ${label}`} required maxLength={100} value={rule.payment} onChange={e => edit({ payment: e.target.value })} /></label></>}
</fieldset>;
})}
<h3>Исключения для маршрутов</h3><p className="device-night-hint">Индивидуальный тариф заменяет основной тариф типа транспорта.</p>
{settings.fareRules.map((rule, index) => <fieldset className="device-passenger-editor" key={index}><legend>Тариф {index + 1}</legend>
<label>Маршрут<select aria-label={`Маршрут тарифа ${index + 1}`} required value={rule.routeId || ''} onChange={e => editRule(index, { routeId: Number(e.target.value) })}>
<option value="">Выберите маршрут</option>{!routes.some(r => r.id === rule.routeId) && rule.routeId > 0 && <option value={rule.routeId}>Маршрут {rule.routeId} · сохранённый тариф</option>}
{routes.map(route => <option key={route.id} value={route.id} disabled={settings.fareRules.some((r, i) => i !== index && r.routeId === route.id)}>№{route.route_number} · {route.name}</option>)}
</select></label><div className="device-form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость тарифа ${index + 1}`} required maxLength={40} value={rule.price} onChange={e => editRule(index, { price: e.target.value })} /></label><label>Проверено<input aria-label={`Дата проверки тарифа ${index + 1}`} required type="date" max={today()} value={rule.checkedAt} onChange={e => editRule(index, { checkedAt: e.target.value })} /></label></div>
<label>Способы оплаты<input aria-label={`Способы оплаты тарифа ${index + 1}`} required maxLength={100} value={rule.payment} onChange={e => editRule(index, { payment: e.target.value })} /></label>
<button type="button" onClick={() => set('fareRules', settings.fareRules.filter((_, i) => i !== index))}>Удалить тариф {index + 1}</button>
</fieldset>)}
<button type="button" disabled={settings.fareRules.length >= 32 || !routes.length} onClick={() => set('fareRules', [...settings.fareRules, { routeId: 0, price: '', payment: '', checkedAt: today() }])}>Добавить тариф</button>
</div>;
}

View File

@@ -1,5 +1,5 @@
import logo from '../assets/logo-with-text.svg';
export default function BrandLogo({ className = '' }) {
return <img src={logo} alt="RideBus" width="168" height="34" className={`brand-logo ${className}`} />;
return <img src={logo} alt="RideBus" width="125" height="34" className={`brand-logo ${className}`} />;
}

View File

@@ -0,0 +1,59 @@
import { useState } from 'react';
import { Check, ChevronDown, MapPinned, Plus } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useCity } from '../contexts/CityContext';
import { getApiOrigin } from '../utils/apiBaseUrl';
const API_ORIGIN = getApiOrigin();
function CityEmblem({ city, className = '' }) {
const avatarUrl = city?.avatar_url
? (city.avatar_url.startsWith('http') ? city.avatar_url : `${API_ORIGIN}${city.avatar_url}`)
: null;
return <span className={`city-emblem ${className}`} aria-hidden="true">
{avatarUrl && <img src={avatarUrl} alt="" onError={event => { event.currentTarget.style.display = 'none'; }} />}
<span className="city-emblem-fallback">{city?.emblem || <MapPinned size={20} />}</span>
</span>;
}
export default function CitySelector() {
const { cities, activeCity, selectCity } = useCity();
const { isAdmin } = useAuth();
const { pathname } = useLocation();
const [open, setOpen] = useState(false);
if (!activeCity) return null;
return <div className="city-selector">
<button type="button" className="sidebar-city-card" aria-haspopup="listbox" aria-expanded={open}
onClick={() => setOpen(current => !current)}>
<CityEmblem city={activeCity} />
<span className="min-w-0 flex-1 text-left">
<span className="block truncate font-medium">{activeCity.name}</span>
<span className="block mt-0.5 text-xs text-gray-500">Транспортная сеть</span>
</span>
<ChevronDown size={14} aria-hidden="true" className={`shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && <div className="city-switcher" role="listbox" aria-label="Выбор города">
<p className="city-switcher-heading">Города</p>
<div className="space-y-1">
{cities.map(city => <button type="button" key={city.id} role="option" aria-selected={city.id === activeCity.id}
className="city-switcher-option" onClick={() => { selectCity(city); setOpen(false); }}>
<CityEmblem city={city} className="is-small" />
<span className="min-w-0 flex-1 truncate">{city.name}</span>
{city.id === activeCity.id && <Check size={16} className="text-primary-600" aria-hidden="true" />}
</button>)}
</div>
{isAdmin && <>
<div className="city-switcher-divider" />
<Link to="/cities" className="city-switcher-option city-switcher-add" onClick={() => setOpen(false)}>
<Plus size={18} aria-hidden="true" /><span>{pathname === '/cities' ? 'Управление городами' : 'Добавить город'}</span>
</Link>
</>}
</div>}
</div>;
}
export { CityEmblem };

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

@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
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: 'Нужна настройка аккаунта',
waiting: 'Ожидание подключения', connecting: 'Подключение к Telegram', ready: 'Канал подключён', error: 'Ошибка подключения'
};
const errorLabels = {
SESSION_MISSING: 'Не найден файл авторизации. Выполните авторизацию аккаунта на сервере.',
AUTH_REQUIRED: 'Требуется повторная авторизация Telegram-аккаунта.',
AUTH_KEY_UNREGISTERED: 'Авторизация отозвана. Подключите аккаунт повторно.',
SESSION_REVOKED: 'Авторизация отозвана. Подключите аккаунт повторно.',
SESSION_FILE_PERMISSIONS: 'Файл авторизации должен быть доступен только владельцу.',
SESSION_FILE_IN_CHECKOUT: 'Перенесите файл авторизации за пределы проекта.',
USER_ACCOUNT_REQUIRED: 'Нужен отдельный пользовательский аккаунт Telegram.',
CHANNEL_PRIVATE: 'Канал недоступен этому аккаунту.', CHANNEL_INVALID: 'Проверьте имя канала в настройках сервера.',
USERNAME_NOT_OCCUPIED: 'Канал с таким именем не найден.',
FLOOD_WAIT: 'Telegram ограничил запросы. Импорт продолжится после паузы.',
HISTORY_LIMIT: 'В канале слишком много сообщений за последние три дня. Нужна проверка импорта.',
IMPORT_FAILED: 'Проверьте соединение с Telegram и миграцию базы данных.'
};
const formatTime = value => value ? new Date(value).toLocaleString('ru-RU', { timeZone: 'Europe/Minsk' }) : 'ещё не выполнялось';
export default function TelegramImportPanel({ routes, onChanged }) {
const { activeCity } = useCity();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(null);
const [showHistory, setShowHistory] = useState(false);
const load = async () => {
try { setData((await telegramImportApi.getAll()).data); setError(''); }
catch (err) { setError(err.response?.data?.error || 'Не удалось загрузить импорт Telegram.'); }
};
useEffect(() => {
load();
const timer = setInterval(load, 30_000);
return () => clearInterval(timer);
}, []);
const sync = async () => {
setBusy(true);
try { await telegramImportApi.sync(); await load(); onChanged(); }
catch (err) { setError(err.response?.data?.error || 'Не удалось прочитать канал.'); }
finally { setBusy(false); }
};
const review = async (row, values) => {
setBusy(true);
try {
await telegramImportApi.review(row.id, { ...values, content_hash: row.content_hash });
setEditing(null); await load(); onChanged();
} catch (err) { setError(err.response?.data?.error || 'Не удалось сохранить решение.'); }
finally { setBusy(false); }
};
const status = data?.status;
const configuredCity = status?.citySlug === activeCity?.slug;
const pending = data?.messages.filter(m => m.status === 'review') || [];
const rows = showHistory ? data?.messages || [] : pending;
return (
<section className="card mb-6" aria-label="Уведомления из Telegram">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><Send className="h-4 w-4 text-primary-600" />Уведомления из Telegram</h2>
<p className="text-sm text-gray-600 mt-1">
{status ? configuredCity ? connectionLabels[status.connection] : 'Для выбранного города источник не настроен' : 'Загрузка источника…'}
{status && <> · @{status.channel}</>}
</p>
{status?.connection === 'disabled' && <p className="text-sm text-gray-500 mt-1">Подключите отдельный аккаунт на сервере. Сообщения об отменах и опозданиях будут поступать автоматически.</p>}
{status?.enabled && <p className="text-xs text-gray-500 mt-1">Последняя проверка: {formatTime(status.lastSyncAt)} · {status.autoPublish ? 'Однозначные сообщения публикуются автоматически' : 'Публикация после проверки'}</p>}
{status?.lastError && <p className="text-sm text-amber-700 mt-2">{errorLabels[status.lastError] || 'Не удалось подключиться к Telegram.'}</p>}
{status?.retryAt && <p className="text-xs text-gray-500 mt-1">Следующая попытка не раньше: {formatTime(status.retryAt)}</p>}
<p className="text-xs text-gray-500 mt-2">Срок действия — до полуночи указанного дня по Минску. АВ/ВОКЗАЛ распознаётся как Автовокзал.</p>
</div>
<button type="button" className="btn-secondary flex items-center justify-center shrink-0" onClick={sync} disabled={busy || !configuredCity || !status?.enabled || status?.missing.length > 0 || status?.syncing}>
<RefreshCw className={`h-4 w-4 mr-2 ${busy || status?.syncing ? 'animate-spin' : ''}`} />Проверить канал
</button>
</div>
{error && <p role="alert" className="text-sm text-red-700 mt-3">{error}</p>}
<TelegramStopAliases onChanged={load} />
{data && (
<div className="mt-4 border-t pt-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-gray-600">На проверке: <strong>{pending.length}</strong></p>
<button className="text-sm text-primary-600 hover:underline" onClick={() => setShowHistory(!showHistory)}>{showHistory ? 'Только на проверке' : 'Показать историю'}</button>
</div>
{rows.map(row => (
<div key={row.id} className="mt-3 rounded-lg border border-gray-200 p-3">
<div className="flex flex-wrap justify-between gap-2 text-xs text-gray-500">
<span>{formatTime(row.published_at)} · {{ review: 'На проверке', published: 'Опубликовано', dismissed: 'Отклонено', expired: 'День уведомления завершён' }[row.status]}</span>
<a href={row.source_url} target="_blank" rel="noreferrer" className="text-primary-600 inline-flex items-center gap-1">Оригинал<ExternalLink className="h-3 w-3" /></a>
</div>
<p className="whitespace-pre-wrap break-words text-sm text-gray-700 mt-2">{row.raw_text || 'Вложение без текста — откройте оригинал.'}</p>
{row.status === 'review' && <>
{row.reasons.map(reason => <p key={reason} className="text-xs text-amber-700 mt-2">{reason}</p>)}
<div className="flex flex-wrap gap-2 mt-3">
<button className="btn-primary text-sm" disabled={busy} onClick={() => setEditing(row)}>Проверить и опубликовать</button>
<button className="btn-secondary text-sm" disabled={busy} onClick={() => review(row, { action: 'dismiss' })}>Отклонить</button>
</div>
</>}
</div>
))}
{!rows.length && <p className="text-sm text-gray-500 mt-3">{showHistory ? 'Сообщений об отменах и опозданиях пока нет.' : 'Нет сообщений, требующих проверки.'}</p>}
</div>
)}
{editing && <TelegramReviewForm key={editing.id} row={editing} routes={routes} busy={busy} onCancel={() => setEditing(null)} onSubmit={values => review(editing, values)} />}
</section>
);
}
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 initialNumbers = [...new Set((row.parsed?.selectedRouteNumbers || row.parsed?.groups?.map(g => g.routeNumber) || [])
.map(normalizeRouteNumber))];
const initialType = row.parsed?.alertType || 'cancellation';
const draftFor = (numbers, date, alertType) => {
const delay = alertType === 'delay';
const possible = delay && row.parsed?.delayPossible;
const prefix = delay ? possible ? 'Возможное опоздание' : 'Задержка' : 'Отмена';
const description = delay ? possible ? 'возможны опоздания рейсов' : 'с опозданием выполняются рейсы' : 'не выполняются рейсы';
const drafts = numbers.map(number => row.drafts?.find(d => normalizeRouteNumber(d.routeNumber) === number));
const complete = drafts.length && drafts.every(Boolean);
return {
title: `${prefix} отдельных рейсов${numbers.length ? ` ${numbers.map(n => `№${n}`).join(', ')}` : ''}`,
message: complete ? `${date.split('-').reverse().join('.')}: ${description}:\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 initialDate = row.parsed?.serviceDate || publicationDate;
const [form, setForm] = useState({
action: 'publish', alert_type: initialType, service_date: initialDate, route_numbers: initialNumbers,
...draftFor(initialNumbers, initialDate, initialType)
});
const [editedText, setEditedText] = useState(false);
const change = e => {
const { name, value } = e.target;
if (name === 'title' || name === 'message') setEditedText(true);
setForm(current => ({ ...current, [name]: value,
...(name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value) ? draftFor(current.route_numbers, value, current.alert_type) : {}),
...(name === 'alert_type' && !editedText ? draftFor(current.route_numbers, current.service_date, value) : {}) }));
};
const changeRoutes = route_numbers => setForm(current => ({ ...current, route_numbers,
...(!editedText ? draftFor(route_numbers, current.service_date, current.alert_type) : {}) }));
return (
<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>
<label className="text-sm block">Тип уведомления<select className="input mt-1 w-full" name="alert_type" value={form.alert_type} onChange={change} disabled={busy}>
<option value="cancellation">Отмена рейсов</option><option value="delay">Опоздание рейсов</option>
</select></label>
<RouteNumberPicker routes={routes} value={form.route_numbers} onChange={changeRoutes} disabled={busy} required />
<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>
</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">Текст уведомления<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>
<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>
);
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react';
import { BookOpen, Pencil, Trash2, X } from 'lucide-react';
import { stopsApi, telegramImportApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
const emptyForm = { alias: '', stop_id: '' };
export default function TelegramStopAliases({ onChanged }) {
const { activeCity } = useCity();
const [open, setOpen] = useState(false);
const [aliases, setAliases] = useState([]);
const [stops, setStops] = useState([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
useEffect(() => {
setEditingId(null); setForm(emptyForm); setNotice(''); setError('');
setAliases([]); setStops([]);
if (!open) return;
let current = true;
setLoading(true);
Promise.all([telegramImportApi.getStopAliases(), stopsApi.getAll()])
.then(([dictionary, catalog]) => { if (current) { setAliases(dictionary.data); setStops(catalog.data); } })
.catch(err => { if (current) setError(err.response?.data?.error || 'Не удалось загрузить словарь остановок.'); })
.finally(() => { if (current) setLoading(false); });
return () => { current = false; };
}, [open, activeCity?.id]);
const refresh = async () => { setAliases((await telegramImportApi.getStopAliases()).data); onChanged(); };
const save = async event => {
event.preventDefault(); setBusy(true); setError(''); setNotice('');
try {
if (editingId === null) await telegramImportApi.createStopAlias(form);
else await telegramImportApi.updateStopAlias(editingId, form);
setEditingId(null); setForm(emptyForm); await refresh();
setNotice('Соответствие сохранено. При следующем чтении канала сообщения на проверке за последние три дня будут разобраны заново.');
} catch (err) { setError(err.response?.data?.error || 'Не удалось сохранить соответствие.'); }
finally { setBusy(false); }
};
const remove = async id => {
setBusy(true); setError(''); setNotice('');
try {
await telegramImportApi.deleteStopAlias(id);
if (editingId === id) { setEditingId(null); setForm(emptyForm); }
await refresh(); setNotice('Соответствие удалено.');
} catch (err) { setError(err.response?.data?.error || 'Не удалось удалить соответствие.'); }
finally { setBusy(false); }
};
return <div className="mt-4 border-t pt-3">
<button type="button" className="text-sm text-primary-600 inline-flex items-center gap-2 hover:underline" aria-expanded={open} onClick={() => setOpen(!open)} disabled={busy}>
<BookOpen className="h-4 w-4" />{open ? 'Скрыть словарь остановок' : 'Словарь названий остановок'}
</button>
{open && <div className="mt-3 space-y-3">
<p className="text-sm text-gray-600">Свяжите название из канала с остановкой {activeCity?.name ? `города ${activeCity.name}` : 'выбранного города'}. Например: ЗОДЧЕГО → Зодчего Иоанна. Регистр, пробелы и знаки препинания не учитываются.</p>
{error && <p role="alert" className="text-sm text-red-700">{error}</p>}
{notice && <p role="status" className="text-sm text-green-700">{notice}</p>}
{loading ? <p className="text-sm text-gray-500">Загрузка словаря…</p> : <>
<form onSubmit={save} className="rounded-lg bg-gray-50 p-3 space-y-3">
<div className="flex justify-between items-center"><h3 className="text-sm font-medium">{editingId === null ? 'Новое соответствие' : 'Изменить соответствие'}</h3>
{editingId !== null && <button type="button" aria-label="Отменить изменение соответствия" onClick={() => { setEditingId(null); setForm(emptyForm); }} disabled={busy}><X className="h-4 w-4" /></button>}
</div>
<div className="grid sm:grid-cols-2 gap-3">
<label className="text-sm">Название в канале<input className="input mt-1 w-full" placeholder="ЗОДЧЕГО" value={form.alias} maxLength={200} onChange={e => setForm({ ...form, alias: e.target.value })} required disabled={busy} /></label>
<label className="text-sm">Остановка в админке<select className="input mt-1 w-full" value={form.stop_id} onChange={e => setForm({ ...form, stop_id: e.target.value })} required disabled={busy}>
<option value="">Выберите остановку</option>
{form.stop_id && !stops.some(stop => Number(stop.id) === Number(form.stop_id)) && <option value={form.stop_id} disabled>Остановка отключена — выберите другую</option>}
{stops.map(stop => <option key={stop.id} value={stop.id}>{stop.name}{stop.address ? ` · ${stop.address}` : ''} · #{stop.id}</option>)}
</select></label>
</div>
<button type="submit" className="btn-primary text-sm" disabled={busy || !stops.length}>{editingId === null ? 'Добавить соответствие' : 'Сохранить соответствие'}</button>
</form>
<div className="space-y-2">
{aliases.map(alias => <div key={alias.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-gray-200 p-3">
<div className="min-w-0 break-words text-sm"><strong>{alias.alias}</strong> <span className="text-gray-400">→</span> {alias.stop_name}
{!alias.stop_active && <span className="text-xs text-amber-700 ml-2">Остановка отключена</span>}
<p className="text-xs text-gray-500">{alias.stop_address ? `${alias.stop_address} · ` : ''}Остановка #{alias.stop_id}</p>
</div>
<div className="flex gap-3 shrink-0">
<button type="button" className="text-primary-600 text-sm inline-flex items-center gap-1" disabled={busy} onClick={() => { setEditingId(alias.id); setForm({ alias: alias.alias, stop_id: String(alias.stop_id) }); setNotice(''); setError(''); }}><Pencil className="h-3 w-3" />Изменить</button>
<button type="button" className="text-red-600 text-sm inline-flex items-center gap-1" disabled={busy} onClick={() => remove(alias.id)}><Trash2 className="h-3 w-3" />Удалить</button>
</div>
</div>)}
{!aliases.length && <p className="text-sm text-gray-500">Добавьте первое соответствие. Для нескольких вариантов одного названия можно создать отдельные записи.</p>}
</div>
<p className="text-xs text-gray-500">Изменения применяются к новым сообщениям и сообщениям на проверке. Опубликованные уведомления сохраняют свой текст.</p>
</>}
</div>}
</div>;
}

View File

@@ -59,13 +59,7 @@ export function AuthProvider({ children }) {
password
});
const { user, accessToken, refreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
navigate('/');
completeLogin(response.data);
return { success: true };
} catch (error) {
@@ -77,6 +71,32 @@ export function AuthProvider({ children }) {
}
};
const loginWithPasskey = async (credential) => {
try {
const response = await axios.post(
`${API_BASE_URL}/auth/passkeys/authentication/verify`,
{ response: credential }
);
completeLogin(response.data);
return { success: true };
} catch (error) {
console.error('Passkey login error:', error);
return {
success: false,
error: error.response?.data?.error || 'Не удалось выполнить вход по passkey'
};
}
};
const completeLogin = ({ user, accessToken, refreshToken }) => {
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
navigate('/');
};
const logout = async () => {
const refreshToken = localStorage.getItem('refreshToken');
@@ -102,6 +122,7 @@ export function AuthProvider({ children }) {
user,
loading,
login,
loginWithPasskey,
logout,
getAccessToken,
isAuthenticated: !!user,

View File

@@ -0,0 +1,58 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { citiesApi } from '../services/api';
const CityContext = createContext(null);
const STORAGE_KEY = 'activeCityId';
export function CityProvider({ children }) {
const [cities, setCities] = useState([]);
const [activeCity, setActiveCity] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
async function loadCities() {
setError('');
try {
const { data } = await citiesApi.getAll();
const savedId = Number(localStorage.getItem(STORAGE_KEY));
const selected = data.find(city => city.id === savedId)
|| data.find(city => city.slug === 'polotsk')
|| data[0]
|| null;
setCities(data);
setActiveCity(selected);
if (selected) localStorage.setItem(STORAGE_KEY, String(selected.id));
} catch (requestError) {
setError(requestError.response?.data?.error || 'Не удалось загрузить список городов.');
} finally {
setLoading(false);
}
}
useEffect(() => { loadCities(); }, []);
function selectCity(city) {
setActiveCity(city);
localStorage.setItem(STORAGE_KEY, String(city.id));
}
function addCity(city) {
setCities(current => [...current, city].sort((left, right) => left.name.localeCompare(right.name, 'ru')));
selectCity(city);
}
function replaceCity(city) {
setCities(current => current.map(item => item.id === city.id ? city : item));
setActiveCity(current => current?.id === city.id ? city : current);
}
return <CityContext.Provider value={{ cities, activeCity, loading, error, selectCity, addCity, replaceCity, reloadCities: loadCities }}>
{children}
</CityContext.Provider>;
}
export function useCity() {
const context = useContext(CityContext);
if (!context) throw new Error('useCity must be used within CityProvider');
return context;
}

View File

@@ -23,12 +23,24 @@
}
.sidebar-brand-row { @apply flex items-center justify-between px-4 py-3; }
.sidebar-brand { @apply flex items-center gap-2 text-lg font-bold text-gray-900; }
.brand-logo { display: block; width: 168px; height: 34px; object-fit: contain; }
.brand-logo { display: block; width: 125px; height: 34px; object-fit: contain; }
.brand-mark { @apply flex h-8 w-8 shrink-0 items-center justify-center rounded-lg; background: #ff6900; }
.sidebar-menu-button { @apply flex h-11 w-11 items-center justify-center rounded-lg hover:bg-black/[0.04] lg:hidden; }
.sidebar-content { @apply hidden min-h-0 flex-col px-4 pb-4; max-height: calc(100dvh - 68px); overflow-y: auto; }
.sidebar-content.is-open { display: flex; }
.sidebar-account { @apply flex items-center justify-between gap-3 rounded-xl bg-white px-3 py-2 text-sm; min-height: 56px; }
.city-selector { position: relative; margin-top: 12px; }
.sidebar-city-card { @apply flex w-full items-center justify-between gap-3 rounded-xl bg-white px-3 py-2 text-sm transition-colors hover:bg-gray-50; min-height: 64px; }
.city-emblem { @apply relative flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary-50 text-lg; overflow: hidden; }
.city-emblem > img { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; object-fit: cover; }
.city-emblem-fallback { @apply flex h-full w-full items-center justify-center; }
.city-emblem.is-small { @apply h-8 w-8 text-base; }
.city-emblem.is-large { @apply h-14 w-14 rounded-xl text-3xl; }
.city-switcher { position: absolute; z-index: 50; top: calc(100% + 8px); left: 0; right: 0; min-width: 232px; padding: 8px; border-radius: 12px; background: #fff; box-shadow: 0 12px 32px #00000026, 0 2px 8px #0000000d; }
.city-switcher-heading { @apply px-3 pb-2 pt-1 text-xs uppercase tracking-wide text-gray-500; }
.city-switcher-option { @apply flex min-h-[44px] w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-gray-800 hover:bg-gray-50; }
.city-switcher-divider { height: 1px; margin: 6px 4px; background: #f0f0f0; }
.city-switcher-add { @apply text-primary-700; }
.sidebar-group-title { @apply px-3 pb-2 pt-5 text-xs font-normal uppercase tracking-wide text-gray-500; }
.sidebar-link { @apply flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-3 text-sm text-gray-600 transition-colors hover:bg-black/[0.04]; }
.sidebar-link.is-active { @apply bg-black/[0.04] font-semibold text-gray-900; }
@@ -50,10 +62,225 @@
.stat-card-icon { @apply flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 text-gray-600; }
.stat-card-link { @apply mt-auto flex items-center justify-between border-t border-gray-100 pt-3 text-sm text-primary-600; }
.dashboard-intro { @apply flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between; }
.login-surface { @apply flex min-h-screen items-center justify-center p-4; background: #f5f5f5; }
.login-card { @apply rounded-xl bg-white p-6 sm:p-8; }
.cities-page { @apply space-y-6; }
.cities-heading { @apply flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between; }
.cities-heading > div > p { @apply mt-2 text-gray-500; }
.city-form { @apply space-y-5; }
.city-form h2 { @apply text-lg font-semibold; }
.city-form h2 + p { @apply mt-1 text-sm text-gray-500; }
.city-form-grid { @apply grid gap-4 sm:grid-cols-2; }
.city-form-hint { @apply mt-1 block text-xs text-gray-500; }
.city-form-error { @apply rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700; }
.city-form-notice { @apply rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800; }
.city-avatar-field { @apply flex flex-col gap-3; }
.city-avatar-preview { @apply h-20 w-20 rounded-xl border border-gray-200 bg-gray-50 object-cover; }
.city-center-picker { @apply space-y-2 sm:col-span-2; }
.city-center-map { height: 320px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; }
.city-center-coordinates { @apply text-sm text-gray-600; }
.city-center-coordinates strong { @apply text-gray-900; }
.city-avatar-editor { @apply relative flex shrink-0 flex-col items-end gap-2; }
.city-avatar-editor .btn-secondary { @apply inline-flex items-center gap-2; }
.city-avatar-editor-error { @apply absolute right-0 top-full z-10 mt-1 w-52 rounded-md bg-red-50 px-2 py-1 text-right text-xs text-red-700 shadow-sm; }
.cities-grid { @apply grid gap-4 md:grid-cols-2; }
.city-network-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 24px; row-gap: 16px; min-height: 144px; }
.city-network-card.is-active { border: 2px solid #2f72e0; box-shadow: 0 0 0 2px rgb(47 114 224 / 10%); }
.city-card-summary { @apply flex min-w-0 items-center gap-4; }
.city-card-actions { @apply flex flex-wrap items-center justify-end gap-2; }
.city-edit-button { @apply inline-flex items-center gap-2; }
.city-network-card h2 { @apply text-lg font-semibold; }
.city-network-card p { @apply mt-1 text-sm text-gray-500; }
.city-current { @apply inline-flex shrink-0 items-center gap-1 rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700; white-space: nowrap; }
.city-isolation-note { @apply flex items-start gap-3 rounded-xl bg-primary-50 p-4 text-sm text-primary-700; }
.city-isolation-note svg { @apply mt-0.5 shrink-0; }
.login-surface {
display: grid;
min-height: 100dvh;
place-items: start center;
padding: max(32px, calc((100dvh - 595px) / 2)) 16px;
background: #e6e6e6;
}
.login-layout { width: min(100%, 332px); }
.login-card {
width: 100%;
padding: 40px 40px 32px;
border-radius: 12px;
background: #fff;
box-shadow: 0 24px 30px rgb(0 10 28 / 15%);
}
.login-brand {
display: flex;
width: 130px;
height: 44px;
align-items: flex-start;
justify-content: center;
margin: 0 auto 28px;
padding-bottom: 14px;
}
.brand-logo.login-brand-logo { width: 130px; height: 27px; }
.login-methods { width: 100%; }
.login-password-choice,
.login-passkey-button,
.login-password-submit {
display: inline-flex;
width: 100%;
align-items: center;
justify-content: center;
border: 0;
font-size: 15px;
font-weight: 400;
line-height: 16px;
transition: background-color 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
}
.login-password-choice {
height: 44px;
padding: 0 16px;
border-radius: 8px;
color: #000;
background: #f2f3f7;
}
.login-password-choice:hover:not(:disabled) { background: #e8ebf0; }
.login-divider {
position: relative;
height: 1px;
margin: 28px 0 44px;
background: #8c98a9;
}
.login-divider span {
position: absolute;
top: 50%;
left: 50%;
padding: 0 16px;
color: #8c98a9;
background: #fff;
font-size: 15px;
line-height: 19px;
transform: translate(-50%, -50%);
}
.login-passkey-button {
height: 48px;
gap: 4px;
padding: 1px 16px;
border-radius: 8px;
color: #fff;
background: #61a246;
}
.login-passkey-button:hover:not(:disabled) { background: #518b3a; }
.login-passkey-button img,
.login-passkey-button svg { width: 24px; height: 24px; flex: 0 0 24px; }
.login-passkey-button:disabled,
.login-password-choice:disabled,
.login-password-submit:disabled { opacity: .55; }
.login-passkey-hint {
margin: 28px 0 0;
color: #8c98a9;
font-size: 15px;
line-height: 19.2px;
text-align: center;
}
.login-error {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 16px;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b42318;
background: #fef2f2;
font-size: 13px;
line-height: 18px;
}
.login-error svg { width: 18px; height: 18px; flex: 0 0 18px; }
.login-password-form { display: grid; gap: 16px; }
.login-password-heading {
display: grid;
grid-template-columns: 32px 1fr 32px;
align-items: center;
margin: 0 0 4px;
}
.login-password-heading h2 {
margin: 0;
color: #000;
font-size: 16px;
font-weight: 500;
line-height: 20px;
text-align: center;
}
.login-back-button {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border: 0;
border-radius: 8px;
color: #536174;
background: transparent;
}
.login-back-button:hover:not(:disabled) { background: #f2f3f7; }
.login-back-button svg { width: 20px; height: 20px; }
.login-field { display: grid; gap: 6px; color: #536174; font-size: 13px; line-height: 16px; }
.login-input-wrap { position: relative; display: block; }
.login-input-wrap svg {
position: absolute;
top: 50%;
left: 12px;
width: 18px;
height: 18px;
color: #8c98a9;
transform: translateY(-50%);
}
.login-input-wrap input {
width: 100%;
height: 44px;
padding: 0 12px 0 40px;
border: 1px solid #c9cfd8;
border-radius: 8px;
color: #000;
background: #fff;
font-size: 15px !important;
line-height: 20px;
}
.login-input-wrap input::placeholder { color: #8c98a9; }
.login-input-wrap input:focus { border-color: #0958d9; outline: none; box-shadow: 0 0 0 2px rgb(9 88 217 / 15%); }
.login-password-submit {
height: 44px;
gap: 8px;
margin-top: 4px;
border-radius: 8px;
color: #fff;
background: #ff6900;
font-weight: 500;
}
.login-password-submit:hover:not(:disabled) { background: #e95f00; }
.login-password-submit svg { width: 18px; height: 18px; }
.login-device-card {
display: flex;
min-height: 104px;
align-items: flex-start;
gap: 16px;
margin-top: 16px;
padding: 28px 40px;
border-radius: 12px;
color: #000;
background: #fff;
box-shadow: 0 24px 30px rgb(0 10 28 / 15%);
}
.login-device-card img { width: 72px; height: 45px; flex: 0 0 72px; }
.login-device-card p { margin: -1px 0 0; font-size: 15px; line-height: 20.8px; }
.login-footer { min-height: 64px; margin-top: 16px; color: #8c98a9; font-size: 13px; line-height: 20px; text-align: center; }
.login-footer p { margin: 0; }
@media (max-width: 374px) {
.login-card { padding: 28px 24px; }
.login-device-card { padding: 24px; }
}
@media (max-width: 1499px) {
.city-network-card { grid-template-columns: minmax(0, 1fr); }
.city-card-actions { justify-content: flex-start; }
}
@media (max-width: 767px) {
.app-main.app-main-wide { padding: 24px 16px; }
.city-network-card { grid-template-columns: minmax(0, 1fr); }
.city-card-actions { justify-content: flex-start; }
}
@media (min-width: 1024px) {
.app-sidebar { position: fixed; inset: 0 auto 0 0; width: 264px; padding: 16px; }

View File

@@ -3,8 +3,12 @@ import { Plus, Edit, Trash2, AlertCircle, AlertTriangle, Info, Clock, Filter, Re
import { alertsApi, routesApi } from '../services/api';
import { AlertTypeBadge } from '../components/AlertComponents';
import { PageLoader } from '../components/AnimatedComponents';
import TelegramImportPanel from '../components/TelegramImportPanel';
import RouteNumberPicker, { alertRouteNumbers } from '../components/RouteNumberPicker';
import { useAuth } from '../contexts/AuthContext';
export default function AlertsPage() {
const { user } = useAuth();
const [alerts, setAlerts] = useState([]);
const [routes, setRoutes] = useState([]);
const [templates, setTemplates] = useState([]);
@@ -14,6 +18,22 @@ export default function AlertsPage() {
const [filterType, setFilterType] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [refreshing, setRefreshing] = useState(false);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
let timer;
const updateClock = () => {
clearTimeout(timer);
const current = new Date();
setNow(current);
const boundaries = alerts.flatMap(item => [item.start_time, item.end_time])
.filter(Boolean).map(value => new Date(value).getTime()).filter(value => value > current.getTime());
if (boundaries.length) timer = setTimeout(updateClock, Math.min(Math.min(...boundaries) - current.getTime() + 5, 2_147_483_647));
};
updateClock();
document.addEventListener('visibilitychange', updateClock);
return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', updateClock); };
}, [alerts]);
useEffect(() => { loadData(); }, []);
@@ -74,7 +94,7 @@ export default function AlertsPage() {
loadData();
} catch (error) {
console.error('Error saving alert:', error);
alert('Ошибка сохранения уведомления');
throw new Error(error.response?.data?.error || 'Ошибка сохранения уведомления');
}
};
@@ -96,12 +116,12 @@ export default function AlertsPage() {
}
const statusCounts = alerts.reduce((counts, alert) => {
counts[getAlertStatus(alert)] += 1;
counts[getAlertStatus(alert, now)] += 1;
return counts;
}, { active: 0, scheduled: 0, draft: 0, expired: 0 });
const filteredAlerts = alerts.filter(alert =>
(filterType === 'all' || alert.alert_type === filterType)
&& (filterStatus === 'all' || getAlertStatus(alert) === filterStatus)
&& (filterStatus === 'all' || getAlertStatus(alert, now) === filterStatus)
);
return (
@@ -154,6 +174,8 @@ export default function AlertsPage() {
</div>
{/* Filters */}
{user?.role === 'admin' && <TelegramImportPanel routes={routes} onChanged={loadData} />}
<div className="card mb-6 space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Тип уведомления</h3>
@@ -258,6 +280,7 @@ export default function AlertsPage() {
key={alert.id}
alert={alert}
index={index}
now={now}
onEdit={() => handleEdit(alert)}
onDelete={() => handleDelete(alert.id)}
/>
@@ -294,7 +317,7 @@ export default function AlertsPage() {
function getAlertStatus(alert, now = new Date()) {
if (!alert.is_active) return 'draft';
if (alert.end_time && new Date(alert.end_time) < now) return 'expired';
if (alert.end_time && new Date(alert.end_time) <= now) return 'expired';
if (alert.start_time && new Date(alert.start_time) > now) return 'scheduled';
return 'active';
}
@@ -320,7 +343,7 @@ function FilterButton({ active, onClick, icon: Icon, color = 'primary', children
);
}
function AlertCard({ alert, index, onEdit, onDelete }) {
function AlertCard({ alert, index, now, onEdit, onDelete }) {
const alertIcons = {
delay: { icon: Clock, color: 'text-amber-600' },
cancellation: { icon: AlertTriangle, color: 'text-red-600' },
@@ -331,7 +354,7 @@ function AlertCard({ alert, index, onEdit, onDelete }) {
const config = alertIcons[alert.alert_type] || alertIcons.info;
const Icon = config.icon;
const status = getAlertStatus(alert);
const status = getAlertStatus(alert, now);
const statusLabels = {
active: { label: 'В эфире', className: 'bg-green-100 text-green-700' },
scheduled: { label: 'Запланировано', className: 'bg-blue-100 text-blue-600' },
@@ -360,17 +383,10 @@ function AlertCard({ alert, index, onEdit, onDelete }) {
<AlertTypeBadge type={alert.alert_type} />
<span className={`text-xs px-2 py-0.5 rounded-full ${statusLabel.className}`}>{statusLabel.label}</span>
</div>
{alert.route_name && (
<div className="flex items-center gap-2 mb-2">
<span
className="px-2 py-0.5 rounded text-xs font-bold text-white"
style={{ backgroundColor: alert.color || '#6B7280' }}
>
{alert.route_number}
</span>
<span className="text-sm text-gray-600">{alert.route_name}</span>
</div>
)}
<div className="flex flex-wrap 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 className="text-xs text-gray-500">{alertRouteNumbers(alert).length ? 'Все направления' : 'Общее уведомление'}</span>
</div>
</div>
</div>
@@ -430,12 +446,11 @@ function toDateTimeInput(value = new Date()) {
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
}
// PostgreSQL currently stores alert timestamps without a timezone. Keep UTC
// values there, while the operator always sees and enters Polotsk time.
// Store an explicit UTC instant; the operator enters and sees Minsk time.
function toApiDateTime(value) {
if (!value) return null;
const date = new Date(`${value}:00${MINSK_OFFSET}`);
return Number.isNaN(date.getTime()) ? value : date.toISOString().slice(0, 19);
return Number.isNaN(date.getTime()) ? value : date.toISOString();
}
function toMinskDate(value) {
@@ -445,9 +460,10 @@ function toMinskDate(value) {
function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
const [idempotencyKey] = useState(createAlertIdempotencyKey);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
const [formData, setFormData] = useState({
route_id: alert?.route_id || '',
route_numbers: alertRouteNumbers(alert, routes),
alert_type: alert?.alert_type || 'info',
title: alert?.title || '',
message: alert?.message || '',
@@ -461,14 +477,16 @@ function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
const data = {
...formData,
route_id: formData.route_id || null,
start_time: toApiDateTime(formData.start_time),
end_time: toApiDateTime(formData.end_time)
};
setSubmitting(true);
try {
setError('');
await onSubmit(data, alert ? null : idempotencyKey);
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
@@ -519,25 +537,9 @@ function AlertForm({ alert, routes, templates, onSubmit, onCancel }) {
<p className="mt-2 text-xs text-gray-500">Шаблон заполнит тип, заголовок и текст — всё можно отредактировать перед сохранением.</p>
</div>
{/* Route */}
<div>
<label className="label">
Маршрут <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>
<RouteNumberPicker routes={routes} value={formData.route_numbers} disabled={submitting}
onChange={route_numbers => setFormData(current => ({ ...current, route_numbers }))} />
{error && <p role="alert" className="text-sm text-red-700">{error}</p>}
{/* Alert Type */}
<div>

View File

@@ -0,0 +1,285 @@
import { useEffect, useRef, useState } from 'react';
import { CheckCircle2, ImagePlus, Loader2, MapPinned, Pencil, Plus } from 'lucide-react';
import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import { citiesApi } from '../services/api';
import { CityEmblem } from '../components/CitySelector';
import { useCity } from '../contexts/CityContext';
import 'leaflet/dist/leaflet.css';
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png'
});
const BELARUS_CENTER = [53.7098, 27.9534];
const acceptedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp']);
const transliteration = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya'
};
const emptyForm = { name: '', slug: '', emblem: '🏙️', center_lat: '', center_lon: '' };
function slugFromName(name) {
return String(name || '').toLowerCase().split('').map(char => transliteration[char] ?? char)
.join('').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function cityToForm(city) {
return {
name: city.name || '',
slug: city.slug || '',
emblem: city.emblem || '🏙️',
center_lat: city.center_lat == null ? '' : String(city.center_lat),
center_lon: city.center_lon == null ? '' : String(city.center_lon)
};
}
function isImageValid(file) {
if (!acceptedImageTypes.has(file.type)) return 'Выберите изображение в формате JPG, PNG или WEBP.';
if (file.size > 5 * 1024 * 1024) return 'Размер изображения не должен превышать 5 МБ.';
return '';
}
function MapViewport({ position }) {
const map = useMap();
useEffect(() => {
if (position) map.setView(position, Math.max(map.getZoom(), 12));
}, [map, position]);
return null;
}
function CenterMapEvents({ onSelect }) {
useMapEvents({ click: event => onSelect(event.latlng.lat, event.latlng.lng) });
return null;
}
function DraggableCenterMarker({ position, onSelect }) {
const markerRef = useRef(null);
return <Marker position={position} draggable ref={markerRef} eventHandlers={{
dragend: () => {
const point = markerRef.current?.getLatLng();
if (point) onSelect(point.lat, point.lng);
}
}} />;
}
function CityCenterPicker({ position, fallbackCenter, onSelect }) {
return <div className="city-center-picker">
<span className="label">Центр города на карте</span>
<p className="city-form-hint">Нажмите на карту, затем при необходимости перетащите маркер. Вручную координаты вводить не нужно.</p>
<div className="city-center-map">
<MapContainer center={position || fallbackCenter} zoom={position ? 13 : 8} className="h-full w-full" scrollWheelZoom>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapViewport position={position} />
<CenterMapEvents onSelect={onSelect} />
{position && <DraggableCenterMarker position={position} onSelect={onSelect} />}
</MapContainer>
</div>
<p className="city-center-coordinates">
{position ? <>Выбрано: <strong>{position[0].toFixed(6)}, {position[1].toFixed(6)}</strong></> : 'Точка ещё не выбрана.'}
</p>
</div>;
}
function CityAvatarEditor({ city }) {
const { replaceCity } = useCity();
const inputRef = useRef(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
async function selectImage(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
const validationError = isImageValid(file);
if (validationError) return setError(validationError);
setUploading(true);
setError('');
try {
const { data } = await citiesApi.uploadAvatar(city.id, file);
replaceCity(data.city);
} catch (requestError) {
setError(requestError.response?.data?.error || 'Не удалось загрузить изображение.');
} finally {
setUploading(false);
}
}
return <div className="city-avatar-editor">
<input ref={inputRef} type="file" className="hidden" accept="image/jpeg,image/png,image/webp" onChange={selectImage} />
<button type="button" className="btn-secondary" disabled={uploading} onClick={() => inputRef.current?.click()}>
{uploading ? <Loader2 size={16} className="animate-spin" aria-hidden="true" /> : <ImagePlus size={16} aria-hidden="true" />}
{city.avatar_url ? 'Заменить герб' : 'Загрузить герб'}
</button>
{error && <p className="city-avatar-editor-error" role="alert">{error}</p>}
</div>;
}
export default function CitiesPage() {
const { cities, activeCity, addCity, replaceCity } = useCity();
const [showForm, setShowForm] = useState(false);
const [editingCity, setEditingCity] = useState(null);
const [form, setForm] = useState(emptyForm);
const [slugEdited, setSlugEdited] = useState(false);
const [avatarFile, setAvatarFile] = useState(null);
const [avatarPreview, setAvatarPreview] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const isEditing = Boolean(editingCity);
const selectedPosition = form.center_lat !== '' && form.center_lon !== ''
? [Number(form.center_lat), Number(form.center_lon)]
: null;
const mapCity = editingCity || activeCity;
const fallbackCenter = mapCity?.center_lat != null && mapCity?.center_lon != null
? [Number(mapCity.center_lat), Number(mapCity.center_lon)]
: BELARUS_CENTER;
useEffect(() => () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
}, [avatarPreview]);
function clearEditor() {
setForm(emptyForm);
setSlugEdited(false);
setAvatarFile(null);
setAvatarPreview('');
setEditingCity(null);
}
function closeEditor() {
clearEditor();
setShowForm(false);
setError('');
}
function openCreate() {
clearEditor();
setNotice('');
setError('');
setShowForm(true);
}
function openEdit(city) {
setEditingCity(city);
setForm(cityToForm(city));
setSlugEdited(true);
setAvatarFile(null);
setAvatarPreview('');
setNotice('');
setError('');
setShowForm(true);
}
function updateForm(field, value) {
setForm(current => {
const next = { ...current, [field]: value };
if (field === 'name' && !slugEdited) next.slug = slugFromName(value);
return next;
});
}
function selectCenter(latitude, longitude) {
setForm(current => ({ ...current, center_lat: latitude.toFixed(6), center_lon: longitude.toFixed(6) }));
}
function selectNewAvatar(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
const validationError = isImageValid(file);
if (validationError) return setError(validationError);
setError('');
setAvatarFile(file);
setAvatarPreview(URL.createObjectURL(file));
}
async function submit(event) {
event.preventDefault();
if (!selectedPosition) {
setError('Укажите центр города точкой на карте.');
return;
}
setSaving(true);
setError('');
setNotice('');
try {
let city;
if (editingCity) {
const { data } = await citiesApi.update(editingCity.id, form);
city = data;
} else {
const { data } = await citiesApi.create(form);
city = data;
}
let avatarWarning = '';
if (avatarFile) {
try {
const upload = await citiesApi.uploadAvatar(city.id, avatarFile);
city = upload.data.city;
} catch (uploadError) {
avatarWarning = uploadError.response?.data?.error || 'Изменения города сохранены, но изображение не удалось загрузить.';
}
}
if (editingCity) replaceCity(city);
else addCity(city);
const action = editingCity ? 'Изменения города сохранены.' : 'Город добавлен.';
closeEditor();
setNotice(avatarWarning || action);
} catch (requestError) {
setError(requestError.response?.data?.error || (editingCity ? 'Не удалось сохранить изменения города.' : 'Не удалось добавить город.'));
} finally {
setSaving(false);
}
}
return <div className="cities-page">
<header className="cities-heading">
<div><h1 className="page-title">Города</h1><p>У каждого города — свои маршруты, остановки, расписания и уведомления.</p></div>
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={showForm ? closeEditor : openCreate}>
<Plus size={18} aria-hidden="true" />{showForm ? 'Закрыть форму' : 'Добавить город'}
</button>
</header>
{notice && <p className="city-form-notice" role="status">{notice}</p>}
{showForm && <form className="card city-form" onSubmit={submit}>
<div><h2>{isEditing ? `Редактирование: ${editingCity.name}` : 'Новый город'}</h2><p>{isEditing ? 'Измените данные города и сохраните их. Данные его транспортной сети останутся без изменений.' : 'После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.'}</p></div>
{error && <p className="city-form-error" role="alert">{error}</p>}
<div className="city-form-grid">
<label><span className="label">Название</span><input required className="input" value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="Например, Новополоцк" /></label>
<label><span className="label">Код города</span><input required pattern="[a-z0-9]+(-[a-z0-9]+)*" className="input" value={form.slug} onChange={event => { setSlugEdited(true); updateForm('slug', event.target.value.toLowerCase()); }} placeholder="novopolotsk" /><span className="city-form-hint">Латинские буквы, цифры и дефисы.</span></label>
<label><span className="label">Иконка без картинки</span><input maxLength="64" className="input" value={form.emblem} onChange={event => updateForm('emblem', event.target.value)} placeholder="🏙️" /><span className="city-form-hint">Показывается, если кастомное изображение не задано.</span></label>
<label className="city-avatar-field"><span className="label">{isEditing ? 'Новая картинка для герба' : 'Картинка для герба'}</span><input type="file" accept="image/jpeg,image/png,image/webp" onChange={selectNewAvatar} /><span className="city-form-hint">JPG, PNG или WEBP, до 5 МБ.</span>{avatarPreview && <img className="city-avatar-preview" src={avatarPreview} alt="Предпросмотр герба города" />}</label>
<CityCenterPicker position={selectedPosition} fallbackCenter={fallbackCenter} onSelect={selectCenter} />
</div>
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : isEditing ? 'Сохранить изменения' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={closeEditor}>Отмена</button></div>
</form>}
<section className="cities-grid" aria-label="Доступные города">
{cities.map(city => <article key={city.id} className={`card city-network-card ${city.id === activeCity?.id ? 'is-active' : ''}`}>
<div className="city-card-summary">
<CityEmblem city={city} className="is-large" />
<div className="min-w-0"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}</p></div>
</div>
<div className="city-card-actions">
{city.id === activeCity?.id && <span className="city-current"><CheckCircle2 size={16} aria-hidden="true" />Активен</span>}
<button type="button" className="btn-secondary city-edit-button" onClick={() => openEdit(city)}><Pencil size={16} aria-hidden="true" />Редактировать</button>
<CityAvatarEditor city={city} />
</div>
</article>)}
</section>
<div className="city-isolation-note"><MapPinned size={20} aria-hidden="true" /><p>Переключение города в боковой панели меняет рабочий контур целиком: данные одного города не отображаются и не редактируются в другом.</p></div>
</div>;
}

View File

@@ -6,6 +6,7 @@ import { PageLoader } from '../components/AnimatedComponents';
import { AlertList } from '../components/AlertComponents';
import dashboardBackground from '../assets/dashboard-background.png';
import './Dashboard.css';
import { useCity } from '../contexts/CityContext';
const TIME_ZONE = 'Europe/Minsk';
const numberFormat = new Intl.NumberFormat('ru-RU');
@@ -30,6 +31,7 @@ function formatDate(value, options) {
}
export default function Dashboard() {
const { activeCity } = useCity();
const [data, setData] = useState({ routes: null, stops: null, sync: null, holidays: null, alerts: null });
const [failed, setFailed] = useState([]);
const [loading, setLoading] = useState(true);
@@ -95,7 +97,7 @@ export default function Dashboard() {
{!loaded ? <PageLoader /> : <>
<section className="dashboard-summary" aria-labelledby="summary-title">
<div className="dashboard-summary-heading">
<h2 id="summary-title">Транспорт Полоцка</h2>
<h2 id="summary-title">Транспорт {activeCity?.name}</h2>
<span>{updatedAt ? `Обновлено в ${formatDate(updatedAt, { hour: '2-digit', minute: '2-digit' })}` : 'Ожидаем актуальные данные'}</span>
</div>
<div className="dashboard-summary-grid">

View File

@@ -0,0 +1,5 @@
.devices-page{max-width:1200px;margin:auto;color:#17251d}.devices-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:28px}.devices-heading h1{font-size:32px;font-weight:700;margin:4px 0 8px}.devices-heading p{color:#657068}.devices-heading>div:last-child{display:flex;gap:10px}.devices-page button,.device-dialog button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid #d8dfda;border-radius:12px;background:white;padding:10px 16px;font-weight:600;min-height:44px}.devices-page button:disabled,.device-dialog button:disabled{opacity:.45;cursor:not-allowed}.devices-page .device-primary,.device-dialog .device-primary{background:#1c6c46;color:white;border-color:#1c6c46}.device-kicker{font-size:11px;font-weight:700;letter-spacing:.14em;color:#6a7c70}.devices-empty{text-align:center;background:white;border:1px dashed #c6d3ca;border-radius:24px;padding:64px 24px}.devices-empty svg{margin:auto;color:#478a62}.devices-empty h2{font-weight:650;font-size:22px;margin:16px}.devices-empty p{color:#657068;margin:0 0 24px}.device-list{display:grid;gap:14px}.device-card{display:flex;align-items:center;gap:20px;background:white;border:1px solid #dfe7e1;border-radius:20px;padding:24px}.device-card-icon{background:#edf5ef;border-radius:14px;padding:16px;color:#276347}.device-card-body{flex:1;min-width:0}.device-card-title{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.device-card h2{font-weight:650;font-size:20px;overflow-wrap:anywhere}.device-card p{color:#58675d;margin:6px 0 12px}.device-state{background:#f1f3f1;color:#6d766f;font-size:12px;border-radius:999px;padding:5px 10px}.device-state.online{background:#e0f3e5;color:#176439}.device-card-meta{display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:#69766d}.device-card-meta>span,.device-notice{display:flex;align-items:center;gap:6px}.device-card[data-revoked=true]{opacity:.65}.device-notice,.device-error{border-radius:12px;padding:14px 18px;margin:16px 0;background:#e8f4eb;color:#22633e}.device-error{background:#fff0ec;color:#973b22}.device-dialog{padding:0;border:1px solid #dde5df;border-radius:24px;width:min(660px,calc(100% - 24px));max-height:calc(100dvh - 32px);color:#17251d;box-shadow:0 24px 100px #17251d33}.device-dialog::backdrop{background:#11211880;backdrop-filter:blur(4px)}.device-dialog form{display:flex;flex-direction:column;max-height:calc(100dvh - 34px)}.device-dialog header,.device-dialog footer{padding:22px 26px;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-shrink:0}.device-dialog header{border-bottom:1px solid #e4ebe6}.device-dialog h2{font-size:23px;font-weight:700;margin-top:4px}.device-form-content{padding:24px 26px;overflow:auto}.device-form-content>label{margin-bottom:16px}.device-dialog label{display:flex;flex-direction:column;gap:8px;font-size:13px;font-weight:600}.device-dialog input,.device-dialog select,.device-dialog textarea{border:1px solid #d0dbd3;border-radius:10px;padding:11px 12px;font-size:15px;background:#fafcfb;min-width:0;width:100%}.device-dialog input:focus,.device-dialog select:focus,.device-dialog textarea:focus{outline:2px solid #478a62;outline-offset:2px}.device-dialog .device-code-input{font-size:34px;letter-spacing:.35em;text-align:center;font-variant-numeric:tabular-nums}.device-dialog small{color:#718076;font-weight:400}.device-dialog h3{font-size:16px;font-weight:700;margin:24px 0 14px}.device-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:18px}.device-dialog .device-check{flex-direction:row;align-items:center;padding:12px 0;font-weight:500}.device-check input{width:18px;height:18px;accent-color:#1c6c46}.device-dialog footer{border-top:1px solid #e4ebe6;justify-content:flex-end}.device-dialog .device-revoke{margin-right:auto;color:#963e2d;background:#fff8f5}.device-revoke-prompt{border:1px solid #eccabd;border-radius:12px;padding:16px;background:#fff8f5}.device-revoke-prompt p{font-size:14px;margin-bottom:12px}.device-revoke-prompt button{margin-right:8px}@media(max-width:700px){.devices-heading{align-items:flex-start;flex-direction:column}.device-card{flex-wrap:wrap;padding:18px;gap:12px}.device-card-icon{padding:12px}.device-card-body{flex-basis:calc(100% - 65px)}.device-card>button{width:100%}.device-form-grid{grid-template-columns:1fr}.device-dialog header,.device-dialog footer,.device-form-content{padding:18px}.device-card-meta{gap:8px}}
.device-night-hint{font-size:13px;line-height:1.5;color:#69766d;margin:0 0 20px}
.device-passenger-editor{min-width:0;border:1px solid #d0dbd3;border-radius:14px;padding:16px;margin:14px 0}.device-passenger-editor legend{font-size:13px;font-weight:600;color:#657068;padding:0 6px}.device-passenger-editor>label{margin-bottom:14px}.device-passenger-editor>button{font-size:13px;margin-top:6px}.device-passenger-settings>button{margin-bottom:8px}

View File

@@ -0,0 +1,93 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Monitor, Plus, RefreshCw, X, Link2, Save, Unplug, CheckCircle2, Clock3 } from 'lucide-react';
import { boardDevicesApi, stopsApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
import './DevicesPage.css';
import { BoardPassengerSettings, passengerDefaults, cityFareDefaults } from '../components/BoardPassengerSettings';
const defaults = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, journeyTranslitEnabled: false, showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near', nightMode: 'auto', nightWakeMinutes: 60, adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
const darkColors = [['lime', 'Лайм'], ['amber', 'Янтарь'], ['cyan', 'Голубой'], ['mint', 'Мята'], ['lavender', 'Лаванда'], ['white', 'Белый']];
const lightColors = [['blue', 'Синий'], ['forest', 'Зелёный'], ['crimson', 'Кармин'], ['violet', 'Фиолетовый'], ['teal', 'Бирюзовый'], ['graphite', 'Графит']];
const date = value => value ? new Date(value).toLocaleString('ru-RU', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : 'Ещё не выходило на связь';
const message = error => error.response?.data?.message || 'Не удалось выполнить запрос. Попробуйте ещё раз';
function DeviceForm({ device, stops, cityId, onClose, onSaved }) {
const dialog = useRef(null);
const [code, setCode] = useState(''), [name, setName] = useState(device?.name || '');
const [settings, setSettings] = useState({ ...defaults, ...passengerDefaults, fareByType: cityFareDefaults(cityId), stopId: stops[0]?.id || 1, ...device?.settings });
const [error, setError] = useState(''), [busy, setBusy] = useState(false);
const [detached, setDetached] = useState(false), [routes, setRoutes] = useState([]);
useEffect(() => { let alive = true; setRoutes([]); boardDevicesApi.routes(settings.stopId).then(({ data }) => { if (alive) setRoutes(data); }).catch(() => { if (alive) setError('Не удалось загрузить маршруты площадки'); }); return () => { alive = false; }; }, [settings.stopId]);
useEffect(() => { const previous = document.activeElement; dialog.current?.showModal(); return () => { dialog.current?.close(); previous?.focus?.(); }; }, []);
const set = (key, value) => setSettings(previous => ({ ...previous, [key]: value }));
async function save(event) {
event.preventDefault(); if (busy) return; setBusy(true); setError('');
try {
const payload = { name, settings, ...(device ? { revision: device.revision } : { code }) };
if (device) await boardDevicesApi.update(device.id, payload); else await boardDevicesApi.claim(payload);
onSaved(device ? 'Настройки сохранены' : 'Устройство подключено');
} catch (e) { setError(message(e)); } finally { setBusy(false); }
}
async function revoke() {
setBusy(true); setError('');
try { await boardDevicesApi.revoke(device.id); onSaved('Устройство отвязано. Табло узнает об этом при следующем подключении'); }
catch (e) { setError(message(e)); setBusy(false); }
}
const select = (key, label, values, numeric = false) => <label>{label}<select value={settings[key]} onChange={e => set(key, numeric ? Number(e.target.value) : e.target.value)}>{values.map(([value, text]) => <option key={value} value={value}>{text}</option>)}</select></label>;
const check = (key, label) => <label className="device-check"><input type="checkbox" checked={settings[key]} onChange={e => set(key, e.target.checked)} />{label}</label>;
return <dialog ref={dialog} className="device-dialog" aria-labelledby="device-dialog-title" onCancel={e => { e.preventDefault(); if (!busy) onClose(); }}>
<form onSubmit={save}>
<header><div><span className="device-kicker">RIDEBUS BOARD</span><h2 id="device-dialog-title">{device ? 'Настройки устройства' : 'Подключить табло'}</h2></div><button type="button" aria-label="Закрыть" disabled={busy} onClick={onClose}><X size={22} /></button></header>
<div className="device-form-content">
{!device && <label>Код на экране табло<input autoFocus className="device-code-input" inputMode="numeric" autoComplete="off" pattern="[0-9]{4}" maxLength={4} required value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="0000" /><small>Четыре цифры. Код действует 5 минут</small></label>}
<label>Название устройства<input autoFocus={Boolean(device)} maxLength={100} required value={name} onChange={e => setName(e.target.value)} placeholder="Швейная фабрика · улица" /></label>
<label>Остановка и площадка<select value={settings.stopId} onChange={e => setSettings(previous => ({ ...previous, stopId: Number(e.target.value), routeFilter: '' }))}>{stops.map(stop => <option key={stop.id} value={stop.id}>{stop.name}{stop.description ? ` · ${stop.description}` : ''}</option>)}</select></label>
{select('routeFilter', 'Маршрут', [['', 'Все маршруты остановки'], ...routes.map(route => [String(route.id), `№${route.route_number} · ${route.name}`])])}
<h3>Оформление и читаемость</h3><div className="device-form-grid">
{select('theme', 'Тема', [['dark', 'Тёмная'], ['light', 'Светлая']])}
{settings.theme === 'light' ? select('lightColorScheme', 'Цветовая схема', lightColors) : select('colorScheme', 'Цветовая схема', darkColors)}
{select('displayProfile', 'Профиль экрана', [['tv', 'Телевизор'], ['street', 'Уличное табло']])}
{select('viewingDistance', 'Расстояние просмотра', [['near', 'Близко'], ['medium', 'Средне'], ['far', 'Далеко']])}
{select('rowCount', 'Следующие рейсы', [2, 3, 4, 5, 6].map(n => [n, n]), true)}
</div>
<h3>Экраны</h3><div className="device-form-grid">{[['mainSeconds', 'Главный экран, сек', 5, 120], ['infoSeconds', 'Информация, сек', 5, 60]].map(([key, label, min, max]) => <label key={key}>{label}<input type="number" required min={min} max={max} value={settings[key]} onChange={e => set(key, Number(e.target.value))} /></label>)}</div>
{check('holdNear', 'Удерживать ближайший рейс за 2 минуты')}{check('showNextSameRoute', 'Следующий рейс того же направления')}{check('longWaitEnabled', 'Крупное время при долгом ожидании')}
<h3>Ночной режим</h3><div className="device-form-grid">
{select('nightMode', 'Ночной режим', [['off', 'Выключен'], ['auto', 'Автоматически']])}
{settings.nightMode === 'auto' && select('nightWakeMinutes', 'Вернуть обычное табло', [[45, 'За 45 минут до рейса'], [60, 'За 60 минут до рейса']], true)}
</div><p className="device-night-hint">Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. По полному расписанию остановки.</p>
<h3>Блоки информации</h3>{check('infoServiceEnabled', 'Дальше по ближайшему рейсу')}{check('infoWeatherEnabled', 'Погода')}{check('infoQrEnabled', 'QR-код расписания')}{check('journeyTranslitEnabled', 'Транслит названий остановок')}
{check('lastTripNoticeEnabled', 'Напоминание о последнем рейсе')}{check('weatherHintEnabled', 'Вероятность осадков')}{check('infoPlacesEnabled', 'До важных мест')}{check('infoFareEnabled', 'Оплата поездки')}
<BoardPassengerSettings settings={settings} stops={stops} routes={routes} cityId={cityId} set={set} />
<h3>Реклама · предпросмотр</h3>{check('adsEnabled', 'Добавить рекламу в цикл')}
{settings.adsEnabled && <><label>Длительность, сек<input type="number" min={5} max={30} required value={settings.adSeconds} onChange={e => set('adSeconds', Number(e.target.value))} /></label><label>Заголовок<input maxLength={80} value={settings.adTitle} onChange={e => set('adTitle', e.target.value)} /></label><label>Текст<textarea maxLength={200} value={settings.adText} onChange={e => set('adText', e.target.value)} /></label></>}
{error && <p className="device-error" role="alert">{error}</p>}
{detached && <div className="device-revoke-prompt"><p>Отвязать «{device.name}»? Табло прекратит показ при следующей связи с сервером. Для повторного подключения понадобится новый код.</p><button type="button" disabled={busy} onClick={revoke}>Да, отвязать</button><button type="button" disabled={busy} onClick={() => setDetached(false)}>Отмена</button></div>}
</div>
<footer>{device && <button type="button" className="device-revoke" disabled={busy} onClick={() => setDetached(true)}><Unplug size={17} />Отвязать</button>}<button type="submit" className="device-primary" disabled={busy || !stops.length}>{device ? <Save size={18} /> : <Link2 size={18} />}{busy ? 'Сохраняем…' : device ? 'Сохранить настройки' : 'Подключить'}</button></footer>
</form>
</dialog>;
}
export default function DevicesPage() {
const { activeCity } = useCity();
const [devices, setDevices] = useState([]), [stops, setStops] = useState([]);
const [editor, setEditor] = useState(null), [error, setError] = useState(''), [loading, setLoading] = useState(true), [notice, setNotice] = useState('');
const request = useRef(0);
const load = useCallback(async () => {
const id = ++request.current;
try { const { data } = await boardDevicesApi.list(); if (id === request.current) { setDevices(data); setError(''); } }
catch (e) { if (id === request.current) setError(message(e)); }
finally { if (id === request.current) setLoading(false); }
}, []);
useEffect(() => { void load(); const interval = setInterval(load, 15000); return () => { clearInterval(interval); request.current++; }; }, [load]);
useEffect(() => { let alive = true; stopsApi.getAll().then(s => { if (alive) setStops(s.data); }).catch(() => { if (alive) setError('Не удалось загрузить остановки и маршруты'); }); return () => { alive = false; }; }, []);
return <section className="devices-page">
<header className="devices-heading"><div><span className="device-kicker">RIDEBUS BOARD · {activeCity.name}</span><h1>Устройства</h1><p>Табло остановок и телевизоры. Подключение по коду с экрана.</p></div><div><button onClick={load} aria-label="Обновить устройства"><RefreshCw size={18} /></button><button className="device-primary" disabled={!stops.length} onClick={() => { setNotice(''); setEditor({}); }}><Plus size={18} />Добавить устройство</button></div></header>
{notice && <p className="device-notice" role="status"><CheckCircle2 size={18} />{notice}</p>}{error && <p className="device-error" role="alert">{error}</p>}
{!loading && !devices.length && <div className="devices-empty"><Monitor size={42} /><h2>Подключите первое табло</h2><p>Откройте RideBus Board на телевизоре и введите появившийся код здесь.</p><button className="device-primary" disabled={!stops.length} onClick={() => setEditor({})}>Подключить по коду</button></div>}
{loading && <p>Загружаем устройства…</p>}
<div className="device-list">{devices.map(device => <article key={device.id} className="device-card" data-revoked={Boolean(device.revokedAt)}><div className="device-card-icon"><Monitor size={26} /></div><div className="device-card-body"><div className="device-card-title"><h2>{device.name}</h2><span className={`device-state ${device.online ? 'online' : ''}`}>{device.revokedAt ? 'Отвязано' : device.online ? 'В сети' : 'Нет связи'}</span></div><p>{device.stopName}{device.stopDirection ? ` · ${device.stopDirection}` : ''}</p><div className="device-card-meta"><span><Clock3 size={15} />{date(device.lastSeenAt)}</span><span>{device.deviceInfo.width && device.deviceInfo.height ? `${device.deviceInfo.width}×${device.deviceInfo.height}` : 'Размер экрана неизвестен'}</span><span>{device.revokedAt ? 'Доступ отозван' : device.appliedRevision === device.revision ? 'Настройки применены' : 'Ожидают применения'} · v{device.revision}</span></div></div><button disabled={Boolean(device.revokedAt) || !stops.length} onClick={() => { setNotice(''); setEditor(device); }}>Настроить</button></article>)}</div>
{editor && <DeviceForm device={editor.id ? editor : null} stops={stops} cityId={activeCity.id} onClose={() => setEditor(null)} onSaved={text => { setEditor(null); setNotice(text); void load(); }} />}
</section>;
}

Some files were not shown because too many files have changed in this diff Show More