commit 3565390a49b004373942be42fa1e358c9f24cfba Author: Zakhar Timoshenko Date: Tue Apr 28 22:51:54 2026 +0300 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..590aa22 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Database +DB_PASSWORD=your_secure_password_here + +# API Configuration +API_KEY_SALT=generate_random_salt_with_openssl +CORS_ORIGIN=http://localhost:3001 + +# Frontend +API_URL=http://localhost:3000/api/v1 + +# Production settings (uncomment for production) +# NODE_ENV=production +# CORS_ORIGIN=https://admin.yourdomain.com +# API_URL=https://api.yourdomain.com/api/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0624a34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock + +# Environment variables +.env +.env.local +.env.production + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ +*.tgz + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Docker volumes +postgres_data/ +redis_data/ + +# SSL certificates +nginx/ssl/ + +# Temp files +*.tmp +.cache/ diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..99b69a9 --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -0,0 +1,186 @@ +# Структура проекта Polotsk Transit API + +## 📁 Обзор директорий + +``` +polotsk-transit-api/ +├── backend/ # Node.js API сервер +│ ├── src/ +│ │ ├── config/ # Конфигурация (DB, Redis, Logger) +│ │ ├── controllers/ # Бизнес-логика +│ │ │ ├── routesController.js # Управление маршрутами +│ │ │ ├── stopsController.js # Управление остановками + PostGIS +│ │ │ ├── etaController.js # Расчет ETA +│ │ │ └── syncController.js # Offline синхронизация +│ │ ├── middleware/ # Middleware (Auth, Rate Limiting) +│ │ ├── routes/ # Express маршруты +│ │ └── index.js # Точка входа +│ ├── migrations/ # SQL миграции (PostGIS схема) +│ ├── package.json +│ └── Dockerfile +│ +├── frontend/ # React админ-панель +│ ├── src/ +│ │ ├── components/ # React компоненты +│ │ │ ├── RouteForm.jsx # Форма маршрута +│ │ │ └── StopForm.jsx # Форма остановки +│ │ ├── pages/ # Страницы +│ │ │ ├── Dashboard.jsx # Главная страница +│ │ │ ├── RoutesPage.jsx # Управление маршрутами +│ │ │ ├── StopsPage.jsx # Остановки + карта Leaflet +│ │ │ └── SchedulesPage.jsx # Расписания (заглушка) +│ │ ├── services/ # API клиент +│ │ ├── App.jsx # Главный компонент + роутинг +│ │ ├── main.jsx # Точка входа +│ │ └── index.css # Tailwind стили +│ ├── package.json +│ ├── vite.config.js +│ └── Dockerfile +│ +├── nginx/ # Reverse proxy конфигурация +│ └── nginx.conf # Nginx для API + Admin + SSL +│ +├── docker-compose.yml # Оркестрация всех сервисов +├── .env.example # Шаблон переменных окружения +├── .gitignore +├── README.md # Полная документация +└── QUICKSTART.md # Быстрый старт + +``` + +## 🎯 Ключевые файлы + +### Backend + +**Конфигурация:** +- `backend/src/config/database.js` - PostgreSQL connection pool +- `backend/src/config/redis.js` - Redis клиент для кеширования +- `backend/src/config/logger.js` - Winston logger + +**Контроллеры:** +- `backend/src/controllers/routesController.js` - CRUD маршрутов + кеширование +- `backend/src/controllers/stopsController.js` - CRUD остановок + PostGIS геопоиск +- `backend/src/controllers/etaController.js` - Умный расчет времени прибытия +- `backend/src/controllers/syncController.js` - Полная и инкрементальная синхронизация + +**Middleware:** +- `backend/src/middleware/auth.js` - API key аутентификация +- `backend/src/middleware/rateLimiter.js` - Rate limiting с Redis + +**Миграции:** +- `backend/migrations/001_init.sql` - Полная схема БД с PostGIS + +### Frontend + +**Страницы:** +- `frontend/src/pages/Dashboard.jsx` - Статистика и быстрый старт +- `frontend/src/pages/RoutesPage.jsx` - Управление маршрутами с карточками +- `frontend/src/pages/StopsPage.jsx` - Карта Leaflet + список остановок +- `frontend/src/pages/SchedulesPage.jsx` - Заглушка для будущего функционала + +**Компоненты:** +- `frontend/src/components/RouteForm.jsx` - Форма создания/редактирования маршрута +- `frontend/src/components/StopForm.jsx` - Форма создания/редактирования остановки + +**API сервис:** +- `frontend/src/services/api.js` - Axios клиент со всеми endpoints + +### Инфраструктура + +**Docker:** +- `docker-compose.yml` - 5 сервисов (PostgreSQL, Redis, Backend, Frontend, Nginx) +- `backend/Dockerfile` - Node.js production образ +- `frontend/Dockerfile` - Multi-stage build с Nginx + +**Nginx:** +- `nginx/nginx.conf` - Reverse proxy с SSL, rate limiting, безопасностью + +## 🔧 Основные технологии + +| Компонент | Технология | Назначение | +|-----------|------------|------------| +| API Server | Express.js | REST API | +| База данных | PostgreSQL + PostGIS | Хранение данных + геопространственные запросы | +| Кеш | Redis | Кеширование + pub/sub для WebSocket | +| Frontend | React + Vite | Админ-панель | +| Карты | React Leaflet | Интерактивная карта | +| Стили | Tailwind CSS | Быстрая разработка UI | +| Контейнеризация | Docker Compose | Оркестрация сервисов | +| Reverse Proxy | Nginx | SSL, rate limiting, безопасность | + +## 📊 Схема базы данных (кратко) + +```sql +routes -- Маршруты (номер, название, тип, цвет) +stops -- Остановки (название, GEOGRAPHY(Point), адрес) +route_stops -- М:М связь маршрутов и остановок (с sequence и time_offset) +schedules -- Расписания (departure_times[], day_type) +vehicles -- Транспортные средства +historical_delays -- Статистика задержек для умного ETA +alerts -- Уведомления и алерты +api_keys -- API ключи для аутентификации +sync_log -- Лог изменений для offline синхронизации +``` + +## 🚀 Что уже работает + +✅ Полный REST API +✅ CRUD для маршрутов и остановок +✅ PostGIS геопространственные запросы (ближайшие остановки) +✅ Расчет ETA на основе расписания + исторических задержек +✅ Offline синхронизация (полная и инкрементальная) +✅ Веб админ-панель с картой +✅ Docker deployment +✅ API key аутентификация +✅ Rate limiting +✅ Кеширование через Redis +✅ Логирование + +## 📝 Что добавить в будущем + +**Версия 2.0 (Краудсорсинг):** +- [ ] WebSocket для реалтайм GPS +- [ ] Endpoint для приема GPS от пользователей +- [ ] Агрегация и фильтрация GPS данных +- [ ] Функция "Я еду в маршруте" + +**Версия 3.0 (Продвинутые функции):** +- [ ] IoT GPS-трекеры +- [ ] ML для предсказания задержек +- [ ] Push уведомления +- [ ] Детальная аналитика + +## 💡 Советы по развертыванию + +1. **Обязательно смените пароли** в `.env` перед продакшеном +2. **Используйте Cloudflare Tunnel** для скрытия домашнего IP +3. **Настройте SSL** через Let's Encrypt или Cloudflare +4. **Включите fail2ban** для защиты от брутфорса +5. **Настройте бэкапы** PostgreSQL (pg_dump + cron) +6. **Мониторинг** через Docker stats или Prometheus + +## 🎓 Интеграция с мобильными приложениями + +Используйте эндпоинт `/api/v1/sync` для offline-first подхода: + +1. При первом запуске - полная синхронизация (без `lastSync`) +2. При последующих - инкрементальная (с `lastSync=timestamp`) +3. Храните данные локально (SQLite, Realm, Core Data) +4. Обновляйте в фоне каждые 5-10 минут + +Пример для iOS: +```swift +func syncData() async { + let lastSync = UserDefaults.standard.object(forKey: "lastSync") as? Date + let params = lastSync != nil ? ["lastSync": ISO8601DateFormatter().string(from: lastSync!)] : [:] + + let data = try await api.get("/sync", params: params) + await database.updateLocalData(data) + + UserDefaults.standard.set(Date(), forKey: "lastSync") +} +``` + +--- + +**Создано для Полоцка 🇧🇾 | MIT License** diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..100acd5 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,224 @@ +# 🚀 Быстрый старт + +## За 5 минут к рабочему API + +### Шаг 1: Подготовка +```bash +# Убедитесь, что установлен Docker +docker --version +docker-compose --version + +# Если нет - установите: +# Ubuntu/Debian: +sudo apt update +sudo apt install docker.io docker-compose + +# Добавьте пользователя в группу docker +sudo usermod -aG docker $USER +# Перелогиньтесь после этого +``` + +### Шаг 2: Настройка +```bash +# Создайте .env файл +cp .env.example .env + +# Сгенерируйте безопасный пароль +openssl rand -base64 32 + +# Отредактируйте .env и вставьте сгенерированный пароль в DB_PASSWORD +nano .env +``` + +### Шаг 3: Запуск +```bash +# Запустите все сервисы +docker-compose up -d + +# Дождитесь инициализации (30-60 секунд) +docker-compose logs -f postgres + +# Когда увидите "database system is ready to accept connections" - нажмите Ctrl+C +``` + +### Шаг 4: Проверка +```bash +# Проверьте API +curl http://localhost:3000/health + +# Ожидаемый ответ: +# {"status":"healthy","timestamp":"2025-11-19T...","version":"1.0.0"} + +# Проверьте базу данных +curl -H "X-API-Key: dev_key_12345678" http://localhost:3000/api/v1/sync/status +``` + +### Шаг 5: Откройте админ-панель +Откройте в браузере: http://localhost:3001 + +**Готово! 🎉** + +--- + +## Первые данные + +### 1. Создайте первый маршрут +Откройте админ-панель → Маршруты → "Добавить маршрут" + +Или через API: +```bash +curl -X POST http://localhost:3000/api/v1/routes \ + -H "X-API-Key: dev_key_12345678" \ + -H "Content-Type: application/json" \ + -d '{ + "route_number": "1", + "name": "Центр - Вокзал", + "type": "bus", + "color": "#FF6B6B" + }' +``` + +### 2. Добавьте остановки +Откройте админ-панель → Остановки → Кликните на карту + +Или через API: +```bash +curl -X POST http://localhost:3000/api/v1/stops \ + -H "X-API-Key: dev_key_12345678" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Площадь Свободы", + "latitude": 55.4869, + "longitude": 28.7856, + "address": "Центр города" + }' +``` + +### 3. Проверьте геопоиск +```bash +# Найдите остановки в радиусе 500м от центра Полоцка +curl -H "X-API-Key: dev_key_12345678" \ + "http://localhost:3000/api/v1/stops/nearby?lat=55.4869&lon=28.7856&radius=500" +``` + +--- + +## Доступ из локальной сети + +### Вариант 1: Прямой доступ (не безопасно для интернета) +```bash +# Узнайте IP вашего сервера +ip addr show | grep "inet " + +# Доступ с других устройств в той же сети: +# http://YOUR_SERVER_IP:3000 - API +# http://YOUR_SERVER_IP:3001 - Admin +``` + +### Вариант 2: Cloudflare Tunnel (рекомендуется для интернета) +```bash +# Установите cloudflared +curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared +sudo mv cloudflared /usr/local/bin/ +sudo chmod +x /usr/local/bin/cloudflared + +# Авторизуйтесь +cloudflared tunnel login + +# Создайте туннель +cloudflared tunnel create polotsk-transit + +# Создайте конфиг +cat > ~/.cloudflared/config.yml << EOF +tunnel: YOUR_TUNNEL_ID +credentials-file: /home/$USER/.cloudflared/YOUR_TUNNEL_ID.json + +ingress: + - hostname: api.yourdomain.com + service: http://localhost:3000 + - hostname: admin.yourdomain.com + service: http://localhost:3001 + - service: http_status:404 +EOF + +# Запустите туннель +cloudflared tunnel run polotsk-transit +``` + +--- + +## Создание API ключа для вашего приложения + +```bash +# Войдите в PostgreSQL +docker exec -it polotsk-transit-db psql -U transit_admin -d polotsk_transit + +# Создайте новый API ключ +INSERT INTO api_keys (key, app_name, rate_limit, is_active) +VALUES ('YOUR_GENERATED_KEY_HERE', 'My iOS App', 10000, true); + +# Проверьте +SELECT * FROM api_keys; + +# Выйдите +\q +``` + +Генерация безопасного ключа: +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +--- + +## Остановка и удаление + +```bash +# Остановить все сервисы +docker-compose down + +# Удалить все данные (ОСТОРОЖНО!) +docker-compose down -v + +# Удалить образы +docker-compose down --rmi all +``` + +--- + +## Частые проблемы + +**"Cannot connect to database":** +```bash +# Проверьте, что PostgreSQL запущен +docker-compose ps +docker-compose logs postgres + +# Перезапустите +docker-compose restart postgres +``` + +**"Port already in use":** +```bash +# Измените порты в docker-compose.yml +# Например, вместо "3000:3000" используйте "3001:3000" +``` + +**"Permission denied":** +```bash +# Убедитесь, что пользователь в группе docker +sudo usermod -aG docker $USER +# Перелогиньтесь +``` + +--- + +## Следующие шаги + +1. Прочитайте полный README.md +2. Добавьте свои маршруты и остановки +3. Настройте расписания (в следующей версии) +4. Интегрируйте API в ваше мобильное приложение +5. Настройте безопасный доступ через Cloudflare + +**Удачи! 🚀** diff --git a/README.md b/README.md new file mode 100644 index 0000000..b26ab56 --- /dev/null +++ b/README.md @@ -0,0 +1,345 @@ +# Polotsk Transit API + +Система управления информацией об общественном транспорте города Полоцка с REST API, веб-панелью администратора и поддержкой offline-синхронизации. + +## 🚀 Возможности + +### MVP (Версия 1.0 - статические данные) +- ✅ REST API для маршрутов и остановок +- ✅ Геопространственные запросы (PostGIS) +- ✅ Расчет ETA на основе расписания +- ✅ Offline-синхронизация для мобильных приложений +- ✅ Веб-панель администратора +- ✅ Кеширование через Redis +- ✅ Аутентификация по API ключам +- ✅ Rate limiting + +### Технологический стек + +**Backend:** +- Node.js + Express +- PostgreSQL + PostGIS +- Redis +- Docker + +**Frontend (Admin Panel):** +- React + Vite +- Tailwind CSS +- React Leaflet (карты) +- Axios + +## 📋 Требования + +- Docker и Docker Compose +- Node.js 18+ (для разработки без Docker) +- Статический IP или динамический DNS +- ~2GB свободного места на диске + +## 🛠 Установка и запуск + +### 1. Клонирование и настройка + +```bash +# Создайте .env файл из примера +cp .env.example .env + +# Настройте переменные окружения +nano .env +``` + +Важные переменные в `.env`: +```env +# Обязательно измените в продакшене! +DB_PASSWORD=your_secure_password_here +API_KEY_SALT=generate_random_salt_with_openssl + +# Для разработки оставьте как есть +CORS_ORIGIN=http://localhost:3001 +API_URL=http://localhost:3000/api/v1 +``` + +Генерация безопасного salt: +```bash +openssl rand -base64 32 +``` + +### 2. Запуск с Docker (рекомендуется) + +```bash +# Сборка и запуск всех сервисов +docker-compose up -d + +# Проверка статуса +docker-compose ps + +# Просмотр логов +docker-compose logs -f +``` + +Сервисы будут доступны: +- **API**: http://localhost:3000 +- **Admin Panel**: http://localhost:3001 +- **PostgreSQL**: localhost:5432 +- **Redis**: localhost:6379 + +### 3. Проверка работоспособности + +```bash +# Проверка API +curl http://localhost:3000/health + +# Проверка базы данных +docker exec -it polotsk-transit-db psql -U transit_admin -d polotsk_transit -c "SELECT COUNT(*) FROM routes;" +``` + +### 4. Разработка без Docker + +**Backend:** +```bash +cd backend +npm install +cp .env.example .env + +# Убедитесь, что PostgreSQL и Redis запущены локально +# Настройте DATABASE_URL и REDIS_URL в .env + +npm run dev +``` + +**Frontend:** +```bash +cd frontend +npm install +cp .env.example .env + +npm run dev +``` + +## 📱 Использование API + +### Аутентификация + +Все запросы требуют API ключ в заголовке: +```bash +curl -H "X-API-Key: dev_key_12345678" http://localhost:3000/api/v1/routes +``` + +### Основные эндпоинты + +**Маршруты:** +```bash +GET /api/v1/routes # Все маршруты +GET /api/v1/routes/:id # Маршрут с остановками +POST /api/v1/routes # Создать маршрут +PUT /api/v1/routes/:id # Обновить маршрут +DELETE /api/v1/routes/:id # Удалить маршрут +``` + +**Остановки:** +```bash +GET /api/v1/stops # Все остановки +GET /api/v1/stops/:id # Конкретная остановка +GET /api/v1/stops/nearby?lat=55.4869&lon=28.7856&radius=500 # Ближайшие +POST /api/v1/stops # Создать остановку +PUT /api/v1/stops/:id # Обновить остановку +DELETE /api/v1/stops/:id # Удалить остановку +``` + +**ETA (расчет времени прибытия):** +```bash +POST /api/v1/eta/calculate +{ + "stopId": 1, + "routeId": 1 +} + +GET /api/v1/eta/stop/:stopId # ETA для всех маршрутов на остановке +``` + +**Синхронизация (для offline-режима):** +```bash +GET /api/v1/sync # Полная синхронизация +GET /api/v1/sync?lastSync=2025-11-19T10:00:00Z # Инкрементальная +GET /api/v1/sync/status # Статус системы +``` + +### Примеры запросов + +**Создание маршрута:** +```bash +curl -X POST http://localhost:3000/api/v1/routes \ + -H "X-API-Key: dev_key_12345678" \ + -H "Content-Type: application/json" \ + -d '{ + "route_number": "1", + "name": "Центр - Вокзал", + "type": "bus", + "color": "#0066CC", + "description": "Основной маршрут через центр города" + }' +``` + +**Создание остановки:** +```bash +curl -X POST http://localhost:3000/api/v1/stops \ + -H "X-API-Key: dev_key_12345678" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Площадь Свободы", + "latitude": 55.4869, + "longitude": 28.7856, + "address": "ул. Ленина, 1" + }' +``` + +**Поиск ближайших остановок:** +```bash +curl -H "X-API-Key: dev_key_12345678" \ + "http://localhost:3000/api/v1/stops/nearby?lat=55.4869&lon=28.7856&radius=500" +``` + +## 🔐 Безопасность домашнего сервера + +### Обязательные меры безопасности + +1. **Cloudflare Tunnel** (рекомендуется): +```bash +# Установка cloudflared +curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared +sudo mv cloudflared /usr/local/bin/ +sudo chmod +x /usr/local/bin/cloudflared + +# Аутентификация +cloudflared tunnel login + +# Создание туннеля +cloudflared tunnel create polotsk-transit + +# Запуск +cloudflared tunnel run polotsk-transit +``` + +2. **Настройка SSL в Nginx:** +```bash +# Получение сертификата Let's Encrypt +sudo certbot --nginx -d api.yourdomain.com -d admin.yourdomain.com + +# Сертификаты автоматически обновятся +``` + +3. **Fail2ban:** +```bash +sudo apt install fail2ban +sudo systemctl enable fail2ban +sudo systemctl start fail2ban +``` + +4. **Мониторинг:** +```bash +# Установка Prometheus + Grafana (опционально) +# Или простой мониторинг через Docker stats +docker stats +``` + +### Создание API ключей + +API ключи можно создать через базу данных: + +```sql +INSERT INTO api_keys (key, app_name, rate_limit) +VALUES ('your_secure_key_here', 'iOS App', 10000); +``` + +Или через скрипт: +```javascript +const crypto = require('crypto'); +const apiKey = crypto.randomBytes(32).toString('hex'); +console.log(apiKey); +``` + +## 📊 Админ-панель + +Откройте http://localhost:3001 в браузере. + +**Функции:** +- Управление маршрутами +- Создание и редактирование остановок на карте +- Просмотр статистики +- Управление расписаниями (в разработке) + +## 🗄 Структура базы данных + +```sql +routes # Маршруты +stops # Остановки (с PostGIS геометрией) +route_stops # Связь маршрутов и остановок +schedules # Расписания +vehicles # Транспортные средства +historical_delays # Статистика задержек +alerts # Уведомления +api_keys # API ключи +sync_log # Лог изменений для синхронизации +``` + +## 🔄 Обновления + +```bash +# Остановка сервисов +docker-compose down + +# Обновление кода +git pull + +# Перезапуск +docker-compose up -d --build +``` + +## 🐛 Troubleshooting + +**База данных не стартует:** +```bash +docker-compose logs postgres +docker-compose down -v # Удалит volumes, ОСТОРОЖНО! +docker-compose up -d +``` + +**API не отвечает:** +```bash +docker-compose logs backend +docker-compose restart backend +``` + +**Frontend не собирается:** +```bash +cd frontend +npm install +npm run build +``` + +**PostGIS ошибки:** +```bash +docker exec -it polotsk-transit-db psql -U transit_admin -d polotsk_transit +SELECT PostGIS_Version(); +``` + +## 📝 Следующие шаги (Roadmap) + +### Версия 2.0 - Краудсорсинг GPS +- [ ] Endpoint для получения GPS от пользователей +- [ ] Агрегация и фильтрация GPS данных +- [ ] WebSocket для реалтайм-обновлений +- [ ] Функция "Я еду в маршруте" в приложениях + +### Версия 3.0 - Продвинутые функции +- [ ] IoT GPS-трекеры интеграция +- [ ] Машинное обучение для предсказания задержек +- [ ] Push-уведомления +- [ ] Аналитика и отчеты + +## 🤝 Контакты и поддержка + +Создано для города Полоцка 🇧🇾 + +## 📄 Лицензия + +MIT License - используйте свободно! diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c295cc8 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,24 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm i --only=production + +# Copy application files +COPY . . + +# Create logs directory +RUN mkdir -p logs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +CMD ["npm", "start"] diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..a5aa5e9 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,187 @@ +-- Enable PostGIS extension +CREATE EXTENSION IF NOT EXISTS postgis; + +-- API Keys table +CREATE TABLE api_keys ( + id SERIAL PRIMARY KEY, + key VARCHAR(64) UNIQUE NOT NULL, + app_name VARCHAR(100) NOT NULL, + rate_limit INTEGER DEFAULT 1000, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP +); + +-- Routes table (маршруты) +CREATE TABLE routes ( + id SERIAL PRIMARY KEY, + route_number VARCHAR(10) NOT NULL, + name VARCHAR(200) NOT NULL, + type VARCHAR(20) NOT NULL CHECK (type IN ('bus', 'minibus', 'trolleybus', 'tram')), + color VARCHAR(7) DEFAULT '#0066CC', + is_active BOOLEAN DEFAULT true, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Stops table (остановки) with geospatial support +CREATE TABLE stops ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + location GEOGRAPHY(Point, 4326) NOT NULL, + address VARCHAR(300), + description TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create spatial index for stops +CREATE INDEX idx_stops_location ON stops USING GIST(location); + +-- Route stops junction table (связь маршрутов и остановок) +CREATE TABLE route_stops ( + id SERIAL PRIMARY KEY, + route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + time_offset_minutes INTEGER NOT NULL DEFAULT 0, + UNIQUE(route_id, stop_id, sequence) +); + +-- Schedules table (расписание) +CREATE TABLE schedules ( + id SERIAL PRIMARY KEY, + route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + day_type VARCHAR(20) NOT NULL CHECK (day_type IN ('weekday', 'saturday', 'sunday', 'holiday')), + departure_times TIME[] NOT NULL, + valid_from DATE, + valid_until DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Vehicles table (транспортные средства) +CREATE TABLE vehicles ( + id SERIAL PRIMARY KEY, + vehicle_number VARCHAR(20) NOT NULL, + registration VARCHAR(20), + type VARCHAR(20) NOT NULL, + capacity INTEGER, + route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Historical delays table (статистика задержек) +CREATE TABLE historical_delays ( + id SERIAL PRIMARY KEY, + route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + stop_id INTEGER NOT NULL REFERENCES stops(id) ON DELETE CASCADE, + hour_of_day INTEGER NOT NULL CHECK (hour_of_day BETWEEN 0 AND 23), + day_type VARCHAR(20) NOT NULL, + avg_delay_minutes INTEGER NOT NULL, + sample_count INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(route_id, stop_id, hour_of_day, day_type) +); + +-- Alerts table (уведомления и оповещения) +CREATE TABLE alerts ( + id SERIAL PRIMARY KEY, + route_id INTEGER REFERENCES routes(id) ON DELETE CASCADE, + alert_type VARCHAR(20) NOT NULL CHECK (alert_type IN ('delay', 'cancellation', 'detour', 'info')), + title VARCHAR(200) NOT NULL, + message TEXT NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Sync log table (для offline синхронизации) +CREATE TABLE sync_log ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(50) NOT NULL, + record_id INTEGER NOT NULL, + action VARCHAR(20) NOT NULL CHECK (action IN ('insert', 'update', 'delete')), + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes +CREATE INDEX idx_routes_number ON routes(route_number); +CREATE INDEX idx_routes_active ON routes(is_active); +CREATE INDEX idx_stops_name ON stops(name); +CREATE INDEX idx_route_stops_route ON route_stops(route_id); +CREATE INDEX idx_route_stops_stop ON route_stops(stop_id); +CREATE INDEX idx_schedules_route ON schedules(route_id); +CREATE INDEX idx_vehicles_route ON vehicles(route_id); +CREATE INDEX idx_alerts_active ON alerts(is_active, start_time, end_time); +CREATE INDEX idx_sync_log_timestamp ON sync_log(timestamp); + +-- Create function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create triggers for updated_at +CREATE TRIGGER update_routes_updated_at BEFORE UPDATE ON routes + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_stops_updated_at BEFORE UPDATE ON stops + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_schedules_updated_at BEFORE UPDATE ON schedules + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_vehicles_updated_at BEFORE UPDATE ON vehicles + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Create function to log changes for sync +CREATE OR REPLACE FUNCTION log_sync_changes() +RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'DELETE') THEN + INSERT INTO sync_log (table_name, record_id, action) + VALUES (TG_TABLE_NAME, OLD.id, 'delete'); + RETURN OLD; + ELSIF (TG_OP = 'UPDATE') THEN + INSERT INTO sync_log (table_name, record_id, action) + VALUES (TG_TABLE_NAME, NEW.id, 'update'); + RETURN NEW; + ELSIF (TG_OP = 'INSERT') THEN + INSERT INTO sync_log (table_name, record_id, action) + VALUES (TG_TABLE_NAME, NEW.id, 'insert'); + RETURN NEW; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Create sync triggers for main tables +CREATE TRIGGER sync_routes AFTER INSERT OR UPDATE OR DELETE ON routes + FOR EACH ROW EXECUTE FUNCTION log_sync_changes(); + +CREATE TRIGGER sync_stops AFTER INSERT OR UPDATE OR DELETE ON stops + FOR EACH ROW EXECUTE FUNCTION log_sync_changes(); + +CREATE TRIGGER sync_route_stops AFTER INSERT OR UPDATE OR DELETE ON route_stops + FOR EACH ROW EXECUTE FUNCTION log_sync_changes(); + +CREATE TRIGGER sync_schedules AFTER INSERT OR UPDATE OR DELETE ON schedules + FOR EACH ROW EXECUTE FUNCTION log_sync_changes(); + +-- Insert sample API key for development +INSERT INTO api_keys (key, app_name, rate_limit) +VALUES ('dev_key_12345678', 'Development App', 10000); + +-- Grant permissions +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO transit_admin; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO transit_admin; diff --git a/backend/migrations/002_holidays.sql b/backend/migrations/002_holidays.sql new file mode 100644 index 0000000..72d3d96 --- /dev/null +++ b/backend/migrations/002_holidays.sql @@ -0,0 +1,86 @@ +-- Holidays table (праздничные дни Беларуси) +CREATE TABLE IF NOT EXISTS holidays ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL UNIQUE, + name VARCHAR(200) NOT NULL, + is_recurring BOOLEAN DEFAULT false, -- повторяется каждый год + recurring_month INTEGER, -- месяц для повторяющихся + recurring_day INTEGER, -- день для повторяющихся + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Index for fast date lookup +CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date); + +-- Insert Belarus official holidays (recurring) +-- Эти праздники повторяются каждый год в одну и ту же дату +INSERT INTO holidays (date, name, is_recurring, recurring_month, recurring_day) VALUES + ('2025-01-01', 'Новый год', true, 1, 1), + ('2025-01-07', 'Рождество (православное)', true, 1, 7), + ('2025-03-08', 'День женщин', true, 3, 8), + ('2025-05-01', 'Праздник труда', true, 5, 1), + ('2025-05-09', 'День Победы', true, 5, 9), + ('2025-07-03', 'День Независимости', true, 7, 3), + ('2025-11-07', 'День Октябрьской революции', true, 11, 7), + ('2025-12-25', 'Рождество (католическое)', true, 12, 25) +ON CONFLICT (date) DO NOTHING; + +-- Радуница (переходящая дата) - нужно добавлять вручную каждый год +-- 2025: 29 апреля +-- 2026: 21 апреля +INSERT INTO holidays (date, name, is_recurring, recurring_month, recurring_day) VALUES + ('2025-04-29', 'Радуница', false, NULL, NULL), + ('2026-04-21', 'Радуница', false, NULL, NULL) +ON CONFLICT (date) DO NOTHING; + +-- Function to check if a date is a holiday +CREATE OR REPLACE FUNCTION is_holiday(check_date DATE) +RETURNS BOOLEAN AS $$ +BEGIN + -- Check exact date match + IF EXISTS (SELECT 1 FROM holidays WHERE date = check_date) THEN + RETURN TRUE; + END IF; + + -- Check recurring holidays (for future years) + IF EXISTS ( + SELECT 1 FROM holidays + WHERE is_recurring = true + AND recurring_month = EXTRACT(MONTH FROM check_date) + AND recurring_day = EXTRACT(DAY FROM check_date) + ) THEN + RETURN TRUE; + END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql; + +-- Function to get schedule type for a date +-- Returns: 'weekday', 'saturday', 'sunday', or 'holiday' +CREATE OR REPLACE FUNCTION get_schedule_type(check_date DATE) +RETURNS VARCHAR(20) AS $$ +DECLARE + day_of_week INTEGER; +BEGIN + -- Check if it's a holiday first (holidays use sunday schedule) + IF is_holiday(check_date) THEN + RETURN 'holiday'; + END IF; + + -- Get day of week (0 = Sunday, 6 = Saturday) + day_of_week := EXTRACT(DOW FROM check_date); + + IF day_of_week = 0 THEN + RETURN 'sunday'; + ELSIF day_of_week = 6 THEN + RETURN 'saturday'; + ELSE + RETURN 'weekday'; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- Add sync trigger for holidays +CREATE TRIGGER sync_holidays AFTER INSERT OR UPDATE OR DELETE ON holidays + FOR EACH ROW EXECUTE FUNCTION log_sync_changes(); \ No newline at end of file diff --git a/backend/migrations/003_users_auth.sql b/backend/migrations/003_users_auth.sql new file mode 100644 index 0000000..fae1ba7 --- /dev/null +++ b/backend/migrations/003_users_auth.sql @@ -0,0 +1,44 @@ +-- Users table for authentication +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(100), + email VARCHAR(100), + role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')) DEFAULT 'user', + is_active BOOLEAN DEFAULT true, + last_login TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_role ON users(role); + +-- Update trigger +CREATE OR REPLACE FUNCTION update_users_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_users_updated_at(); + +-- Sessions table +CREATE TABLE user_sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + refresh_token VARCHAR(500) NOT NULL, + expires_at TIMESTAMP NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_sessions_user_id ON user_sessions(user_id); +CREATE INDEX idx_sessions_token ON user_sessions(refresh_token); \ No newline at end of file diff --git a/backend/migrations/004_users_avatars.sql b/backend/migrations/004_users_avatars.sql new file mode 100644 index 0000000..adbe1d2 --- /dev/null +++ b/backend/migrations/004_users_avatars.sql @@ -0,0 +1,7 @@ +-- Add avatar_url column to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(255); + +-- Create index for faster lookups +CREATE INDEX IF NOT EXISTS idx_users_avatar ON users(avatar_url); + +COMMENT ON COLUMN users.avatar_url IS 'URL or path to user avatar image'; \ No newline at end of file diff --git a/backend/migrations/005_realtime_gps.sql b/backend/migrations/005_realtime_gps.sql new file mode 100644 index 0000000..cd394f7 --- /dev/null +++ b/backend/migrations/005_realtime_gps.sql @@ -0,0 +1,57 @@ +-- Realtime GPS telemetry storage +CREATE TABLE IF NOT EXISTS telemetry_samples ( + id BIGSERIAL PRIMARY KEY, + vehicle_id INTEGER REFERENCES vehicles(id) ON DELETE SET NULL, + route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL, + source_type VARCHAR(30) NOT NULL DEFAULT 'manual', + source_ref VARCHAR(120), + location GEOGRAPHY(Point, 4326) NOT NULL, + speed_kmh NUMERIC(6, 2), + heading NUMERIC(6, 2), + accuracy_meters NUMERIC(6, 2), + recorded_at TIMESTAMP NOT NULL, + received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_valid BOOLEAN DEFAULT true, + raw_payload JSONB DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_telemetry_samples_vehicle_id ON telemetry_samples(vehicle_id); +CREATE INDEX IF NOT EXISTS idx_telemetry_samples_route_id ON telemetry_samples(route_id); +CREATE INDEX IF NOT EXISTS idx_telemetry_samples_recorded_at ON telemetry_samples(recorded_at DESC); +CREATE INDEX IF NOT EXISTS idx_telemetry_samples_location ON telemetry_samples USING GIST(location); + +-- Latest live position for each vehicle +CREATE TABLE IF NOT EXISTS vehicle_live_state ( + vehicle_id INTEGER PRIMARY KEY REFERENCES vehicles(id) ON DELETE CASCADE, + route_id INTEGER NOT NULL REFERENCES routes(id) ON DELETE CASCADE, + location GEOGRAPHY(Point, 4326) NOT NULL, + speed_kmh NUMERIC(6, 2), + heading NUMERIC(6, 2), + accuracy_meters NUMERIC(6, 2), + source_type VARCHAR(30) NOT NULL DEFAULT 'manual', + source_ref VARCHAR(120), + last_seen_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_vehicle_live_state_route_id ON vehicle_live_state(route_id); +CREATE INDEX IF NOT EXISTS idx_vehicle_live_state_last_seen_at ON vehicle_live_state(last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_vehicle_live_state_location ON vehicle_live_state USING GIST(location); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_proc + WHERE proname = 'update_updated_at_column' + ) AND NOT EXISTS ( + SELECT 1 + FROM pg_trigger + WHERE tgname = 'update_vehicle_live_state_updated_at' + ) THEN + CREATE TRIGGER update_vehicle_live_state_updated_at + BEFORE UPDATE ON vehicle_live_state + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..36201f2 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,40 @@ +{ + "name": "polotsk-transit-api", + "version": "1.0.0", + "description": "Polotsk Public Transit API", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "migrate": "node src/utils/migrate.js", + "seed": "node src/utils/seed.js" + }, + "keywords": [ + "transit", + "api", + "polotsk" + ], + "author": "Zakhar", + "license": "MIT", + "dependencies": { + "bcrypt": "^6.0.0", + "bcryptjs": "^2.4.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.3", + "multer": "^2.0.2", + "pg": "^8.11.3", + "redis": "^4.6.10", + "socket.io": "^4.7.2", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "ws": "^8.20.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/backend/scripts/create-admin.js b/backend/scripts/create-admin.js new file mode 100644 index 0000000..3702eaa --- /dev/null +++ b/backend/scripts/create-admin.js @@ -0,0 +1,29 @@ +const bcrypt = require('bcrypt'); +const pool = require('../src/config/database'); + +async function createAdmin() { + const username = 'admin'; + const password = 'admin123'; // Измените после первого входа! + const passwordHash = await bcrypt.hash(password, 10); + + try { + const result = await pool.query(` + INSERT INTO users (username, password_hash, full_name, email, role, is_active) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (username) DO UPDATE + SET password_hash = $2 + RETURNING id, username, role + `, [username, passwordHash, 'Главный администратор', 'admin@polotsk-transit.local', 'admin', true]); + + console.log('✅ Мастер-аккаунт создан:', result.rows[0]); + console.log('Username: admin'); + console.log('Password: admin123'); + console.log('⚠️ ОБЯЗАТЕЛЬНО смените пароль после первого входа!'); + } catch (error) { + console.error('❌ Ошибка:', error); + } finally { + await pool.end(); + } +} + +createAdmin(); \ No newline at end of file diff --git a/backend/src/config/database.js b/backend/src/config/database.js new file mode 100644 index 0000000..e9d06eb --- /dev/null +++ b/backend/src/config/database.js @@ -0,0 +1,25 @@ +const { Pool } = require('pg'); +require('dotenv').config(); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}); + +pool.on('error', (err) => { + console.error('Unexpected error on idle client', err); + process.exit(-1); +}); + +// Test connection +pool.query('SELECT NOW()', (err, res) => { + if (err) { + console.error('Database connection failed:', err); + } else { + console.log('Database connected successfully'); + } +}); + +module.exports = pool; diff --git a/backend/src/config/logger.js b/backend/src/config/logger.js new file mode 100644 index 0000000..9ef9de5 --- /dev/null +++ b/backend/src/config/logger.js @@ -0,0 +1,36 @@ +const winston = require('winston'); +const path = require('path'); + +const logger = winston.createLogger({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.splat(), + winston.format.json() + ), + defaultMeta: { service: 'polotsk-transit-api' }, + transports: [ + // Write all logs with level 'error' and below to error.log + new winston.transports.File({ + filename: path.join(__dirname, '../../logs/error.log'), + level: 'error' + }), + // Write all logs with level 'info' and below to combined.log + new winston.transports.File({ + filename: path.join(__dirname, '../../logs/combined.log') + }) + ] +}); + +// If we're not in production, log to console as well +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + })); +} + +module.exports = logger; diff --git a/backend/src/config/redis.js b/backend/src/config/redis.js new file mode 100644 index 0000000..6769eab --- /dev/null +++ b/backend/src/config/redis.js @@ -0,0 +1,21 @@ +const redis = require('redis'); +require('dotenv').config(); + +const client = redis.createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379' +}); + +client.on('error', (err) => { + console.error('Redis Client Error:', err); +}); + +client.on('connect', () => { + console.log('Redis connected successfully'); +}); + +// Connect to Redis +(async () => { + await client.connect(); +})(); + +module.exports = client; diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js new file mode 100644 index 0000000..bc0ee88 --- /dev/null +++ b/backend/src/controllers/authController.js @@ -0,0 +1,232 @@ +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const pool = require('../config/database'); +const logger = require('../config/logger'); + +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 { + // Login + async login(req, res) { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required' }); + } + + // Find user + const userResult = await pool.query( + 'SELECT * FROM users WHERE username = $1 AND is_active = true', + [username] + ); + + if (userResult.rows.length === 0) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const user = userResult.rows[0]; + + // Verify password + const validPassword = await bcrypt.compare(password, user.password_hash); + if (!validPassword) { + 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 + }); + } catch (error) { + logger.error('Login error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Refresh token + async refresh(req, res) { + try { + const { refreshToken } = req.body; + + if (!refreshToken) { + return res.status(400).json({ error: 'Refresh token required' }); + } + + // Verify refresh token + let decoded; + try { + decoded = jwt.verify(refreshToken, JWT_SECRET); + } catch (error) { + return res.status(401).json({ error: 'Invalid refresh token' }); + } + + // Check if session exists and not expired + const sessionResult = await pool.query( + `SELECT s.*, u.username, u.role, u.is_active + FROM user_sessions s + JOIN users u ON s.user_id = u.id + WHERE s.refresh_token = $1 AND s.expires_at > NOW()`, + [refreshToken] + ); + + if (sessionResult.rows.length === 0) { + return res.status(401).json({ error: 'Session expired or invalid' }); + } + + const session = sessionResult.rows[0]; + + if (!session.is_active) { + return res.status(401).json({ error: 'User is inactive' }); + } + + // Generate new access token + const accessToken = jwt.sign( + { id: session.user_id, username: session.username, role: session.role }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); + + res.json({ accessToken }); + } catch (error) { + logger.error('Token refresh error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Logout + async logout(req, res) { + try { + const { refreshToken } = req.body; + + if (refreshToken) { + await pool.query( + 'DELETE FROM user_sessions WHERE refresh_token = $1', + [refreshToken] + ); + } + + res.json({ message: 'Logged out successfully' }); + } catch (error) { + logger.error('Logout error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Get current user + async me(req, res) { + try { + const userResult = await pool.query( + 'SELECT id, username, full_name, email, role, avatar_url, last_login, created_at FROM users WHERE id = $1', + [req.user.id] + ); + + if (userResult.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json(userResult.rows[0]); + } catch (error) { + logger.error('Get user error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Change password + async changePassword(req, res) { + try { + const { currentPassword, newPassword } = req.body; + + if (!currentPassword || !newPassword) { + return res.status(400).json({ error: 'Current and new password required' }); + } + + if (newPassword.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + // Get user + const userResult = await pool.query( + 'SELECT password_hash FROM users WHERE id = $1', + [req.user.id] + ); + + if (userResult.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + // Verify current password + const validPassword = await bcrypt.compare( + currentPassword, + userResult.rows[0].password_hash + ); + + if (!validPassword) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + // Hash new password + const newPasswordHash = await bcrypt.hash(newPassword, 10); + + // Update password + await pool.query( + 'UPDATE users SET password_hash = $1 WHERE id = $2', + [newPasswordHash, req.user.id] + ); + + // Invalidate all sessions except current + const { refreshToken } = req.body; + if (refreshToken) { + await pool.query( + 'DELETE FROM user_sessions WHERE user_id = $1 AND refresh_token != $2', + [req.user.id, refreshToken] + ); + } + + logger.info(`Password changed for user: ${req.user.username}`); + + res.json({ message: 'Password changed successfully' }); + } catch (error) { + logger.error('Change password error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +} + +module.exports = new AuthController(); \ No newline at end of file diff --git a/backend/src/controllers/etaController.js b/backend/src/controllers/etaController.js new file mode 100644 index 0000000..2a2cd9d --- /dev/null +++ b/backend/src/controllers/etaController.js @@ -0,0 +1,271 @@ +const pool = require('../config/database'); +const logger = require('../config/logger'); + +class EtaController { + // Calculate ETA for a stop on a route + async calculateEta(req, res) { + const { stopId, routeId } = req.body; + const currentTime = new Date(); + + if (!stopId || !routeId) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['stopId', 'routeId'] + }); + } + + try { + // Get schedule type for today (holiday-aware) + const typeResult = await pool.query( + 'SELECT get_schedule_type(CURRENT_DATE) as schedule_type' + ); + let scheduleType = typeResult.rows[0].schedule_type; + + // Get schedule for this route + let scheduleResult = await pool.query(` + SELECT departure_times + FROM schedules + WHERE route_id = $1 + AND day_type = $2 + AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) + AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + LIMIT 1 + `, [routeId, scheduleType]); + + // If holiday and no specific holiday schedule, fall back to sunday + if (scheduleResult.rows.length === 0 && scheduleType === 'holiday') { + scheduleType = 'sunday'; + scheduleResult = await pool.query(` + SELECT departure_times + FROM schedules + WHERE route_id = $1 + AND day_type = 'sunday' + AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) + AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + LIMIT 1 + `, [routeId]); + } + + if (scheduleResult.rows.length === 0) { + return res.status(404).json({ + error: 'No schedule found', + message: 'Schedule not available for this route and day type', + scheduleType: scheduleType + }); + } + + const departureTimes = scheduleResult.rows[0].departure_times; + + // Get time offset for this stop + const stopResult = await pool.query(` + SELECT time_offset_minutes + FROM route_stops + WHERE route_id = $1 AND stop_id = $2 + `, [routeId, stopId]); + + if (stopResult.rows.length === 0) { + return res.status(404).json({ + error: 'Stop not found on route', + message: 'This stop is not part of this route' + }); + } + + const timeOffset = stopResult.rows[0].time_offset_minutes; + + // Calculate ETAs + const currentTimeStr = currentTime.toTimeString().split(' ')[0]; + const etas = []; + + for (const departureTime of departureTimes) { + if (departureTime > currentTimeStr) { + // Add time offset to get arrival time at this stop + const arrival = this.addMinutesToTime(departureTime, timeOffset); + + // Get historical delay for this hour and day type + const delay = await this.getHistoricalDelay( + routeId, + stopId, + currentTime.getHours(), + scheduleType === 'holiday' ? 'sunday' : scheduleType + ); + + const adjustedArrival = this.addMinutesToTime(arrival, delay); + const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival); + + etas.push({ + scheduledTime: arrival, + estimatedTime: adjustedArrival, + minutesUntil: minutesUntil, + delay: delay + }); + + // Return next 3 arrivals + if (etas.length >= 3) break; + } + } + + if (etas.length === 0) { + return res.json({ + message: 'No more arrivals today', + nextDay: departureTimes[0] + }); + } + + res.json({ + routeId, + stopId, + currentTime: currentTimeStr, + dayType: scheduleType, + arrivals: etas + }); + + } catch (error) { + logger.error('Error calculating ETA:', error); + res.status(500).json({ error: 'Failed to calculate ETA' }); + } + } + + // Get multiple ETAs for a stop (all routes) + async getStopEtas(req, res) { + const { stopId } = req.params; + + try { + // 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]); + + const etaPromises = routesResult.rows.map(route => + this.calculateEtaForRoute(stopId, route.id) + .then(etas => ({ + ...route, + arrivals: etas + })) + .catch(err => { + logger.error(`Error calculating ETA for route ${route.id}:`, err); + return { + ...route, + arrivals: [], + error: 'Could not calculate ETA' + }; + }) + ); + + const results = await Promise.all(etaPromises); + + res.json({ + stopId, + routes: results + }); + + } catch (error) { + logger.error('Error getting stop ETAs:', error); + res.status(500).json({ error: 'Failed to get stop ETAs' }); + } + } + + // Helper: Calculate ETA for a specific route (internal use) + async calculateEtaForRoute(stopId, routeId) { + const currentTime = new Date(); + const dayType = this.getDayType(currentTime); + + const scheduleResult = await pool.query(` + SELECT departure_times + FROM schedules + WHERE route_id = $1 AND day_type = $2 + AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) + AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + LIMIT 1 + `, [routeId, dayType]); + + if (scheduleResult.rows.length === 0) return []; + + const departureTimes = scheduleResult.rows[0].departure_times; + + const stopResult = await pool.query(` + SELECT time_offset_minutes + FROM route_stops + WHERE route_id = $1 AND stop_id = $2 + `, [routeId, stopId]); + + if (stopResult.rows.length === 0) return []; + + const timeOffset = stopResult.rows[0].time_offset_minutes; + const currentTimeStr = currentTime.toTimeString().split(' ')[0]; + const etas = []; + + for (const departureTime of departureTimes) { + if (departureTime > currentTimeStr) { + const arrival = this.addMinutesToTime(departureTime, timeOffset); + const delay = await this.getHistoricalDelay( + routeId, + stopId, + currentTime.getHours(), + dayType + ); + + const adjustedArrival = this.addMinutesToTime(arrival, delay); + const minutesUntil = this.getMinutesDifference(currentTimeStr, adjustedArrival); + + etas.push({ + scheduledTime: arrival, + estimatedTime: adjustedArrival, + minutesUntil: minutesUntil, + delay: delay + }); + + if (etas.length >= 3) break; + } + } + + return etas; + } + + // Helper: Get historical delay + async getHistoricalDelay(routeId, stopId, hour, dayType) { + try { + const result = await pool.query(` + SELECT avg_delay_minutes + FROM historical_delays + WHERE route_id = $1 + AND stop_id = $2 + AND hour_of_day = $3 + AND day_type = $4 + `, [routeId, stopId, hour, dayType]); + + return result.rows.length > 0 ? result.rows[0].avg_delay_minutes : 0; + } catch (error) { + logger.error('Error fetching historical delay:', error); + return 0; + } + } + + // Helper: Determine day type + getDayType(date) { + const day = date.getDay(); + if (day === 0) return 'sunday'; + if (day === 6) return 'saturday'; + return 'weekday'; + } + + // Helper: Add minutes to time string + addMinutesToTime(timeStr, minutes) { + const [hours, mins, secs] = timeStr.split(':').map(Number); + const totalMins = hours * 60 + mins + minutes; + const newHours = Math.floor(totalMins / 60) % 24; + const newMins = totalMins % 60; + return `${String(newHours).padStart(2, '0')}:${String(newMins).padStart(2, '0')}:${String(secs || 0).padStart(2, '0')}`; + } + + // Helper: Get minutes difference between two times + getMinutesDifference(time1, time2) { + const [h1, m1] = time1.split(':').map(Number); + const [h2, m2] = time2.split(':').map(Number); + return (h2 * 60 + m2) - (h1 * 60 + m1); + } +} + +module.exports = new EtaController(); \ No newline at end of file diff --git a/backend/src/controllers/holidaysController.js b/backend/src/controllers/holidaysController.js new file mode 100644 index 0000000..0c38178 --- /dev/null +++ b/backend/src/controllers/holidaysController.js @@ -0,0 +1,223 @@ +const pool = require('../config/database'); +const redisClient = require('../config/redis'); +const logger = require('../config/logger'); + +class HolidaysController { + // Get all holidays + async getAllHolidays(req, res) { + const { year } = req.query; + + try { + let query = ` + SELECT * FROM holidays + ORDER BY date + `; + let params = []; + + if (year) { + query = ` + SELECT * FROM holidays + WHERE EXTRACT(YEAR FROM date) = $1 + OR is_recurring = true + ORDER BY + CASE WHEN EXTRACT(YEAR FROM date) = $1 THEN date + ELSE MAKE_DATE($1::int, recurring_month, recurring_day) + END + `; + params = [year]; + } + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + logger.error('Error fetching holidays:', error); + res.status(500).json({ error: 'Failed to fetch holidays' }); + } + } + + // Get holidays for a specific year (with resolved recurring dates) + async getHolidaysForYear(req, res) { + const { year } = req.params; + + try { + const result = await pool.query(` + SELECT + id, + CASE + WHEN is_recurring THEN MAKE_DATE($1::int, recurring_month, recurring_day) + ELSE date + END as date, + name, + is_recurring + FROM holidays + WHERE EXTRACT(YEAR FROM date) = $1 + OR is_recurring = true + ORDER BY date + `, [year]); + + res.json(result.rows); + } catch (error) { + logger.error('Error fetching holidays for year:', error); + res.status(500).json({ error: 'Failed to fetch holidays' }); + } + } + + // Check if a specific date is a holiday + async checkHoliday(req, res) { + const { date } = req.params; + + try { + const result = await pool.query( + 'SELECT is_holiday($1::date) as is_holiday', + [date] + ); + + const holidayInfo = await pool.query(` + SELECT name FROM holidays + WHERE date = $1 + OR (is_recurring = true + AND recurring_month = EXTRACT(MONTH FROM $1::date) + AND recurring_day = EXTRACT(DAY FROM $1::date)) + LIMIT 1 + `, [date]); + + res.json({ + date: date, + isHoliday: result.rows[0].is_holiday, + holidayName: holidayInfo.rows[0]?.name || null + }); + } catch (error) { + logger.error('Error checking holiday:', error); + res.status(500).json({ error: 'Failed to check holiday' }); + } + } + + // Add a holiday + async createHoliday(req, res) { + const { date, name, isRecurring } = req.body; + + if (!date || !name) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['date', 'name'] + }); + } + + try { + const dateObj = new Date(date); + const month = dateObj.getMonth() + 1; + const day = dateObj.getDate(); + + const result = await pool.query(` + INSERT INTO holidays (date, name, is_recurring, recurring_month, recurring_day) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (date) DO UPDATE SET name = $2 + RETURNING * + `, [date, name, isRecurring || false, isRecurring ? month : null, isRecurring ? day : null]); + + logger.info(`Holiday created: ${name} on ${date}`); + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error('Error creating holiday:', error); + res.status(500).json({ error: 'Failed to create holiday' }); + } + } + + // Update a holiday + async updateHoliday(req, res) { + const { id } = req.params; + const { date, name, isRecurring } = req.body; + + try { + let month = null; + let day = null; + + if (date && isRecurring) { + const dateObj = new Date(date); + month = dateObj.getMonth() + 1; + day = dateObj.getDate(); + } + + const result = await pool.query(` + UPDATE holidays + SET + date = COALESCE($1, date), + name = COALESCE($2, name), + is_recurring = COALESCE($3, is_recurring), + recurring_month = $4, + recurring_day = $5 + WHERE id = $6 + RETURNING * + `, [date, name, isRecurring, month, day, id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Holiday not found' }); + } + + logger.info(`Holiday ${id} updated`); + res.json(result.rows[0]); + } catch (error) { + logger.error('Error updating holiday:', error); + res.status(500).json({ error: 'Failed to update holiday' }); + } + } + + // Delete a holiday + async deleteHoliday(req, res) { + const { id } = req.params; + + try { + const result = await pool.query( + 'DELETE FROM holidays WHERE id = $1 RETURNING *', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Holiday not found' }); + } + + logger.info(`Holiday ${id} deleted`); + res.json({ message: 'Holiday deleted successfully' }); + } catch (error) { + logger.error('Error deleting holiday:', error); + res.status(500).json({ error: 'Failed to delete holiday' }); + } + } + + // Get upcoming holidays + async getUpcomingHolidays(req, res) { + const { limit = 5 } = req.query; + + try { + const result = await pool.query(` + WITH upcoming AS ( + SELECT + id, + name, + CASE + WHEN is_recurring THEN + CASE + WHEN MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int, recurring_month, recurring_day) >= CURRENT_DATE + THEN MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int, recurring_month, recurring_day) + ELSE MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::int + 1, recurring_month, recurring_day) + END + ELSE date + END as next_date, + is_recurring + FROM holidays + ) + SELECT * FROM upcoming + WHERE next_date >= CURRENT_DATE + ORDER BY next_date + LIMIT $1 + `, [limit]); + + res.json(result.rows); + } catch (error) { + logger.error('Error fetching upcoming holidays:', error); + res.status(500).json({ error: 'Failed to fetch upcoming holidays' }); + } + } +} + +module.exports = new HolidaysController(); \ No newline at end of file diff --git a/backend/src/controllers/routeStopsController.js b/backend/src/controllers/routeStopsController.js new file mode 100644 index 0000000..e82ee3e --- /dev/null +++ b/backend/src/controllers/routeStopsController.js @@ -0,0 +1,282 @@ +const pool = require('../config/database'); +const redisClient = require('../config/redis'); +const logger = require('../config/logger'); + +class RouteStopsController { + // Get all stops for a route (ordered by sequence) + async getRouteStops(req, res) { + const { routeId } = req.params; + + try { + const result = await pool.query(` + SELECT + rs.id as route_stop_id, + rs.sequence, + rs.time_offset_minutes, + s.id as stop_id, + s.name, + s.address, + ST_Y(s.location::geometry) as latitude, + 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 + ORDER BY rs.sequence + `, [routeId]); + + res.json(result.rows); + } catch (error) { + logger.error('Error fetching route stops:', error); + res.status(500).json({ error: 'Failed to fetch route stops' }); + } + } + + // Add stop to route + async addStopToRoute(req, res) { + const { routeId } = req.params; + const { stopId, sequence, timeOffsetMinutes = 0 } = req.body; + + if (!stopId) { + return res.status(400).json({ + error: 'Missing required field: stopId' + }); + } + + try { + // Check if route exists + const routeCheck = await pool.query( + 'SELECT id FROM routes WHERE id = $1', + [routeId] + ); + if (routeCheck.rows.length === 0) { + 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) { + return res.status(404).json({ error: 'Stop not found' }); + } + + // If sequence not provided, add to the end + let actualSequence = sequence; + if (actualSequence === undefined || actualSequence === null) { + const maxSeq = await pool.query( + 'SELECT COALESCE(MAX(sequence), 0) + 1 as next_seq FROM route_stops WHERE route_id = $1', + [routeId] + ); + actualSequence = maxSeq.rows[0].next_seq; + } else { + // Shift existing stops if inserting in the middle + await pool.query(` + UPDATE route_stops + SET sequence = sequence + 1 + WHERE route_id = $1 AND sequence >= $2 + `, [routeId, actualSequence]); + } + + // Insert the new route_stop + const result = await pool.query(` + INSERT INTO route_stops (route_id, stop_id, sequence, time_offset_minutes) + VALUES ($1, $2, $3, $4) + ON CONFLICT (route_id, stop_id, sequence) DO UPDATE + SET time_offset_minutes = $4 + RETURNING * + `, [routeId, stopId, actualSequence, timeOffsetMinutes]); + + // Invalidate cache + await redisClient.del(`route:${routeId}`); + await redisClient.del('routes:all'); + + logger.info(`Stop ${stopId} added to route ${routeId} at position ${actualSequence}`); + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error('Error adding stop to route:', error); + res.status(500).json({ error: 'Failed to add stop to route' }); + } + } + + // Update stop in route (change sequence or time offset) + async updateRouteStop(req, res) { + const { routeId, routeStopId } = req.params; + const { sequence, timeOffsetMinutes } = req.body; + + try { + // Get current route_stop + const current = await pool.query( + 'SELECT * FROM route_stops WHERE id = $1 AND route_id = $2', + [routeStopId, routeId] + ); + + if (current.rows.length === 0) { + return res.status(404).json({ error: 'Route stop not found' }); + } + + const currentStop = current.rows[0]; + + // If sequence is changing, reorder other stops + if (sequence !== undefined && sequence !== currentStop.sequence) { + if (sequence > currentStop.sequence) { + // Moving down: shift intermediate stops up + await pool.query(` + UPDATE route_stops + SET sequence = sequence - 1 + WHERE route_id = $1 + AND sequence > $2 + AND sequence <= $3 + `, [routeId, currentStop.sequence, sequence]); + } else { + // Moving up: shift intermediate stops down + await pool.query(` + UPDATE route_stops + SET sequence = sequence + 1 + WHERE route_id = $1 + AND sequence >= $2 + AND sequence < $3 + `, [routeId, sequence, currentStop.sequence]); + } + } + + // Update the route_stop + const result = await pool.query(` + UPDATE route_stops + SET + sequence = COALESCE($1, sequence), + time_offset_minutes = COALESCE($2, time_offset_minutes) + WHERE id = $3 AND route_id = $4 + RETURNING * + `, [sequence, timeOffsetMinutes, routeStopId, routeId]); + + // Invalidate cache + await redisClient.del(`route:${routeId}`); + + logger.info(`Route stop ${routeStopId} updated`); + res.json(result.rows[0]); + } catch (error) { + logger.error('Error updating route stop:', error); + res.status(500).json({ error: 'Failed to update route stop' }); + } + } + + // Remove stop from route + async removeStopFromRoute(req, res) { + const { routeId, routeStopId } = req.params; + + try { + // 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', + [routeStopId, routeId] + ); + + if (current.rows.length === 0) { + return res.status(404).json({ error: 'Route stop not found' }); + } + + const removedSequence = current.rows[0].sequence; + + // Delete the route_stop + await pool.query( + 'DELETE FROM route_stops WHERE id = $1 AND route_id = $2', + [routeStopId, routeId] + ); + + // Reorder remaining stops + await pool.query(` + UPDATE route_stops + SET sequence = sequence - 1 + WHERE route_id = $1 AND sequence > $2 + `, [routeId, removedSequence]); + + // Invalidate cache + await redisClient.del(`route:${routeId}`); + await redisClient.del('routes:all'); + + logger.info(`Stop removed from route ${routeId}`); + res.json({ message: 'Stop removed from route successfully' }); + } catch (error) { + logger.error('Error removing stop from route:', error); + res.status(500).json({ error: 'Failed to remove stop from route' }); + } + } + + // Bulk update route stops (reorder all at once) + async reorderRouteStops(req, res) { + const { routeId } = req.params; + const { stops } = req.body; // Array of { stopId, sequence, timeOffsetMinutes } + + if (!Array.isArray(stops) || stops.length === 0) { + return res.status(400).json({ + error: 'Invalid request', + message: 'stops must be a non-empty array' + }); + } + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Delete all existing route_stops for this route + await client.query( + 'DELETE FROM route_stops WHERE route_id = $1', + [routeId] + ); + + // Insert new order + for (const stop of stops) { + await client.query(` + INSERT INTO route_stops (route_id, stop_id, sequence, time_offset_minutes) + VALUES ($1, $2, $3, $4) + `, [routeId, stop.stopId, stop.sequence, stop.timeOffsetMinutes || 0]); + } + + await client.query('COMMIT'); + + // Invalidate cache + await redisClient.del(`route:${routeId}`); + await redisClient.del('routes:all'); + + logger.info(`Route ${routeId} stops reordered: ${stops.length} stops`); + res.json({ message: 'Route stops reordered successfully', count: stops.length }); + } catch (error) { + await client.query('ROLLBACK'); + logger.error('Error reordering route stops:', error); + res.status(500).json({ error: 'Failed to reorder route stops' }); + } finally { + client.release(); + } + } + + // Get available stops (not yet in this route) + async getAvailableStops(req, res) { + const { routeId } = req.params; + + try { + const result = await pool.query(` + SELECT + s.id, + s.name, + s.address, + ST_Y(s.location::geometry) as latitude, + ST_X(s.location::geometry) as longitude + FROM stops s + WHERE s.is_active = true + AND s.id NOT IN ( + SELECT stop_id FROM route_stops WHERE route_id = $1 + ) + ORDER BY s.name + `, [routeId]); + + res.json(result.rows); + } catch (error) { + logger.error('Error fetching available stops:', error); + res.status(500).json({ error: 'Failed to fetch available stops' }); + } + } +} + +module.exports = new RouteStopsController(); \ No newline at end of file diff --git a/backend/src/controllers/routesController.js b/backend/src/controllers/routesController.js new file mode 100644 index 0000000..822e3d3 --- /dev/null +++ b/backend/src/controllers/routesController.js @@ -0,0 +1,181 @@ +const pool = require('../config/database'); +const redisClient = require('../config/redis'); +const logger = require('../config/logger'); + +const CACHE_TTL = 3600; // 1 hour + +class RoutesController { + // Get all routes + async getAllRoutes(req, res) { + try { + // Try cache first + const cached = await redisClient.get('routes:all'); + if (cached) { + return res.json(JSON.parse(cached)); + } + + const result = await pool.query(` + SELECT + r.*, + 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 + GROUP BY r.id + ORDER BY r.route_number + `); + + const routes = result.rows; + + // Cache the result + await redisClient.setEx('routes:all', CACHE_TTL, JSON.stringify(routes)); + + res.json(routes); + } catch (error) { + logger.error('Error fetching routes:', error); + res.status(500).json({ error: 'Failed to fetch routes' }); + } + } + + // Get route by ID with stops + async getRouteById(req, res) { + const { id } = req.params; + + try { + const cacheKey = `route:${id}`; + const cached = await redisClient.get(cacheKey); + if (cached) { + return res.json(JSON.parse(cached)); + } + + // Get route info + const routeResult = await pool.query( + 'SELECT * FROM routes WHERE id = $1 AND is_active = true', + [id] + ); + + if (routeResult.rows.length === 0) { + return res.status(404).json({ error: 'Route not found' }); + } + + // Get stops for this route + const stopsResult = await pool.query(` + SELECT + s.*, + ST_Y(s.location::geometry) as latitude, + ST_X(s.location::geometry) as longitude, + rs.sequence, + rs.time_offset_minutes + FROM stops s + JOIN route_stops rs ON s.id = rs.stop_id + WHERE rs.route_id = $1 + ORDER BY rs.sequence + `, [id]); + + const route = { + ...routeResult.rows[0], + stops: stopsResult.rows + }; + + // Cache the result + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(route)); + + res.json(route); + } catch (error) { + logger.error('Error fetching route:', error); + res.status(500).json({ error: 'Failed to fetch route' }); + } + } + + // Create new route + async createRoute(req, res) { + const { route_number, name, type, color, description } = req.body; + + if (!route_number || !name || !type) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['route_number', 'name', 'type'] + }); + } + + try { + const result = await pool.query(` + INSERT INTO routes (route_number, name, type, color, description) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, [route_number, name, type, color || '#0066CC', description]); + + // Invalidate cache + await redisClient.del('routes:all'); + + logger.info(`Route created: ${route_number} - ${name}`); + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error('Error creating route:', error); + res.status(500).json({ error: 'Failed to create route' }); + } + } + + // Update route + async updateRoute(req, res) { + const { id } = req.params; + const { route_number, name, type, color, description, is_active } = req.body; + + try { + const result = await pool.query(` + UPDATE routes + SET + route_number = COALESCE($1, route_number), + name = COALESCE($2, name), + type = COALESCE($3, type), + color = COALESCE($4, color), + description = COALESCE($5, description), + is_active = COALESCE($6, is_active) + WHERE id = $7 + RETURNING * + `, [route_number, name, type, color, description, is_active, id]); + + 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}`); + + logger.info(`Route updated: ${id}`); + res.json(result.rows[0]); + } catch (error) { + logger.error('Error updating route:', error); + res.status(500).json({ error: 'Failed to update route' }); + } + } + + // Delete route + async deleteRoute(req, res) { + const { id } = req.params; + + try { + const result = await pool.query( + 'DELETE FROM routes WHERE id = $1 RETURNING *', + [id] + ); + + 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}`); + + logger.info(`Route deleted: ${id}`); + res.json({ message: 'Route deleted successfully' }); + } catch (error) { + logger.error('Error deleting route:', error); + res.status(500).json({ error: 'Failed to delete route' }); + } + } +} + +module.exports = new RoutesController(); diff --git a/backend/src/controllers/schedulesController.js b/backend/src/controllers/schedulesController.js new file mode 100644 index 0000000..43d8c30 --- /dev/null +++ b/backend/src/controllers/schedulesController.js @@ -0,0 +1,348 @@ +const pool = require('../config/database'); +const redisClient = require('../config/redis'); +const logger = require('../config/logger'); + +class SchedulesController { + // Get all schedules for a route + async getRouteSchedules(req, res) { + const { routeId } = req.params; + + try { + const result = await pool.query(` + 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 + ORDER BY + CASE s.day_type + WHEN 'weekday' THEN 1 + WHEN 'saturday' THEN 2 + WHEN 'sunday' THEN 3 + WHEN 'holiday' THEN 4 + END + `, [routeId]); + + res.json(result.rows); + } catch (error) { + logger.error('Error fetching route schedules:', error); + res.status(500).json({ error: 'Failed to fetch schedules' }); + } + } + + // Get schedule by ID + async getScheduleById(req, res) { + const { id } = req.params; + + try { + const result = await pool.query(` + SELECT + s.*, + r.route_number, + r.name as route_name + FROM schedules s + JOIN routes r ON s.route_id = r.id + WHERE s.id = $1 + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Schedule not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + logger.error('Error fetching schedule:', error); + res.status(500).json({ error: 'Failed to fetch schedule' }); + } + } + + // Create schedule + async createSchedule(req, res) { + const { routeId, dayType, departureTimes, validFrom, validUntil } = req.body; + + if (!routeId || !dayType || !departureTimes) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['routeId', 'dayType', 'departureTimes'] + }); + } + + // Validate day type + const validDayTypes = ['weekday', 'saturday', 'sunday', 'holiday']; + if (!validDayTypes.includes(dayType)) { + return res.status(400).json({ + error: 'Invalid day type', + valid: validDayTypes + }); + } + + // Validate and sort departure times + const sortedTimes = this.validateAndSortTimes(departureTimes); + if (!sortedTimes) { + return res.status(400).json({ + error: 'Invalid departure times format', + expected: 'Array of times in HH:MM or HH:MM:SS format' + }); + } + + try { + // Check if schedule already exists for this route and day type + const existing = await pool.query(` + SELECT id FROM schedules + WHERE route_id = $1 AND day_type = $2 + AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + `, [routeId, dayType]); + + if (existing.rows.length > 0) { + return res.status(409).json({ + error: 'Schedule already exists', + message: `A schedule for ${dayType} already exists for this route`, + existingId: existing.rows[0].id + }); + } + + const result = await pool.query(` + INSERT INTO schedules (route_id, day_type, departure_times, valid_from, valid_until) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, [routeId, dayType, sortedTimes, validFrom || null, validUntil || null]); + + // Invalidate cache + await redisClient.del(`route:${routeId}`); + + logger.info(`Schedule created for route ${routeId}, day type: ${dayType}`); + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error('Error creating schedule:', error); + res.status(500).json({ error: 'Failed to create schedule' }); + } + } + + // Update schedule + async updateSchedule(req, res) { + const { id } = req.params; + const { departureTimes, validFrom, validUntil } = req.body; + + try { + // Get current schedule to find route_id + const current = await pool.query('SELECT route_id FROM schedules WHERE id = $1', [id]); + if (current.rows.length === 0) { + return res.status(404).json({ error: 'Schedule not found' }); + } + + let sortedTimes = null; + if (departureTimes) { + sortedTimes = this.validateAndSortTimes(departureTimes); + if (!sortedTimes) { + return res.status(400).json({ + error: 'Invalid departure times format' + }); + } + } + + const result = await pool.query(` + UPDATE schedules + SET + departure_times = COALESCE($1, departure_times), + valid_from = COALESCE($2, valid_from), + valid_until = COALESCE($3, valid_until), + updated_at = CURRENT_TIMESTAMP + WHERE id = $4 + RETURNING * + `, [sortedTimes, validFrom, validUntil, id]); + + // Invalidate cache + await redisClient.del(`route:${current.rows[0].route_id}`); + + logger.info(`Schedule ${id} updated`); + res.json(result.rows[0]); + } catch (error) { + logger.error('Error updating schedule:', error); + res.status(500).json({ error: 'Failed to update schedule' }); + } + } + + // Delete schedule + async deleteSchedule(req, res) { + const { id } = req.params; + + try { + const result = await pool.query( + 'DELETE FROM schedules WHERE id = $1 RETURNING route_id', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Schedule not found' }); + } + + // Invalidate cache + await redisClient.del(`route:${result.rows[0].route_id}`); + + logger.info(`Schedule ${id} deleted`); + res.json({ message: 'Schedule deleted successfully' }); + } catch (error) { + logger.error('Error deleting schedule:', error); + res.status(500).json({ error: 'Failed to delete schedule' }); + } + } + + // Get schedule for today (or specific date) for a route + async getTodaySchedule(req, res) { + const { routeId } = req.params; + const { date } = req.query; // Optional: specific date in YYYY-MM-DD format + + try { + const targetDate = date || new Date().toISOString().split('T')[0]; + + // Get schedule type for the date (considers holidays) + const typeResult = await pool.query( + 'SELECT get_schedule_type($1::date) as schedule_type', + [targetDate] + ); + + let scheduleType = typeResult.rows[0].schedule_type; + + // If holiday, use sunday schedule (as per Belarus rules) + // But first check if there's a specific holiday schedule + let result = await pool.query(` + 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 + 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]); + + // If holiday and no specific holiday schedule, fall back to sunday + if (result.rows.length === 0 && scheduleType === 'holiday') { + result = await pool.query(` + 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 + 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]); + } + + if (result.rows.length === 0) { + return res.status(404).json({ + error: 'No schedule found', + date: targetDate, + scheduleType: scheduleType + }); + } + + // Check if today is a holiday + const holidayCheck = await pool.query( + 'SELECT name FROM holidays WHERE date = $1', + [targetDate] + ); + + res.json({ + date: targetDate, + scheduleType: scheduleType, + isHoliday: holidayCheck.rows.length > 0, + holidayName: holidayCheck.rows[0]?.name || null, + schedule: result.rows[0] + }); + } catch (error) { + logger.error('Error fetching today schedule:', error); + res.status(500).json({ error: 'Failed to fetch schedule' }); + } + } + + // Copy schedule from one day type to another + async copySchedule(req, res) { + const { routeId } = req.params; + const { fromDayType, toDayType } = req.body; + + if (!fromDayType || !toDayType) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['fromDayType', 'toDayType'] + }); + } + + try { + // Get source schedule + const source = await pool.query(` + SELECT departure_times FROM schedules + WHERE route_id = $1 AND day_type = $2 + ORDER BY created_at DESC + LIMIT 1 + `, [routeId, fromDayType]); + + if (source.rows.length === 0) { + return res.status(404).json({ + error: 'Source schedule not found' + }); + } + + // Check if target already exists + const existing = await pool.query(` + SELECT id FROM schedules + WHERE route_id = $1 AND day_type = $2 + `, [routeId, toDayType]); + + if (existing.rows.length > 0) { + // Update existing + const result = await pool.query(` + UPDATE schedules + SET departure_times = $1, updated_at = CURRENT_TIMESTAMP + WHERE route_id = $2 AND day_type = $3 + RETURNING * + `, [source.rows[0].departure_times, routeId, toDayType]); + + res.json(result.rows[0]); + } else { + // Create new + const result = await pool.query(` + INSERT INTO schedules (route_id, day_type, departure_times) + VALUES ($1, $2, $3) + RETURNING * + `, [routeId, toDayType, source.rows[0].departure_times]); + + res.status(201).json(result.rows[0]); + } + + // Invalidate cache + await redisClient.del(`route:${routeId}`); + + logger.info(`Schedule copied from ${fromDayType} to ${toDayType} for route ${routeId}`); + } catch (error) { + logger.error('Error copying schedule:', error); + res.status(500).json({ error: 'Failed to copy schedule' }); + } + } + + // Helper: Validate and sort times + validateAndSortTimes(times) { + if (!Array.isArray(times)) return null; + + const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])(:[0-5][0-9])?$/; + const validTimes = []; + + for (const time of times) { + if (!timeRegex.test(time)) return null; + + // Normalize to HH:MM:SS format + const parts = time.split(':'); + const normalized = `${parts[0].padStart(2, '0')}:${parts[1]}:${parts[2] || '00'}`; + validTimes.push(normalized); + } + + // Sort times + validTimes.sort(); + return validTimes; + } +} + +module.exports = new SchedulesController(); \ No newline at end of file diff --git a/backend/src/controllers/stopsController.js b/backend/src/controllers/stopsController.js new file mode 100644 index 0000000..accd29d --- /dev/null +++ b/backend/src/controllers/stopsController.js @@ -0,0 +1,237 @@ +const pool = require('../config/database'); +const redisClient = require('../config/redis'); +const logger = require('../config/logger'); + +const CACHE_TTL = 3600; + +class StopsController { + // Get all stops + async getAllStops(req, res) { + try { + const cached = await redisClient.get('stops:all'); + if (cached) { + return res.json(JSON.parse(cached)); + } + + const result = await pool.query(` + SELECT + s.*, + ST_Y(s.location::geometry) as latitude, + ST_X(s.location::geometry) as longitude, + 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 + GROUP BY s.id + ORDER BY s.name + `); + + const stops = result.rows; + await redisClient.setEx('stops:all', CACHE_TTL, JSON.stringify(stops)); + + res.json(stops); + } catch (error) { + logger.error('Error fetching stops:', error); + res.status(500).json({ error: 'Failed to fetch stops' }); + } + } + + // Get stop by ID + async getStopById(req, res) { + const { id } = req.params; + + try { + const result = await pool.query(` + SELECT + s.*, + 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]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Stop not found' }); + } + + // Get routes passing through this stop + const routesResult = await pool.query(` + SELECT + r.*, + rs.sequence, + 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 + ORDER BY r.route_number + `, [id]); + + const stop = { + ...result.rows[0], + routes: routesResult.rows + }; + + res.json(stop); + } catch (error) { + logger.error('Error fetching stop:', error); + res.status(500).json({ error: 'Failed to fetch stop' }); + } + } + + // Get nearby stops (PostGIS query) + async getNearbyStops(req, res) { + const { lat, lon, radius = 500 } = req.query; + + if (!lat || !lon) { + return res.status(400).json({ + error: 'Missing required parameters', + required: ['lat', 'lon'] + }); + } + + try { + const result = await pool.query(` + SELECT + s.*, + ST_Y(s.location::geometry) as latitude, + ST_X(s.location::geometry) as longitude, + ST_Distance( + s.location, + ST_MakePoint($2, $1)::geography + ) as distance_meters + FROM stops s + WHERE s.is_active = true + AND ST_DWithin( + s.location, + ST_MakePoint($2, $1)::geography, + $3 + ) + ORDER BY distance_meters + LIMIT 20 + `, [parseFloat(lat), parseFloat(lon), parseFloat(radius)]); + + res.json(result.rows); + } catch (error) { + logger.error('Error fetching nearby stops:', error); + res.status(500).json({ error: 'Failed to fetch nearby stops' }); + } + } + + // Create stop + async createStop(req, res) { + const { name, latitude, longitude, address, description } = req.body; + + if (!name || !latitude || !longitude) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['name', 'latitude', 'longitude'] + }); + } + + try { + const result = await pool.query(` + INSERT INTO stops (name, location, address, description) + VALUES ( + $1, + ST_SetSRID(ST_MakePoint($3, $2), 4326)::geography, + $4, + $5 + ) + RETURNING + *, + ST_Y(location::geometry) as latitude, + ST_X(location::geometry) as longitude + `, [name, latitude, longitude, address, description]); + + await redisClient.del('stops:all'); + + logger.info(`Stop created: ${name}`); + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error('Error creating stop:', error); + res.status(500).json({ error: 'Failed to create stop' }); + } + } + + // Update stop + async updateStop(req, res) { + const { id } = req.params; + const { name, latitude, longitude, address, description, is_active } = req.body; + + try { + let query = 'UPDATE stops SET '; + const values = []; + let paramCount = 1; + + if (name !== undefined) { + query += `name = $${paramCount}, `; + values.push(name); + paramCount++; + } + if (latitude !== undefined && longitude !== undefined) { + query += `location = ST_SetSRID(ST_MakePoint($${paramCount + 1}, $${paramCount}), 4326)::geography, `; + values.push(latitude, longitude); + paramCount += 2; + } + if (address !== undefined) { + query += `address = $${paramCount}, `; + values.push(address); + paramCount++; + } + if (description !== undefined) { + query += `description = $${paramCount}, `; + values.push(description); + paramCount++; + } + if (is_active !== undefined) { + query += `is_active = $${paramCount}, `; + values.push(is_active); + paramCount++; + } + + 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); + + const result = await pool.query(query, values); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Stop not found' }); + } + + await redisClient.del('stops:all'); + + logger.info(`Stop updated: ${id}`); + res.json(result.rows[0]); + } catch (error) { + logger.error('Error updating stop:', error); + res.status(500).json({ error: 'Failed to update stop' }); + } + } + + // Delete stop + async deleteStop(req, res) { + const { id } = req.params; + + try { + const result = await pool.query( + 'DELETE FROM stops WHERE id = $1 RETURNING *', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Stop not found' }); + } + + await redisClient.del('stops:all'); + + logger.info(`Stop deleted: ${id}`); + res.json({ message: 'Stop deleted successfully' }); + } catch (error) { + logger.error('Error deleting stop:', error); + res.status(500).json({ error: 'Failed to delete stop' }); + } + } +} + +module.exports = new StopsController(); diff --git a/backend/src/controllers/syncController.js b/backend/src/controllers/syncController.js new file mode 100644 index 0000000..d1f93f6 --- /dev/null +++ b/backend/src/controllers/syncController.js @@ -0,0 +1,250 @@ +const pool = require('../config/database'); +const logger = require('../config/logger'); + +class SyncController { + // Get all data or incremental updates + async sync(req, res) { + const { lastSync } = req.query; + + try { + if (!lastSync) { + // Full sync - first time + return await this.fullSync(req, res); + } else { + // Incremental sync + return await this.incrementalSync(req, res, lastSync); + } + } catch (error) { + logger.error('Error during sync:', error); + res.status(500).json({ error: 'Sync failed' }); + } + } + + // Full synchronization (all data) + async fullSync(req, res) { + try { + // Get all active routes + const routesResult = await pool.query(` + SELECT * FROM routes WHERE is_active = true + `); + + // Get all active stops with coordinates + const stopsResult = await pool.query(` + SELECT + *, + ST_Y(location::geometry) as latitude, + ST_X(location::geometry) as longitude + FROM stops + WHERE is_active = true + `); + + // Get route-stop relationships + const routeStopsResult = await pool.query(` + SELECT rs.* + FROM route_stops rs + 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 + `); + + // 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 + AND (s.valid_from IS NULL OR s.valid_from <= CURRENT_DATE) + AND (s.valid_until IS NULL OR s.valid_until >= CURRENT_DATE) + `); + + // Get active alerts + const alertsResult = await pool.query(` + SELECT * FROM alerts + WHERE is_active = true + AND start_time <= CURRENT_TIMESTAMP + AND (end_time IS NULL OR end_time >= CURRENT_TIMESTAMP) + `); + + // Get holidays for current and next year + const holidaysResult = await pool.query(` + SELECT + id, + CASE + WHEN is_recurring THEN date + ELSE date + END as date, + name, + is_recurring, + recurring_month, + recurring_day + FROM holidays + WHERE EXTRACT(YEAR FROM date) >= EXTRACT(YEAR FROM CURRENT_DATE) + OR is_recurring = true + ORDER BY date + `); + + const syncData = { + syncType: 'full', + timestamp: new Date().toISOString(), + version: '1.0', + data: { + routes: routesResult.rows, + stops: stopsResult.rows, + routeStops: routeStopsResult.rows, + schedules: schedulesResult.rows, + alerts: alertsResult.rows, + holidays: holidaysResult.rows + }, + metadata: { + routesCount: routesResult.rows.length, + stopsCount: stopsResult.rows.length, + schedulesCount: schedulesResult.rows.length, + holidaysCount: holidaysResult.rows.length + } + }; + + logger.info('Full sync completed'); + res.json(syncData); + + } catch (error) { + logger.error('Full sync error:', error); + throw error; + } + } + + // Incremental synchronization (changes since lastSync) + async incrementalSync(req, res, lastSync) { + try { + const lastSyncDate = new Date(lastSync); + + // Get changes from sync_log + const changesResult = await pool.query(` + SELECT DISTINCT ON (table_name, record_id) + table_name, + record_id, + action, + timestamp + FROM sync_log + WHERE timestamp > $1 + ORDER BY table_name, record_id, timestamp DESC + `, [lastSyncDate]); + + const changes = { + routes: { updated: [], deleted: [] }, + stops: { updated: [], deleted: [] }, + routeStops: { updated: [], deleted: [] }, + schedules: { updated: [], deleted: [] } + }; + + // Process changes + for (const change of changesResult.rows) { + const { table_name, record_id, action } = change; + + if (action === 'delete') { + if (changes[table_name]) { + changes[table_name].deleted.push(record_id); + } + } else { + // Get updated record + const record = await this.getRecord(table_name, record_id); + if (record && changes[table_name]) { + changes[table_name].updated.push(record); + } + } + } + + // Get new alerts + const alertsResult = await pool.query(` + SELECT * FROM alerts + WHERE is_active = true + AND created_at > $1 + `, [lastSyncDate]); + + const syncData = { + syncType: 'incremental', + timestamp: new Date().toISOString(), + lastSync: lastSync, + changes: changes, + alerts: alertsResult.rows, + metadata: { + changesCount: changesResult.rows.length + } + }; + + logger.info(`Incremental sync completed: ${changesResult.rows.length} changes`); + res.json(syncData); + + } catch (error) { + logger.error('Incremental sync error:', error); + throw error; + } + } + + // Helper: Get record from table + async getRecord(tableName, recordId) { + try { + let query = ''; + + switch (tableName) { + case 'routes': + query = 'SELECT * FROM routes WHERE id = $1 AND is_active = true'; + break; + case 'stops': + query = ` + SELECT + *, + ST_Y(location::geometry) as latitude, + ST_X(location::geometry) as longitude + FROM stops + WHERE id = $1 AND is_active = true + `; + break; + case 'route_stops': + query = 'SELECT * FROM route_stops WHERE id = $1'; + break; + case 'schedules': + query = ` + SELECT * FROM schedules + WHERE id = $1 + AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) + AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) + `; + break; + default: + return null; + } + + const result = await pool.query(query, [recordId]); + return result.rows.length > 0 ? result.rows[0] : null; + + } catch (error) { + logger.error(`Error getting record from ${tableName}:`, error); + return null; + } + } + + // Get sync status + async getSyncStatus(req, res) { + try { + 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 + `); + + res.json({ + status: 'ready', + timestamp: new Date().toISOString(), + database: stats.rows[0] + }); + + } catch (error) { + logger.error('Error getting sync status:', error); + res.status(500).json({ error: 'Failed to get sync status' }); + } + } +} + +module.exports = new SyncController(); \ No newline at end of file diff --git a/backend/src/controllers/telemetryController.js b/backend/src/controllers/telemetryController.js new file mode 100644 index 0000000..ba9bbfe --- /dev/null +++ b/backend/src/controllers/telemetryController.js @@ -0,0 +1,84 @@ +const logger = require('../config/logger'); +const telemetryService = require('../services/telemetryService'); +const { broadcastToRoute } = require('../services/realtimeService'); + +function sendError(res, error, fallbackMessage) { + if (error.status) { + return res.status(error.status).json({ error: error.message }); + } + + logger.error(fallbackMessage, error); + return res.status(500).json({ error: fallbackMessage }); +} + +class TelemetryController { + async ingest(req, res) { + try { + const sourceType = req.body.sourceType || (req.user ? 'user' : 'api-key'); + const sourceRef = req.body.sourceRef || ( + req.user + ? `user:${req.user.id}` + : req.apiKeyInfo + ? `api-key:${req.apiKeyInfo.id}` + : null + ); + + const vehicle = await telemetryService.ingestTelemetry({ + ...req.body, + sourceType, + sourceRef, + rawPayload: req.body + }); + + broadcastToRoute(vehicle.route_id, { + type: 'vehicle:position', + routeId: vehicle.route_id, + vehicle + }); + + return res.status(201).json({ + message: 'Telemetry ingested successfully', + vehicle + }); + } catch (error) { + return sendError(res, error, 'Failed to ingest telemetry'); + } + } + + async getRouteLiveVehicles(req, res) { + try { + const includeStale = req.query.includeStale === 'true'; + const staleAfterSeconds = req.query.staleAfterSeconds + ? Number.parseInt(req.query.staleAfterSeconds, 10) + : undefined; + + const vehicles = await telemetryService.getRouteLiveVehicles(req.params.routeId, { + includeStale, + staleAfterSeconds + }); + + return res.json(vehicles); + } catch (error) { + return sendError(res, error, 'Failed to fetch route live vehicles'); + } + } + + async getVehicleLiveState(req, res) { + try { + const staleAfterSeconds = req.query.staleAfterSeconds + ? Number.parseInt(req.query.staleAfterSeconds, 10) + : undefined; + + const vehicle = await telemetryService.getVehicleLiveState( + req.params.vehicleId, + staleAfterSeconds + ); + + return res.json(vehicle); + } catch (error) { + return sendError(res, error, 'Failed to fetch vehicle live state'); + } + } +} + +module.exports = new TelemetryController(); diff --git a/backend/src/controllers/uploadController.js b/backend/src/controllers/uploadController.js new file mode 100644 index 0000000..9acf1c8 --- /dev/null +++ b/backend/src/controllers/uploadController.js @@ -0,0 +1,143 @@ +const pool = require('../config/database'); +const logger = require('../config/logger'); +const fs = require('fs').promises; +const path = require('path'); + +class UploadController { + async uploadAvatar(req, res) { + try { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + const userId = req.user.id; + const file = req.file; + + // Generate URL for the avatar + const avatarUrl = `/uploads/avatars/${file.filename}`; + + // Get old avatar to delete it + const oldUserResult = await pool.query( + 'SELECT avatar_url FROM users WHERE id = $1', + [userId] + ); + + // Update user's avatar_url in database + const result = await pool.query( + 'UPDATE users SET avatar_url = $1 WHERE id = $2 RETURNING id, username, avatar_url', + [avatarUrl, userId] + ); + + // Delete old avatar if exists + if (oldUserResult.rows[0]?.avatar_url) { + const oldAvatarPath = path.join(__dirname, '../../public', oldUserResult.rows[0].avatar_url); + try { + await fs.unlink(oldAvatarPath); + } catch (error) { + logger.warn('Failed to delete old avatar:', error.message); + } + } + + logger.info(`Avatar uploaded for user: ${result.rows[0].username}`); + + res.json({ + message: 'Avatar uploaded successfully', + avatar_url: avatarUrl, + user: result.rows[0] + }); + } catch (error) { + logger.error('Upload avatar error:', error); + + // Clean up uploaded file on error + if (req.file) { + try { + await fs.unlink(req.file.path); + } catch (unlinkError) { + logger.error('Failed to clean up file:', unlinkError); + } + } + + res.status(500).json({ error: 'Internal server error' }); + } + } + + async deleteAvatar(req, res) { + try { + const userId = req.user.id; + + // Get current avatar + const userResult = await pool.query( + 'SELECT avatar_url FROM users WHERE id = $1', + [userId] + ); + + if (!userResult.rows[0]?.avatar_url) { + return res.status(404).json({ error: 'No avatar to delete' }); + } + + const avatarPath = path.join(__dirname, '../../public', userResult.rows[0].avatar_url); + + // Delete from database + await pool.query( + 'UPDATE users SET avatar_url = NULL WHERE id = $1', + [userId] + ); + + // Delete file + try { + await fs.unlink(avatarPath); + } catch (error) { + logger.warn('Failed to delete avatar file:', error.message); + } + + logger.info(`Avatar deleted for user ID: ${userId}`); + + res.json({ message: 'Avatar deleted successfully' }); + } catch (error) { + logger.error('Delete avatar error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Admin endpoint to delete any user's avatar + async deleteUserAvatar(req, res) { + try { + const { userId } = req.params; + + const userResult = await pool.query( + 'SELECT avatar_url FROM users WHERE id = $1', + [userId] + ); + + if (userResult.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + if (!userResult.rows[0]?.avatar_url) { + return res.status(404).json({ error: 'User has no avatar' }); + } + + const avatarPath = path.join(__dirname, '../../public', userResult.rows[0].avatar_url); + + await pool.query( + 'UPDATE users SET avatar_url = NULL WHERE id = $1', + [userId] + ); + + try { + await fs.unlink(avatarPath); + } catch (error) { + logger.warn('Failed to delete avatar file:', error.message); + } + + logger.info(`Avatar deleted for user ID: ${userId} by admin: ${req.user.username}`); + + res.json({ message: 'Avatar deleted successfully' }); + } catch (error) { + logger.error('Delete user avatar error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +} + +module.exports = new UploadController(); \ No newline at end of file diff --git a/backend/src/controllers/usersController.js b/backend/src/controllers/usersController.js new file mode 100644 index 0000000..97c0fdd --- /dev/null +++ b/backend/src/controllers/usersController.js @@ -0,0 +1,182 @@ +const bcrypt = require('bcrypt'); +const pool = require('../config/database'); +const logger = require('../config/logger'); + +class UsersController { + // Get all users (admin only) + async getAllUsers(req, res) { + try { + const result = await pool.query(` + SELECT id, username, full_name, email, role, is_active, last_login, created_at + FROM users + ORDER BY created_at DESC + `); + + res.json(result.rows); + } catch (error) { + logger.error('Get users error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Get user by ID + async getUserById(req, res) { + try { + const { id } = req.params; + + const result = await pool.query(` + SELECT id, username, full_name, email, role, is_active, last_login, created_at + FROM users + WHERE id = $1 + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + logger.error('Get user error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Create user (admin only) + async createUser(req, res) { + try { + const { username, password, full_name, email, role = 'user' } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + // Check if username exists + const existingUser = await pool.query( + 'SELECT id FROM users WHERE username = $1', + [username] + ); + + if (existingUser.rows.length > 0) { + return res.status(400).json({ error: 'Username already exists' }); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 10); + + // Create user + const result = await pool.query(` + INSERT INTO users (username, password_hash, full_name, email, role, is_active) + VALUES ($1, $2, $3, $4, $5, true) + RETURNING id, username, full_name, email, role, is_active, created_at + `, [username, passwordHash, full_name || null, email || null, role]); + + logger.info(`User created: ${username} by ${req.user.username}`); + + res.status(201).json(result.rows[0]); + } catch (error) { + logger.error('Create user error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Update user (admin only) + async updateUser(req, res) { + try { + const { id } = req.params; + const { full_name, email, role, is_active } = req.body; + + // Don't allow deactivating yourself + if (id == req.user.id && is_active === false) { + return res.status(400).json({ error: 'Cannot deactivate yourself' }); + } + + const result = await pool.query(` + UPDATE users + SET full_name = COALESCE($1, full_name), + email = COALESCE($2, email), + role = COALESCE($3, role), + is_active = COALESCE($4, is_active) + WHERE id = $5 + RETURNING id, username, full_name, email, role, is_active + `, [full_name, email, role, is_active, id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + logger.info(`User updated: ${result.rows[0].username} by ${req.user.username}`); + + res.json(result.rows[0]); + } catch (error) { + logger.error('Update user error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Delete user (admin only) + async deleteUser(req, res) { + try { + const { id } = req.params; + + // Don't allow deleting yourself + if (id == req.user.id) { + return res.status(400).json({ error: 'Cannot delete yourself' }); + } + + const result = await pool.query( + 'DELETE FROM users WHERE id = $1 RETURNING username', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + logger.info(`User deleted: ${result.rows[0].username} by ${req.user.username}`); + + res.json({ message: 'User deleted successfully' }); + } catch (error) { + logger.error('Delete user error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + + // Reset user password (admin only) + async resetPassword(req, res) { + try { + const { id } = req.params; + const { newPassword } = req.body; + + if (!newPassword || newPassword.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + const passwordHash = await bcrypt.hash(newPassword, 10); + + const result = await pool.query( + 'UPDATE users SET password_hash = $1 WHERE id = $2 RETURNING username', + [passwordHash, id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + // Invalidate all user sessions + await pool.query('DELETE FROM user_sessions WHERE user_id = $1', [id]); + + logger.info(`Password reset for user: ${result.rows[0].username} by ${req.user.username}`); + + res.json({ message: 'Password reset successfully' }); + } catch (error) { + logger.error('Reset password error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +} + +module.exports = new UsersController(); \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..57624aa --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,155 @@ +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const compression = require('compression'); +require('dotenv').config(); + +const logger = require('./config/logger'); +const apiRoutes = require('./routes/api'); +const { authenticateApiKey } = require('./middleware/auth'); +const createRateLimiter = require('./middleware/rateLimiter'); +const { ensureRealtimeSchema } = require('./services/realtimeSchema'); +const { attachRealtimeServer, closeRealtimeServer } = require('./services/realtimeService'); + +const app = express(); +const PORT = process.env.PORT || 3000; +let server; + +// Security middleware +app.use(helmet({ + crossOriginResourcePolicy: false, +})); +app.use(cors({ + origin: process.env.CORS_ORIGIN || '*', + credentials: true +})); + +// Body parsing +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Compression +app.use(compression()); + +// Static files for avatars +const path = require('path'); +app.use('/uploads', express.static(path.join(__dirname, '../public/uploads'))); + +// Logging middleware +app.use((req, res, next) => { + logger.info(`${req.method} ${req.path}`, { + ip: req.ip, + userAgent: req.get('user-agent') + }); + next(); +}); + +// Health check (no auth required) +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + version: '1.0.0' + }); +}); + +// Auth routes (no API key required) +const authRouter = require('./routes/auth'); +app.use('/api/v1/auth', + createRateLimiter(100, 3600000), // 100 requests per hour for auth + authRouter +); + +// Upload routes (JWT auth required, no API key) - TEMPORARILY DISABLED +// Uncomment after installing multer: npm install multer +try { + const uploadRouter = require('./routes/upload'); + app.use('/api/v1/upload', + createRateLimiter(50, 3600000), // 50 uploads per hour + uploadRouter + ); + logger.info('Upload routes enabled'); +} catch (error) { + logger.warn('Upload routes disabled - multer not installed. Run: npm install multer'); +} + +// API routes with API key authentication and rate limiting +app.use('/api/v1', + authenticateApiKey, + createRateLimiter(1000, 3600000), // 1000 requests per hour + apiRoutes +); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ + error: 'Not Found', + message: `Route ${req.method} ${req.path} not found` + }); +}); + +// Global error handler +app.use((err, req, res, next) => { + logger.error('Unhandled error:', err); + + // Multer errors + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ + error: 'File too large', + message: 'Maximum file size is 5MB' + }); + } + + if (err.message && err.message.includes('Only image files are allowed')) { + return res.status(400).json({ + error: 'Invalid file type', + message: err.message + }); + } + + res.status(500).json({ + error: 'Internal Server Error', + message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong' + }); +}); + +// Graceful shutdown +function shutdown(signal) { + logger.info('SIGTERM signal received: closing HTTP server'); + logger.info(`${signal} signal received: closing HTTP server`); + + closeRealtimeServer(); + + if (!server) { + process.exit(0); + } + + server.close(() => { + logger.info('HTTP server closed'); + process.exit(0); + }); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +async function startServer() { + try { + await ensureRealtimeSchema(); + + server = app.listen(PORT, () => { + logger.info(`Server is running on port ${PORT}`); + logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`); + logger.info(`API Base: http://localhost:${PORT}/api/v1`); + }); + + attachRealtimeServer(server); + } catch (error) { + logger.error('Failed to start server:', error); + process.exit(1); + } +} + +startServer(); + +module.exports = app; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..c437d75 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,115 @@ +const pool = require('../config/database'); +const logger = require('../config/logger'); +const jwt = require('jsonwebtoken'); + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; + +function verifyJwtToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + return null; + } +} + +async function getApiKeyInfo(apiKey, touchLastUsed = false) { + if (!apiKey) { + return null; + } + + const query = touchLastUsed + ? `UPDATE api_keys + SET last_used_at = CURRENT_TIMESTAMP + WHERE key = $1 AND is_active = true + RETURNING id, app_name, rate_limit` + : `SELECT id, app_name, rate_limit + FROM api_keys + WHERE key = $1 AND is_active = true`; + + const result = await pool.query(query, [apiKey]); + return result.rows[0] || null; +} + +// Verify JWT token +function authenticateToken(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + if (!token) { + return res.status(401).json({ error: 'Access token required' }); + } + + const user = verifyJwtToken(token); + + if (!user) { + return res.status(403).json({ error: 'Invalid or expired token' }); + } + + req.user = user; + next(); +} + +// Check if user is admin +function requireAdmin(req, res, next) { + if (!req.user || req.user.role !== 'admin') { + return res.status(403).json({ error: 'Admin access required' }); + } + next(); +} + +async function authenticateApiKey(req, res, next) { + // Check for JWT token first + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (token) { + // If JWT token is present, verify it + try { + const user = verifyJwtToken(token); + if (!user) { + throw new Error('Invalid JWT token'); + } + req.user = user; + return next(); + } catch (error) { + // JWT token is invalid, continue to API key check + logger.warn('Invalid JWT token in request'); + } + } + + // Fall back to API key authentication + const apiKey = req.headers['x-api-key']; + + if (!apiKey) { + return res.status(401).json({ + error: 'Authentication required', + message: 'Please provide either Authorization header with JWT token or X-API-Key header' + }); + } + + try { + const apiKeyInfo = await getApiKeyInfo(apiKey, true); + + if (!apiKeyInfo) { + logger.warn(`Invalid API key attempt: ${apiKey.substring(0, 8)}...`); + return res.status(403).json({ + error: 'Invalid API key', + message: 'The provided API key is invalid or inactive' + }); + } + + req.apiKeyInfo = apiKeyInfo; + next(); + } catch (error) { + logger.error('Authentication error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +module.exports = { + authenticateToken, + requireAdmin, + authenticateApiKey, + verifyJwtToken, + getApiKeyInfo +}; diff --git a/backend/src/middleware/rateLimiter.js b/backend/src/middleware/rateLimiter.js new file mode 100644 index 0000000..8e09d12 --- /dev/null +++ b/backend/src/middleware/rateLimiter.js @@ -0,0 +1,66 @@ +const rateLimit = require('express-rate-limit'); +const redisClient = require('../config/redis'); + +// In-memory fallback store if Redis is unavailable +const memoryStore = new Map(); + +async function getRateLimitStore() { + // Custom Redis store + return { + async increment(key) { + try { + const current = await redisClient.get(key); + const count = current ? parseInt(current) + 1 : 1; + await redisClient.setEx(key, 3600, count.toString()); // 1 hour TTL + return { totalHits: count, resetTime: null }; + } catch (error) { + // Fallback to memory store + const count = (memoryStore.get(key) || 0) + 1; + memoryStore.set(key, count); + setTimeout(() => memoryStore.delete(key), 3600000); // 1 hour + return { totalHits: count, resetTime: null }; + } + }, + async decrement(key) { + try { + await redisClient.decr(key); + } catch (error) { + const count = memoryStore.get(key) || 0; + memoryStore.set(key, Math.max(0, count - 1)); + } + }, + async resetKey(key) { + try { + await redisClient.del(key); + } catch (error) { + memoryStore.delete(key); + } + } + }; +} + +const createRateLimiter = (maxRequests = 1000, windowMs = 3600000) => { + return rateLimit({ + windowMs, // 1 hour default + max: maxRequests, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => { + // Use API key if available, otherwise IP + return req.apiKeyInfo?.id || req.ip; + }, + handler: (req, res) => { + res.status(429).json({ + error: 'Too many requests', + message: 'Rate limit exceeded. Please try again later.', + retryAfter: Math.ceil(windowMs / 1000) + }); + }, + skip: (req) => { + // Skip rate limiting for health checks + return req.path === '/health'; + } + }); +}; + +module.exports = createRateLimiter; diff --git a/backend/src/middleware/upload.js b/backend/src/middleware/upload.js new file mode 100644 index 0000000..825515b --- /dev/null +++ b/backend/src/middleware/upload.js @@ -0,0 +1,47 @@ +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); + +// Ensure upload directories exist +const uploadDir = path.join(__dirname, '../../public/uploads/avatars'); +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +// Configure storage +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + // Generate unique filename: userId-timestamp.ext + const userId = req.user.id; + const timestamp = Date.now(); + const ext = path.extname(file.originalname); + cb(null, `user-${userId}-${timestamp}${ext}`); + } +}); + +// File filter - only images +const fileFilter = (req, file, cb) => { + const allowedTypes = /jpeg|jpg|png|gif|webp/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (extname && mimetype) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed (jpeg, jpg, png, gif, webp)')); + } +}; + +// Configure multer +const upload = multer({ + storage: storage, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB limit + }, + fileFilter: fileFilter +}); + +module.exports = upload; \ No newline at end of file diff --git a/backend/src/routes/alerts.js b/backend/src/routes/alerts.js new file mode 100644 index 0000000..a580562 --- /dev/null +++ b/backend/src/routes/alerts.js @@ -0,0 +1,209 @@ +const express = require('express'); +const router = express.Router(); +const pool = require('../config/database'); + +// Get all alerts (with optional filters) +router.get('/', async (req, res) => { + try { + const { route_id, is_active, alert_type } = req.query; + + let query = ` + SELECT a.*, r.name as route_name, r.route_number, r.color + FROM alerts a + LEFT JOIN routes r ON a.route_id = r.id + WHERE 1=1 + `; + const params = []; + let paramCount = 1; + + if (route_id) { + query += ` AND a.route_id = $${paramCount}`; + params.push(route_id); + paramCount++; + } + + if (is_active !== undefined) { + query += ` AND a.is_active = $${paramCount}`; + params.push(is_active === 'true'); + paramCount++; + } + + if (alert_type) { + query += ` AND a.alert_type = $${paramCount}`; + params.push(alert_type); + paramCount++; + } + + query += ' ORDER BY a.start_time DESC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + console.error('Error fetching alerts:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Get active alerts (current time between start_time and end_time) +router.get('/active', async (req, res) => { + try { + const { route_id } = req.query; + + let query = ` + SELECT a.*, r.name as route_name, r.route_number, r.color + FROM alerts a + LEFT JOIN routes r ON a.route_id = r.id + WHERE a.is_active = true + AND a.start_time <= NOW() + AND (a.end_time IS NULL OR a.end_time >= NOW()) + `; + const params = []; + + if (route_id) { + query += ' AND a.route_id = $1'; + params.push(route_id); + } + + query += ' ORDER BY a.start_time DESC'; + + const result = await pool.query(query, params); + res.json(result.rows); + } catch (error) { + console.error('Error fetching active alerts:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Get alert by ID +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 + FROM alerts a + LEFT JOIN routes r ON a.route_id = r.id + WHERE a.id = $1`, + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Alert not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching alert:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Create new alert +router.post('/', async (req, res) => { + try { + const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body; + + // Validation + if (!alert_type || !title || !message || !start_time) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const validTypes = ['delay', 'cancellation', 'detour', 'info']; + if (!validTypes.includes(alert_type)) { + return res.status(400).json({ error: 'Invalid alert_type' }); + } + + const result = await pool.query( + `INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [route_id || null, alert_type, title, message, start_time, end_time || null, is_active !== false] + ); + + res.status(201).json(result.rows[0]); + } catch (error) { + console.error('Error creating alert:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Update alert +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; + + // Check if alert exists + const checkResult = await pool.query('SELECT id FROM alerts WHERE id = $1', [id]); + if (checkResult.rows.length === 0) { + return res.status(404).json({ error: 'Alert not found' }); + } + + // Validation + if (alert_type) { + const validTypes = ['delay', 'cancellation', 'detour', 'info']; + if (!validTypes.includes(alert_type)) { + return res.status(400).json({ error: 'Invalid alert_type' }); + } + } + + const result = await pool.query( + `UPDATE alerts + SET route_id = COALESCE($1, route_id), + 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 + RETURNING *`, + [route_id, alert_type, title, message, start_time, end_time, is_active, id] + ); + + res.json(result.rows[0]); + } catch (error) { + console.error('Error updating alert:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delete alert +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]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Alert not found' }); + } + + res.json({ message: 'Alert deleted successfully' }); + } catch (error) { + console.error('Error deleting alert:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Deactivate expired alerts (utility endpoint) +router.post('/cleanup', async (req, res) => { + try { + const result = await pool.query( + `UPDATE alerts + SET is_active = false + WHERE is_active = true + AND end_time IS NOT NULL + AND end_time < NOW() + RETURNING id` + ); + + res.json({ + message: 'Cleanup completed', + deactivated_count: result.rows.length + }); + } catch (error) { + console.error('Error cleaning up alerts:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/api.js b/backend/src/routes/api.js new file mode 100644 index 0000000..25337fd --- /dev/null +++ b/backend/src/routes/api.js @@ -0,0 +1,78 @@ +const express = require('express'); +const router = express.Router(); + +// Controllers +const routesController = require('../controllers/routesController'); +const stopsController = require('../controllers/stopsController'); +const routeStopsController = require('../controllers/routeStopsController'); +const schedulesController = require('../controllers/schedulesController'); +const holidaysController = require('../controllers/holidaysController'); +const etaController = require('../controllers/etaController'); +const syncController = require('../controllers/syncController'); +const telemetryController = require('../controllers/telemetryController'); + +// Routes +router.get('/routes', routesController.getAllRoutes.bind(routesController)); +router.get('/routes/:id', routesController.getRouteById.bind(routesController)); +router.post('/routes', routesController.createRoute.bind(routesController)); +router.put('/routes/:id', routesController.updateRoute.bind(routesController)); +router.delete('/routes/:id', routesController.deleteRoute.bind(routesController)); + +// Route Stops (привязка остановок к маршрутам) +router.get('/routes/:routeId/stops', routeStopsController.getRouteStops.bind(routeStopsController)); +router.get('/routes/:routeId/available-stops', routeStopsController.getAvailableStops.bind(routeStopsController)); +router.post('/routes/:routeId/stops', routeStopsController.addStopToRoute.bind(routeStopsController)); +router.put('/routes/:routeId/stops/:routeStopId', routeStopsController.updateRouteStop.bind(routeStopsController)); +router.delete('/routes/:routeId/stops/:routeStopId', routeStopsController.removeStopFromRoute.bind(routeStopsController)); +router.put('/routes/:routeId/stops-reorder', routeStopsController.reorderRouteStops.bind(routeStopsController)); + +// Route Schedules (расписания маршрутов) +router.get('/routes/:routeId/schedules', schedulesController.getRouteSchedules.bind(schedulesController)); +router.get('/routes/:routeId/schedule/today', schedulesController.getTodaySchedule.bind(schedulesController)); +router.post('/routes/:routeId/schedules/copy', schedulesController.copySchedule.bind(schedulesController)); + +// Schedules (общие) +router.get('/schedules/:id', schedulesController.getScheduleById.bind(schedulesController)); +router.post('/schedules', schedulesController.createSchedule.bind(schedulesController)); +router.put('/schedules/:id', schedulesController.updateSchedule.bind(schedulesController)); +router.delete('/schedules/:id', schedulesController.deleteSchedule.bind(schedulesController)); + +// Holidays (праздничные дни) +router.get('/holidays', holidaysController.getAllHolidays.bind(holidaysController)); +router.get('/holidays/upcoming', holidaysController.getUpcomingHolidays.bind(holidaysController)); +router.get('/holidays/year/:year', holidaysController.getHolidaysForYear.bind(holidaysController)); +router.get('/holidays/check/:date', holidaysController.checkHoliday.bind(holidaysController)); +router.post('/holidays', holidaysController.createHoliday.bind(holidaysController)); +router.put('/holidays/:id', holidaysController.updateHoliday.bind(holidaysController)); +router.delete('/holidays/:id', holidaysController.deleteHoliday.bind(holidaysController)); + +// Stops +router.get('/stops', stopsController.getAllStops.bind(stopsController)); +router.get('/stops/nearby', stopsController.getNearbyStops.bind(stopsController)); +router.get('/stops/:id', stopsController.getStopById.bind(stopsController)); +router.post('/stops', stopsController.createStop.bind(stopsController)); +router.put('/stops/:id', stopsController.updateStop.bind(stopsController)); +router.delete('/stops/:id', stopsController.deleteStop.bind(stopsController)); + +// ETA +router.post('/eta/calculate', etaController.calculateEta.bind(etaController)); +router.get('/eta/stop/:stopId', etaController.getStopEtas.bind(etaController)); + +// Realtime GPS / Telemetry +router.post('/telemetry/ingest', telemetryController.ingest.bind(telemetryController)); +router.get('/realtime/routes/:routeId/vehicles', telemetryController.getRouteLiveVehicles.bind(telemetryController)); +router.get('/realtime/vehicles/:vehicleId', telemetryController.getVehicleLiveState.bind(telemetryController)); + +// Sync +router.get('/sync', syncController.sync.bind(syncController)); +router.get('/sync/status', syncController.getSyncStatus.bind(syncController)); + +// Alerts +const alertsRouter = require('./alerts'); +router.use('/alerts', alertsRouter); + +// Users management (requires JWT auth, handled in routes/users.js) +const usersRouter = require('./users'); +router.use('/users', usersRouter); + +module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..45e1da2 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const authController = require('../controllers/authController'); +const { authenticateToken } = require('../middleware/auth'); + +// Public routes +router.post('/login', authController.login.bind(authController)); +router.post('/refresh', authController.refresh.bind(authController)); +router.post('/logout', authController.logout.bind(authController)); + +// Protected routes +router.get('/me', authenticateToken, authController.me.bind(authController)); +router.post('/change-password', authenticateToken, authController.changePassword.bind(authController)); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/upload.js b/backend/src/routes/upload.js new file mode 100644 index 0000000..3ed7aa4 --- /dev/null +++ b/backend/src/routes/upload.js @@ -0,0 +1,29 @@ +const express = require('express'); +const router = express.Router(); +const uploadController = require('../controllers/uploadController'); +const { authenticateToken, requireAdmin } = require('../middleware/auth'); +const upload = require('../middleware/upload'); + +// User routes - authenticated users can upload/delete their own avatar +router.post( + '/avatar', + authenticateToken, + upload.single('avatar'), + uploadController.uploadAvatar.bind(uploadController) +); + +router.delete( + '/avatar', + authenticateToken, + uploadController.deleteAvatar.bind(uploadController) +); + +// Admin routes - admins can delete any user's avatar +router.delete( + '/avatar/:userId', + authenticateToken, + requireAdmin, + uploadController.deleteUserAvatar.bind(uploadController) +); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..c6e4da9 --- /dev/null +++ b/backend/src/routes/users.js @@ -0,0 +1,17 @@ +const express = require('express'); +const router = express.Router(); +const usersController = require('../controllers/usersController'); +const { authenticateToken, requireAdmin } = require('../middleware/auth'); + +// All routes require authentication and admin role +router.use(authenticateToken); +router.use(requireAdmin); + +router.get('/', usersController.getAllUsers.bind(usersController)); +router.get('/:id', usersController.getUserById.bind(usersController)); +router.post('/', usersController.createUser.bind(usersController)); +router.put('/:id', usersController.updateUser.bind(usersController)); +router.delete('/:id', usersController.deleteUser.bind(usersController)); +router.post('/:id/reset-password', usersController.resetPassword.bind(usersController)); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/services/realtimeSchema.js b/backend/src/services/realtimeSchema.js new file mode 100644 index 0000000..b7ffc72 --- /dev/null +++ b/backend/src/services/realtimeSchema.js @@ -0,0 +1,30 @@ +const fs = require('fs').promises; +const path = require('path'); + +const pool = require('../config/database'); +const logger = require('../config/logger'); + +let ensurePromise = null; + +async function ensureRealtimeSchema() { + if (ensurePromise) { + return ensurePromise; + } + + ensurePromise = (async () => { + const migrationPath = path.join(__dirname, '../../migrations/005_realtime_gps.sql'); + const sql = await fs.readFile(migrationPath, 'utf8'); + + await pool.query(sql); + logger.info('Realtime GPS schema ensured'); + })().catch((error) => { + ensurePromise = null; + throw error; + }); + + return ensurePromise; +} + +module.exports = { + ensureRealtimeSchema +}; diff --git a/backend/src/services/realtimeService.js b/backend/src/services/realtimeService.js new file mode 100644 index 0000000..825f537 --- /dev/null +++ b/backend/src/services/realtimeService.js @@ -0,0 +1,304 @@ +const WebSocket = require('ws'); + +const logger = require('../config/logger'); +const telemetryService = require('./telemetryService'); +const { verifyJwtToken, getApiKeyInfo } = require('../middleware/auth'); + +const routeSubscriptions = new Map(); + +let webSocketServer = null; +let heartbeatInterval = null; + +function sendMessage(ws, payload) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return; + } + + ws.send(JSON.stringify(payload)); +} + +function addRouteSubscription(ws, routeId) { + const routeKey = String(routeId); + + if (!routeSubscriptions.has(routeKey)) { + routeSubscriptions.set(routeKey, new Set()); + } + + routeSubscriptions.get(routeKey).add(ws); + ws.subscriptions.add(routeKey); +} + +function removeRouteSubscription(ws, routeId) { + const routeKey = String(routeId); + const subscribers = routeSubscriptions.get(routeKey); + + if (!subscribers) { + return; + } + + subscribers.delete(ws); + ws.subscriptions.delete(routeKey); + + if (subscribers.size === 0) { + routeSubscriptions.delete(routeKey); + } +} + +function cleanupSocket(ws) { + if (!ws.subscriptions) { + return; + } + + for (const routeId of ws.subscriptions) { + removeRouteSubscription(ws, routeId); + } +} + +async function handleAuthMessage(ws, payload) { + const token = typeof payload.token === 'string' ? payload.token.trim() : ''; + const apiKey = typeof payload.apiKey === 'string' ? payload.apiKey.trim() : ''; + + if (!token && !apiKey) { + sendMessage(ws, { + type: 'error', + message: 'Provide token or apiKey in auth message' + }); + return; + } + + if (token) { + const user = verifyJwtToken(token); + + if (!user) { + sendMessage(ws, { + type: 'error', + message: 'Invalid or expired token' + }); + return; + } + + ws.auth = { + type: 'jwt', + user + }; + + sendMessage(ws, { + type: 'auth:ok', + authType: 'jwt', + user: { + id: user.id, + username: user.username, + role: user.role + } + }); + + return; + } + + const apiKeyInfo = await getApiKeyInfo(apiKey, true); + + if (!apiKeyInfo) { + sendMessage(ws, { + type: 'error', + message: 'Invalid API key' + }); + return; + } + + ws.auth = { + type: 'apiKey', + apiKeyInfo + }; + + sendMessage(ws, { + type: 'auth:ok', + authType: 'apiKey', + appName: apiKeyInfo.app_name + }); +} + +async function handleSubscribeRoute(ws, payload) { + if (!ws.auth) { + sendMessage(ws, { + type: 'error', + message: 'Authenticate first' + }); + return; + } + + const routeId = Number.parseInt(payload.routeId, 10); + + if (!Number.isInteger(routeId) || routeId <= 0) { + sendMessage(ws, { + type: 'error', + message: 'routeId must be a positive integer' + }); + return; + } + + addRouteSubscription(ws, routeId); + + const vehicles = await telemetryService.getRouteLiveVehicles(routeId).catch((error) => { + logger.error('Failed to build realtime route snapshot:', error); + return []; + }); + + sendMessage(ws, { + type: 'subscription:ok', + routeId + }); + + sendMessage(ws, { + type: 'snapshot:route', + routeId, + vehicles + }); +} + +function handleUnsubscribeRoute(ws, payload) { + const routeId = Number.parseInt(payload.routeId, 10); + + if (!Number.isInteger(routeId) || routeId <= 0) { + sendMessage(ws, { + type: 'error', + message: 'routeId must be a positive integer' + }); + return; + } + + removeRouteSubscription(ws, routeId); + + sendMessage(ws, { + type: 'subscription:removed', + routeId + }); +} + +function broadcastToRoute(routeId, payload) { + const subscribers = routeSubscriptions.get(String(routeId)); + + if (!subscribers || subscribers.size === 0) { + return; + } + + for (const ws of subscribers) { + sendMessage(ws, payload); + } +} + +function attachRealtimeServer(server) { + if (webSocketServer) { + return webSocketServer; + } + + webSocketServer = new WebSocket.Server({ + server, + path: '/ws/realtime' + }); + + webSocketServer.on('connection', (ws, request) => { + ws.isAlive = true; + ws.subscriptions = new Set(); + ws.auth = null; + + logger.info(`Realtime websocket connected from ${request.socket.remoteAddress}`); + + sendMessage(ws, { + type: 'welcome', + message: 'Realtime GPS websocket connected' + }); + + ws.on('pong', () => { + ws.isAlive = true; + }); + + ws.on('message', async (rawMessage) => { + try { + const payload = JSON.parse(rawMessage.toString()); + + switch (payload.type) { + case 'auth': + await handleAuthMessage(ws, payload); + break; + case 'subscribe:route': + await handleSubscribeRoute(ws, payload); + break; + case 'unsubscribe:route': + handleUnsubscribeRoute(ws, payload); + break; + case 'ping': + sendMessage(ws, { type: 'pong', timestamp: new Date().toISOString() }); + break; + default: + sendMessage(ws, { + type: 'error', + message: `Unsupported message type: ${payload.type}` + }); + } + } catch (error) { + logger.error('Realtime websocket message error:', error); + sendMessage(ws, { + type: 'error', + message: 'Invalid realtime message payload' + }); + } + }); + + ws.on('close', () => { + cleanupSocket(ws); + logger.info('Realtime websocket disconnected'); + }); + + ws.on('error', (error) => { + logger.error('Realtime websocket error:', error); + cleanupSocket(ws); + }); + }); + + heartbeatInterval = setInterval(() => { + if (!webSocketServer) { + return; + } + + webSocketServer.clients.forEach((ws) => { + if (ws.isAlive === false) { + cleanupSocket(ws); + return ws.terminate(); + } + + ws.isAlive = false; + ws.ping(); + }); + }, 30000); + + webSocketServer.on('close', () => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + }); + + logger.info('Realtime websocket server attached at /ws/realtime'); + + return webSocketServer; +} + +function closeRealtimeServer() { + if (!webSocketServer) { + return; + } + + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + + webSocketServer.close(); + webSocketServer = null; +} + +module.exports = { + attachRealtimeServer, + broadcastToRoute, + closeRealtimeServer +}; diff --git a/backend/src/services/telemetryService.js b/backend/src/services/telemetryService.js new file mode 100644 index 0000000..1f666f0 --- /dev/null +++ b/backend/src/services/telemetryService.js @@ -0,0 +1,386 @@ +const pool = require('../config/database'); +const logger = require('../config/logger'); +const { ensureRealtimeSchema } = require('./realtimeSchema'); + +const DEFAULT_STALE_AFTER_SECONDS = 180; + +function createHttpError(status, message) { + const error = new Error(message); + error.status = status; + return error; +} + +function parseCoordinate(value, fieldName) { + const parsed = Number.parseFloat(value); + + if (!Number.isFinite(parsed)) { + throw createHttpError(400, `${fieldName} must be a valid number`); + } + + return parsed; +} + +function parseOptionalNumber(value, fieldName) { + if (value === undefined || value === null || value === '') { + return null; + } + + const parsed = Number.parseFloat(value); + + if (!Number.isFinite(parsed)) { + throw createHttpError(400, `${fieldName} must be a valid number`); + } + + return parsed; +} + +class TelemetryService { + async ingestTelemetry(payload) { + await ensureRealtimeSchema(); + + const lat = parseCoordinate(payload.lat, 'lat'); + const lon = parseCoordinate(payload.lon, 'lon'); + + if (lat < -90 || lat > 90) { + throw createHttpError(400, 'lat must be between -90 and 90'); + } + + if (lon < -180 || lon > 180) { + throw createHttpError(400, 'lon must be between -180 and 180'); + } + + const speedKmh = parseOptionalNumber(payload.speedKmh, 'speedKmh'); + const heading = parseOptionalNumber(payload.heading, 'heading'); + const accuracyMeters = parseOptionalNumber(payload.accuracyMeters, 'accuracyMeters'); + + if (heading !== null && (heading < 0 || heading > 360)) { + throw createHttpError(400, 'heading must be between 0 and 360'); + } + + if (!payload.vehicleId && !payload.vehicleNumber) { + throw createHttpError(400, 'vehicleId or vehicleNumber is required'); + } + + const route = await this.getRoute(payload.routeId); + const vehicle = await this.resolveVehicle({ + vehicleId: payload.vehicleId, + vehicleNumber: payload.vehicleNumber, + vehicleType: payload.vehicleType, + route + }); + + const effectiveRouteId = route?.id || vehicle.route_id; + + if (!effectiveRouteId) { + throw createHttpError(400, 'routeId is required when the vehicle is not already assigned to a route'); + } + + if (vehicle.route_id !== effectiveRouteId) { + await pool.query( + 'UPDATE vehicles SET route_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + [effectiveRouteId, vehicle.id] + ); + } + + const recordedAt = payload.recordedAt ? new Date(payload.recordedAt) : new Date(); + + if (Number.isNaN(recordedAt.getTime())) { + throw createHttpError(400, 'recordedAt must be a valid ISO datetime string'); + } + + const sourceType = payload.sourceType || 'manual'; + const sourceRef = payload.sourceRef || null; + const rawPayload = payload.rawPayload || payload; + + await pool.query( + `INSERT INTO telemetry_samples ( + vehicle_id, + route_id, + source_type, + source_ref, + location, + speed_kmh, + heading, + accuracy_meters, + recorded_at, + raw_payload + ) VALUES ( + $1, + $2, + $3, + $4, + ST_SetSRID(ST_MakePoint($6, $5), 4326)::geography, + $7, + $8, + $9, + $10, + $11::jsonb + )`, + [ + vehicle.id, + effectiveRouteId, + sourceType, + sourceRef, + lat, + lon, + speedKmh, + heading, + accuracyMeters, + recordedAt.toISOString(), + JSON.stringify(rawPayload) + ] + ); + + await pool.query( + `INSERT INTO vehicle_live_state ( + vehicle_id, + route_id, + location, + speed_kmh, + heading, + accuracy_meters, + source_type, + source_ref, + last_seen_at + ) VALUES ( + $1, + $2, + ST_SetSRID(ST_MakePoint($4, $3), 4326)::geography, + $5, + $6, + $7, + $8, + $9, + $10 + ) + ON CONFLICT (vehicle_id) DO UPDATE SET + route_id = EXCLUDED.route_id, + location = EXCLUDED.location, + speed_kmh = EXCLUDED.speed_kmh, + heading = EXCLUDED.heading, + accuracy_meters = EXCLUDED.accuracy_meters, + source_type = EXCLUDED.source_type, + source_ref = EXCLUDED.source_ref, + last_seen_at = EXCLUDED.last_seen_at, + updated_at = CURRENT_TIMESTAMP`, + [ + vehicle.id, + effectiveRouteId, + lat, + lon, + speedKmh, + heading, + accuracyMeters, + sourceType, + sourceRef, + recordedAt.toISOString() + ] + ); + + const liveState = await this.getVehicleLiveState(vehicle.id, DEFAULT_STALE_AFTER_SECONDS); + + logger.info(`Telemetry ingested for vehicle ${vehicle.id} on route ${effectiveRouteId}`); + + return liveState; + } + + async getRouteLiveVehicles(routeId, options = {}) { + await ensureRealtimeSchema(); + + const routeIdInt = Number.parseInt(routeId, 10); + + if (!Number.isInteger(routeIdInt) || routeIdInt <= 0) { + throw createHttpError(400, 'routeId must be a positive integer'); + } + + const staleAfterSeconds = Number.parseInt( + options.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS, + 10 + ); + const includeStale = Boolean(options.includeStale); + + let query = ` + SELECT + vls.vehicle_id, + v.vehicle_number, + v.registration, + v.type AS vehicle_type, + v.capacity, + vls.route_id, + r.route_number, + r.name AS route_name, + r.color AS route_color, + ST_Y(vls.location::geometry) AS latitude, + ST_X(vls.location::geometry) AS longitude, + vls.speed_kmh, + vls.heading, + vls.accuracy_meters, + vls.source_type, + vls.source_ref, + vls.last_seen_at, + vls.updated_at, + CASE + WHEN vls.last_seen_at >= CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second') THEN true + ELSE false + END AS is_online, + GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - vls.last_seen_at))))::int AS seconds_since_update, + CASE WHEN COALESCE(vls.speed_kmh, 0) >= 1 THEN true ELSE false END AS is_moving + FROM vehicle_live_state vls + JOIN vehicles v ON v.id = vls.vehicle_id + JOIN routes r ON r.id = vls.route_id + WHERE vls.route_id = $1 + AND v.is_active = true + `; + + if (!includeStale) { + query += ` + AND vls.last_seen_at >= CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second') + `; + } + + query += ` + ORDER BY vls.last_seen_at DESC, v.vehicle_number ASC + `; + + const result = await pool.query(query, [routeIdInt, staleAfterSeconds]); + return result.rows; + } + + async getVehicleLiveState(vehicleId, staleAfterSeconds = DEFAULT_STALE_AFTER_SECONDS) { + await ensureRealtimeSchema(); + + const vehicleIdInt = Number.parseInt(vehicleId, 10); + + if (!Number.isInteger(vehicleIdInt) || vehicleIdInt <= 0) { + throw createHttpError(400, 'vehicleId must be a positive integer'); + } + + const result = await pool.query( + `SELECT + vls.vehicle_id, + v.vehicle_number, + v.registration, + v.type AS vehicle_type, + v.capacity, + vls.route_id, + r.route_number, + r.name AS route_name, + r.color AS route_color, + ST_Y(vls.location::geometry) AS latitude, + ST_X(vls.location::geometry) AS longitude, + vls.speed_kmh, + vls.heading, + vls.accuracy_meters, + vls.source_type, + vls.source_ref, + vls.last_seen_at, + vls.updated_at, + CASE + WHEN vls.last_seen_at >= CURRENT_TIMESTAMP - ($2 * INTERVAL '1 second') THEN true + ELSE false + END AS is_online, + GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - vls.last_seen_at))))::int AS seconds_since_update, + CASE WHEN COALESCE(vls.speed_kmh, 0) >= 1 THEN true ELSE false END AS is_moving + FROM vehicle_live_state vls + JOIN vehicles v ON v.id = vls.vehicle_id + JOIN routes r ON r.id = vls.route_id + WHERE vls.vehicle_id = $1 + LIMIT 1`, + [vehicleIdInt, staleAfterSeconds] + ); + + if (result.rows.length === 0) { + throw createHttpError(404, 'Live state not found for this vehicle'); + } + + return result.rows[0]; + } + + async getRoute(routeId) { + if (routeId === undefined || routeId === null || routeId === '') { + return null; + } + + const routeIdInt = Number.parseInt(routeId, 10); + + if (!Number.isInteger(routeIdInt) || routeIdInt <= 0) { + throw createHttpError(400, 'routeId must be a positive integer'); + } + + const result = await pool.query( + 'SELECT * FROM routes WHERE id = $1 AND is_active = true', + [routeIdInt] + ); + + if (result.rows.length === 0) { + throw createHttpError(404, 'Route not found'); + } + + return result.rows[0]; + } + + async resolveVehicle({ vehicleId, vehicleNumber, vehicleType, route }) { + if (vehicleId !== undefined && vehicleId !== null && vehicleId !== '') { + const vehicleIdInt = Number.parseInt(vehicleId, 10); + + if (!Number.isInteger(vehicleIdInt) || vehicleIdInt <= 0) { + throw createHttpError(400, 'vehicleId must be a positive integer'); + } + + const result = await pool.query( + 'SELECT * FROM vehicles WHERE id = $1', + [vehicleIdInt] + ); + + if (result.rows.length === 0) { + throw createHttpError(404, 'Vehicle not found'); + } + + if (!result.rows[0].is_active) { + throw createHttpError(400, 'Vehicle is inactive'); + } + + return result.rows[0]; + } + + const cleanedVehicleNumber = typeof vehicleNumber === 'string' + ? vehicleNumber.trim() + : ''; + + if (!cleanedVehicleNumber) { + throw createHttpError(400, 'vehicleNumber must be a non-empty string'); + } + + const existing = await pool.query( + 'SELECT * FROM vehicles WHERE vehicle_number = $1 LIMIT 1', + [cleanedVehicleNumber] + ); + + if (existing.rows.length > 0) { + if (!existing.rows[0].is_active) { + throw createHttpError(400, 'Vehicle is inactive'); + } + + return existing.rows[0]; + } + + if (!route) { + throw createHttpError(400, 'routeId is required to auto-create a vehicle by vehicleNumber'); + } + + const resolvedType = vehicleType || route.type || 'bus'; + + const created = await pool.query( + `INSERT INTO vehicles (vehicle_number, type, route_id, is_active) + VALUES ($1, $2, $3, true) + RETURNING *`, + [cleanedVehicleNumber, resolvedType, route.id] + ); + + logger.info(`Auto-created vehicle ${cleanedVehicleNumber} for route ${route.id}`); + + return created.rows[0]; + } +} + +module.exports = new TelemetryService(); diff --git a/backend/src/utils/migrate.js b/backend/src/utils/migrate.js new file mode 100644 index 0000000..203ec72 --- /dev/null +++ b/backend/src/utils/migrate.js @@ -0,0 +1,34 @@ +const fs = require('fs').promises; +const path = require('path'); + +const pool = require('../config/database'); + +async function runMigrations() { + const migrationsDir = path.join(__dirname, '../../migrations'); + const migrationFiles = (await fs.readdir(migrationsDir)) + .filter((file) => file.endsWith('.sql')) + .sort(); + + const client = await pool.connect(); + + try { + for (const file of migrationFiles) { + const fullPath = path.join(migrationsDir, file); + const sql = await fs.readFile(fullPath, 'utf8'); + + console.log(`Applying migration: ${file}`); + await client.query(sql); + } + + console.log('All migrations applied successfully'); + } finally { + client.release(); + await pool.end(); + } +} + +runMigrations().catch(async (error) => { + console.error('Migration failed:', error); + await pool.end(); + process.exit(1); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..588269b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,96 @@ +version: '3.8' + +services: + postgres: + image: postgis/postgis:15-3.3 + container_name: polotsk-transit-db + environment: + POSTGRES_DB: polotsk_transit + POSTGRES_USER: transit_admin + POSTGRES_PASSWORD: ${DB_PASSWORD:-change_me_in_production} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backend/migrations:/docker-entrypoint-initdb.d + networks: + - transit-network + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: polotsk-transit-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - transit-network + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: polotsk-transit-api + environment: + NODE_ENV: production + PORT: 3000 + DATABASE_URL: postgresql://transit_admin:${DB_PASSWORD:-change_me_in_production}@postgres:5432/polotsk_transit + REDIS_URL: redis://redis:6379 + API_KEY_SALT: ${API_KEY_SALT:-generate_random_salt} + JWT_SECRET: ${JWT_SECRET:-change_me_in_production_use_random_string} + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001} + ports: + - "3000:3000" + depends_on: + - postgres + - redis + networks: + - transit-network + restart: unless-stopped + volumes: + - ./backend/logs:/app/logs + - avatars_data:/app/public/uploads/avatars + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1} + container_name: polotsk-transit-admin + environment: + VITE_API_URL: ${API_URL:-http://localhost:3000/api/v1} + ports: + - "3001:80" + depends_on: + - backend + networks: + - transit-network + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: polotsk-transit-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - backend + - frontend + networks: + - transit-network + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + avatars_data: + +networks: + transit-network: + driver: bridge diff --git a/docs/api-documentation.md b/docs/api-documentation.md new file mode 100644 index 0000000..837ccea --- /dev/null +++ b/docs/api-documentation.md @@ -0,0 +1,1554 @@ +# Обзор проекта + +`polotsk-transit-api` — это монорепозиторий с backend API на Node.js/Express и frontend-админкой на React/Vite. По коду проект сейчас реализует панель управления справочными и операционными данными общественного транспорта: маршруты, остановки, связки маршрут-остановка, расписания, ETA, праздничные дни, уведомления, пользователей и offline-синхронизацию. + +Фактически найдено: + +- Backend API: Express REST API, версия `v1` +- Frontend: SPA-админка на React, использующая тот же API +- Хранилище: PostgreSQL + PostGIS +- Кеш/вспомогательный слой: Redis +- Reverse proxy: Nginx +- Статические файлы: аватары пользователей через `/uploads/...` + +Не найдено в реальной реализации: + +- GraphQL schema/resolvers +- RPC endpoints +- WebSocket/realtime endpoints +- API для водителей +- API для диспетчеров +- API для GPS/телеметрии +- API для статусов рейсов/trip management +- API отчетности + +Отдельно: в SQL-схеме есть сущность `vehicles`, а в ETA используется таблица `historical_delays`, но публичных CRUD-endpoints для этих сущностей в коде нет. + +# Архитектура + +## Высокоуровневая схема + +- `frontend/` — React/Vite SPA для операторов и администраторов +- `backend/` — Express API с REST-маршрутами +- `backend/migrations/` — SQL-схема БД, функции и триггеры +- `nginx/` — reverse proxy и rate limiting на уровне веб-сервера +- `docker-compose.yml` — оркестрация Postgres, Redis, backend, frontend и Nginx + +## Backend поток запроса + +1. Nginx проксирует запросы на backend или frontend. +2. Express применяет `helmet`, `cors`, `compression`, body parsers и request logging. +3. Для `/api/v1/auth/*` действует отдельный rate limit. +4. Для `/api/v1/upload/*` действует JWT-аутентификация и отдельный rate limit. +5. Для `/api/v1/*` действует middleware `authenticateApiKey`, который принимает либо JWT Bearer token, либо `X-API-Key`. +6. Контроллеры работают с PostgreSQL напрямую через `pg`. +7. Отдельные read-маршруты и карточки сущностей кешируются в Redis. +8. Изменения основных таблиц пишутся в `sync_log` триггерами БД для offline sync. + +## Версионирование + +- Найдена только одна версия API: `/api/v1` + +## Реализация API + +- Только REST over HTTP +- JSON для большинства endpoints +- `multipart/form-data` для загрузки аватаров + +# Основные модули + +## Backend-модули + +- `backend/src/index.js` — точка входа приложения, middleware, mount routes, global error handler +- `backend/src/routes/auth.js` + `backend/src/controllers/authController.js` — JWT login/refresh/logout/profile/password +- `backend/src/routes/users.js` + `backend/src/controllers/usersController.js` — управление пользователями +- `backend/src/routes/upload.js` + `backend/src/controllers/uploadController.js` — загрузка/удаление аватаров +- `backend/src/routes/api.js` — основной REST router `v1` +- `backend/src/controllers/routesController.js` — CRUD маршрутов +- `backend/src/controllers/stopsController.js` — CRUD остановок и геопоиск через PostGIS +- `backend/src/controllers/routeStopsController.js` — управление остановками внутри маршрута +- `backend/src/controllers/schedulesController.js` — расписания маршрутов +- `backend/src/controllers/holidaysController.js` — праздники и календарная логика +- `backend/src/controllers/etaController.js` — ETA на основе расписания и исторических задержек +- `backend/src/controllers/syncController.js` — full/incremental offline sync +- `backend/src/routes/alerts.js` — CRUD уведомлений +- `backend/src/middleware/auth.js` — JWT/API-key auth + admin guard +- `backend/src/middleware/rateLimiter.js` — rate limiting +- `backend/src/middleware/upload.js` — multer storage/filter/size limits + +## Frontend-модули + +- `frontend/src/services/api.js` — axios client, auth interceptors, API surface +- `frontend/src/contexts/AuthContext.jsx` — login, refresh, logout, profile bootstrap +- `frontend/src/components/ProtectedRoute.jsx` — frontend RBAC guard +- `frontend/src/pages/*.jsx` — операционные экраны для маршрутов, остановок, расписаний, праздников, уведомлений, пользователей и профиля + +## Инфраструктурные модули + +- `backend/src/config/database.js` — connection pool PostgreSQL +- `backend/src/config/redis.js` — Redis client +- `backend/src/config/logger.js` — Winston logging +- `backend/migrations/001_init.sql` — основная схема БД и sync triggers +- `backend/migrations/002_holidays.sql` — праздники Беларуси + SQL functions `is_holiday()` и `get_schedule_type()` +- `backend/migrations/003_users_auth.sql` — пользователи и refresh sessions +- `backend/migrations/004_users_avatars.sql` — аватары пользователей +- `backend/scripts/create-admin.js` — bootstrap admin account + +# Аутентификация и авторизация + +## Найденные механизмы auth + +- `X-API-Key` для machine-to-machine и общих API-вызовов +- `Authorization: Bearer ` для операторов/админов админ-панели +- refresh token хранится в таблице `user_sessions` + +## Реальное поведение middleware + +- `/health` — без аутентификации +- `/api/v1/auth/login`, `/api/v1/auth/refresh`, `/api/v1/auth/logout` — без API key и без JWT +- `/api/v1/auth/me`, `/api/v1/auth/change-password` — только JWT +- `/api/v1/upload/*` — только JWT +- `/api/v1/upload/avatar/:userId` — JWT + роль `admin` +- `/api/v1/users/*` — сначала проходит outer middleware `/api/v1`, затем дополнительно требует JWT и роль `admin` +- Остальные `/api/v1/*` — принимают либо Bearer JWT, либо `X-API-Key` + +## Роли + +- `admin` +- `user` + +## RBAC, найденный в backend + +- `requireAdmin` реально используется только для: +- `/api/v1/users/*` +- `DELETE /api/v1/upload/avatar/:userId` + +Предположение, подтвержденное кодом frontend и backend: обычный пользователь с валидным JWT может работать с маршрутами, остановками, расписаниями, праздниками и уведомлениями, потому что на этих ресурсах server-side role guard не применен. + +## Rate limiting + +- `/api/v1/auth/*` — `100` запросов в час +- `/api/v1/upload/*` — `50` запросов в час +- `/api/v1/*` — `1000` запросов в час +- При превышении возвращается `429 Too Many Requests` + +Важно: в `backend/src/middleware/rateLimiter.js` есть заготовка Redis store, но она не подключена к `express-rate-limit`; по факту используется стандартный in-memory store библиотеки. Это влияет на реальное поведение в multi-instance production. + +# Сущности домена + +## Основные сущности + +### Route + +- Таблица: `routes` +- Поля: `id`, `route_number`, `name`, `type`, `color`, `is_active`, `description`, `created_at`, `updated_at` +- Enum `type`: `bus`, `minibus`, `trolleybus`, `tram` + +### Stop + +- Таблица: `stops` +- Поля: `id`, `name`, `location`, `address`, `description`, `is_active`, `created_at`, `updated_at` +- Дополнительные вычисляемые поля в API: `latitude`, `longitude` +- В list/detail некоторых запросов также возвращается raw-поле `location`; его точный формат зависит от сериализации PostGIS драйвером `pg`, поэтому для клиентов безопаснее использовать `latitude`/`longitude` + +### RouteStop + +- Таблица: `route_stops` +- Связь many-to-many между `routes` и `stops` +- Поля: `id`, `route_id`, `stop_id`, `sequence`, `time_offset_minutes` + +### Schedule + +- Таблица: `schedules` +- Поля: `id`, `route_id`, `day_type`, `departure_times`, `valid_from`, `valid_until`, `created_at`, `updated_at` +- Enum `day_type`: `weekday`, `saturday`, `sunday`, `holiday` +- `departure_times` хранится как массив `TIME[]` + +### Holiday + +- Таблица: `holidays` +- Поля: `id`, `date`, `name`, `is_recurring`, `recurring_month`, `recurring_day`, `created_at` + +### Alert + +- Таблица: `alerts` +- Поля: `id`, `route_id`, `alert_type`, `title`, `message`, `start_time`, `end_time`, `is_active`, `created_at` +- Enum `alert_type`: `delay`, `cancellation`, `detour`, `info` +- На read-endpoints дополнительно могут приходить `route_name`, `route_number`, `color` + +### User + +- Таблица: `users` +- Поля: `id`, `username`, `password_hash`, `full_name`, `email`, `role`, `is_active`, `last_login`, `created_at`, `updated_at`, `avatar_url` + +### UserSession + +- Таблица: `user_sessions` +- Поля: `id`, `user_id`, `refresh_token`, `expires_at`, `ip_address`, `user_agent`, `created_at` + +### ApiKey + +- Таблица: `api_keys` +- Поля: `id`, `key`, `app_name`, `rate_limit`, `is_active`, `created_at`, `last_used_at` + +### HistoricalDelay + +- Таблица: `historical_delays` +- Поля: `route_id`, `stop_id`, `hour_of_day`, `day_type`, `avg_delay_minutes`, `sample_count` +- Используется только в ETA + +### Vehicle + +- Таблица: `vehicles` +- Поля: `vehicle_number`, `registration`, `type`, `capacity`, `route_id`, `is_active` +- API endpoints не найдены + +### SyncLog + +- Таблица: `sync_log` +- Поля: `table_name`, `record_id`, `action`, `timestamp` +- Наполняется триггерами БД + +## DTO / схемы, восстановленные по коду + +### Auth DTO + +- `LoginRequest`: `{ username: string, password: string }` +- `LoginResponse`: `{ user: UserLoginPayload, accessToken: string, refreshToken: string }` +- `RefreshRequest`: `{ refreshToken: string }` +- `RefreshResponse`: `{ accessToken: string }` +- `LogoutRequest`: `{ refreshToken?: string }` +- `ChangePasswordRequest`: `{ currentPassword: string, newPassword: string, refreshToken?: string }` + +### Route DTO + +- `CreateRouteRequest`: `{ route_number: string, name: string, type: RouteType, color?: string, description?: string }` +- `UpdateRouteRequest`: `{ route_number?: string, name?: string, type?: RouteType, color?: string, description?: string, is_active?: boolean }` +- `RouteSummary`: `Route + { stops_count }` +- `RouteDetail`: `Route + { stops: StopOnRoute[] }` + +### Stop DTO + +- `CreateStopRequest`: `{ name: string, latitude: number, longitude: number, address?: string, description?: string }` +- `UpdateStopRequest`: `{ name?: string, latitude?: number, longitude?: number, address?: string, description?: string, is_active?: boolean }` +- `StopSummary`: `Stop + { latitude, longitude, routes_count }` +- `StopDetail`: `Stop + { latitude, longitude, routes: RouteAtStop[] }` + +### RouteStop DTO + +- `AddRouteStopRequest`: `{ stopId: number, sequence?: number, timeOffsetMinutes?: number }` +- `UpdateRouteStopRequest`: `{ sequence?: number, timeOffsetMinutes?: number }` +- `ReorderRouteStopsRequest`: `{ stops: Array<{ stopId: number, sequence: number, timeOffsetMinutes?: number }> }` + +### Schedule DTO + +- `CreateScheduleRequest`: `{ routeId: number, dayType: ScheduleDayType, departureTimes: string[], validFrom?: string|null, validUntil?: string|null }` +- `UpdateScheduleRequest`: `{ departureTimes?: string[], validFrom?: string|null, validUntil?: string|null }` +- `CopyScheduleRequest`: `{ fromDayType: ScheduleDayType, toDayType: ScheduleDayType }` + +### Holiday DTO + +- `CreateHolidayRequest`: `{ date: string, name: string, isRecurring?: boolean }` +- `UpdateHolidayRequest`: `{ date?: string, name?: string, isRecurring?: boolean }` + +### Alert DTO + +- `CreateAlertRequest`: `{ route_id?: number|null, alert_type: AlertType, title: string, message: string, start_time: string, end_time?: string|null, is_active?: boolean }` +- `UpdateAlertRequest`: те же поля, все опциональны + +### User DTO + +- `CreateUserRequest`: `{ username: string, password: string, full_name?: string, email?: string, role?: 'admin'|'user' }` +- `UpdateUserRequest`: `{ full_name?: string, email?: string, role?: 'admin'|'user', is_active?: boolean }` +- `ResetPasswordRequest`: `{ newPassword: string }` + +### Upload DTO + +- `AvatarUploadRequest`: multipart form-data, поле `avatar` +- `AvatarUploadResponse`: `{ message: string, avatar_url: string, user: { id, username, avatar_url } }` + +### ETA DTO + +- `CalculateEtaRequest`: `{ stopId: number, routeId: number }` +- `CalculateEtaResponse`: `{ routeId, stopId, currentTime, dayType, arrivals: Arrival[] }` +- `Arrival`: `{ scheduledTime: string, estimatedTime: string, minutesUntil: number, delay: number }` + +### Sync DTO + +- `FullSyncResponse`: `{ syncType: 'full', timestamp, version, data, metadata }` +- `IncrementalSyncResponse`: `{ syncType: 'incremental', timestamp, lastSync, changes, alerts, metadata }` + +## Связи предметной области + +- `Route 1 -> N Schedule` +- `Route N <-> N Stop` через `RouteStop` +- `RouteStop.sequence` определяет фактический порядок остановок в маршруте +- `RouteStop.time_offset_minutes` связывает расписание отправления маршрута с расчетным временем прибытия на конкретную остановку +- `HistoricalDelay` привязан к `(route, stop, hour_of_day, day_type)` и корректирует ETA +- `Alert N -> 0..1 Route` +- `Vehicle N -> 0..1 Route` в схеме БД +- `User 1 -> N UserSession` +- `SyncLog` отслеживает изменения `routes`, `stops`, `route_stops`, `schedules`, `holidays` + +## Что отсутствует в модели относительно исходного предположения + +- `рейсы / trips` — не найдены как отдельная сущность +- `водители / drivers` — не найдены +- `диспетчеры / dispatchers` — не найдены как отдельная сущность +- `GPS / телеметрия` — не найдены +- `статусы рейсов` — не найдены +- `отчетность` — не найдены API/endpoints + +# API-документация + +## System + +### [GET] /health + +- Описание: health check приложения +- Авторизация: не требуется +- Параметры path: нет +- Query-параметры: нет +- Headers: нет обязательных +- Request Body: нет +- Response 200/201: `{ status, timestamp, version }` +- Ошибки: стандартный `404`/`500` для неверного маршрута или runtime error, но в коде endpoint всегда отвечает `200` +- Связанные сущности: нет +- Где найдено в коде: `backend/src/index.js` -> inline handler `app.get('/health', ...)` + +## Auth + +### [POST] /api/v1/auth/login + +- Описание: логин оператора/администратора, выдает access/refresh tokens +- Авторизация: не требуется +- Параметры path: нет +- Query-параметры: нет +- Headers: `Content-Type: application/json` +- Request Body: `LoginRequest` +- Response 200/201: `LoginResponse`; `user` возвращается без `password_hash` +- Ошибки: +- `400` — нет `username` или `password` +- `401` — неверные credentials или пользователь неактивен/не найден +- `429` — превышен rate limit auth +- `500` — внутренняя ошибка +- Связанные сущности: `User`, `UserSession` +- Где найдено в коде: `backend/src/routes/auth.js` -> `authController.login`, `backend/src/controllers/authController.js#login` +- Пример запроса: + +```json +{ + "username": "admin", + "password": "admin123" +} +``` + +- Пример ответа: + +```json +{ + "user": { + "id": 1, + "username": "admin", + "full_name": "Главный администратор", + "email": "admin@polotsk-transit.local", + "role": "admin", + "is_active": true, + "last_login": "2026-04-16T10:00:00.000Z", + "created_at": "2026-04-01T08:00:00.000Z", + "updated_at": "2026-04-16T10:00:00.000Z", + "avatar_url": null + }, + "accessToken": "jwt-access-token", + "refreshToken": "jwt-refresh-token" +} +``` + +### [POST] /api/v1/auth/refresh + +- Описание: обмен refresh token на новый access token +- Авторизация: не требуется +- Параметры path: нет +- Query-параметры: нет +- Headers: `Content-Type: application/json` +- Request Body: `RefreshRequest` +- Response 200/201: `{ accessToken }` +- Ошибки: +- `400` — не передан `refreshToken` +- `401` — invalid refresh token / session expired / user inactive +- `429` — превышен rate limit auth +- `500` — внутренняя ошибка +- Связанные сущности: `UserSession`, `User` +- Где найдено в коде: `backend/src/routes/auth.js` -> `authController.refresh`, `backend/src/controllers/authController.js#refresh` + +### [POST] /api/v1/auth/logout + +- Описание: удаляет запись refresh session по `refreshToken` +- Авторизация: не требуется +- Параметры path: нет +- Query-параметры: нет +- Headers: `Content-Type: application/json` +- Request Body: `{ refreshToken?: string }` +- Response 200/201: `{ message: 'Logged out successfully' }` +- Ошибки: +- `429` — превышен rate limit auth +- `500` — внутренняя ошибка +- Связанные сущности: `UserSession` +- Где найдено в коде: `backend/src/routes/auth.js` -> `authController.logout`, `backend/src/controllers/authController.js#logout` + +### [GET] /api/v1/auth/me + +- Описание: возвращает текущего пользователя по JWT +- Авторизация: JWT Bearer token +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization: Bearer ` +- Request Body: нет +- Response 200/201: `{ id, username, full_name, email, role, avatar_url, last_login, created_at }` +- Ошибки: +- `401` — токен отсутствует +- `403` — токен невалиден или истек +- `404` — пользователь не найден +- `500` — внутренняя ошибка +- Связанные сущности: `User` +- Где найдено в коде: `backend/src/routes/auth.js` -> `authenticateToken` + `authController.me`, `backend/src/controllers/authController.js#me` + +### [POST] /api/v1/auth/change-password + +- Описание: меняет пароль текущего пользователя +- Авторизация: JWT Bearer token +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization: Bearer `, `Content-Type: application/json` +- Request Body: `ChangePasswordRequest` +- Response 200/201: `{ message: 'Password changed successfully' }` +- Ошибки: +- `400` — нет `currentPassword`/`newPassword`, либо новый пароль короче 6 символов +- `401` — текущий пароль неверный +- `403` — токен невалиден/истек +- `404` — пользователь не найден +- `500` — внутренняя ошибка +- Связанные сущности: `User`, `UserSession` +- Где найдено в коде: `backend/src/routes/auth.js` -> `authenticateToken` + `authController.changePassword`, `backend/src/controllers/authController.js#changePassword` + +## Upload + +### [POST] /api/v1/upload/avatar + +- Описание: загружает или заменяет аватар текущего пользователя +- Авторизация: JWT Bearer token +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization: Bearer `, `Content-Type: multipart/form-data` +- Request Body: `AvatarUploadRequest` c файлом `avatar` +- Response 200/201: `AvatarUploadResponse` +- Ошибки: +- `400` — файл не передан, файл > 5MB, неверный mime/type +- `401` — токен отсутствует +- `403` — токен невалиден/истек +- `429` — превышен upload rate limit +- `500` — внутренняя ошибка +- Связанные сущности: `User` +- Где найдено в коде: `backend/src/routes/upload.js` -> `upload.single('avatar')` + `uploadController.uploadAvatar`, `backend/src/controllers/uploadController.js#uploadAvatar`, `backend/src/middleware/upload.js` + +### [DELETE] /api/v1/upload/avatar + +- Описание: удаляет аватар текущего пользователя +- Авторизация: JWT Bearer token +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization: Bearer ` +- Request Body: нет +- Response 200/201: `{ message: 'Avatar deleted successfully' }` +- Ошибки: +- `401` — токен отсутствует +- `403` — токен невалиден/истек +- `404` — у пользователя нет аватара +- `429` — превышен upload rate limit +- `500` — внутренняя ошибка +- Связанные сущности: `User` +- Где найдено в коде: `backend/src/routes/upload.js` -> `uploadController.deleteAvatar`, `backend/src/controllers/uploadController.js#deleteAvatar` + +### [DELETE] /api/v1/upload/avatar/:userId + +- Описание: админ удаляет аватар любого пользователя +- Авторизация: JWT Bearer token + роль `admin` +- Параметры path: `userId` +- Query-параметры: нет +- Headers: `Authorization: Bearer ` +- Request Body: нет +- Response 200/201: `{ message: 'Avatar deleted successfully' }` +- Ошибки: +- `401` — токен отсутствует +- `403` — токен невалиден/истек или нет admin роли +- `404` — пользователь не найден или у него нет аватара +- `429` — превышен upload rate limit +- `500` — внутренняя ошибка +- Связанные сущности: `User` +- Где найдено в коде: `backend/src/routes/upload.js` -> `requireAdmin` + `uploadController.deleteUserAvatar`, `backend/src/controllers/uploadController.js#deleteUserAvatar` + +## Routes + +### [GET] /api/v1/routes + +- Описание: список активных маршрутов +- Авторизация: `Authorization: Bearer ` или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `RouteSummary[]` +- Ошибки: +- `401` — не передан ни JWT, ни `X-API-Key` +- `403` — токен/API key невалидны +- `429` — общий rate limit API +- `500` — `Failed to fetch routes` +- Связанные сущности: `Route`, `RouteStop` +- Где найдено в коде: `backend/src/routes/api.js` -> `routesController.getAllRoutes`, `backend/src/controllers/routesController.js#getAllRoutes` + +### [GET] /api/v1/routes/:id + +- Описание: маршрут с вложенным списком остановок в порядке следования +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `RouteDetail` +- Ошибки: +- `404` — маршрут не найден или неактивен +- `401`/`403`/`429` +- `500` — `Failed to fetch route` +- Связанные сущности: `Route`, `RouteStop`, `Stop` +- Где найдено в коде: `backend/src/routes/api.js` -> `routesController.getRouteById`, `backend/src/controllers/routesController.js#getRouteById` + +### [POST] /api/v1/routes + +- Описание: создает маршрут +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `CreateRouteRequest` +- Response 200/201: созданный `Route` +- Ошибки: +- `400` — отсутствуют `route_number`, `name` или `type` +- `401`/`403`/`429` +- `500` — `Failed to create route`; DB constraint violations также уйдут в `500`, отдельного `409` нет +- Связанные сущности: `Route` +- Где найдено в коде: `backend/src/routes/api.js` -> `routesController.createRoute`, `backend/src/controllers/routesController.js#createRoute` +- Пример запроса: + +```json +{ + "route_number": "1", + "name": "Центр - Вокзал", + "type": "bus", + "color": "#0066CC", + "description": "Основной маршрут через центр" +} +``` + +### [PUT] /api/v1/routes/:id + +- Описание: частично обновляет маршрут +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `UpdateRouteRequest` +- Response 200/201: обновленный `Route` +- Ошибки: +- `404` — маршрут не найден +- `401`/`403`/`429` +- `500` — `Failed to update route` +- Связанные сущности: `Route` +- Где найдено в коде: `backend/src/routes/api.js` -> `routesController.updateRoute`, `backend/src/controllers/routesController.js#updateRoute` + +### [DELETE] /api/v1/routes/:id + +- Описание: удаляет маршрут +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ message: 'Route deleted successfully' }` +- Ошибки: +- `404` — маршрут не найден +- `401`/`403`/`429` +- `500` — `Failed to delete route` +- Связанные сущности: `Route`, каскадно `RouteStop`, `Schedule`, `HistoricalDelay`, `Alert(route_id)` +- Где найдено в коде: `backend/src/routes/api.js` -> `routesController.deleteRoute`, `backend/src/controllers/routesController.js#deleteRoute` + +## Route Stops + +### [GET] /api/v1/routes/:routeId/stops + +- Описание: ordered stop list для маршрута +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `routeId` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `RouteStop[]` в обогащенном виде `{ route_stop_id, sequence, time_offset_minutes, stop_id, name, address, latitude, longitude }` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to fetch route stops` +- Связанные сущности: `RouteStop`, `Stop`, `Route` +- Где найдено в коде: `backend/src/routes/api.js` -> `routeStopsController.getRouteStops`, `backend/src/controllers/routeStopsController.js#getRouteStops` + +### [GET] /api/v1/routes/:routeId/available-stops + +- Описание: активные остановки, еще не входящие в маршрут +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `routeId` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: массив `{ id, name, address, latitude, longitude }` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to fetch available stops` +- Связанные сущности: `Route`, `Stop`, `RouteStop` +- Где найдено в коде: `backend/src/routes/api.js` -> `routeStopsController.getAvailableStops`, `backend/src/controllers/routeStopsController.js#getAvailableStops` + +### [POST] /api/v1/routes/:routeId/stops + +- Описание: добавляет остановку в маршрут +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `routeId` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `AddRouteStopRequest` +- Response 200/201: raw запись `route_stops` +- Ошибки: +- `400` — нет `stopId` +- `404` — маршрут или остановка не найдены +- `401`/`403`/`429` +- `500` — `Failed to add stop to route` +- Связанные сущности: `RouteStop`, `Route`, `Stop` +- Где найдено в коде: `backend/src/routes/api.js` -> `routeStopsController.addStopToRoute`, `backend/src/controllers/routeStopsController.js#addStopToRoute` + +### [PUT] /api/v1/routes/:routeId/stops/:routeStopId + +- Описание: меняет позицию остановки в маршруте и/или `time_offset_minutes` +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `routeId`, `routeStopId` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `UpdateRouteStopRequest` +- Response 200/201: обновленная raw запись `route_stops` +- Ошибки: +- `404` — route stop не найден +- `401`/`403`/`429` +- `500` — `Failed to update route stop` +- Связанные сущности: `RouteStop`, `Route` +- Где найдено в коде: `backend/src/routes/api.js` -> `routeStopsController.updateRouteStop`, `backend/src/controllers/routeStopsController.js#updateRouteStop` + +### [DELETE] /api/v1/routes/:routeId/stops/:routeStopId + +- Описание: удаляет остановку из маршрута и переупорядочивает sequence +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `routeId`, `routeStopId` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ message: 'Stop removed from route successfully' }` +- Ошибки: +- `404` — route stop не найден +- `401`/`403`/`429` +- `500` — `Failed to remove stop from route` +- Связанные сущности: `RouteStop`, `Route` +- Где найдено в коде: `backend/src/routes/api.js` -> `routeStopsController.removeStopFromRoute`, `backend/src/controllers/routeStopsController.js#removeStopFromRoute` + +### [PUT] /api/v1/routes/:routeId/stops-reorder + +- Описание: bulk-reorder всех остановок маршрута +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `routeId` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `ReorderRouteStopsRequest` +- Response 200/201: `{ message: 'Route stops reordered successfully', count }` +- Ошибки: +- `400` — `stops` не массив или пустой +- `401`/`403`/`429` +- `500` — `Failed to reorder route stops` +- Связанные сущности: `RouteStop`, `Route`, `Stop` +- Где найдено в коде: `backend/src/routes/api.js` -> `routeStopsController.reorderRouteStops`, `backend/src/controllers/routeStopsController.js#reorderRouteStops` + +## Schedules + +### [GET] /api/v1/routes/:routeId/schedules + +- Описание: все расписания маршрута по типам дней +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `routeId` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `Schedule[]` с дополнительными `route_number`, `route_name` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to fetch schedules` +- Связанные сущности: `Schedule`, `Route` +- Где найдено в коде: `backend/src/routes/api.js` -> `schedulesController.getRouteSchedules`, `backend/src/controllers/schedulesController.js#getRouteSchedules` + +### [GET] /api/v1/routes/:routeId/schedule/today + +- Описание: возвращает действующее расписание на дату, с учетом SQL-функции `get_schedule_type()` и праздников +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `routeId` +- Query-параметры: `date` (опционально, `YYYY-MM-DD`) +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: +- `{ date, scheduleType, isHoliday, holidayName, schedule }` +- если праздник и отдельного `holiday` расписания нет, endpoint fallback-ится на `sunday` +- Ошибки: +- `404` — расписание на нужный тип дня не найдено +- `401`/`403`/`429` +- `500` — `Failed to fetch schedule` +- Связанные сущности: `Schedule`, `Route`, `Holiday` +- Где найдено в коде: `backend/src/routes/api.js` -> `schedulesController.getTodaySchedule`, `backend/src/controllers/schedulesController.js#getTodaySchedule` + +### [POST] /api/v1/routes/:routeId/schedules/copy + +- Описание: копирует departure times из одного day type в другой +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `routeId` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `CopyScheduleRequest` +- Response 200/201: существующее обновленное либо новое `Schedule` +- Ошибки: +- `400` — отсутствуют `fromDayType`/`toDayType` +- `404` — source schedule не найден +- `401`/`403`/`429` +- `500` — `Failed to copy schedule` +- Связанные сущности: `Schedule`, `Route` +- Где найдено в коде: `backend/src/routes/api.js` -> `schedulesController.copySchedule`, `backend/src/controllers/schedulesController.js#copySchedule` + +### [GET] /api/v1/schedules/:id + +- Описание: чтение конкретного расписания +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `Schedule` с `route_number`, `route_name` +- Ошибки: +- `404` — расписание не найдено +- `401`/`403`/`429` +- `500` — `Failed to fetch schedule` +- Связанные сущности: `Schedule`, `Route` +- Где найдено в коде: `backend/src/routes/api.js` -> `schedulesController.getScheduleById`, `backend/src/controllers/schedulesController.js#getScheduleById` + +### [POST] /api/v1/schedules + +- Описание: создает расписание маршрута +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `CreateScheduleRequest` +- Response 200/201: созданный `Schedule` +- Ошибки: +- `400` — нет `routeId`, `dayType` или `departureTimes` +- `400` — invalid `dayType` +- `400` — invalid departure time format, ожидается `HH:MM` или `HH:MM:SS` +- `409` — уже есть активное расписание на этот `routeId + dayType` +- `401`/`403`/`429` +- `500` — `Failed to create schedule` +- Связанные сущности: `Schedule`, `Route` +- Где найдено в коде: `backend/src/routes/api.js` -> `schedulesController.createSchedule`, `backend/src/controllers/schedulesController.js#createSchedule` + +### [PUT] /api/v1/schedules/:id + +- Описание: обновляет departure times и/или validity window +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `UpdateScheduleRequest` +- Response 200/201: обновленный `Schedule` +- Ошибки: +- `404` — расписание не найдено +- `400` — invalid departure time format +- `401`/`403`/`429` +- `500` — `Failed to update schedule` +- Связанные сущности: `Schedule` +- Где найдено в коде: `backend/src/routes/api.js` -> `schedulesController.updateSchedule`, `backend/src/controllers/schedulesController.js#updateSchedule` + +### [DELETE] /api/v1/schedules/:id + +- Описание: удаляет расписание +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ message: 'Schedule deleted successfully' }` +- Ошибки: +- `404` — расписание не найдено +- `401`/`403`/`429` +- `500` — `Failed to delete schedule` +- Связанные сущности: `Schedule` +- Где найдено в коде: `backend/src/routes/api.js` -> `schedulesController.deleteSchedule`, `backend/src/controllers/schedulesController.js#deleteSchedule` + +## Holidays + +### [GET] /api/v1/holidays + +- Описание: список праздников; optional filter по году +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: `year` (опционально) +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: +- без `year`: массив raw `Holiday` +- с `year`: массив raw `Holiday`, отсортированный так, чтобы recurring праздники шли по target year, но поле `date` для recurring остается исходным stored date +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to fetch holidays` +- Связанные сущности: `Holiday` +- Где найдено в коде: `backend/src/routes/api.js` -> `holidaysController.getAllHolidays`, `backend/src/controllers/holidaysController.js#getAllHolidays` + +### [GET] /api/v1/holidays/upcoming + +- Описание: ближайшие праздники относительно `CURRENT_DATE` +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: `limit` (опционально, по умолчанию `5`) +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: массив `{ id, name, next_date, is_recurring }` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to fetch upcoming holidays` +- Связанные сущности: `Holiday` +- Где найдено в коде: `backend/src/routes/api.js` -> `holidaysController.getUpcomingHolidays`, `backend/src/controllers/holidaysController.js#getUpcomingHolidays` + +### [GET] /api/v1/holidays/year/:year + +- Описание: праздники конкретного года с resolved recurring dates +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `year` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: массив `{ id, date, name, is_recurring }` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to fetch holidays for year` +- Связанные сущности: `Holiday` +- Где найдено в коде: `backend/src/routes/api.js` -> `holidaysController.getHolidaysForYear`, `backend/src/controllers/holidaysController.js#getHolidaysForYear` + +### [GET] /api/v1/holidays/check/:date + +- Описание: проверка, является ли дата праздником +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `date` (`YYYY-MM-DD`) +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ date, isHoliday, holidayName }` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to check holiday` +- Связанные сущности: `Holiday` +- Где найдено в коде: `backend/src/routes/api.js` -> `holidaysController.checkHoliday`, `backend/src/controllers/holidaysController.js#checkHoliday` + +### [POST] /api/v1/holidays + +- Описание: создает праздник +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `CreateHolidayRequest` +- Response 200/201: созданный/обновленный raw `Holiday` +- Ошибки: +- `400` — нет `date` или `name` +- `401`/`403`/`429` +- `500` — `Failed to create holiday` +- Связанные сущности: `Holiday` +- Где найдено в коде: `backend/src/routes/api.js` -> `holidaysController.createHoliday`, `backend/src/controllers/holidaysController.js#createHoliday` + +### [PUT] /api/v1/holidays/:id + +- Описание: обновляет праздник +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `UpdateHolidayRequest` +- Response 200/201: обновленный raw `Holiday` +- Ошибки: +- `404` — праздник не найден +- `401`/`403`/`429` +- `500` — `Failed to update holiday` +- Связанные сущности: `Holiday` +- Где найдено в коде: `backend/src/routes/api.js` -> `holidaysController.updateHoliday`, `backend/src/controllers/holidaysController.js#updateHoliday` + +### [DELETE] /api/v1/holidays/:id + +- Описание: удаляет праздник +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ message: 'Holiday deleted successfully' }` +- Ошибки: +- `404` — праздник не найден +- `401`/`403`/`429` +- `500` — `Failed to delete holiday` +- Связанные сущности: `Holiday` +- Где найдено в коде: `backend/src/routes/api.js` -> `holidaysController.deleteHoliday`, `backend/src/controllers/holidaysController.js#deleteHoliday` + +## Stops + +### [GET] /api/v1/stops + +- Описание: список активных остановок +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `StopSummary[]` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to fetch stops` +- Связанные сущности: `Stop`, `RouteStop` +- Где найдено в коде: `backend/src/routes/api.js` -> `stopsController.getAllStops`, `backend/src/controllers/stopsController.js#getAllStops` + +### [GET] /api/v1/stops/nearby + +- Описание: geospatial поиск остановок в радиусе +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: +- `lat` — обязательно +- `lon` — обязательно +- `radius` — опционально, default `500` +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: массив остановок с `latitude`, `longitude`, `distance_meters` +- Ошибки: +- `400` — отсутствует `lat` или `lon` +- `401`/`403`/`429` +- `500` — `Failed to fetch nearby stops` +- Связанные сущности: `Stop` +- Где найдено в коде: `backend/src/routes/api.js` -> `stopsController.getNearbyStops`, `backend/src/controllers/stopsController.js#getNearbyStops` + +### [GET] /api/v1/stops/:id + +- Описание: остановка с маршрутами, проходящими через нее +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `StopDetail` +- Ошибки: +- `404` — остановка не найдена или неактивна +- `401`/`403`/`429` +- `500` — `Failed to fetch stop` +- Связанные сущности: `Stop`, `Route`, `RouteStop` +- Где найдено в коде: `backend/src/routes/api.js` -> `stopsController.getStopById`, `backend/src/controllers/stopsController.js#getStopById` + +### [POST] /api/v1/stops + +- Описание: создает остановку +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `CreateStopRequest` +- Response 200/201: созданный raw `Stop` + `latitude` + `longitude` +- Ошибки: +- `400` — нет `name`, `latitude` или `longitude` +- `401`/`403`/`429` +- `500` — `Failed to create stop` +- Связанные сущности: `Stop` +- Где найдено в коде: `backend/src/routes/api.js` -> `stopsController.createStop`, `backend/src/controllers/stopsController.js#createStop` + +### [PUT] /api/v1/stops/:id + +- Описание: частично обновляет остановку +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `UpdateStopRequest` +- Response 200/201: обновленный raw `Stop` + `latitude` + `longitude` +- Ошибки: +- `404` — остановка не найдена +- `401`/`403`/`429` +- `500` — `Failed to update stop` +- Примечание: если body пустой, контроллер соберет невалидный SQL и фактически вернет `500` +- Связанные сущности: `Stop` +- Где найдено в коде: `backend/src/routes/api.js` -> `stopsController.updateStop`, `backend/src/controllers/stopsController.js#updateStop` + +### [DELETE] /api/v1/stops/:id + +- Описание: удаляет остановку +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ message: 'Stop deleted successfully' }` +- Ошибки: +- `404` — остановка не найдена +- `401`/`403`/`429` +- `500` — `Failed to delete stop` +- Связанные сущности: `Stop`, каскадно `RouteStop`, `HistoricalDelay` +- Где найдено в коде: `backend/src/routes/api.js` -> `stopsController.deleteStop`, `backend/src/controllers/stopsController.js#deleteStop` + +## ETA + +### [POST] /api/v1/eta/calculate + +- Описание: считает ETA для одной пары `routeId + stopId` +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `CalculateEtaRequest` +- Response 200/201: +- если есть ближайшие рейсы: `{ routeId, stopId, currentTime, dayType, arrivals }` +- если на сегодня рейсов больше нет: `{ message: 'No more arrivals today', nextDay }` +- Ошибки: +- `400` — нет `stopId` или `routeId` +- `404` — нет расписания для текущего day type +- `404` — остановка не принадлежит маршруту +- `401`/`403`/`429` +- `500` — `Failed to calculate ETA` +- Связанные сущности: `Schedule`, `RouteStop`, `HistoricalDelay`, `Holiday` +- Где найдено в коде: `backend/src/routes/api.js` -> `etaController.calculateEta`, `backend/src/controllers/etaController.js#calculateEta` + +### [GET] /api/v1/eta/stop/:stopId + +- Описание: ETA по всем маршрутам для остановки +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `stopId` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ stopId, routes: Array<{ id, route_number, name, type, color, arrivals, error? }> }` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to get stop ETAs` +- Связанные сущности: `Stop`, `Route`, `Schedule`, `RouteStop`, `HistoricalDelay` +- Где найдено в коде: `backend/src/routes/api.js` -> `etaController.getStopEtas`, `backend/src/controllers/etaController.js#getStopEtas` +- Важное отличие: helper `calculateEtaForRoute()` определяет day type по JS `Date#getDay()` и не использует SQL-функцию `get_schedule_type()`, поэтому праздничные даты здесь обрабатываются иначе, чем в `POST /eta/calculate` + +## Sync + +### [GET] /api/v1/sync + +- Описание: full sync или incremental sync для offline-first клиентов +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: `lastSync` (опционально, ISO datetime) +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: +- без `lastSync`: `FullSyncResponse` +- с `lastSync`: `IncrementalSyncResponse` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Sync failed` +- Связанные сущности: `Route`, `Stop`, `RouteStop`, `Schedule`, `Alert`, `Holiday`, `SyncLog` +- Где найдено в коде: `backend/src/routes/api.js` -> `syncController.sync`, `backend/src/controllers/syncController.js#sync` +- Детали full sync: +- `data.routes` +- `data.stops` +- `data.routeStops` +- `data.schedules` +- `data.alerts` только активные +- `data.holidays` +- Детали incremental sync: +- `changes.routes.updated/deleted` +- `changes.stops.updated/deleted` +- `changes.routeStops.updated/deleted` +- `changes.schedules.updated/deleted` +- `alerts` — только новые активные alerts, созданные после `lastSync` + +### [GET] /api/v1/sync/status + +- Описание: статус системы синхронизации и агрегированные counts +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ status: 'ready', timestamp, database: { routes_count, stops_count, schedules_count, last_change } }` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Failed to get sync status` +- Связанные сущности: `SyncLog`, `Route`, `Stop`, `Schedule` +- Где найдено в коде: `backend/src/routes/api.js` -> `syncController.getSyncStatus`, `backend/src/controllers/syncController.js#getSyncStatus` + +## Alerts + +### [GET] /api/v1/alerts + +- Описание: список уведомлений с фильтрами +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: +- `route_id` — опционально +- `is_active` — опционально, строка `'true'` или `'false'` +- `alert_type` — опционально, `delay|cancellation|detour|info` +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `Alert[]` с join-полями `route_name`, `route_number`, `color` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Internal server error` +- Связанные сущности: `Alert`, `Route` +- Где найдено в коде: `backend/src/routes/alerts.js` -> inline `router.get('/')` + +### [GET] /api/v1/alerts/active + +- Описание: только активные и актуальные по времени уведомления +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: `route_id` (опционально) +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `Alert[]` с join-полями маршрута +- Ошибки: +- `401`/`403`/`429` +- `500` — `Internal server error` +- Связанные сущности: `Alert`, `Route` +- Где найдено в коде: `backend/src/routes/alerts.js` -> inline `router.get('/active')` + +### [GET] /api/v1/alerts/:id + +- Описание: получение одного уведомления +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: один `Alert` с join-полями маршрута +- Ошибки: +- `404` — `Alert not found` +- `401`/`403`/`429` +- `500` — `Internal server error` +- Связанные сущности: `Alert`, `Route` +- Где найдено в коде: `backend/src/routes/alerts.js` -> inline `router.get('/:id')` + +### [POST] /api/v1/alerts + +- Описание: создает уведомление +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `CreateAlertRequest` +- Response 200/201: raw `Alert` без join-полей +- Ошибки: +- `400` — отсутствуют обязательные поля +- `400` — invalid `alert_type` +- `401`/`403`/`429` +- `500` — `Internal server error` +- Связанные сущности: `Alert`, `Route` +- Где найдено в коде: `backend/src/routes/alerts.js` -> inline `router.post('/')` + +### [PUT] /api/v1/alerts/:id + +- Описание: обновляет уведомление +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: `UpdateAlertRequest` +- Response 200/201: raw обновленный `Alert` +- Ошибки: +- `404` — `Alert not found` +- `400` — invalid `alert_type` +- `401`/`403`/`429` +- `500` — `Internal server error` +- Связанные сущности: `Alert`, `Route` +- Где найдено в коде: `backend/src/routes/alerts.js` -> inline `router.put('/:id')` + +### [DELETE] /api/v1/alerts/:id + +- Описание: удаляет уведомление +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ message: 'Alert deleted successfully' }` +- Ошибки: +- `404` — `Alert not found` +- `401`/`403`/`429` +- `500` — `Internal server error` +- Связанные сущности: `Alert` +- Где найдено в коде: `backend/src/routes/alerts.js` -> inline `router.delete('/:id')` + +### [POST] /api/v1/alerts/cleanup + +- Описание: деактивирует истекшие уведомления +- Авторизация: Bearer JWT или `X-API-Key` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `{ message: 'Cleanup completed', deactivated_count }` +- Ошибки: +- `401`/`403`/`429` +- `500` — `Internal server error` +- Связанные сущности: `Alert` +- Где найдено в коде: `backend/src/routes/alerts.js` -> inline `router.post('/cleanup')` + +## Users + +### [GET] /api/v1/users + +- Описание: список пользователей +- Авторизация: JWT Bearer token + роль `admin` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization: Bearer ` +- Request Body: нет +- Response 200/201: массив `{ id, username, full_name, email, role, is_active, last_login, created_at }` +- Ошибки: +- `401` — нет/невалиден JWT +- `403` — нет admin роли +- `429` — общий rate limit API +- `500` — `Internal server error` +- Связанные сущности: `User` +- Где найдено в коде: `backend/src/routes/users.js` -> `authenticateToken` + `requireAdmin` + `usersController.getAllUsers`, `backend/src/controllers/usersController.js#getAllUsers` + +### [GET] /api/v1/users/:id + +- Описание: чтение одного пользователя +- Авторизация: JWT Bearer token + роль `admin` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization: Bearer ` +- Request Body: нет +- Response 200/201: `{ id, username, full_name, email, role, is_active, last_login, created_at }` +- Ошибки: +- `401`/`403`/`429` +- `404` — `User not found` +- `500` — `Internal server error` +- Связанные сущности: `User` +- Где найдено в коде: `backend/src/routes/users.js` -> `usersController.getUserById`, `backend/src/controllers/usersController.js#getUserById` + +### [POST] /api/v1/users + +- Описание: создание пользователя +- Авторизация: JWT Bearer token + роль `admin` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization: Bearer `, `Content-Type: application/json` +- Request Body: `CreateUserRequest` +- Response 200/201: `{ id, username, full_name, email, role, is_active, created_at }` +- Ошибки: +- `400` — нет `username`/`password` +- `400` — пароль короче 6 символов +- `400` — username уже существует +- `401`/`403`/`429` +- `500` — `Internal server error` +- Связанные сущности: `User` +- Где найдено в коде: `backend/src/routes/users.js` -> `usersController.createUser`, `backend/src/controllers/usersController.js#createUser` + +### [PUT] /api/v1/users/:id + +- Описание: обновляет профиль, роль и активность пользователя +- Авторизация: JWT Bearer token + роль `admin` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization: Bearer `, `Content-Type: application/json` +- Request Body: `UpdateUserRequest` +- Response 200/201: `{ id, username, full_name, email, role, is_active }` +- Ошибки: +- `400` — нельзя деактивировать самого себя +- `401`/`403`/`429` +- `404` — `User not found` +- `500` — `Internal server error` +- Связанные сущности: `User` +- Где найдено в коде: `backend/src/routes/users.js` -> `usersController.updateUser`, `backend/src/controllers/usersController.js#updateUser` + +### [DELETE] /api/v1/users/:id + +- Описание: удаляет пользователя +- Авторизация: JWT Bearer token + роль `admin` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization: Bearer ` +- Request Body: нет +- Response 200/201: `{ message: 'User deleted successfully' }` +- Ошибки: +- `400` — нельзя удалить самого себя +- `401`/`403`/`429` +- `404` — `User not found` +- `500` — `Internal server error` +- Связанные сущности: `User`, каскадно `UserSession` +- Где найдено в коде: `backend/src/routes/users.js` -> `usersController.deleteUser`, `backend/src/controllers/usersController.js#deleteUser` + +### [POST] /api/v1/users/:id/reset-password + +- Описание: сбрасывает пароль пользователя и инвалидирует все его сессии +- Авторизация: JWT Bearer token + роль `admin` +- Параметры path: `id` +- Query-параметры: нет +- Headers: `Authorization: Bearer `, `Content-Type: application/json` +- Request Body: `ResetPasswordRequest` +- Response 200/201: `{ message: 'Password reset successfully' }` +- Ошибки: +- `400` — нет `newPassword` или пароль короче 6 символов +- `401`/`403`/`429` +- `404` — `User not found` +- `500` — `Internal server error` +- Связанные сущности: `User`, `UserSession` +- Где найдено в коде: `backend/src/routes/users.js` -> `usersController.resetPassword`, `backend/src/controllers/usersController.js#resetPassword` + +## Telemetry / Realtime + +### [POST] /api/v1/telemetry/ingest + +- Описание: принимает GPS/телеметрию, при необходимости автоматически создает транспорт по `vehicleNumber`, обновляет `vehicle_live_state` и пушит событие в realtime-канал маршрута +- Авторизация: `Authorization: Bearer ` или `X-API-Key: ` +- Параметры path: нет +- Query-параметры: нет +- Headers: `Authorization` или `X-API-Key`, `Content-Type: application/json` +- Request Body: +- `vehicleId?: number` +- `vehicleNumber?: string` +- `routeId?: number` +- `vehicleType?: 'bus' | 'minibus' | 'trolleybus' | 'tram'` +- `lat: number` +- `lon: number` +- `speedKmh?: number | null` +- `heading?: number | null` +- `accuracyMeters?: number | null` +- `recordedAt?: string (ISO datetime)` +- `sourceType?: string` +- `sourceRef?: string` +- Response 200/201: +- `{ message: 'Telemetry ingested successfully', vehicle: LiveVehicleState }` +- `vehicle` содержит: +- `vehicle_id`, `vehicle_number`, `registration`, `vehicle_type`, `capacity` +- `route_id`, `route_number`, `route_name`, `route_color` +- `latitude`, `longitude`, `speed_kmh`, `heading`, `accuracy_meters` +- `source_type`, `source_ref`, `last_seen_at`, `updated_at` +- `is_online`, `seconds_since_update`, `is_moving` +- Ошибки: +- `400` — некорректные `lat/lon`, `heading`, `recordedAt`, отсутствует `vehicleId|vehicleNumber`, `routeId` нужен для авто-создания транспорта +- `404` — `Route not found`, `Vehicle not found` +- `500` — `Failed to ingest telemetry` +- Связанные сущности: `vehicles`, `telemetry_samples`, `vehicle_live_state`, `routes` +- Где найдено в коде: `backend/src/routes/api.js` -> `telemetryController.ingest`, `backend/src/controllers/telemetryController.js#ingest`, `backend/src/services/telemetryService.js#ingestTelemetry`, `backend/migrations/005_realtime_gps.sql` + +Пример запроса: + +```json +{ + "routeId": 7, + "vehicleNumber": "DEMO-7A", + "vehicleType": "bus", + "lat": 55.4869, + "lon": 28.7856, + "speedKmh": 24, + "heading": 90, + "accuracyMeters": 5, + "recordedAt": "2026-04-16T10:30:00.000Z" +} +``` + +### [GET] /api/v1/realtime/routes/:routeId/vehicles + +- Описание: отдает snapshot живых транспортных единиц по маршруту +- Авторизация: `Authorization: Bearer ` или `X-API-Key: ` +- Параметры path: `routeId` +- Query-параметры: +- `includeStale?: boolean` — включать offline/stale машины +- `staleAfterSeconds?: number` — окно online/offline, по умолчанию `180` +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `LiveVehicleState[]` +- Ошибки: +- `400` — `routeId must be a positive integer` +- `500` — `Failed to fetch route live vehicles` +- Связанные сущности: `vehicle_live_state`, `vehicles`, `routes` +- Где найдено в коде: `backend/src/routes/api.js` -> `telemetryController.getRouteLiveVehicles`, `backend/src/controllers/telemetryController.js#getRouteLiveVehicles`, `backend/src/services/telemetryService.js#getRouteLiveVehicles` + +### [GET] /api/v1/realtime/vehicles/:vehicleId + +- Описание: отдает последнюю известную live-позицию конкретной транспортной единицы +- Авторизация: `Authorization: Bearer ` или `X-API-Key: ` +- Параметры path: `vehicleId` +- Query-параметры: +- `staleAfterSeconds?: number` — окно online/offline, по умолчанию `180` +- Headers: `Authorization` или `X-API-Key` +- Request Body: нет +- Response 200/201: `LiveVehicleState` +- Ошибки: +- `400` — `vehicleId must be a positive integer` +- `404` — `Live state not found for this vehicle` +- `500` — `Failed to fetch vehicle live state` +- Связанные сущности: `vehicle_live_state`, `vehicles`, `routes` +- Где найдено в коде: `backend/src/routes/api.js` -> `telemetryController.getVehicleLiveState`, `backend/src/controllers/telemetryController.js#getVehicleLiveState`, `backend/src/services/telemetryService.js#getVehicleLiveState` + +# WebSocket / Realtime API + +Реализован WebSocket endpoint для подписки на живые GPS-обновления: + +- Endpoint: `GET ws(s):///ws/realtime` +- Где найдено: `backend/src/services/realtimeService.js#attachRealtimeServer`, подключение в `backend/src/index.js#startServer`, proxy в `nginx/nginx.conf` +- Протокол: JSON messages поверх WebSocket +- Аутентификация: после подключения клиент обязан отправить `{ "type": "auth", "token": "" }` или `{ "type": "auth", "apiKey": "" }` + +Поддерживаемые входящие сообщения: + +- `auth` +- `subscribe:route` +- `unsubscribe:route` +- `ping` + +Поддерживаемые исходящие сообщения: + +- `welcome` +- `auth:ok` +- `subscription:ok` +- `subscription:removed` +- `snapshot:route` +- `vehicle:position` +- `pong` +- `error` + +Пример handshake: + +```json +{ "type": "auth", "token": "" } +``` + +```json +{ "type": "subscribe:route", "routeId": 7 } +``` + +Пример snapshot: + +```json +{ + "type": "snapshot:route", + "routeId": 7, + "vehicles": [ + { + "vehicle_id": 12, + "vehicle_number": "DEMO-7A", + "route_id": 7, + "latitude": 55.4869, + "longitude": 28.7856, + "speed_kmh": 24, + "is_online": true, + "seconds_since_update": 3 + } + ] +} +``` + +# Внутренние интеграции + +## PostgreSQL / PostGIS + +- Все бизнес-данные хранятся в PostgreSQL +- Геооперации по остановкам выполняются через PostGIS `GEOGRAPHY(Point, 4326)` +- SQL-функции: +- `is_holiday(date)` — проверка праздничной даты +- `get_schedule_type(date)` — `weekday|saturday|sunday|holiday` + +## Redis + +- Кешируются: +- `routes:all` +- `route:{id}` +- `stops:all` +- При изменениях кэш инвалидируется в контроллерах + +## Sync triggers + +- Триггеры `log_sync_changes()` пишут изменения в `sync_log` для: +- `routes` +- `stops` +- `route_stops` +- `schedules` +- `holidays` + +## Static media + +- Публичная раздача файлов: `/uploads/*` +- Фактически используются аватары: `/uploads/avatars/` +- Путь примонтирован через `express.static(...)` в `backend/src/index.js` + +## Frontend internal API layer + +- `frontend/src/services/api.js` автоматически: +- добавляет Bearer JWT, если он есть +- иначе подставляет `X-API-Key` +- делает refresh access token при `401/403` + +## Nginx + +- Проксирует `/api/` на backend +- Проксирует `/` на frontend +- Включает свои rate limits + +## Bootstrap scripts + +- `backend/scripts/create-admin.js` создает пользователя `admin / admin123` +- Это operational helper, а не API endpoint + +# Проблемы и неясности + +## Подтвержденные проблемы + +- Swagger/OpenAPI annotations в коде не найдены; auto-generated API docs отсутствуют +- GraphQL schema/resolvers не найдены +- Realtime теперь реализован через `ws`, но зависимость `socket.io` все еще лежит в `backend/package.json` и больше не соответствует фактической реализации +- `vehicles` есть в БД, но не имеет ни backend API, ни frontend UI +- `historical_delays` участвует в ETA, но CRUD/ingest API для наполнения этой таблицы не найден +- Инкрементальный sync не обрабатывает `holidays`, хотя на таблицу есть trigger `sync_holidays` +- Инкрементальный sync не обрабатывает обновления/удаления `alerts`; он возвращает только новые активные alerts после `lastSync` +- `GET /api/v1/eta/stop/:stopId` использует JS-логику day type и не учитывает SQL-функцию `get_schedule_type()`, поэтому поведение на праздниках расходится с `POST /api/v1/eta/calculate` +- `GET /api/v1/holidays?year=YYYY` сортирует recurring праздники под целевой год, но не резолвит поле `date`; корректно резолвит даты только `GET /api/v1/holidays/year/:year` +- `PUT /api/v1/stops/:id` при пустом body генерирует невалидный SQL и, по сути, сваливается в `500` +- В `backend/src/middleware/rateLimiter.js` реализован helper Redis store, но он не подключен в конфиг `rateLimit()` +- `backend/package.json` теперь содержит рабочий `src/utils/migrate.js`, но `src/utils/seed.js` все еще отсутствует +- Frontend ожидает `route.schedules_count` в `Dashboard.jsx` и `RoutesPage.jsx`, но backend `GET /api/v1/routes` это поле не возвращает + +## Неясности и предположения + +- Raw поле `location` в stop responses зависит от PostGIS/pg serialization; API фактически отдает его, но стабильный контракт по формату в коде не зафиксирован +- Агрегаты `COUNT(*)` и `COUNT(DISTINCT ...)` из PostgreSQL через `pg` часто приходят строками; frontend использует их без строгой типизации, поэтому клиентам лучше не полагаться на numeric JSON type без дополнительной нормализации +- Для create/update endpoints многие DB constraint violations не маппятся в `409/422`, а улетают в общий `500` + +# Рекомендации + +- Вынести этот восстановленный контракт в официальный `OpenAPI 3.x` и использовать его как single source of truth +- Добавить schema validation на входе (`zod`, `joi`, `express-validator`) и описать ошибки `400/422` детерминированно +- Явно разделить права API key и operator/admin JWT: +- read-only API key для публичных/mobile клиентов +- mutation endpoints только для JWT и соответствующих ролей +- Нормализовать stop responses: отдавать только `latitude/longitude`, а raw `location` убрать из внешнего API +- Доделать incremental sync для `holidays` и полного lifecycle `alerts` +- Привести ETA logic к одному источнику day-type truth: везде использовать SQL-функцию `get_schedule_type()` +- Либо добавить API для `vehicles`, telemetry, trip status и reporting, либо убрать эти сущности из предположений/README, чтобы документация совпадала с реальностью +- Исправить rate limiter так, чтобы Redis store реально использовался в production +- Исправить frontend env contract: использовать `VITE_API_URL` и согласовать `docker-compose.yml` +- Исправить backend `GET /routes`, если UI действительно нуждается в `schedules_count` +- Удалить или реализовать отсутствующие npm scripts `migrate` и `seed` +- Убрать default admin credentials из operational flow и заменить на безопасный bootstrap через env/CLI diff --git a/docs/openapi-draft.yaml b/docs/openapi-draft.yaml new file mode 100644 index 0000000..2244d98 --- /dev/null +++ b/docs/openapi-draft.yaml @@ -0,0 +1,2944 @@ +openapi: 3.0.3 +info: + title: Polotsk Transit API + version: 1.0.0-draft + description: | + Черновик OpenAPI 3.0, восстановленный по реальному коду проекта. + В проекте нет встроенного swagger/openapi генератора, поэтому часть схем описана + по фактическим SQL-запросам и runtime-поведению контроллеров. +servers: + - url: http://localhost:3000 + description: Local backend +tags: + - name: System + - name: Auth + - name: Upload + - name: Routes + - name: Route Stops + - name: Schedules + - name: Holidays + - name: Stops + - name: ETA + - name: Sync + - name: Alerts + - name: Users + - name: Telemetry + - name: Realtime +paths: + /health: + get: + tags: [System] + summary: Health check + security: [] + responses: + '200': + description: Service status + content: + application/json: + schema: + type: object + required: [status, timestamp, version] + properties: + status: + type: string + example: healthy + timestamp: + type: string + format: date-time + version: + type: string + example: 1.0.0 + /api/v1/auth/login: + post: + tags: [Auth] + summary: Login + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Authenticated + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/auth/refresh: + post: + tags: [Auth] + summary: Refresh access token + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshRequest' + responses: + '200': + description: New access token + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/auth/logout: + post: + tags: [Auth] + summary: Logout by refresh token + security: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/LogoutRequest' + responses: + '200': + description: Logged out + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/auth/me: + get: + tags: [Auth] + summary: Current user + security: + - BearerAuth: [] + responses: + '200': + description: Current authenticated user + content: + application/json: + schema: + $ref: '#/components/schemas/UserMe' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/auth/change-password: + post: + tags: [Auth] + summary: Change password + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChangePasswordRequest' + responses: + '200': + description: Password changed + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/upload/avatar: + post: + tags: [Upload] + summary: Upload or replace current user avatar + security: + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [avatar] + properties: + avatar: + type: string + format: binary + responses: + '200': + description: Avatar uploaded + content: + application/json: + schema: + $ref: '#/components/schemas/AvatarUploadResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + tags: [Upload] + summary: Delete current user avatar + security: + - BearerAuth: [] + responses: + '200': + description: Avatar deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/upload/avatar/{userId}: + delete: + tags: [Upload] + summary: Admin deletes any user avatar + security: + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/UserId' + responses: + '200': + description: Avatar deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/routes: + get: + tags: [Routes] + summary: List active routes + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: Route list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RouteSummary' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + tags: [Routes] + summary: Create route + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRouteRequest' + responses: + '201': + description: Route created + content: + application/json: + schema: + $ref: '#/components/schemas/Route' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/routes/{id}: + get: + tags: [Routes] + summary: Get route detail with stops + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Route detail + content: + application/json: + schema: + $ref: '#/components/schemas/RouteDetail' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + put: + tags: [Routes] + summary: Update route + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateRouteRequest' + responses: + '200': + description: Updated route + content: + application/json: + schema: + $ref: '#/components/schemas/Route' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + tags: [Routes] + summary: Delete route + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Route deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/routes/{routeId}/stops: + get: + tags: [Route Stops] + summary: Get ordered stops for route + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouteId' + responses: + '200': + description: Route stop list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RouteStopView' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + tags: [Route Stops] + summary: Add stop to route + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddRouteStopRequest' + responses: + '201': + description: Route stop created + content: + application/json: + schema: + $ref: '#/components/schemas/RouteStopRecord' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/routes/{routeId}/available-stops: + get: + tags: [Route Stops] + summary: Stops not yet assigned to route + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouteId' + responses: + '200': + description: Available stops + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AvailableStop' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/routes/{routeId}/stops/{routeStopId}: + put: + tags: [Route Stops] + summary: Update route stop position or offset + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouteId' + - $ref: '#/components/parameters/RouteStopId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateRouteStopRequest' + responses: + '200': + description: Updated route stop + content: + application/json: + schema: + $ref: '#/components/schemas/RouteStopRecord' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + tags: [Route Stops] + summary: Remove stop from route + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouteId' + - $ref: '#/components/parameters/RouteStopId' + responses: + '200': + description: Route stop deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/routes/{routeId}/stops-reorder: + put: + tags: [Route Stops] + summary: Bulk reorder route stops + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReorderRouteStopsRequest' + responses: + '200': + description: Route stops reordered + content: + application/json: + schema: + type: object + required: [message, count] + properties: + message: + type: string + count: + type: integer + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/routes/{routeId}/schedules: + get: + tags: [Schedules] + summary: List route schedules + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouteId' + responses: + '200': + description: Schedules for route + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ScheduleWithRoute' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/routes/{routeId}/schedule/today: + get: + tags: [Schedules] + summary: Get active schedule for date + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouteId' + - in: query + name: date + schema: + type: string + format: date + required: false + responses: + '200': + description: Schedule for date + content: + application/json: + schema: + $ref: '#/components/schemas/TodayScheduleResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/routes/{routeId}/schedules/copy: + post: + tags: [Schedules] + summary: Copy schedule from one day type to another + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouteId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CopyScheduleRequest' + responses: + '200': + description: Existing schedule updated + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + '201': + description: New schedule created + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/schedules: + post: + tags: [Schedules] + summary: Create schedule + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateScheduleRequest' + responses: + '201': + description: Schedule created + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + '400': + $ref: '#/components/responses/BadRequest' + '409': + description: Schedule already exists + content: + application/json: + schema: + type: object + properties: + error: + type: string + message: + type: string + existingId: + type: integer + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/schedules/{id}: + get: + tags: [Schedules] + summary: Get schedule by id + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Schedule detail + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleWithRoute' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + put: + tags: [Schedules] + summary: Update schedule + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateScheduleRequest' + responses: + '200': + description: Schedule updated + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + tags: [Schedules] + summary: Delete schedule + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Schedule deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/holidays: + get: + tags: [Holidays] + summary: List holidays + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: query + name: year + schema: + type: integer + required: false + responses: + '200': + description: Holiday list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Holiday' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + tags: [Holidays] + summary: Create holiday + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateHolidayRequest' + responses: + '201': + description: Holiday created + content: + application/json: + schema: + $ref: '#/components/schemas/Holiday' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/holidays/upcoming: + get: + tags: [Holidays] + summary: Get upcoming holidays + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: query + name: limit + schema: + type: integer + required: false + responses: + '200': + description: Upcoming holidays + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UpcomingHoliday' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/holidays/year/{year}: + get: + tags: [Holidays] + summary: Get holidays for year with resolved recurring dates + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: path + name: year + required: true + schema: + type: integer + responses: + '200': + description: Holidays for year + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/HolidayForYear' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/holidays/check/{date}: + get: + tags: [Holidays] + summary: Check if date is holiday + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: path + name: date + required: true + schema: + type: string + format: date + responses: + '200': + description: Holiday check + content: + application/json: + schema: + type: object + required: [date, isHoliday, holidayName] + properties: + date: + type: string + format: date + isHoliday: + type: boolean + holidayName: + type: string + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/holidays/{id}: + put: + tags: [Holidays] + summary: Update holiday + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateHolidayRequest' + responses: + '200': + description: Holiday updated + content: + application/json: + schema: + $ref: '#/components/schemas/Holiday' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + tags: [Holidays] + summary: Delete holiday + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Holiday deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/stops: + get: + tags: [Stops] + summary: List active stops + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: Stop list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/StopSummary' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + tags: [Stops] + summary: Create stop + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateStopRequest' + responses: + '201': + description: Stop created + content: + application/json: + schema: + $ref: '#/components/schemas/Stop' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/stops/nearby: + get: + tags: [Stops] + summary: Find nearby stops + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: query + name: lat + required: true + schema: + type: number + - in: query + name: lon + required: true + schema: + type: number + - in: query + name: radius + required: false + schema: + type: number + default: 500 + responses: + '200': + description: Nearby stops + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/Stop' + - type: object + properties: + distance_meters: + type: number + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/stops/{id}: + get: + tags: [Stops] + summary: Get stop detail + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Stop detail + content: + application/json: + schema: + $ref: '#/components/schemas/StopDetail' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + put: + tags: [Stops] + summary: Update stop + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateStopRequest' + responses: + '200': + description: Stop updated + content: + application/json: + schema: + $ref: '#/components/schemas/Stop' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + tags: [Stops] + summary: Delete stop + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Stop deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/eta/calculate: + post: + tags: [ETA] + summary: Calculate ETA for route+stop + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CalculateEtaRequest' + responses: + '200': + description: ETA calculated + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/CalculateEtaResponse' + - type: object + properties: + message: + type: string + nextDay: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/eta/stop/{stopId}: + get: + tags: [ETA] + summary: ETA for all routes at stop + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: path + name: stopId + required: true + schema: + type: integer + responses: + '200': + description: ETA list + content: + application/json: + schema: + $ref: '#/components/schemas/StopEtasResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/sync: + get: + tags: [Sync] + summary: Full or incremental sync + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: query + name: lastSync + required: false + schema: + type: string + format: date-time + responses: + '200': + description: Sync payload + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/FullSyncResponse' + - $ref: '#/components/schemas/IncrementalSyncResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/sync/status: + get: + tags: [Sync] + summary: Sync status + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: Status payload + content: + application/json: + schema: + $ref: '#/components/schemas/SyncStatusResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/alerts: + get: + tags: [Alerts] + summary: List alerts + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: query + name: route_id + required: false + schema: + type: integer + - in: query + name: is_active + required: false + schema: + type: boolean + - in: query + name: alert_type + required: false + schema: + $ref: '#/components/schemas/AlertType' + responses: + '200': + description: Alert list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AlertRead' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + tags: [Alerts] + summary: Create alert + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAlertRequest' + responses: + '201': + description: Alert created + content: + application/json: + schema: + $ref: '#/components/schemas/Alert' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/alerts/active: + get: + tags: [Alerts] + summary: List currently active alerts + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: query + name: route_id + required: false + schema: + type: integer + responses: + '200': + description: Active alerts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AlertRead' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/alerts/cleanup: + post: + tags: [Alerts] + summary: Deactivate expired alerts + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: Cleanup stats + content: + application/json: + schema: + type: object + required: [message, deactivated_count] + properties: + message: + type: string + deactivated_count: + type: integer + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/alerts/{id}: + get: + tags: [Alerts] + summary: Get alert by id + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Alert detail + content: + application/json: + schema: + $ref: '#/components/schemas/AlertRead' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + put: + tags: [Alerts] + summary: Update alert + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAlertRequest' + responses: + '200': + description: Alert updated + content: + application/json: + schema: + $ref: '#/components/schemas/Alert' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + tags: [Alerts] + summary: Delete alert + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: Alert deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/users: + get: + tags: [Users] + summary: List users + security: + - BearerAuth: [] + responses: + '200': + description: User list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserAdminView' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + tags: [Users] + summary: Create user + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '201': + description: User created + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreated' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/users/{id}: + get: + tags: [Users] + summary: Get user by id + security: + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: User detail + content: + application/json: + schema: + $ref: '#/components/schemas/UserAdminView' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + put: + tags: [Users] + summary: Update user + security: + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + responses: + '200': + description: User updated + content: + application/json: + schema: + $ref: '#/components/schemas/UserUpdated' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + tags: [Users] + summary: Delete user + security: + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: User deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/users/{id}/reset-password: + post: + tags: [Users] + summary: Reset user password + security: + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResetPasswordRequest' + responses: + '200': + description: Password reset + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/telemetry/ingest: + post: + tags: [Telemetry] + summary: Ingest GPS telemetry + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TelemetryIngestRequest' + example: + routeId: 7 + vehicleNumber: DEMO-7A + vehicleType: bus + lat: 55.4869 + lon: 28.7856 + speedKmh: 24 + heading: 90 + accuracyMeters: 5 + recordedAt: '2026-04-16T10:30:00.000Z' + responses: + '201': + description: Telemetry accepted + content: + application/json: + schema: + $ref: '#/components/schemas/TelemetryIngestResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/realtime/routes/{routeId}/vehicles: + get: + tags: [Realtime] + summary: Live vehicles snapshot for route + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: path + name: routeId + required: true + schema: + type: integer + - in: query + name: includeStale + schema: + type: boolean + - in: query + name: staleAfterSeconds + schema: + type: integer + default: 180 + responses: + '200': + description: Route live vehicles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LiveVehicleState' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/realtime/vehicles/{vehicleId}: + get: + tags: [Realtime] + summary: Live vehicle state + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - in: path + name: vehicleId + required: true + schema: + type: integer + - in: query + name: staleAfterSeconds + schema: + type: integer + default: 180 + responses: + '200': + description: Vehicle live state + content: + application/json: + schema: + $ref: '#/components/schemas/LiveVehicleState' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + Id: + in: path + name: id + required: true + schema: + type: integer + RouteId: + in: path + name: routeId + required: true + schema: + type: integer + RouteStopId: + in: path + name: routeStopId + required: true + schema: + type: integer + UserId: + in: path + name: userId + required: true + schema: + type: integer + responses: + BadRequest: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Unauthorized: + description: Authentication required or invalid credentials + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Forbidden: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + NotFound: + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + TooManyRequests: + description: Too many requests + content: + application/json: + schema: + type: object + properties: + error: + type: string + message: + type: string + retryAfter: + type: integer + InternalError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + schemas: + ErrorResponse: + type: object + properties: + error: + type: string + message: + type: string + MessageResponse: + type: object + required: [message] + properties: + message: + type: string + RouteType: + type: string + enum: [bus, minibus, trolleybus, tram] + ScheduleDayType: + type: string + enum: [weekday, saturday, sunday, holiday] + AlertType: + type: string + enum: [delay, cancellation, detour, info] + UserRole: + type: string + enum: [admin, user] + Route: + type: object + properties: + id: + type: integer + route_number: + type: string + name: + type: string + type: + $ref: '#/components/schemas/RouteType' + color: + type: string + is_active: + type: boolean + description: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + RouteSummary: + allOf: + - $ref: '#/components/schemas/Route' + - type: object + properties: + stops_count: + oneOf: + - type: integer + - type: string + Stop: + type: object + properties: + id: + type: integer + name: + type: string + location: + description: Raw PostGIS geography serialization; exact shape is not guaranteed + nullable: true + latitude: + type: number + longitude: + type: number + address: + type: string + nullable: true + description: + type: string + nullable: true + is_active: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + StopSummary: + allOf: + - $ref: '#/components/schemas/Stop' + - type: object + properties: + routes_count: + oneOf: + - type: integer + - type: string + StopOnRoute: + allOf: + - $ref: '#/components/schemas/Stop' + - type: object + properties: + sequence: + type: integer + time_offset_minutes: + type: integer + RouteDetail: + allOf: + - $ref: '#/components/schemas/Route' + - type: object + properties: + stops: + type: array + items: + $ref: '#/components/schemas/StopOnRoute' + RouteAtStop: + allOf: + - $ref: '#/components/schemas/Route' + - type: object + properties: + sequence: + type: integer + time_offset_minutes: + type: integer + StopDetail: + allOf: + - $ref: '#/components/schemas/Stop' + - type: object + properties: + routes: + type: array + items: + $ref: '#/components/schemas/RouteAtStop' + RouteStopRecord: + type: object + properties: + id: + type: integer + route_id: + type: integer + stop_id: + type: integer + sequence: + type: integer + time_offset_minutes: + type: integer + RouteStopView: + type: object + properties: + route_stop_id: + type: integer + sequence: + type: integer + time_offset_minutes: + type: integer + stop_id: + type: integer + name: + type: string + address: + type: string + nullable: true + latitude: + type: number + longitude: + type: number + AvailableStop: + type: object + properties: + id: + type: integer + name: + type: string + address: + type: string + nullable: true + latitude: + type: number + longitude: + type: number + Schedule: + type: object + properties: + id: + type: integer + route_id: + type: integer + day_type: + $ref: '#/components/schemas/ScheduleDayType' + departure_times: + type: array + items: + type: string + valid_from: + type: string + format: date + nullable: true + valid_until: + type: string + format: date + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ScheduleWithRoute: + allOf: + - $ref: '#/components/schemas/Schedule' + - type: object + properties: + route_number: + type: string + route_name: + type: string + TodayScheduleResponse: + type: object + properties: + date: + type: string + format: date + scheduleType: + $ref: '#/components/schemas/ScheduleDayType' + isHoliday: + type: boolean + holidayName: + type: string + nullable: true + schedule: + $ref: '#/components/schemas/ScheduleWithRoute' + Holiday: + type: object + properties: + id: + type: integer + date: + type: string + format: date + name: + type: string + is_recurring: + type: boolean + recurring_month: + type: integer + nullable: true + recurring_day: + type: integer + nullable: true + created_at: + type: string + format: date-time + HolidayForYear: + type: object + properties: + id: + type: integer + date: + type: string + format: date + name: + type: string + is_recurring: + type: boolean + UpcomingHoliday: + type: object + properties: + id: + type: integer + name: + type: string + next_date: + type: string + format: date + is_recurring: + type: boolean + Alert: + type: object + properties: + id: + type: integer + route_id: + type: integer + nullable: true + alert_type: + $ref: '#/components/schemas/AlertType' + title: + type: string + message: + type: string + start_time: + type: string + format: date-time + end_time: + type: string + format: date-time + nullable: true + is_active: + type: boolean + created_at: + type: string + format: date-time + AlertRead: + allOf: + - $ref: '#/components/schemas/Alert' + - type: object + properties: + route_name: + type: string + nullable: true + route_number: + type: string + nullable: true + color: + type: string + nullable: true + UserLoginPayload: + type: object + properties: + id: + type: integer + username: + type: string + full_name: + type: string + nullable: true + email: + type: string + nullable: true + role: + $ref: '#/components/schemas/UserRole' + is_active: + type: boolean + last_login: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + avatar_url: + type: string + nullable: true + UserMe: + type: object + properties: + id: + type: integer + username: + type: string + full_name: + type: string + nullable: true + email: + type: string + nullable: true + role: + $ref: '#/components/schemas/UserRole' + avatar_url: + type: string + nullable: true + last_login: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + UserAdminView: + type: object + properties: + id: + type: integer + username: + type: string + full_name: + type: string + nullable: true + email: + type: string + nullable: true + role: + $ref: '#/components/schemas/UserRole' + is_active: + type: boolean + last_login: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + UserCreated: + type: object + properties: + id: + type: integer + username: + type: string + full_name: + type: string + nullable: true + email: + type: string + nullable: true + role: + $ref: '#/components/schemas/UserRole' + is_active: + type: boolean + created_at: + type: string + format: date-time + UserUpdated: + type: object + properties: + id: + type: integer + username: + type: string + full_name: + type: string + nullable: true + email: + type: string + nullable: true + role: + $ref: '#/components/schemas/UserRole' + is_active: + type: boolean + Arrival: + type: object + properties: + scheduledTime: + type: string + estimatedTime: + type: string + minutesUntil: + type: integer + delay: + type: integer + CalculateEtaResponse: + type: object + properties: + routeId: + type: integer + stopId: + type: integer + currentTime: + type: string + dayType: + $ref: '#/components/schemas/ScheduleDayType' + arrivals: + type: array + items: + $ref: '#/components/schemas/Arrival' + StopEtasRoute: + type: object + properties: + id: + type: integer + route_number: + type: string + name: + type: string + type: + $ref: '#/components/schemas/RouteType' + color: + type: string + arrivals: + type: array + items: + $ref: '#/components/schemas/Arrival' + error: + type: string + nullable: true + StopEtasResponse: + type: object + properties: + stopId: + oneOf: + - type: integer + - type: string + routes: + type: array + items: + $ref: '#/components/schemas/StopEtasRoute' + FullSyncResponse: + type: object + properties: + syncType: + type: string + enum: [full] + timestamp: + type: string + format: date-time + version: + type: string + data: + type: object + properties: + routes: + type: array + items: + $ref: '#/components/schemas/Route' + stops: + type: array + items: + $ref: '#/components/schemas/Stop' + routeStops: + type: array + items: + $ref: '#/components/schemas/RouteStopRecord' + schedules: + type: array + items: + $ref: '#/components/schemas/Schedule' + alerts: + type: array + items: + $ref: '#/components/schemas/Alert' + holidays: + type: array + items: + $ref: '#/components/schemas/Holiday' + metadata: + type: object + properties: + routesCount: + type: integer + stopsCount: + type: integer + schedulesCount: + type: integer + holidaysCount: + type: integer + IncrementalSyncResponse: + type: object + properties: + syncType: + type: string + enum: [incremental] + timestamp: + type: string + format: date-time + lastSync: + type: string + format: date-time + changes: + type: object + properties: + routes: + $ref: '#/components/schemas/SyncChangeBucketRoutes' + stops: + $ref: '#/components/schemas/SyncChangeBucketStops' + routeStops: + $ref: '#/components/schemas/SyncChangeBucketRouteStops' + schedules: + $ref: '#/components/schemas/SyncChangeBucketSchedules' + alerts: + type: array + items: + $ref: '#/components/schemas/Alert' + metadata: + type: object + properties: + changesCount: + type: integer + SyncChangeBucketRoutes: + type: object + properties: + updated: + type: array + items: + $ref: '#/components/schemas/Route' + deleted: + type: array + items: + type: integer + SyncChangeBucketStops: + type: object + properties: + updated: + type: array + items: + $ref: '#/components/schemas/Stop' + deleted: + type: array + items: + type: integer + SyncChangeBucketRouteStops: + type: object + properties: + updated: + type: array + items: + $ref: '#/components/schemas/RouteStopRecord' + deleted: + type: array + items: + type: integer + SyncChangeBucketSchedules: + type: object + properties: + updated: + type: array + items: + $ref: '#/components/schemas/Schedule' + deleted: + type: array + items: + type: integer + SyncStatusResponse: + type: object + properties: + status: + type: string + timestamp: + type: string + format: date-time + database: + type: object + properties: + routes_count: + oneOf: + - type: integer + - type: string + stops_count: + oneOf: + - type: integer + - type: string + schedules_count: + oneOf: + - type: integer + - type: string + last_change: + type: string + format: date-time + nullable: true + LoginRequest: + type: object + required: [username, password] + properties: + username: + type: string + password: + type: string + LoginResponse: + type: object + properties: + user: + $ref: '#/components/schemas/UserLoginPayload' + accessToken: + type: string + refreshToken: + type: string + RefreshRequest: + type: object + required: [refreshToken] + properties: + refreshToken: + type: string + RefreshResponse: + type: object + required: [accessToken] + properties: + accessToken: + type: string + LogoutRequest: + type: object + properties: + refreshToken: + type: string + ChangePasswordRequest: + type: object + required: [currentPassword, newPassword] + properties: + currentPassword: + type: string + newPassword: + type: string + minLength: 6 + refreshToken: + type: string + CreateRouteRequest: + type: object + required: [route_number, name, type] + properties: + route_number: + type: string + name: + type: string + type: + $ref: '#/components/schemas/RouteType' + color: + type: string + default: '#0066CC' + description: + type: string + UpdateRouteRequest: + type: object + properties: + route_number: + type: string + name: + type: string + type: + $ref: '#/components/schemas/RouteType' + color: + type: string + description: + type: string + is_active: + type: boolean + CreateStopRequest: + type: object + required: [name, latitude, longitude] + properties: + name: + type: string + latitude: + type: number + longitude: + type: number + address: + type: string + description: + type: string + UpdateStopRequest: + type: object + properties: + name: + type: string + latitude: + type: number + longitude: + type: number + address: + type: string + description: + type: string + is_active: + type: boolean + AddRouteStopRequest: + type: object + required: [stopId] + properties: + stopId: + type: integer + sequence: + type: integer + timeOffsetMinutes: + type: integer + default: 0 + UpdateRouteStopRequest: + type: object + properties: + sequence: + type: integer + timeOffsetMinutes: + type: integer + ReorderRouteStopsRequest: + type: object + required: [stops] + properties: + stops: + type: array + items: + type: object + required: [stopId, sequence] + properties: + stopId: + type: integer + sequence: + type: integer + timeOffsetMinutes: + type: integer + CreateScheduleRequest: + type: object + required: [routeId, dayType, departureTimes] + properties: + routeId: + type: integer + dayType: + $ref: '#/components/schemas/ScheduleDayType' + departureTimes: + type: array + items: + type: string + validFrom: + type: string + format: date + nullable: true + validUntil: + type: string + format: date + nullable: true + UpdateScheduleRequest: + type: object + properties: + departureTimes: + type: array + items: + type: string + validFrom: + type: string + format: date + nullable: true + validUntil: + type: string + format: date + nullable: true + CopyScheduleRequest: + type: object + required: [fromDayType, toDayType] + properties: + fromDayType: + $ref: '#/components/schemas/ScheduleDayType' + toDayType: + $ref: '#/components/schemas/ScheduleDayType' + CreateHolidayRequest: + type: object + required: [date, name] + properties: + date: + type: string + format: date + name: + type: string + isRecurring: + type: boolean + UpdateHolidayRequest: + type: object + properties: + date: + type: string + format: date + name: + type: string + isRecurring: + type: boolean + CreateAlertRequest: + type: object + required: [alert_type, title, message, start_time] + properties: + route_id: + type: integer + nullable: true + alert_type: + $ref: '#/components/schemas/AlertType' + title: + type: string + message: + type: string + start_time: + type: string + format: date-time + end_time: + type: string + format: date-time + nullable: true + is_active: + type: boolean + UpdateAlertRequest: + type: object + properties: + route_id: + type: integer + nullable: true + alert_type: + $ref: '#/components/schemas/AlertType' + title: + type: string + message: + type: string + start_time: + type: string + format: date-time + end_time: + type: string + format: date-time + nullable: true + is_active: + type: boolean + CreateUserRequest: + type: object + required: [username, password] + properties: + username: + type: string + password: + type: string + minLength: 6 + full_name: + type: string + email: + type: string + format: email + role: + $ref: '#/components/schemas/UserRole' + UpdateUserRequest: + type: object + properties: + full_name: + type: string + email: + type: string + format: email + role: + $ref: '#/components/schemas/UserRole' + is_active: + type: boolean + ResetPasswordRequest: + type: object + required: [newPassword] + properties: + newPassword: + type: string + minLength: 6 + AvatarUploadResponse: + type: object + properties: + message: + type: string + avatar_url: + type: string + user: + type: object + properties: + id: + type: integer + username: + type: string + avatar_url: + type: string + nullable: true + CalculateEtaRequest: + type: object + required: [stopId, routeId] + properties: + stopId: + type: integer + routeId: + type: integer + TelemetryIngestRequest: + type: object + required: [lat, lon] + properties: + vehicleId: + type: integer + nullable: true + vehicleNumber: + type: string + nullable: true + routeId: + type: integer + nullable: true + vehicleType: + $ref: '#/components/schemas/RouteType' + lat: + type: number + minimum: -90 + maximum: 90 + lon: + type: number + minimum: -180 + maximum: 180 + speedKmh: + type: number + nullable: true + heading: + type: number + minimum: 0 + maximum: 360 + nullable: true + accuracyMeters: + type: number + nullable: true + recordedAt: + type: string + format: date-time + nullable: true + sourceType: + type: string + nullable: true + sourceRef: + type: string + nullable: true + LiveVehicleState: + type: object + properties: + vehicle_id: + type: integer + vehicle_number: + type: string + nullable: true + registration: + type: string + nullable: true + vehicle_type: + $ref: '#/components/schemas/RouteType' + capacity: + type: integer + nullable: true + route_id: + type: integer + route_number: + type: string + route_name: + type: string + route_color: + type: string + nullable: true + latitude: + type: number + longitude: + type: number + speed_kmh: + type: number + nullable: true + heading: + type: number + nullable: true + accuracy_meters: + type: number + nullable: true + source_type: + type: string + nullable: true + source_ref: + type: string + nullable: true + last_seen_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + is_online: + type: boolean + seconds_since_update: + type: integer + is_moving: + type: boolean + TelemetryIngestResponse: + type: object + properties: + message: + type: string + vehicle: + $ref: '#/components/schemas/LiveVehicleState' diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..ef6765b --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +.env.* +.DS_Store +npm-debug.log* diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..102aaa7 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:3000/api/v1 +VITE_API_KEY=dev_key_12345678 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..43b3a3f --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,26 @@ +# Build stage +FROM node:18-alpine as build + +WORKDIR /app + +ARG VITE_API_URL=http://localhost:3000/api/v1 +ENV VITE_API_URL=$VITE_API_URL + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ed34962 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + Polotsk Transit Admin + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..08a0608 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1000; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # SPA routing + location / { + try_files $uri $uri/ /index.html; + } + + # Always revalidate the HTML shell so the app picks up the newest hashed assets + location = /index.html { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c4cc6c3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "polotsk-transit-admin", + "version": "1.0.0", + "description": "Admin panel for Polotsk Transit API", + "private": true, + "type": "module", + "dependencies": { + "@tanstack/react-table": "^8.10.7", + "axios": "^1.6.2", + "date-fns": "^2.30.0", + "leaflet": "^1.9.4", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", + "react-router-dom": "^6.20.0", + "recharts": "^3.4.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "vite": "^5.0.8" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..15b0b84 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,281 @@ +import { useState } from 'react'; +import { BrowserRouter as Router, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom'; +import { Bus, MapPin, Calendar, Settings, Gift, Menu, X, AlertCircle, Users, UserCircle, LogOut } from 'lucide-react'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { Avatar } from './components/AvatarUpload'; + +// Pages +import LoginPage from './pages/LoginPage'; +import Dashboard from './pages/Dashboard'; +import RoutesPage from './pages/RoutesPage'; +import RouteEditorPage from './pages/RouteEditorPage'; +import StopsPage from './pages/StopsPage'; +import SchedulesPage from './pages/SchedulesPage'; +import HolidaysPage from './pages/HolidaysPage'; +import AlertsPage from './pages/AlertsPage'; +import UsersPage from './pages/UsersPage'; +import ProfilePage from './pages/ProfilePage'; + +function App() { + return ( + + + + + + ); +} + +function AppContent() { + const { isAuthenticated } = useAuth(); + + if (!isAuthenticated) { + return ( + + } /> + } /> + + ); + } + + return ( +
+ + +
+ +
+ + +
+ ); +} + +function AnimatedRoutes() { + const location = useLocation(); + const { isAdmin } = useAuth(); + + return ( +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {isAdmin && ( + } /> + )} + } /> + +
+ ); +} + +function Navigation() { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [showUserMenu, setShowUserMenu] = useState(false); + const { user, logout, isAdmin } = useAuth(); + + return ( + + ); +} + +function MobileBottomNav() { + const location = useLocation(); + const { isAdmin } = useAuth(); + + const navItems = [ + { to: '/', icon: Settings, label: 'Главная' }, + { to: '/routes', icon: Bus, label: 'Маршруты' }, + { to: '/stops', icon: MapPin, label: 'Остановки' }, + { to: '/alerts', icon: AlertCircle, label: 'Уведомл.' }, + isAdmin + ? { to: '/users', icon: Users, label: 'Пользов.' } + : { to: '/profile', icon: UserCircle, label: 'Профиль' }, + ]; + + return ( +
+
+ {navItems.map(({ to, icon: Icon, label }) => { + const isActive = location.pathname === to || + (to !== '/' && location.pathname.startsWith(to)); + + return ( + + + {label} + {isActive && ( +
+ )} + + ); + })} +
+
+ ); +} + +function NavLink({ to, icon, children }) { + const location = useLocation(); + const isActive = location.pathname === to || + (to !== '/' && location.pathname.startsWith(to)); + + return ( + + {icon} + {children} + + ); +} + +function MobileNavLink({ to, icon, children, onClick, delay = 0 }) { + const location = useLocation(); + const isActive = location.pathname === to || + (to !== '/' && location.pathname.startsWith(to)); + + return ( + + {icon} + {children} + + ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/components/AlertComponents.jsx b/frontend/src/components/AlertComponents.jsx new file mode 100644 index 0000000..68abab9 --- /dev/null +++ b/frontend/src/components/AlertComponents.jsx @@ -0,0 +1,178 @@ +import { AlertCircle, AlertTriangle, Info, Clock, X } from 'lucide-react'; + +// Alert Badge Component - для отображения одного уведомления +export function AlertBadge({ alert, onClose, compact = false }) { + const alertConfig = { + delay: { + icon: Clock, + bgColor: 'bg-amber-50', + borderColor: 'border-amber-200', + iconColor: 'text-amber-600', + textColor: 'text-amber-900' + }, + cancellation: { + icon: AlertTriangle, + bgColor: 'bg-red-50', + borderColor: 'border-red-200', + iconColor: 'text-red-600', + textColor: 'text-red-900' + }, + detour: { + icon: AlertCircle, + bgColor: 'bg-blue-50', + borderColor: 'border-blue-200', + iconColor: 'text-blue-600', + textColor: 'text-blue-900' + }, + info: { + icon: Info, + bgColor: 'bg-gray-50', + borderColor: 'border-gray-200', + iconColor: 'text-gray-600', + textColor: 'text-gray-900' + } + }; + + const config = alertConfig[alert.alert_type] || alertConfig.info; + const Icon = config.icon; + + if (compact) { + return ( +
+ + + {alert.title} + +
+ ); + } + + return ( +
+
+ +
+
+

{alert.title}

+ {onClose && ( + + )} +
+

{alert.message}

+ {alert.route_name && ( +
+ + {alert.route_number} + + {alert.route_name} +
+ )} + {(alert.start_time || alert.end_time) && ( +
+ {alert.start_time && ( + + С {new Date(alert.start_time).toLocaleString('ru-RU', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + })} + + )} + {alert.end_time && ( + + {alert.start_time && ' до '} + {new Date(alert.end_time).toLocaleString('ru-RU', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + })} + + )} +
+ )} +
+
+
+ ); +} + +// Alert List Component - для списка уведомлений +export function AlertList({ alerts, onDismiss, emptyMessage = 'Нет активных уведомлений' }) { + if (!alerts || alerts.length === 0) { + return ( +
+ + {emptyMessage} +
+ ); + } + + return ( +
+ {alerts.map((alert, index) => ( +
+ onDismiss(alert.id) : null} + /> +
+ ))} +
+ ); +} + +// Alert Banner Component - для показа на верху страницы +export function AlertBanner({ alerts, maxShow = 3 }) { + if (!alerts || alerts.length === 0) return null; + + const displayAlerts = alerts.slice(0, maxShow); + const hasMore = alerts.length > maxShow; + + return ( +
+ {displayAlerts.map(alert => ( + + ))} + {hasMore && ( +
+ + И ещё {alerts.length - maxShow} уведомлений + +
+ )} +
+ ); +} + +// Alert Type Badge - для отображения типа уведомления +export function AlertTypeBadge({ type }) { + const typeConfig = { + delay: { label: 'Задержка', color: 'bg-amber-100 text-amber-700' }, + cancellation: { label: 'Отмена', color: 'bg-red-100 text-red-700' }, + detour: { label: 'Изменение маршрута', color: 'bg-blue-100 text-blue-700' }, + info: { label: 'Информация', color: 'bg-gray-100 text-gray-700' } + }; + + const config = typeConfig[type] || typeConfig.info; + + return ( + + {config.label} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/AnimatedComponents.jsx b/frontend/src/components/AnimatedComponents.jsx new file mode 100644 index 0000000..0d1e78a --- /dev/null +++ b/frontend/src/components/AnimatedComponents.jsx @@ -0,0 +1,260 @@ +import { Loader2, CheckCircle, AlertCircle, X } from 'lucide-react'; +import { useState, useEffect } from 'react'; + +// Animated Loading Spinner +export function LoadingSpinner({ size = 'md', className = '' }) { + const sizeClasses = { + sm: 'h-5 w-5', + md: 'h-8 w-8', + lg: 'h-12 w-12', + xl: 'h-16 w-16' + }; + + return ( +
+ +
+ ); +} + +// Full page loading +export function PageLoader() { + return ( +
+ +

Загрузка...

+
+ ); +} + +// Skeleton loader for cards +export function CardSkeleton({ count = 1 }) { + return ( + <> + {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} + + ); +} + +// Skeleton loader for list items +export function ListSkeleton({ count = 5 }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +// Toast notification component +export function Toast({ message, type = 'success', onClose, duration = 3000 }) { + const [isVisible, setIsVisible] = useState(true); + const [isExiting, setIsExiting] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + handleClose(); + }, duration); + + return () => clearTimeout(timer); + }, [duration]); + + const handleClose = () => { + setIsExiting(true); + setTimeout(() => { + setIsVisible(false); + onClose?.(); + }, 200); + }; + + if (!isVisible) return null; + + const typeStyles = { + success: 'bg-green-50 border-green-200 text-green-800', + error: 'bg-red-50 border-red-200 text-red-800', + warning: 'bg-amber-50 border-amber-200 text-amber-800', + info: 'bg-blue-50 border-blue-200 text-blue-800' + }; + + const icons = { + success: , + error: , + warning: , + info: + }; + + return ( +
+
+
+ {icons[type]} +
+

{message}

+ +
+
+ ); +} + +// Animated counter component +export function AnimatedCounter({ value, duration = 1000 }) { + const [displayValue, setDisplayValue] = useState(0); + + useEffect(() => { + if (typeof value !== 'number') { + setDisplayValue(value); + return; + } + + let startTime; + const startValue = displayValue; + const endValue = value; + + const animate = (timestamp) => { + if (!startTime) startTime = timestamp; + const progress = Math.min((timestamp - startTime) / duration, 1); + + // Ease out + const easeOut = 1 - Math.pow(1 - progress, 3); + const currentValue = Math.round(startValue + (endValue - startValue) * easeOut); + + setDisplayValue(currentValue); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); + }, [value, duration]); + + return {displayValue}; +} + +// Animated presence wrapper +export function AnimatedPresence({ children, show, className = '' }) { + const [shouldRender, setShouldRender] = useState(show); + + useEffect(() => { + if (show) setShouldRender(true); + }, [show]); + + const handleAnimationEnd = () => { + if (!show) setShouldRender(false); + }; + + if (!shouldRender) return null; + + return ( +
+ {children} +
+ ); +} + +// Ripple button wrapper +export function RippleButton({ children, onClick, className = '', ...props }) { + const handleClick = (e) => { + const button = e.currentTarget; + const ripple = document.createElement('span'); + const rect = button.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height); + const x = e.clientX - rect.left - size / 2; + const y = e.clientY - rect.top - size / 2; + + ripple.style.width = ripple.style.height = `${size}px`; + ripple.style.left = `${x}px`; + ripple.style.top = `${y}px`; + ripple.className = 'absolute bg-white/30 rounded-full animate-ping pointer-events-none'; + + button.appendChild(ripple); + setTimeout(() => ripple.remove(), 500); + + onClick?.(e); + }; + + return ( + + ); +} + +// Pulsing dot indicator +export function PulsingDot({ color = 'green', size = 'sm' }) { + const sizeClasses = { + sm: 'w-2 h-2', + md: 'w-3 h-3', + lg: 'w-4 h-4' + }; + + const colorClasses = { + green: 'bg-green-500', + red: 'bg-red-500', + amber: 'bg-amber-500', + blue: 'bg-blue-500' + }; + + return ( + + + + + ); +} + +// Progress bar +export function ProgressBar({ value, max = 100, showLabel = false, className = '' }) { + const percentage = Math.min(Math.max((value / max) * 100, 0), 100); + + return ( +
+
+
+
+ {showLabel && ( +

{Math.round(percentage)}%

+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/AvatarUpload.jsx b/frontend/src/components/AvatarUpload.jsx new file mode 100644 index 0000000..95fd5cb --- /dev/null +++ b/frontend/src/components/AvatarUpload.jsx @@ -0,0 +1,215 @@ +import { useState, useRef } from 'react'; +import { Camera, Trash2, User, Loader2 } from 'lucide-react'; +import axios from 'axios'; +import { getApiBaseUrl, getApiOrigin } from '../utils/apiBaseUrl'; + +const API_BASE_URL = getApiBaseUrl(); +const API_ORIGIN = getApiOrigin(); + +export default function AvatarUpload({ currentAvatar, onAvatarChange, size = 'large' }) { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(''); + const fileInputRef = useRef(null); + + const sizeClasses = { + small: 'w-12 h-12', + medium: 'w-20 h-20', + large: 'w-32 h-32' + }; + + const iconSizes = { + small: 'h-6 w-6', + medium: 'h-10 w-10', + large: 'h-16 w-16' + }; + + const handleFileSelect = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + // Validate file type + if (!file.type.startsWith('image/')) { + setError('Пожалуйста, выберите изображение'); + return; + } + + // Validate file size (5MB) + if (file.size > 5 * 1024 * 1024) { + setError('Размер файла не должен превышать 5MB'); + return; + } + + setError(''); + setUploading(true); + + try { + const formData = new FormData(); + formData.append('avatar', file); + + const token = localStorage.getItem('accessToken'); + const response = await axios.post(`${API_BASE_URL}/upload/avatar`, formData, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'multipart/form-data' + } + }); + + if (onAvatarChange) { + onAvatarChange(response.data.avatar_url); + } + } catch (error) { + console.error('Upload error:', error); + setError(error.response?.data?.error || 'Ошибка загрузки'); + } finally { + setUploading(false); + } + }; + + const handleDelete = async () => { + if (!confirm('Удалить аватар?')) return; + + setUploading(true); + setError(''); + + try { + const token = localStorage.getItem('accessToken'); + await axios.delete(`${API_BASE_URL}/upload/avatar`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (onAvatarChange) { + onAvatarChange(null); + } + } catch (error) { + console.error('Delete error:', error); + setError(error.response?.data?.error || 'Ошибка удаления'); + } finally { + setUploading(false); + } + }; + + const getAvatarUrl = () => { + if (!currentAvatar) return null; + // If it's already a full URL, return it + if (currentAvatar.startsWith('http')) return currentAvatar; + // Otherwise, construct the URL + return `${API_ORIGIN}${currentAvatar}`; + }; + + return ( +
+
+
+ {currentAvatar ? ( + Avatar { + e.target.style.display = 'none'; + e.target.nextElementSibling.style.display = 'flex'; + }} + /> + ) : null} +
+ +
+
+ + {/* Upload overlay */} + {!uploading && ( + + )} + + {/* Loading overlay */} + {uploading && ( +
+ +
+ )} + + {/* Delete button */} + {currentAvatar && !uploading && ( + + )} +
+ + {/* Hidden file input */} + + + {/* Error message */} + {error && ( +

{error}

+ )} + + {/* Help text */} + {!error && ( +

+ JPG, PNG, GIF или WEBP
Максимум 5MB +

+ )} +
+ ); +} + +// Simple avatar display component +export function Avatar({ src, alt, size = 'medium', className = '' }) { + const sizeClasses = { + xs: 'w-6 h-6', + sm: 'w-8 h-8', + medium: 'w-10 h-10', + large: 'w-12 h-12', + xl: 'w-16 h-16' + }; + + const iconSizes = { + xs: 'h-3 w-3', + sm: 'h-4 w-4', + medium: 'h-5 w-5', + large: 'h-6 w-6', + xl: 'h-8 w-8' + }; + + const getAvatarUrl = () => { + if (!src) return null; + if (src.startsWith('http')) return src; + return `${API_ORIGIN}${src}`; + }; + + return ( +
+ {src ? ( + {alt { + e.target.style.display = 'none'; + e.target.nextElementSibling.style.display = 'flex'; + }} + /> + ) : null} +
+ +
+
+ ); +} diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..b9f9a91 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -0,0 +1,28 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { PageLoader } from './AnimatedComponents'; + +export function ProtectedRoute({ children, requireAdmin = false }) { + const { user, loading, isAdmin } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ; + } + + if (requireAdmin && !isAdmin) { + return ( +
+
+

Access Denied

+

You don't have permission to access this page.

+
+
+ ); + } + + return children; +} \ No newline at end of file diff --git a/frontend/src/components/RouteForm.jsx b/frontend/src/components/RouteForm.jsx new file mode 100644 index 0000000..f571407 --- /dev/null +++ b/frontend/src/components/RouteForm.jsx @@ -0,0 +1,138 @@ +import { useState, useEffect } from 'react'; +import { X } from 'lucide-react'; + +export default function RouteForm({ route, onSubmit, onCancel }) { + const [formData, setFormData] = useState({ + route_number: '', + name: '', + type: 'bus', + color: '#0066CC', + description: '' + }); + + useEffect(() => { + if (route) { + setFormData({ + route_number: route.route_number, + name: route.name, + type: route.type, + color: route.color, + description: route.description || '' + }); + } + }, [route]); + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit(formData); + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + return ( +
+
+
+

+ {route ? 'Редактировать маршрут' : 'Новый маршрут'} +

+ +
+ +
+
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+ + setFormData(prev => ({ ...prev, color: e.target.value }))} + className="input flex-1" + placeholder="#0066CC" + /> +
+
+ +
+ +