From 049e82654dafafa9fa8b5c1e96b92a99bc3fc014 Mon Sep 17 00:00:00 2001 From: mguschin Date: Fri, 27 Mar 2026 15:42:52 +0300 Subject: [PATCH] revert v2. --- CHANGELOG.md | 64 - Dockerfile.web | 10 - README.md | 159 +- alembic.ini | 43 - cliff.toml | 45 - docs/plans/catalog-browser.md | 262 --- docs/plans/connections-dashboard.md | 192 -- docs/plans/sync-configuration.md | 151 -- docs/plans/vk-connection.md | 189 -- version | 1 - web-python/__init__.py | 0 web-python/auth.py | 23 - web-python/config.py | 34 - web-python/database.py | 19 - web-python/evotor_api.py | 119 - web-python/health_checker.py | 152 -- web-python/main.py | 53 - web-python/migrations/README | 1 - web-python/migrations/env.py | 54 - web-python/migrations/script.py.mako | 26 - .../versions/2c15000e752b_initial.py | 60 - ...e_last_checked_at_to_evotor_connections.py | 26 - ...d0e1f2_add_synced_at_to_cached_products.py | 26 - .../b2c3d4e5f6a7_add_vk_connections_table.py | 37 - ...6a7b8_add_sync_configs_and_sync_filters.py | 48 - .../d4e5f6a7b8c9_add_catalog_cache_tables.py | 70 - ...add_refresh_token_to_evotor_connections.py | 24 - .../f6a7b8c9d0e1_evotor_webhook_token_flow.py | 53 - web-python/models.py | 159 -- web-python/routes/__init__.py | 0 web-python/routes/auth.py | 122 - web-python/routes/catalog.py | 308 --- web-python/routes/connections.py | 125 - web-python/routes/evotor.py | 193 -- web-python/routes/profile.py | 144 -- web-python/routes/reset.py | 107 - web-python/routes/sync.py | 101 - web-python/routes/vk.py | 168 -- web-python/schemas.py | 52 - web-python/static/style.css | 39 - web-python/sync_engine.py | 485 ---- web-python/templates/base.html | 97 - web-python/templates/catalog_groups.html | 108 - web-python/templates/catalog_products.html | 133 -- web-python/templates/catalog_stores.html | 113 - web-python/templates/confirm_email.html | 16 - web-python/templates/connections.html | 67 - web-python/templates/connections_add.html | 39 - web-python/templates/email_confirmed.html | 17 - web-python/templates/evotor.html | 104 - web-python/templates/forgot_password.html | 27 - web-python/templates/login.html | 32 - web-python/templates/message.html | 18 - .../templates/profile_change_password.html | 46 - web-python/templates/profile_delete.html | 41 - web-python/templates/profile_edit.html | 55 - web-python/templates/profile_view.html | 46 - web-python/templates/register.html | 52 - web-python/templates/reset_password.html | 27 - web-python/templates/sync.html | 110 - web-python/templates/vk.html | 109 - web-python/templates/vk_callback.html | 47 - web-python/templates_env.py | 6 - web/.gitignore | 2 - web/drizzle.config.ts | 10 - web/package-lock.json | 2001 ----------------- web/package.json | 28 - web/src/config.ts | 22 - web/src/db/client.ts | 13 - web/src/db/schema.ts | 140 -- web/src/index.ts | 71 - web/src/lib/auth.ts | 21 - web/src/lib/evotorApi.ts | 106 - web/src/lib/healthChecker.ts | 138 -- web/src/lib/render.ts | 39 - web/src/lib/syncEngine.ts | 361 --- web/src/lib/validate.ts | 40 - web/src/routes/auth.ts | 124 - web/src/routes/catalog.ts | 260 --- web/src/routes/connections.ts | 83 - web/src/routes/evotor.ts | 147 -- web/src/routes/profile.ts | 116 - web/src/routes/reset.ts | 100 - web/src/routes/sync.ts | 88 - web/src/routes/vk.ts | 120 - web/src/templates/base.njk | 99 - web/src/templates/catalog_groups.njk | 119 - web/src/templates/catalog_products.njk | 144 -- web/src/templates/catalog_stores.njk | 127 -- web/src/templates/confirm_email.njk | 16 - web/src/templates/connections.njk | 63 - web/src/templates/connections_add.njk | 39 - web/src/templates/email_confirmed.njk | 17 - web/src/templates/evotor.njk | 95 - web/src/templates/forgot_password.njk | 24 - web/src/templates/login.njk | 27 - web/src/templates/message.njk | 18 - web/src/templates/profile_change_password.njk | 31 - web/src/templates/profile_delete.njk | 31 - web/src/templates/profile_edit.njk | 43 - web/src/templates/profile_view.njk | 46 - web/src/templates/register.njk | 44 - web/src/templates/reset_password.njk | 23 - web/src/templates/sync.njk | 101 - web/src/templates/vk.njk | 104 - web/src/templates/vk_callback.njk | 47 - web/static/style.css | 431 ---- web/tsconfig.json | 15 - 108 files changed, 1 insertion(+), 10987 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 Dockerfile.web delete mode 100644 alembic.ini delete mode 100644 cliff.toml delete mode 100644 docs/plans/catalog-browser.md delete mode 100644 docs/plans/connections-dashboard.md delete mode 100644 docs/plans/sync-configuration.md delete mode 100644 docs/plans/vk-connection.md delete mode 100644 version delete mode 100644 web-python/__init__.py delete mode 100644 web-python/auth.py delete mode 100644 web-python/config.py delete mode 100644 web-python/database.py delete mode 100644 web-python/evotor_api.py delete mode 100644 web-python/health_checker.py delete mode 100644 web-python/main.py delete mode 100644 web-python/migrations/README delete mode 100644 web-python/migrations/env.py delete mode 100644 web-python/migrations/script.py.mako delete mode 100644 web-python/migrations/versions/2c15000e752b_initial.py delete mode 100644 web-python/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py delete mode 100644 web-python/migrations/versions/a7b8c9d0e1f2_add_synced_at_to_cached_products.py delete mode 100644 web-python/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py delete mode 100644 web-python/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py delete mode 100644 web-python/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py delete mode 100644 web-python/migrations/versions/e5f6a7b8c9d0_add_refresh_token_to_evotor_connections.py delete mode 100644 web-python/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py delete mode 100644 web-python/models.py delete mode 100644 web-python/routes/__init__.py delete mode 100644 web-python/routes/auth.py delete mode 100644 web-python/routes/catalog.py delete mode 100644 web-python/routes/connections.py delete mode 100644 web-python/routes/evotor.py delete mode 100644 web-python/routes/profile.py delete mode 100644 web-python/routes/reset.py delete mode 100644 web-python/routes/sync.py delete mode 100644 web-python/routes/vk.py delete mode 100644 web-python/schemas.py delete mode 100644 web-python/static/style.css delete mode 100644 web-python/sync_engine.py delete mode 100644 web-python/templates/base.html delete mode 100644 web-python/templates/catalog_groups.html delete mode 100644 web-python/templates/catalog_products.html delete mode 100644 web-python/templates/catalog_stores.html delete mode 100644 web-python/templates/confirm_email.html delete mode 100644 web-python/templates/connections.html delete mode 100644 web-python/templates/connections_add.html delete mode 100644 web-python/templates/email_confirmed.html delete mode 100644 web-python/templates/evotor.html delete mode 100644 web-python/templates/forgot_password.html delete mode 100644 web-python/templates/login.html delete mode 100644 web-python/templates/message.html delete mode 100644 web-python/templates/profile_change_password.html delete mode 100644 web-python/templates/profile_delete.html delete mode 100644 web-python/templates/profile_edit.html delete mode 100644 web-python/templates/profile_view.html delete mode 100644 web-python/templates/register.html delete mode 100644 web-python/templates/reset_password.html delete mode 100644 web-python/templates/sync.html delete mode 100644 web-python/templates/vk.html delete mode 100644 web-python/templates/vk_callback.html delete mode 100644 web-python/templates_env.py delete mode 100644 web/.gitignore delete mode 100644 web/drizzle.config.ts delete mode 100644 web/package-lock.json delete mode 100644 web/package.json delete mode 100644 web/src/config.ts delete mode 100644 web/src/db/client.ts delete mode 100644 web/src/db/schema.ts delete mode 100644 web/src/index.ts delete mode 100644 web/src/lib/auth.ts delete mode 100644 web/src/lib/evotorApi.ts delete mode 100644 web/src/lib/healthChecker.ts delete mode 100644 web/src/lib/render.ts delete mode 100644 web/src/lib/syncEngine.ts delete mode 100644 web/src/lib/validate.ts delete mode 100644 web/src/routes/auth.ts delete mode 100644 web/src/routes/catalog.ts delete mode 100644 web/src/routes/connections.ts delete mode 100644 web/src/routes/evotor.ts delete mode 100644 web/src/routes/profile.ts delete mode 100644 web/src/routes/reset.ts delete mode 100644 web/src/routes/sync.ts delete mode 100644 web/src/routes/vk.ts delete mode 100644 web/src/templates/base.njk delete mode 100644 web/src/templates/catalog_groups.njk delete mode 100644 web/src/templates/catalog_products.njk delete mode 100644 web/src/templates/catalog_stores.njk delete mode 100644 web/src/templates/confirm_email.njk delete mode 100644 web/src/templates/connections.njk delete mode 100644 web/src/templates/connections_add.njk delete mode 100644 web/src/templates/email_confirmed.njk delete mode 100644 web/src/templates/evotor.njk delete mode 100644 web/src/templates/forgot_password.njk delete mode 100644 web/src/templates/login.njk delete mode 100644 web/src/templates/message.njk delete mode 100644 web/src/templates/profile_change_password.njk delete mode 100644 web/src/templates/profile_delete.njk delete mode 100644 web/src/templates/profile_edit.njk delete mode 100644 web/src/templates/profile_view.njk delete mode 100644 web/src/templates/register.njk delete mode 100644 web/src/templates/reset_password.njk delete mode 100644 web/src/templates/sync.njk delete mode 100644 web/src/templates/vk.njk delete mode 100644 web/src/templates/vk_callback.njk delete mode 100644 web/static/style.css delete mode 100644 web/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 00b306d..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,64 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [1.8.2] - 2026-03-06 - -### Miscellaneous - -- Replace EvoSync with ЭВОСИНК throughout UI - -## [1.8.1] - 2026-03-06 - -### Documentation - -- Update changelog for v1.7.3 -- Fix changelog order — 1.8.0 before 1.7.3 - -### Other - -- V1.8.1 - -## [1.7.3] - 2026-03-06 - -### Added - -- Add nginx reverse proxy and Let's Encrypt TLS setup - -### Other - -- V1.7.3 - -## [1.8.0] - 2026-03-06 - -### Added - -- Add Evotor OAuth connection feature with formatted phone input -- Add Alembic database migrations -- Add connections dashboard with background health checks -- Add VK OAuth connection with health checks -- Release v1.8.0 — connections dashboard, VK OAuth, sync config, catalog browser - -### Miscellaneous - -- Add semantic versioning and automatic changelog generation - -## [1.7.2] - 2026-03-05 - -### Other - -- Add user registration and auth web app -- Update docker-compose.yml: remove database service, adjust ports and host -- Integrate Bootstrap 5 and Bootstrap Icons into UI - -## [1.0.0] - 2026-02-02 - -### Other - -- Initial commit -- V1. - - diff --git a/Dockerfile.web b/Dockerfile.web deleted file mode 100644 index b5dd0ac..0000000 --- a/Dockerfile.web +++ /dev/null @@ -1,10 +0,0 @@ -FROM node:20-alpine -WORKDIR /app - -COPY web/package*.json ./ -RUN npm ci - -COPY web/ . -RUN npm run build && cp -r src/templates dist/templates && cp -r static dist/static - -CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 94f8986..6abe416 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,3 @@ # ЭВОСИНК (EvoSync) -Сервис автоматической синхронизации товарного каталога из [Эвотор](https://evotor.ru) в [ВКонтакте](https://vk.com). - -## Возможности - -- Подключение аккаунта Эвотор через OAuth -- Подключение сообщества ВКонтакте через токен -- Фильтрация по магазинам, группам и товарам (включить/исключить) -- Автоматическая фоновая синхронизация по расписанию -- Создание, обновление и удаление товаров в ВК-магазине -- Личный кабинет с веб-интерфейсом -- Просмотр закешированного каталога товаров - -## Стек технологий - -- **Backend**: FastAPI, SQLAlchemy ORM, Alembic -- **База данных**: MariaDB (драйвер pymysql) -- **Шаблоны**: Jinja2 (интерфейс на русском языке) -- **Аутентификация**: Cookie-сессии, bcrypt -- **Деплой**: Docker Compose - -## Быстрый старт - -### Требования - -- Docker и Docker Compose -- MariaDB/MySQL (можно использовать хост-машину или отдельный контейнер) - -### Настройка - -1. Скопируйте файл переменных окружения: - -```bash -cp .env.example .env -``` - -2. Отредактируйте `.env`: - -```env -# База данных -DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync - -# Безопасность (обязательно измените в production) -SECRET_KEY=your-random-secret-key-here - -# URL приложения (используется в OAuth-редиректах) -BASE_URL=https://your-domain.ru - -# Эвотор -EVOTOR_APP_ID=your-evotor-app-id -EVOTOR_WEBHOOK_SECRET=your-webhook-secret - -# Для отдельного MySQL-контейнера (опционально) -DB_ROOT_PASSWORD=rootpass -DB_NAME=evosync -DB_USER=evosync -DB_PASSWORD=evosync -``` - -3. Запустите сервис: - -```bash -docker compose up -d -``` - -Приложение будет доступно по адресу `http://localhost:8080`. - -При старте контейнер автоматически применяет миграции базы данных (`alembic upgrade head`). - -## Переменные окружения - -| Переменная | Обязательно | По умолчанию | Описание | -|---|---|---|---| -| `DATABASE_URL` | Да | — | MySQL connection string (pymysql) | -| `SECRET_KEY` | Да | `change-me-in-production` | Ключ для подписи сессий | -| `BASE_URL` | Да | `http://localhost:8080` | Публичный URL (для OAuth-callback) | -| `EVOTOR_APP_ID` | Да | — | ID приложения Эвотор | -| `EVOTOR_WEBHOOK_SECRET` | Да | — | Секрет для верификации вебхуков Эвотор | -| `VK_API_VERSION` | Нет | `5.131` | Версия VK API | -| `VK_DEFAULT_PHOTO_PATH` | Нет | `/app/default_product.png` | Путь к изображению товара по умолчанию | -| `JIVOSITE_WIDGET_ID` | Нет | — | ID виджета Jivosite (онлайн-чат) | -| `SYNC_INTERVAL_SECONDS` | Нет | `3600` | Интервал синхронизации (сек) | -| `CATALOG_REFRESH_INTERVAL_SECONDS` | Нет | `3600` | Интервал обновления каталога (сек) | -| `HEALTH_CHECK_INTERVAL_SECONDS` | Нет | `600` | Интервал проверки соединений (сек) | - -## Структура проекта - -``` -evo-sync/ -├── web/ -│ ├── main.py # FastAPI-приложение, регистрация роутеров, фоновые задачи -│ ├── config.py # Настройки из переменных окружения (pydantic-settings) -│ ├── models.py # SQLAlchemy-модели (User, EvotorConnection, VkConnection, ...) -│ ├── database.py # Движок и сессия SQLAlchemy -│ ├── auth.py # Хеширование паролей, работа с сессиями -│ ├── schemas.py # Pydantic-схемы для форм -│ ├── sync_engine.py # Фоновый движок синхронизации (Эвотор → ВК) -│ ├── routes/ -│ │ ├── auth.py # Регистрация, вход, выход, подтверждение email -│ │ ├── profile.py # Профиль пользователя -│ │ ├── reset.py # Сброс пароля -│ │ ├── evotor.py # OAuth-подключение Эвотор -│ │ ├── vk.py # Подключение ВКонтакте (ввод токена) -│ │ ├── connections.py # Обзор всех подключений -│ │ ├── sync.py # Настройка и управление синхронизацией -│ │ └── catalog.py # Просмотр закешированного каталога -│ ├── templates/ # Jinja2-шаблоны (русский интерфейс) -│ └── migrations/ # Alembic-миграции -├── Dockerfile.web # Docker-образ веб-приложения (Python 3.12-slim) -├── docker-compose.yml # Оркестрация сервисов -├── docker-entrypoint.sh # Скрипт запуска контейнера -├── alembic.ini # Конфигурация миграций -├── requirements.txt # Python-зависимости -└── .env.example # Шаблон переменных окружения -``` - -## Модели базы данных - -| Модель | Таблица | Назначение | -|---|---|---| -| `User` | `users` | Аккаунт пользователя | -| `EvotorConnection` | `evotor_connections` | OAuth-токен Эвотор | -| `VkConnection` | `vk_connections` | Токен сообщества ВКонтакте | -| `SyncConfig` | `sync_configs` | Настройки синхронизации пользователя | -| `SyncFilter` | `sync_filters` | Фильтры по магазинам/группам/товарам | -| `CachedStore` | `cached_stores` | Кеш магазинов Эвотор | -| `CachedGroup` | `cached_groups` | Кеш групп товаров Эвотор | -| `CachedProduct` | `cached_products` | Кеш товаров Эвотор + статус синхронизации | - -## Как работает синхронизация - -1. По расписанию (каждые `SYNC_INTERVAL_SECONDS` секунд) запускается `sync_engine.run_sync()` -2. Для каждого пользователя с активным `SyncConfig` и подключёнными Эвотор и ВК: - - Загружаются товары и группы из Эвотор API - - Применяются фильтры (включить/исключить магазины, группы, товары) - - Загружаются текущие товары и альбомы из ВК - - Создаются/обновляются/удаляются альбомы и товары в ВК-магазине - - В БД обновляется поле `synced_at` у синхронизированных товаров - -**Особенности:** -- Товары на развес (единицы измерения в граммах) получают скорректированную цену (×10) -- Совпадение товаров ВК и Эвотор происходит по нормализованному названию -- Товары с `allow_to_sell=false` не синхронизируются в ВК - -## Разработка - -Для локальной разработки с горячей перезагрузкой кода папка `./web` монтируется в контейнер как volume. После изменения Python-файлов uvicorn перезапустится автоматически. - -Создание новой миграции: - -```bash -docker compose exec web alembic revision --autogenerate -m "описание изменений" -``` - -Применение миграций вручную: - -```bash -docker compose exec web alembic upgrade head -``` +Сервис автоматической синхронизации товарного каталога из [Эвотор](https://evotor.ru) в магазин [ВКонтакте](https://vk.com). diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index 3e51a57..0000000 --- a/alembic.ini +++ /dev/null @@ -1,43 +0,0 @@ -[alembic] -script_location = web/migrations -prepend_sys_path = . -version_path_separator = os - -# URL is set dynamically in env.py from DATABASE_URL env var -sqlalchemy.url = - -[post_write_hooks] - -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/cliff.toml b/cliff.toml deleted file mode 100644 index c8c21ab..0000000 --- a/cliff.toml +++ /dev/null @@ -1,45 +0,0 @@ -[changelog] -header = """# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n -""" -body = """ -{% if version %}\ - ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} -{% else %}\ - ## [Unreleased] -{% endif %}\ -{% for group, commits in commits | group_by(attribute="group") %} - ### {{ group | striptags | trim | upper_first }} - {% for commit in commits %} - - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\ - {{ commit.message | split(pat="\n") | first | upper_first }}\ - {% endfor %} -{% endfor %}\n -""" -trim = true -footer = "" - -[git] -conventional_commits = true -filter_unconventional = false -split_commits = false -commit_parsers = [ - { message = "^feat", group = "Added" }, - { message = "^fix", group = "Fixed" }, - { message = "^doc", group = "Documentation" }, - { message = "^perf", group = "Performance" }, - { message = "^refactor", group = "Changed" }, - { message = "^style", group = "Styling" }, - { message = "^test", group = "Testing" }, - { message = "^chore\\(release\\)", skip = true }, - { message = "^chore", group = "Miscellaneous" }, - { message = "^ci", group = "CI/CD" }, - { body = ".*security", group = "Security" }, - { message = ".*", group = "Other" }, -] -filter_commits = false -tag_pattern = "v[0-9].*" diff --git a/docs/plans/catalog-browser.md b/docs/plans/catalog-browser.md deleted file mode 100644 index b729e33..0000000 --- a/docs/plans/catalog-browser.md +++ /dev/null @@ -1,262 +0,0 @@ -# Catalog Browser with Filter Management & CSV Export - -## Context - -Users need to browse their Evotor catalog (stores, groups, products) in a table view, manage sync whitelist/blacklist rules inline, and export data to CSV. - -This feature **replaces** the separate `/sync/stores`, `/sync/groups`, `/sync/products` pages from the sync-configuration plan. The catalog browser becomes the unified place for both viewing data and managing filter rules. - -Data is cached in DB with a refresh mechanism — not fetched live on every page load. - -## Data Model - -### Catalog Cache Tables - -``` -tablename: "cached_stores" -- id (Integer, PK) -- user_id (Integer, FK users.id CASCADE) -- evotor_id (String 255) # Evotor UUID -- name (String 255) -- address (String 500, nullable) -- fetched_at (DateTime) # when this snapshot was taken - -UniqueConstraint: (user_id, evotor_id) -Index: user_id -``` - -``` -tablename: "cached_groups" -- id (Integer, PK) -- user_id (Integer, FK users.id CASCADE) -- evotor_id (String 255) # Evotor UUID -- store_evotor_id (String 255) # parent store UUID -- name (String 255) -- fetched_at (DateTime) - -UniqueConstraint: (user_id, evotor_id) -Index: (user_id, store_evotor_id) -``` - -``` -tablename: "cached_products" -- id (Integer, PK) -- user_id (Integer, FK users.id CASCADE) -- evotor_id (String 255) # Evotor UUID -- store_evotor_id (String 255) # parent store UUID -- group_evotor_id (String 255, nullable) # parent group UUID -- name (String 255) -- price (Numeric(12,2), nullable) -- quantity (Numeric(12,3), nullable) -- measure_name (String 20, nullable) -- article_number (String 100, nullable) -- allow_to_sell (Boolean, nullable) -- fetched_at (DateTime) - -UniqueConstraint: (user_id, evotor_id) -Index: (user_id, store_evotor_id, group_evotor_id) -``` - -### `SyncFilter` (from sync-configuration plan, unchanged) - -``` -tablename: "sync_filters" -- sync_config_id, entity_type, entity_id, entity_name, filter_mode, parent_entity_id -``` - -The catalog browser reads from cache tables for display and from `sync_filters` for the current filter state of each entity. - -### Cache Refresh - -`web/evotor_api.py` gets a new function: - -```python -async def refresh_catalog_cache(user_id: int, access_token: str, db: Session): - """Fetch all stores, groups, products from Evotor API and upsert into cache tables.""" -``` - -Triggered by: -- Manual "Обновить" button on the catalog page -- Background job (optional, can reuse health_checker interval or separate setting) -- First visit to catalog if cache is empty - -## Plan - -### 1. New Models — `web/models.py` - -Add `CachedStore`, `CachedGroup`, `CachedProduct` models as described above. - -### 2. Alembic Migration - -Create `cached_stores`, `cached_groups`, `cached_products` tables. - -### 3. Evotor API Helper — `web/evotor_api.py` - -Extend with: - -```python -async def fetch_stores(access_token: str) -> list[dict] -async def fetch_groups(access_token: str, store_id: str) -> list[dict] -async def fetch_products(access_token: str, store_id: str) -> list[dict] -async def refresh_catalog_cache(user_id: int, access_token: str, db: Session) -``` - -`refresh_catalog_cache` does: -1. Fetch all stores -2. For each store, fetch groups and products -3. Upsert into cache tables (delete old rows for user, insert fresh) -4. Update `fetched_at` timestamps - -### 4. Catalog Route — `web/routes/catalog.py` (new) - -**`GET /catalog`** — Stores table. Requires auth + Evotor connection. -- Reads `cached_stores` for user -- If cache is empty, triggers refresh -- Shows table with columns: Название, Адрес, Статус фильтра, Действия -- Each row shows the store's current `SyncFilter` state (included/excluded/no rule) -- Link to drill into groups for each store -- "Обновить каталог" button, "Экспорт CSV" button, back link - -**`GET /catalog/groups?store_id=UUID`** — Groups table for a store. -- Reads `cached_groups` filtered by `store_evotor_id` -- Table columns: Название, Статус фильтра, Кол-во товаров, Действия -- Each row shows group's `SyncFilter` state -- Link to drill into products for each group -- "Экспорт CSV" button, back to stores - -**`GET /catalog/products?store_id=UUID&group_id=UUID`** — Products table for a group. -- Reads `cached_products` filtered by `store_evotor_id` and `group_evotor_id` -- Table columns: Название, Артикул, Цена, Кол-во, Ед. изм., В продаже, Статус фильтра, Действия -- Each row shows product's `SyncFilter` state -- "Экспорт CSV" button, back to groups - -**`GET /catalog/products?store_id=UUID`** — All products for a store (no group filter). -- Same table, but shows all products in the store with a "Группа" column added - -**`POST /catalog/filter`** — Toggle filter for an entity. -- Body: `entity_type`, `entity_id`, `entity_name`, `filter_mode` (include/exclude/none), `parent_entity_id` -- Creates, updates, or deletes the `SyncFilter` row -- Redirects back to the referring page - -**`POST /catalog/refresh`** — Manual cache refresh. -- Calls `refresh_catalog_cache()` -- Redirects back to `/catalog` - -**`GET /catalog/export?type=stores|groups|products&store_id=UUID&group_id=UUID`** — CSV export. -- Reads from cache tables -- Returns `StreamingResponse` with `text/csv` content type and `Content-Disposition: attachment` -- Filename: `{type}_{date}.csv` - -### 5. Templates - -**`web/templates/catalog_stores.html`** — Stores table: - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Каталог [Обновить] [Экспорт CSV] │ -│ Последнее обновление: 06.03.2026 14:30 │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ Магазины │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ Название │ Адрес │ Фильтр │ │ │ -│ ├──────────────────────────────────────────────────────┤ │ -│ │ Чайная │ ул. Мира, 1 │ ✓ Вкл │ [→][▼] │ │ -│ │ Склад │ — │ ✗ Выкл │ [→][▼] │ │ -│ │ Точка 2 │ ул. Мира, 5 │ — Нет │ [→][▼] │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ [→] = перейти к группам │ -│ [▼] = dropdown: Включить / Исключить / Убрать правило │ -│ │ -└──────────────────────────────────────────────────────────────┘ -``` - -**Filter status column** shows: -- `✓ Включено` (green badge) — entity has an "include" rule -- `✗ Исключено` (red badge) — entity has an "exclude" rule -- `— Нет правила` (grey badge) — no filter rule (follows default behavior) - -**Actions column** per row: -- Link icon → drill into children (groups for stores, products for groups) -- Dropdown button with filter actions: "Включить в синхронизацию" / "Исключить из синхронизации" / "Убрать правило". Each is a small POST form to `/catalog/filter`. - -**`web/templates/catalog_groups.html`** — Groups table: -- Breadcrumb: Каталог > {Store name} > Группы -- Same table pattern, columns: Название, Кол-во товаров, Фильтр, Действия -- Drill-down link to products per group - -**`web/templates/catalog_products.html`** — Products table: -- Breadcrumb: Каталог > {Store name} > {Group name} > Товары -- Columns: Название, Артикул, Цена, Кол-во, Ед. изм., В продаже, Фильтр, Действия -- "В продаже" column: green check / red cross based on `allow_to_sell` - -All tables use Bootstrap table styling (`table table-striped table-hover`) with responsive wrapper. - -### 6. CSV Export Format - -**Stores CSV:** -``` -Название,Адрес,ID,Фильтр -Чайная,"ул. Мира, 1",uuid-123,Включено -``` - -**Groups CSV:** -``` -Магазин,Название,ID,Фильтр -Чайная,Белый чай,uuid-456,Включено -``` - -**Products CSV:** -``` -Магазин,Группа,Название,Артикул,Цена,Количество,Ед. измерения,В продаже,ID,Фильтр -Чайная,Белый чай,Бай Му Дань,1005,350.00,180.0,г,Да,uuid-789,Включено -``` - -UTF-8 with BOM (`\ufeff`) for Excel compatibility. Delimiter: comma. - -### 7. Update Sync Configuration Plan - -The `/sync` page links to `/catalog` instead of separate filter pages: -- "Настроить фильтры" button → `/catalog` -- Filter summary on `/sync` reads from `SyncFilter` table (unchanged) -- Remove `/sync/stores`, `/sync/groups`, `/sync/products` routes from sync-configuration plan — replaced by catalog browser - -### 8. Navbar / Navigation - -Add "Каталог" link to navbar for logged-in users. Order: Подключения → Каталог → Синхронизация → Личный кабинет → Выход. - -### 9. Register Route — `web/main.py` - -```python -from web.routes import catalog -app.include_router(catalog.router) -``` - -## Files Summary - -| File | Action | -|------|--------| -| `web/models.py` | Modify — add `CachedStore`, `CachedGroup`, `CachedProduct` | -| `web/evotor_api.py` | Create — API fetch + cache refresh functions | -| `web/routes/catalog.py` | Create — catalog routes (tables, filter toggle, refresh, CSV export) | -| `web/templates/catalog_stores.html` | Create — stores table | -| `web/templates/catalog_groups.html` | Create — groups table | -| `web/templates/catalog_products.html` | Create — products table | -| `web/templates/base.html` | Modify — add "Каталог" nav link | -| `web/main.py` | Modify — register catalog router | -| `docs/plans/sync-configuration.md` | Update — remove /sync/stores,groups,products; link to /catalog | -| Alembic migration | Create — cache tables | - -## Verification - -1. Run `alembic upgrade head` -2. Visit `/catalog` without Evotor connection → warning to connect first -3. Connect Evotor, visit `/catalog` → triggers first cache refresh, shows stores table -4. Click store → shows groups table with group names from Evotor -5. Click group → shows products table with full product details -6. Toggle filter on a product → badge changes, `SyncFilter` row created in DB -7. Go to `/sync` → filter summary reflects the change -8. Click "Экспорт CSV" on products page → downloads CSV, opens correctly in Excel -9. Click "Обновить каталог" → re-fetches from Evotor API, updates cache -10. Verify breadcrumb navigation works correctly through the hierarchy diff --git a/docs/plans/connections-dashboard.md b/docs/plans/connections-dashboard.md deleted file mode 100644 index 9e8bde1..0000000 --- a/docs/plans/connections-dashboard.md +++ /dev/null @@ -1,192 +0,0 @@ -# Connections Dashboard with Background Health Checks - -## Context - -Users currently access Evotor connection via a dedicated `/evotor` page linked from the navbar. As more integrations are planned, we need a unified **Connections** page where users can manage all their connections: add new ones, view status, edit, and delete. The dashboard starts empty — users explicitly add each connection they need. - -Supported connection types: **Evotor**, **VK** (one per type per user). - -## Data Design - -### Current state (separate models) - -`EvotorConnection` and `VkConnection` remain as-is — they hold service-specific fields (store_id/store_name for Evotor, vk_user_id/first_name/last_name for VK). The connections dashboard reads from both tables. - -No new unified "connection" table needed. The dashboard builds a virtual list by querying both tables. The "add" flow is just a gateway to the existing per-service OAuth pages. - -### Model additions (both `EvotorConnection` and `VkConnection`) - -Already planned: -- `is_online` (Boolean, default=False, server_default="0") -- `last_checked_at` (DateTime, nullable) - -## Plan - -### 1. Model Changes — `web/models.py` - -Add `is_online` and `last_checked_at` to both `EvotorConnection` and `VkConnection`. - -### 2. Alembic Migration - -Add health check fields to both connection tables. - -### 3. Config Addition — `web/config.py` - -Add `HEALTH_CHECK_INTERVAL_SECONDS: int = 600` (10 minutes default). - -### 4. Background Health Checker — `web/health_checker.py` (new) - -- `check_evotor_connection(access_token) -> bool` — async, `GET https://api.evotor.ru/stores` with Bearer token -- `check_vk_connection(access_token) -> bool` — async, `GET https://api.vk.com/method/users.get` with token -- `run_health_checks()` — queries all connection rows, checks each, updates `is_online` and `last_checked_at` -- `health_check_loop(interval)` — infinite loop with `asyncio.sleep` - -### 5. Wire Background Task — `web/main.py` - -Add FastAPI lifespan context manager: -- On startup: `asyncio.create_task(health_check_loop(...))` -- On shutdown: cancel the task -- Register connections router - -### 6. Connections Route — `web/routes/connections.py` (new) - -**`GET /connections`** — Main dashboard. Requires auth. - -Queries both `EvotorConnection` and `VkConnection` for the current user. Builds a list of available service types and their connection state: - -```python -SERVICE_TYPES = [ - {"type": "evotor", "name": "Эвотор", "icon": "bi-shop", "connect_url": "/evotor", "disconnect_url": "/evotor/disconnect"}, - {"type": "vk", "name": "ВКонтакте", "icon": "bi-chat-dots", "connect_url": "/vk", "disconnect_url": "/vk/disconnect"}, -] -``` - -For each type, attach the connection record (or None). Template renders based on state. - -**`GET /connections/add`** — "Add connection" page. - -Shows only service types the user has NOT yet connected: -- Card per available type with service name, icon, short description -- "Подключить" button linking to the service's OAuth page (`/evotor` or `/vk`) -- If all types already connected — message "Все доступные сервисы подключены" -- Back link to `/connections` - -**`POST /connections/delete?type=evotor|vk`** — Delete a connection. - -Same as existing disconnect endpoints but accessed from the dashboard. Deletes the connection record, redirects to `/connections`. - -(The existing `/evotor/disconnect` and `/vk/disconnect` routes remain as aliases.) - -### 7. Templates - -**`web/templates/connections.html`** — Dashboard: - -``` -┌─────────────────────────────────────────────────┐ -│ Подключения [+ Добавить] │ -├─────────────────────────────────────────────────┤ -│ │ -│ ┌─ Card ─────────────────────────────────────┐ │ -│ │ 🏪 Эвотор ● (green) │ │ -│ │ Магазин "Чайная" │ │ -│ │ Последняя проверка: 06.03.2026 14:30 │ │ -│ │ │ │ -│ │ [Настроить] [Отключить] │ │ -│ └────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Card ─────────────────────────────────────┐ │ -│ │ 💬 ВКонтакте ● (green) │ │ -│ │ Иван Иванов │ │ -│ │ Последняя проверка: 06.03.2026 14:30 │ │ -│ │ │ │ -│ │ [Настроить] [Отключить] │ │ -│ └────────────────────────────────────────────┘ │ -│ │ -│ (Нет подключений — нажмите «Добавить») │ -│ │ -└─────────────────────────────────────────────────┘ -``` - -Each connection card: -- Icon + service name + status indicator (green/red/grey) -- Details line (store name for Evotor, profile name for VK) -- Last checked timestamp in card footer -- "Настроить" button → links to service page (`/evotor` or `/vk`) for reconnect/details -- "Отключить" button → POST to `/connections/delete?type=...` with confirmation - -Empty state: message prompting user to add their first connection. - -**`web/templates/connections_add.html`** — Add connection page: - -``` -┌─────────────────────────────────────────────────┐ -│ Добавить подключение │ -├─────────────────────────────────────────────────┤ -│ │ -│ ┌─ Card ─────────────────────────────────────┐ │ -│ │ 🏪 Эвотор │ │ -│ │ Подключите кассу Эвотор для синхронизации │ │ -│ │ каталога товаров. │ │ -│ │ [Подключить →] │ │ -│ └────────────────────────────────────────────┘ │ -│ │ -│ ┌─ Card ─────────────────────────────────────┐ │ -│ │ 💬 ВКонтакте │ │ -│ │ Подключите аккаунт ВКонтакте для │ │ -│ │ публикации товаров в вашу группу. │ │ -│ │ [Подключить →] │ │ -│ └────────────────────────────────────────────┘ │ -│ │ -│ ← Вернуться к подключениям │ -│ │ -└─────────────────────────────────────────────────┘ -``` - -### 8. Navbar Update — `web/templates/base.html` - -Replace "Эвотор" link with "Подключения" → `/connections`. - -### 9. Evotor/VK Callback Updates - -On successful OAuth callback in both `/evotor/callback` and `/vk/callback`: -- Set `is_online=True` and `last_checked_at=now()` -- Redirect to `/connections` (already done for Evotor) - -### 10. Evotor/VK Template Back Links - -Change back links on `/evotor` and `/vk` pages: "Вернуться к подключениям" → `/connections`. - -### 11. Delete Confirmation - -The "Отключить" button on the dashboard uses a simple JS `confirm()` dialog: "Вы уверены, что хотите отключить {service name}?" before submitting the POST form. - -## Files Summary - -| File | Action | -|------|--------| -| `web/models.py` | Modify — add `is_online`, `last_checked_at` to both connection models | -| `web/config.py` | Modify — add `HEALTH_CHECK_INTERVAL_SECONDS` | -| `web/main.py` | Modify — lifespan + register connections router | -| `web/routes/evotor.py` | Modify — set online on callback, redirect to /connections | -| `web/routes/vk.py` | Modify — set online on callback, redirect to /connections | -| `web/routes/connections.py` | Create — dashboard, add page, delete endpoint | -| `web/health_checker.py` | Create — background checks for both Evotor and VK | -| `web/templates/connections.html` | Create — dashboard with cards | -| `web/templates/connections_add.html` | Create — add connection page | -| `web/templates/base.html` | Modify — navbar link | -| `web/templates/evotor.html` | Modify — back link to /connections | -| `web/templates/vk.html` | Modify — back link to /connections | -| Alembic migration | Create | - -## Verification - -1. Run `alembic upgrade head` -2. Start the app, verify background task logs appear -3. Visit `/connections` — empty state, "Добавить" button visible -4. Click "Добавить" → shows Evotor and VK as available services -5. Add Evotor → goes through OAuth → returns to `/connections` with green status card -6. Add VK → same flow → both connections visible -7. Click "Добавить" again → shows "Все доступные сервисы подключены" -8. Click "Отключить" on Evotor → confirmation dialog → connection removed → card disappears -9. Click "Добавить" → Evotor is available again -10. Wait for health check cycle → verify `is_online` and `last_checked_at` update on remaining connections diff --git a/docs/plans/sync-configuration.md b/docs/plans/sync-configuration.md deleted file mode 100644 index 44b824c..0000000 --- a/docs/plans/sync-configuration.md +++ /dev/null @@ -1,151 +0,0 @@ -# Sync Configuration Feature - -## Context - -EvoSync syncs product catalogs from Evotor → VK. Currently sync runs as a shell-based cron service with a hardcoded store ID and a flat-file whitelist of group names (`vk/whitelist`). This doesn't support multi-user or per-user configuration. - -Users need a web UI to: -- Enable/disable the whole sync process -- Configure which stores, groups, and products to sync (whitelist/blacklist) -- Explicitly confirm before sync starts - -The web app will store config in DB; the shell sync service will read from DB instead of flat files. - -## Data Model - -### `SyncConfig` — per-user master switch - -``` -tablename: "sync_configs" -- id (Integer, PK) -- user_id (Integer, FK users.id CASCADE, unique) -- is_enabled (Boolean, default=False) # master on/off -- confirmed_at (DateTime, nullable) # NULL = never confirmed/started -- created_at (DateTime, server_default=now) -- updated_at (DateTime, server_default=now, onupdate=now) - -Relationship: User.sync_config (one-to-one) -``` - -### `SyncFilter` — stores, groups, products filter rules - -``` -tablename: "sync_filters" -- id (Integer, PK) -- sync_config_id (Integer, FK sync_configs.id CASCADE) -- entity_type (String, enum: "store", "group", "product") -- entity_id (String 255) # Evotor UUID -- entity_name (String 255) # human-readable, cached -- filter_mode (String, enum: "include", "exclude") -- parent_entity_id (String 255, nullable) # store_id for groups, group_id for products -- created_at (DateTime, server_default=now) - -UniqueConstraint: (sync_config_id, entity_type, entity_id) -Relationship: SyncConfig.filters (one-to-many) -``` - -### Filter Logic - -The filter model uses **explicit include/exclude rules** with these semantics: -- **No rules for an entity type** = sync everything of that type (default permissive) -- **Any "include" rule exists for a type** = ONLY sync included entities (whitelist mode) -- **Only "exclude" rules for a type** = sync everything EXCEPT excluded (blacklist mode) -- Hierarchy: store filters → group filters → product filters. If a store is excluded, all its groups/products are excluded regardless of their individual rules. - -## Plan - -### 1. New Models — `web/models.py` - -Add `SyncConfig` and `SyncFilter` as described above. Add `sync_config` relationship to `User`. - -### 2. Alembic Migration - -Create `sync_configs` and `sync_filters` tables. - -### 3. Evotor API Helper — `web/evotor_api.py` (new) - -Async functions to fetch data from Evotor API using a user's stored access token: - -```python -async def fetch_stores(access_token: str) -> list[dict]: - """GET https://api.evotor.ru/stores → [{"id": "uuid", "name": "..."}]""" - -async def fetch_groups(access_token: str, store_id: str) -> list[dict]: - """GET https://api.evotor.ru/stores/{store_id}/product-groups → [{"id": "uuid", "name": "..."}]""" - -async def fetch_products(access_token: str, store_id: str) -> list[dict]: - """GET https://api.evotor.ru/stores/{store_id}/products → [{"id": "uuid", "name": "...", "parent_id": "..."}]""" -``` - -Uses `httpx.AsyncClient`. Returns simplified dicts. Raises on auth failure. - -### 4. Sync Config Route — `web/routes/sync.py` (new) - -**`GET /sync`** — Main sync configuration page. -- Requires auth + active Evotor connection -- Loads `SyncConfig` (creates default if missing) -- Shows: master enable/disable toggle, confirm button, link to filter config - -**`POST /sync/toggle`** — Enable/disable sync. -- Toggles `is_enabled`. If enabling for the first time and no filters configured, stays on page with message to configure filters first. - -**`POST /sync/confirm`** — Confirm and start sync. -- Sets `confirmed_at = now()`. Only works if `is_enabled=True` and at least one store is configured. - -**Filter management is handled by the Catalog Browser** (see `docs/plans/catalog-browser.md`). -The `/catalog` page provides table views of stores, groups, and products with inline filter toggle actions. No separate `/sync/stores`, `/sync/groups`, `/sync/products` routes needed. - -### 5. Templates - -**`web/templates/sync.html`** — Main sync page: -- Card with master toggle (on/off switch) -- Status: "Не настроено" / "Настроено, ожидает подтверждения" / "Активна" -- Warning if Evotor not connected (link to /evotor) -- Warning if VK not connected (link to /vk) -- "Настроить фильтры" button → `/catalog` (catalog browser) -- "Подтвердить и запустить" button (disabled until filters configured) -- Summary of current filter rules (X stores, Y groups, Z products) - -### 6. Navbar / Navigation - -Add "Синхронизация" link to navbar (for logged-in users), or add it as a card on the `/connections` page since sync depends on connections. - -### 7. Register Route — `web/main.py` - -```python -from web.routes import sync -app.include_router(sync.router) -``` - -### 8. Shell Script DB Integration - -Modify the sync service to read configuration from DB instead of flat files: - -- Add a Python helper script `run/read_config.py` that queries `sync_configs` + `sync_filters` for a given user and outputs JSON config -- Shell scripts call this helper to get: enabled flag, store IDs, whitelisted/blacklisted group names, product exclusions -- The sync service only runs for users where `is_enabled=True` AND `confirmed_at IS NOT NULL` -- Replaces the flat `vk/whitelist` file - -## Files Summary - -| File | Action | -|------|--------| -| `web/models.py` | Modify — add `SyncConfig`, `SyncFilter` + User relationship | -| `web/routes/sync.py` | Create — sync config routes (toggle, confirm) | -| `web/templates/sync.html` | Create — main sync config page | -| `web/templates/base.html` | Modify — add sync nav link | -| `web/main.py` | Modify — register sync router | -| `run/read_config.py` | Create — DB config reader for shell scripts | -| Alembic migration | Create — sync_configs + sync_filters tables | - -## Verification - -1. Run `alembic upgrade head` -2. Visit `/sync` without Evotor connection → shows warning to connect first -3. Connect Evotor, visit `/sync` → shows disabled state, "Настроить фильтры" button -4. Go to `/sync/stores` → fetches live stores from Evotor API, shows checkboxes -5. Select stores, save → drill into groups, select groups, save → drill into products -6. Back to `/sync` → shows summary of configured filters -7. Enable sync toggle → confirm → `confirmed_at` set -8. Verify `run/read_config.py` outputs correct JSON for the user's config -9. Disable sync → `is_enabled=False`, sync service stops processing this user diff --git a/docs/plans/vk-connection.md b/docs/plans/vk-connection.md deleted file mode 100644 index 220127c..0000000 --- a/docs/plans/vk-connection.md +++ /dev/null @@ -1,189 +0,0 @@ -# VK OAuth Connection Feature - -## Context - -EvoSync syncs product catalogs from Evotor to VK. Users already connect their Evotor account via OAuth. Now we need the same for VK — users authorize via VK OAuth, we store the access token, and show connection status on the connections dashboard. - -## VK OAuth Flow (Web) - -- **Authorize URL**: `https://oauth.vk.com/authorize` -- **Token URL**: `https://oauth.vk.com/access_token` -- **Verify endpoint**: `GET https://api.vk.com/method/users.get?access_token={token}&v=5.131` - - Error code 5 = token invalid/expired -- **Scopes**: `market groups offline` (offline = permanent token, no expiry) -- **Token response fields**: `access_token`, `user_id`, `expires_in` (0 if offline scope used) - -With `offline` scope, tokens don't expire — no refresh logic needed. If a user revokes access on VK's side, the health checker will detect it. - -## Plan - -### 1. New Model — `VkConnection` in `web/models.py` - -```python -class VkConnection(Base): - __tablename__ = "vk_connections" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False) - access_token = Column(Text, nullable=False) - vk_user_id = Column(String(50), nullable=True) # VK user ID from token response - first_name = Column(String(255), nullable=True) # VK profile first name - last_name = Column(String(255), nullable=True) # VK profile last name - is_online = Column(Boolean, default=False, server_default="0", nullable=False) - last_checked_at = Column(DateTime, nullable=True) - connected_at = Column(DateTime, server_default=func.now(), nullable=False) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) - - user = relationship("User", back_populates="vk_connection") -``` - -Add to `User` model: -```python -vk_connection = relationship("VkConnection", back_populates="user", uselist=False) -``` - -### 2. Alembic Migration - -Generate migration for the new `vk_connections` table and the relationship. - -### 3. Config — `web/config.py` - -Add: -```python -VK_CLIENT_ID: str = "" -VK_CLIENT_SECRET: str = "" -VK_SCOPES: str = "market groups offline" -VK_API_VERSION: str = "5.131" -``` - -### 4. VK Route — `web/routes/vk.py` (new) - -Follow the same pattern as `web/routes/evotor.py`: - -**Constants:** -```python -VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize" -VK_TOKEN_URL = "https://oauth.vk.com/access_token" -VK_API_URL = "https://api.vk.com/method" -``` - -**Endpoints:** - -- `GET /vk` — Connection page. Shows connected state (VK profile name, user_id) or disconnected state with explanation and connect button. - -- `GET /vk/connect` — Generate state token, save in session, redirect to: - ``` - https://oauth.vk.com/authorize?client_id={id}&response_type=code - &redirect_uri={BASE_URL}/vk/callback&scope={scopes}&state={state} - &display=page&v=5.131 - ``` - -- `GET /vk/callback` — OAuth callback: - 1. Validate state from session - 2. Exchange code for token via GET to `https://oauth.vk.com/access_token` with params: `client_id`, `client_secret`, `code`, `redirect_uri` (NOTE: VK uses GET, not POST, and params in query string, not body) - 3. Response contains: `access_token`, `user_id`, `expires_in` - 4. Fetch user profile via `users.get` to get first_name, last_name - 5. Save/update `VkConnection` record with `is_online=True`, `last_checked_at=now()` - 6. Redirect to `/connections` - -- `POST /vk/disconnect` — Delete VkConnection record, redirect to `/vk` - -### 5. VK Template — `web/templates/vk.html` (new) - -Same structure as `evotor.html`: - -**Connected state:** -- Status badge: "Подключено" (green) -- VK profile: first_name + last_name -- VK user ID (monospace) -- Connected timestamp -- Buttons: "Переподключить", "Отключить аккаунт ВКонтакте" - -**Disconnected state:** -- Explanation text: "Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать каталог товаров из Эвотор в вашу группу ВКонтакте." -- Bullet points: redirect to VK for auth, auto-setup after confirmation, can disconnect anytime -- Button: "Подключить ВКонтакте" - -**Error display:** same pattern as evotor.html (invalid_state, token_exchange, no_token) - -**Back link:** "Вернуться к подключениям" → `/connections` - -### 6. Register Route — `web/main.py` - -```python -from web.routes import vk -app.include_router(vk.router) -``` - -### 7. Add to Connections Dashboard — `web/routes/connections.py` - -Add VK entry to the connections list: -```python -vk_conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() - -connections.append({ - "name": "ВКонтакте", - "icon": "bi-chat-dots", # or another suitable Bootstrap icon - "connected": vk_conn is not None, - "is_online": vk_conn.is_online if vk_conn else False, - "last_checked_at": vk_conn.last_checked_at if vk_conn else None, - "details": f"{vk_conn.first_name} {vk_conn.last_name}" if vk_conn and vk_conn.first_name else None, - "connect_url": "/vk", - "disconnect_url": "/vk/disconnect", -}) -``` - -### 8. Background Health Check — `web/health_checker.py` - -Add VK check alongside existing Evotor check: - -```python -async def check_vk_connection(access_token: str) -> bool: - """Call users.get to verify VK token is valid.""" - async with httpx.AsyncClient() as client: - resp = await client.get( - "https://api.vk.com/method/users.get", - params={"access_token": access_token, "v": "5.131"}, - timeout=10, - ) - if resp.status_code != 200: - return False - data = resp.json() - # Error code 5 = invalid token - if "error" in data: - return False - return True -``` - -In `run_health_checks()`, add a loop over `VkConnection` rows with the same pattern as Evotor checks. - -## Files Summary - -| File | Action | -|------|--------| -| `web/models.py` | Modify — add `VkConnection` model + User relationship | -| `web/config.py` | Modify — add `VK_*` settings | -| `web/main.py` | Modify — register vk router | -| `web/routes/vk.py` | Create — OAuth flow (connect/callback/disconnect/page) | -| `web/routes/connections.py` | Modify — add VK to connections list | -| `web/health_checker.py` | Modify — add VK health check | -| `web/templates/vk.html` | Create — VK connection page | -| Alembic migration | Create — `vk_connections` table | - -## Env Config Needed - -``` -VK_CLIENT_ID=your_vk_app_id -VK_CLIENT_SECRET=your_vk_app_secret -VK_SCOPES=market groups offline -``` - -## Verification - -1. Run `alembic upgrade head` -2. Visit `/connections` — should show VK as disconnected (grey) -3. Click VK → "Подключить ВКонтакте" → redirects to VK auth -4. After VK auth → callback saves token → redirects to `/connections` → VK shows green -5. Visit `/vk` — shows connected state with VK profile info -6. Disconnect → VK returns to grey on connections page -7. Wait for health check cycle — verify `is_online` and `last_checked_at` update diff --git a/version b/version deleted file mode 100644 index f8e233b..0000000 --- a/version +++ /dev/null @@ -1 +0,0 @@ -1.9.0 diff --git a/web-python/__init__.py b/web-python/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/web-python/auth.py b/web-python/auth.py deleted file mode 100644 index 165f71c..0000000 --- a/web-python/auth.py +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi import Request, Depends -from sqlalchemy.orm import Session -from passlib.context import CryptContext - -from web.database import get_db -from web.models import User - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def hash_password(password: str) -> str: - return pwd_context.hash(password) - - -def verify_password(plain: str, hashed: str) -> bool: - return pwd_context.verify(plain, hashed) - - -def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None: - user_id = request.session.get("user_id") - if not user_id: - return None - return db.query(User).filter(User.id == user_id).first() diff --git a/web-python/config.py b/web-python/config.py deleted file mode 100644 index c1f30f7..0000000 --- a/web-python/config.py +++ /dev/null @@ -1,34 +0,0 @@ -from pydantic_settings import BaseSettings - - -class Settings(BaseSettings): - DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync" - SECRET_KEY: str = "change-me-in-production" - BASE_URL: str = "http://localhost:8080" - PASSWORD_RESET_EXPIRE_MINUTES: int = 60 - - EVOTOR_APP_ID: str = "" - EVOTOR_WEBHOOK_SECRET: str = "" - - JIVOSITE_WIDGET_ID: str = "" - - HEALTH_CHECK_INTERVAL_SECONDS: int = 600 - CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600 - SYNC_INTERVAL_SECONDS: int = 3600 - - VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png" - - VK_CLIENT_ID: str = "" - VK_CLIENT_SECRET: str = "" - VK_API_VERSION: str = "5.131" - - # Docker compose vars (ignored in app, kept for env compatibility) - DB_ROOT_PASSWORD: str = "" - DB_NAME: str = "" - DB_USER: str = "" - DB_PASSWORD: str = "" - - model_config = {"env_file": ".env", "case_sensitive": False} - - -settings = Settings() diff --git a/web-python/database.py b/web-python/database.py deleted file mode 100644 index 16e5b89..0000000 --- a/web-python/database.py +++ /dev/null @@ -1,19 +0,0 @@ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, DeclarativeBase - -from web.config import settings - -engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) -SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) - - -class Base(DeclarativeBase): - pass - - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/web-python/evotor_api.py b/web-python/evotor_api.py deleted file mode 100644 index 0d220a9..0000000 --- a/web-python/evotor_api.py +++ /dev/null @@ -1,119 +0,0 @@ -from datetime import datetime - -import httpx -from sqlalchemy.orm import Session - -EVOTOR_API_BASE = "https://api.evotor.ru" - - -async def fetch_stores(access_token: str) -> list[dict]: - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{EVOTOR_API_BASE}/stores", - headers={"Authorization": f"Bearer {access_token}"}, - timeout=15, - ) - resp.raise_for_status() - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - return [ - { - "id": s.get("uuid") or s.get("id"), - "name": s.get("name"), - "address": s.get("address"), - } - for s in items - ] - - -async def fetch_groups(access_token: str, store_id: str) -> list[dict]: - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups", - headers={"Authorization": f"Bearer {access_token}"}, - timeout=15, - ) - if resp.status_code == 402: - return [] - resp.raise_for_status() - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - return [{"id": g.get("uuid") or g.get("id"), "name": g.get("name")} for g in items] - - -async def fetch_products(access_token: str, store_id: str) -> list[dict]: - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{EVOTOR_API_BASE}/stores/{store_id}/products", - headers={"Authorization": f"Bearer {access_token}"}, - timeout=15, - ) - if resp.status_code == 402: - return [] - resp.raise_for_status() - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - return [ - { - "id": p.get("uuid") or p.get("id"), - "name": p.get("name"), - "parent_id": p.get("parentUuid") or p.get("parent_id"), - "price": p.get("price"), - "quantity": p.get("quantity"), - "measure_name": p.get("measureName") or p.get("measure_name"), - "article_number": p.get("code") or p.get("article_number"), - "allow_to_sell": p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell"), - } - for p in items - ] - - -async def refresh_catalog_cache(user_id: int, access_token: str, db: Session) -> None: - from web.models import CachedStore, CachedGroup, CachedProduct - - now = datetime.utcnow() - - # Delete old cache for user - db.query(CachedProduct).filter(CachedProduct.user_id == user_id).delete() - db.query(CachedGroup).filter(CachedGroup.user_id == user_id).delete() - db.query(CachedStore).filter(CachedStore.user_id == user_id).delete() - db.commit() - - stores = await fetch_stores(access_token) - for store in stores: - db.add(CachedStore( - user_id=user_id, - evotor_id=store["id"], - name=store["name"] or "", - address=store.get("address"), - fetched_at=now, - )) - db.commit() - - for store in stores: - groups = await fetch_groups(access_token, store["id"]) - for group in groups: - db.add(CachedGroup( - user_id=user_id, - evotor_id=group["id"], - store_evotor_id=store["id"], - name=group["name"] or "", - fetched_at=now, - )) - - products = await fetch_products(access_token, store["id"]) - for product in products: - db.add(CachedProduct( - user_id=user_id, - evotor_id=product["id"], - store_evotor_id=store["id"], - group_evotor_id=product.get("parent_id"), - name=product["name"] or "", - price=product.get("price"), - quantity=product.get("quantity"), - measure_name=product.get("measure_name"), - article_number=product.get("article_number"), - allow_to_sell=product.get("allow_to_sell"), - fetched_at=now, - )) - db.commit() diff --git a/web-python/health_checker.py b/web-python/health_checker.py deleted file mode 100644 index 51dcae6..0000000 --- a/web-python/health_checker.py +++ /dev/null @@ -1,152 +0,0 @@ -import asyncio -import logging -from datetime import datetime, timedelta - -import httpx - -from web.database import SessionLocal -from web.models import EvotorConnection, VkConnection, CachedStore - -logger = logging.getLogger("uvicorn.error") - -EVOTOR_STORES_URL = "https://api.evotor.ru/stores" -EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token" -VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById" -VK_API_VERSION = "5.131" - -# Refresh Evotor token if it expires within this window -REFRESH_BEFORE_EXPIRY = timedelta(hours=2) - - -async def _refresh_evotor_token(conn: EvotorConnection) -> str | None: - """Attempt to refresh the Evotor access token. Returns new access token or None.""" - from web.config import settings - if not conn.refresh_token: - return None - try: - async with httpx.AsyncClient() as client: - resp = await client.post( - EVOTOR_TOKEN_URL, - data={ - "grant_type": "refresh_token", - "refresh_token": conn.refresh_token, - }, - auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET), - timeout=15, - ) - if resp.status_code != 200: - return None - data = resp.json() - return data if data.get("access_token") else None - except Exception: - return None - - -async def check_evotor_connection(access_token: str) -> bool: - try: - async with httpx.AsyncClient() as client: - response = await client.get( - EVOTOR_STORES_URL, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=15, - ) - return response.status_code == 200 - except Exception: - return False - - -async def check_vk_connection(access_token: str) -> bool: - try: - async with httpx.AsyncClient() as client: - resp = await client.get( - VK_GROUPS_GET_URL, - params={"access_token": access_token, "v": VK_API_VERSION}, - timeout=10, - ) - if resp.status_code != 200: - return False - data = resp.json() - return "error" not in data - except Exception: - return False - - -async def run_health_checks() -> None: - db = SessionLocal() - try: - now = datetime.utcnow() - - evotor_connections = db.query(EvotorConnection).all() - for conn in evotor_connections: - # Proactively refresh if token expires soon - needs_refresh = ( - conn.refresh_token and - conn.token_expires_at and - conn.token_expires_at - now < REFRESH_BEFORE_EXPIRY - ) - if needs_refresh: - token_data = await _refresh_evotor_token(conn) - if token_data: - conn.access_token = token_data["access_token"] - conn.refresh_token = token_data.get("refresh_token", conn.refresh_token) - expires_in = token_data.get("expires_in") - conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None - logger.info("Refreshed Evotor token for user_id=%d", conn.user_id) - - is_online = await check_evotor_connection(conn.access_token) - - # If offline and not yet tried refresh, attempt it now - if not is_online and conn.refresh_token and not needs_refresh: - token_data = await _refresh_evotor_token(conn) - if token_data: - conn.access_token = token_data["access_token"] - conn.refresh_token = token_data.get("refresh_token", conn.refresh_token) - expires_in = token_data.get("expires_in") - conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None - is_online = await check_evotor_connection(conn.access_token) - if is_online: - logger.info("Evotor token refreshed after failed check for user_id=%d", conn.user_id) - - conn.is_online = is_online - conn.last_checked_at = now - - vk_connections = db.query(VkConnection).all() - for conn in vk_connections: - conn.is_online = await check_vk_connection(conn.access_token) - conn.last_checked_at = now - - db.commit() - - # Refresh catalog cache for online Evotor connections - from web.config import settings - refreshed_catalog = 0 - for conn in evotor_connections: - if not conn.is_online: - continue - cached = db.query(CachedStore).filter(CachedStore.user_id == conn.user_id).first() - cache_age = (now - cached.fetched_at).total_seconds() if cached else None - if cached is None or cache_age >= settings.CATALOG_REFRESH_INTERVAL_SECONDS: - try: - from web.evotor_api import refresh_catalog_cache - await refresh_catalog_cache(conn.user_id, conn.access_token, db) - refreshed_catalog += 1 - except Exception: - logger.exception("Failed to refresh catalog cache for user_id=%d", conn.user_id) - - logger.info( - "Health checks completed: %d Evotor, %d VK, %d catalogs refreshed", - len(evotor_connections), - len(vk_connections), - refreshed_catalog, - ) - except Exception: - logger.exception("Error during health checks") - db.rollback() - finally: - db.close() - - -async def health_check_loop(interval: int) -> None: - while True: - await run_health_checks() - await asyncio.sleep(interval) diff --git a/web-python/main.py b/web-python/main.py deleted file mode 100644 index 84bc7d7..0000000 --- a/web-python/main.py +++ /dev/null @@ -1,53 +0,0 @@ -import asyncio -from contextlib import asynccontextmanager - -from fastapi import FastAPI, Depends, Request -from fastapi.responses import RedirectResponse -from fastapi.staticfiles import StaticFiles -from starlette.middleware.sessions import SessionMiddleware - -from web.auth import get_current_user -from web.config import settings -from web.health_checker import health_check_loop -from web.sync_engine import sync_loop -from web.models import User -from web.routes import auth, profile, reset, evotor, vk, sync, catalog -from web.routes import connections - - -@asynccontextmanager -async def lifespan(app: FastAPI): - tasks = [ - asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS)), - asyncio.create_task(sync_loop(settings.SYNC_INTERVAL_SECONDS)), - ] - yield - for t in tasks: - t.cancel() - for t in tasks: - try: - await t - except asyncio.CancelledError: - pass - - -app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan) - -app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY) -app.mount("/static", StaticFiles(directory="web/static"), name="static") - -app.include_router(auth.router) -app.include_router(profile.router) -app.include_router(reset.router) -app.include_router(evotor.router) -app.include_router(connections.router) -app.include_router(vk.router) -app.include_router(sync.router) -app.include_router(catalog.router) - - -@app.get("/") -def home(request: Request, user: User | None = Depends(get_current_user)): - if user: - return RedirectResponse("/profile", 302) - return RedirectResponse("/login", 302) diff --git a/web-python/migrations/README b/web-python/migrations/README deleted file mode 100644 index 98e4f9c..0000000 --- a/web-python/migrations/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/web-python/migrations/env.py b/web-python/migrations/env.py deleted file mode 100644 index 201b506..0000000 --- a/web-python/migrations/env.py +++ /dev/null @@ -1,54 +0,0 @@ -from logging.config import fileConfig - -from sqlalchemy import engine_from_config -from sqlalchemy import pool - -from alembic import context - -from web.config import settings -from web.database import Base -from web.models import User, EvotorConnection # noqa: F401 — register models with Base - -config = context.config - -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) - -target_metadata = Base.metadata - - -def run_migrations_offline() -> None: - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/web-python/migrations/script.py.mako b/web-python/migrations/script.py.mako deleted file mode 100644 index fbc4b07..0000000 --- a/web-python/migrations/script.py.mako +++ /dev/null @@ -1,26 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/web-python/migrations/versions/2c15000e752b_initial.py b/web-python/migrations/versions/2c15000e752b_initial.py deleted file mode 100644 index f272e92..0000000 --- a/web-python/migrations/versions/2c15000e752b_initial.py +++ /dev/null @@ -1,60 +0,0 @@ -"""initial - -Revision ID: 2c15000e752b -Revises: -Create Date: 2026-03-06 09:07:16.180639 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '2c15000e752b' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "users", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("first_name", sa.String(length=100), nullable=False), - sa.Column("last_name", sa.String(length=100), nullable=False), - sa.Column("email", sa.String(length=255), nullable=False), - sa.Column("phone", sa.String(length=20), nullable=False), - sa.Column("password_hash", sa.String(length=255), nullable=False), - sa.Column("is_email_confirmed", sa.Boolean(), nullable=False), - sa.Column("email_confirm_token", sa.String(length=255), nullable=True), - sa.Column("password_reset_token", sa.String(length=255), nullable=True), - sa.Column("password_reset_expires", sa.DateTime(), nullable=True), - sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) - op.create_index(op.f("ix_users_phone"), "users", ["phone"], unique=True) - - op.create_table( - "evotor_connections", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("access_token", sa.Text(), nullable=False), - sa.Column("store_id", sa.String(length=255), nullable=True), - sa.Column("store_name", sa.String(length=255), nullable=True), - sa.Column("connected_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id"), - ) - - -def downgrade() -> None: - op.drop_table("evotor_connections") - op.drop_index(op.f("ix_users_phone"), table_name="users") - op.drop_index(op.f("ix_users_email"), table_name="users") - op.drop_table("users") diff --git a/web-python/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py b/web-python/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py deleted file mode 100644 index 6a86a68..0000000 --- a/web-python/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py +++ /dev/null @@ -1,26 +0,0 @@ -"""add is_online and last_checked_at to evotor_connections - -Revision ID: a1b2c3d4e5f6 -Revises: 2c15000e752b -Create Date: 2026-03-06 00:00:00.000000 - -""" -from alembic import op -import sqlalchemy as sa - -revision = 'a1b2c3d4e5f6' -down_revision = '2c15000e752b' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column('evotor_connections', - sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0')) - op.add_column('evotor_connections', - sa.Column('last_checked_at', sa.DateTime(), nullable=True)) - - -def downgrade() -> None: - op.drop_column('evotor_connections', 'last_checked_at') - op.drop_column('evotor_connections', 'is_online') diff --git a/web-python/migrations/versions/a7b8c9d0e1f2_add_synced_at_to_cached_products.py b/web-python/migrations/versions/a7b8c9d0e1f2_add_synced_at_to_cached_products.py deleted file mode 100644 index a399d89..0000000 --- a/web-python/migrations/versions/a7b8c9d0e1f2_add_synced_at_to_cached_products.py +++ /dev/null @@ -1,26 +0,0 @@ -"""add synced_at to cached_products - -Revision ID: a7b8c9d0e1f2 -Revises: f6a7b8c9d0e1 -Branch Labels: None -Depends On: None - -""" -from alembic import op -import sqlalchemy as sa - -revision = "a7b8c9d0e1f2" -down_revision = "f6a7b8c9d0e1" -branch_labels = None -depends_on = None - - -def upgrade(): - op.add_column( - "cached_products", - sa.Column("synced_at", sa.DateTime(), nullable=True), - ) - - -def downgrade(): - op.drop_column("cached_products", "synced_at") diff --git a/web-python/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py b/web-python/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py deleted file mode 100644 index c4d8bda..0000000 --- a/web-python/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py +++ /dev/null @@ -1,37 +0,0 @@ -"""add vk_connections table - -Revision ID: b2c3d4e5f6a7 -Revises: a1b2c3d4e5f6 -Create Date: 2026-03-06 00:01:00.000000 - -""" -from alembic import op -import sqlalchemy as sa - -revision = 'b2c3d4e5f6a7' -down_revision = 'a1b2c3d4e5f6' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - 'vk_connections', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('access_token', sa.Text(), nullable=False), - sa.Column('vk_user_id', sa.String(50), nullable=True), - sa.Column('first_name', sa.String(255), nullable=True), - sa.Column('last_name', sa.String(255), nullable=True), - sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'), - sa.Column('last_checked_at', sa.DateTime(), nullable=True), - sa.Column('connected_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id'), - ) - - -def downgrade() -> None: - op.drop_table('vk_connections') diff --git a/web-python/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py b/web-python/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py deleted file mode 100644 index 547c017..0000000 --- a/web-python/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py +++ /dev/null @@ -1,48 +0,0 @@ -"""add sync_configs and sync_filters tables - -Revision ID: c3d4e5f6a7b8 -Revises: b2c3d4e5f6a7 -Create Date: 2026-03-06 00:02:00.000000 - -""" -from alembic import op -import sqlalchemy as sa - -revision = 'c3d4e5f6a7b8' -down_revision = 'b2c3d4e5f6a7' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - 'sync_configs', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='0'), - sa.Column('confirmed_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id'), - ) - op.create_table( - 'sync_filters', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('sync_config_id', sa.Integer(), nullable=False), - sa.Column('entity_type', sa.String(20), nullable=False), - sa.Column('entity_id', sa.String(255), nullable=False), - sa.Column('entity_name', sa.String(255), nullable=True), - sa.Column('filter_mode', sa.String(10), nullable=False), - sa.Column('parent_entity_id', sa.String(255), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.ForeignKeyConstraint(['sync_config_id'], ['sync_configs.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('sync_config_id', 'entity_type', 'entity_id', name='uq_sync_filter'), - ) - - -def downgrade() -> None: - op.drop_table('sync_filters') - op.drop_table('sync_configs') diff --git a/web-python/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py b/web-python/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py deleted file mode 100644 index 24920c3..0000000 --- a/web-python/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py +++ /dev/null @@ -1,70 +0,0 @@ -"""add catalog cache tables - -Revision ID: d4e5f6a7b8c9 -Revises: c3d4e5f6a7b8 -Create Date: 2026-03-06 00:03:00.000000 - -""" -from alembic import op -import sqlalchemy as sa - -revision = 'd4e5f6a7b8c9' -down_revision = 'c3d4e5f6a7b8' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - 'cached_stores', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('evotor_id', sa.String(255), nullable=False), - sa.Column('name', sa.String(255), nullable=False), - sa.Column('address', sa.String(500), nullable=True), - sa.Column('fetched_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_stores'), - ) - op.create_index('ix_cached_stores_user_id', 'cached_stores', ['user_id']) - - op.create_table( - 'cached_groups', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('evotor_id', sa.String(255), nullable=False), - sa.Column('store_evotor_id', sa.String(255), nullable=False), - sa.Column('name', sa.String(255), nullable=False), - sa.Column('fetched_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_groups'), - ) - op.create_index('ix_cached_groups_user_store', 'cached_groups', ['user_id', 'store_evotor_id']) - - op.create_table( - 'cached_products', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('evotor_id', sa.String(255), nullable=False), - sa.Column('store_evotor_id', sa.String(255), nullable=False), - sa.Column('group_evotor_id', sa.String(255), nullable=True), - sa.Column('name', sa.String(255), nullable=False), - sa.Column('price', sa.Numeric(12, 2), nullable=True), - sa.Column('quantity', sa.Numeric(12, 3), nullable=True), - sa.Column('measure_name', sa.String(20), nullable=True), - sa.Column('article_number', sa.String(100), nullable=True), - sa.Column('allow_to_sell', sa.Boolean(), nullable=True), - sa.Column('fetched_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_products'), - ) - op.create_index('ix_cached_products_user_store_group', 'cached_products', ['user_id', 'store_evotor_id', 'group_evotor_id']) - - -def downgrade() -> None: - op.drop_table('cached_products') - op.drop_table('cached_groups') - op.drop_table('cached_stores') diff --git a/web-python/migrations/versions/e5f6a7b8c9d0_add_refresh_token_to_evotor_connections.py b/web-python/migrations/versions/e5f6a7b8c9d0_add_refresh_token_to_evotor_connections.py deleted file mode 100644 index f0ce285..0000000 --- a/web-python/migrations/versions/e5f6a7b8c9d0_add_refresh_token_to_evotor_connections.py +++ /dev/null @@ -1,24 +0,0 @@ -"""add refresh_token and token_expires_at to evotor_connections - -Revision ID: e5f6a7b8c9d0 -Revises: d4e5f6a7b8c9 -Create Date: 2026-03-06 00:04:00.000000 - -""" -from alembic import op -import sqlalchemy as sa - -revision = 'e5f6a7b8c9d0' -down_revision = 'd4e5f6a7b8c9' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column('evotor_connections', sa.Column('refresh_token', sa.Text(), nullable=True)) - op.add_column('evotor_connections', sa.Column('token_expires_at', sa.DateTime(), nullable=True)) - - -def downgrade() -> None: - op.drop_column('evotor_connections', 'token_expires_at') - op.drop_column('evotor_connections', 'refresh_token') diff --git a/web-python/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py b/web-python/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py deleted file mode 100644 index 2d0b305..0000000 --- a/web-python/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py +++ /dev/null @@ -1,53 +0,0 @@ -"""evotor webhook token flow: add evotor_user_id, make user_id nullable - -Revision ID: f6a7b8c9d0e1 -Revises: e5f6a7b8c9d0 -Branch Labels: None -Depends On: None - -""" -from alembic import op -import sqlalchemy as sa - -revision = 'f6a7b8c9d0e1' -down_revision = 'e5f6a7b8c9d0' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - conn = op.get_bind() - - # Check existing columns - columns = [row[0] for row in conn.execute(sa.text( - "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " - "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'evotor_connections'" - ))] - - if 'evotor_user_id' not in columns: - op.add_column('evotor_connections', - sa.Column('evotor_user_id', sa.String(255), nullable=True)) - - # Check existing indexes - indexes = [row[2] for row in conn.execute(sa.text( - "SHOW INDEX FROM evotor_connections" - ))] - - if 'uq_evotor_connections_evotor_user_id' not in indexes: - op.create_unique_constraint('uq_evotor_connections_evotor_user_id', - 'evotor_connections', ['evotor_user_id']) - - if 'ix_evotor_connections_evotor_user_id' not in indexes: - op.create_index('ix_evotor_connections_evotor_user_id', - 'evotor_connections', ['evotor_user_id']) - - op.alter_column('evotor_connections', 'user_id', - existing_type=sa.Integer(), nullable=True) - - -def downgrade() -> None: - op.alter_column('evotor_connections', 'user_id', - existing_type=sa.Integer(), nullable=False) - op.drop_index('ix_evotor_connections_evotor_user_id', 'evotor_connections') - op.drop_constraint('uq_evotor_connections_evotor_user_id', 'evotor_connections') - op.drop_column('evotor_connections', 'evotor_user_id') diff --git a/web-python/models.py b/web-python/models.py deleted file mode 100644 index fcd27b6..0000000 --- a/web-python/models.py +++ /dev/null @@ -1,159 +0,0 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, Numeric, Index -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func - -from web.database import Base - - -class User(Base): - __tablename__ = "users" - - id = Column(Integer, primary_key=True, autoincrement=True) - first_name = Column(String(100), nullable=False) - last_name = Column(String(100), nullable=False) - email = Column(String(255), unique=True, nullable=False, index=True) - phone = Column(String(20), unique=True, nullable=False, index=True) - password_hash = Column(String(255), nullable=False) - is_email_confirmed = Column(Boolean, default=False, nullable=False) - email_confirm_token = Column(String(255), nullable=True) - password_reset_token = Column(String(255), nullable=True) - password_reset_expires = Column(DateTime, nullable=True) - created_at = Column(DateTime, server_default=func.now(), nullable=False) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) - - evotor_connection = relationship("EvotorConnection", back_populates="user", uselist=False) - vk_connection = relationship("VkConnection", back_populates="user", uselist=False) - sync_config = relationship("SyncConfig", back_populates="user", uselist=False) - cached_stores = relationship("CachedStore", back_populates="user", cascade="all, delete-orphan") - cached_groups = relationship("CachedGroup", back_populates="user", cascade="all, delete-orphan") - cached_products = relationship("CachedProduct", back_populates="user", cascade="all, delete-orphan") - - -class EvotorConnection(Base): - __tablename__ = "evotor_connections" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True) - evotor_user_id = Column(String(255), unique=True, nullable=True, index=True) - access_token = Column(Text, nullable=False) - store_id = Column(String(255), nullable=True) - store_name = Column(String(255), nullable=True) - refresh_token = Column(Text, nullable=True) - token_expires_at = Column(DateTime, nullable=True) - is_online = Column(Boolean, default=False, server_default="0", nullable=False) - last_checked_at = Column(DateTime, nullable=True) - connected_at = Column(DateTime, server_default=func.now(), nullable=False) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) - - user = relationship("User", back_populates="evotor_connection") - - -class VkConnection(Base): - __tablename__ = "vk_connections" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False) - access_token = Column(Text, nullable=False) - vk_user_id = Column(String(50), nullable=True) - first_name = Column(String(255), nullable=True) - last_name = Column(String(255), nullable=True) - is_online = Column(Boolean, default=False, server_default="0", nullable=False) - last_checked_at = Column(DateTime, nullable=True) - connected_at = Column(DateTime, server_default=func.now(), nullable=False) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) - - user = relationship("User", back_populates="vk_connection") - - -class SyncConfig(Base): - __tablename__ = "sync_configs" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False) - is_enabled = Column(Boolean, default=False, nullable=False) - confirmed_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, server_default=func.now(), nullable=False) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) - - user = relationship("User", back_populates="sync_config") - filters = relationship("SyncFilter", back_populates="sync_config", cascade="all, delete-orphan") - - -class SyncFilter(Base): - __tablename__ = "sync_filters" - - id = Column(Integer, primary_key=True, autoincrement=True) - sync_config_id = Column(Integer, ForeignKey("sync_configs.id", ondelete="CASCADE"), nullable=False) - entity_type = Column(String(20), nullable=False) # "store", "group", "product" - entity_id = Column(String(255), nullable=False) - entity_name = Column(String(255), nullable=True) - filter_mode = Column(String(10), nullable=False) # "include", "exclude" - parent_entity_id = Column(String(255), nullable=True) - created_at = Column(DateTime, server_default=func.now(), nullable=False) - - __table_args__ = ( - UniqueConstraint("sync_config_id", "entity_type", "entity_id"), - ) - - sync_config = relationship("SyncConfig", back_populates="filters") - - -class CachedStore(Base): - __tablename__ = "cached_stores" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - evotor_id = Column(String(255), nullable=False) - name = Column(String(255), nullable=False) - address = Column(String(500), nullable=True) - fetched_at = Column(DateTime, nullable=False) - - __table_args__ = ( - UniqueConstraint("user_id", "evotor_id"), - Index("ix_cached_stores_user_id", "user_id"), - ) - - user = relationship("User", back_populates="cached_stores") - - -class CachedGroup(Base): - __tablename__ = "cached_groups" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - evotor_id = Column(String(255), nullable=False) - store_evotor_id = Column(String(255), nullable=False) - name = Column(String(255), nullable=False) - fetched_at = Column(DateTime, nullable=False) - - __table_args__ = ( - UniqueConstraint("user_id", "evotor_id"), - Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"), - ) - - user = relationship("User", back_populates="cached_groups") - - -class CachedProduct(Base): - __tablename__ = "cached_products" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - evotor_id = Column(String(255), nullable=False) - store_evotor_id = Column(String(255), nullable=False) - group_evotor_id = Column(String(255), nullable=True) - name = Column(String(255), nullable=False) - price = Column(Numeric(12, 2), nullable=True) - quantity = Column(Numeric(12, 3), nullable=True) - measure_name = Column(String(20), nullable=True) - article_number = Column(String(100), nullable=True) - allow_to_sell = Column(Boolean, nullable=True) - fetched_at = Column(DateTime, nullable=False) - synced_at = Column(DateTime, nullable=True) - - __table_args__ = ( - UniqueConstraint("user_id", "evotor_id"), - Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"), - ) - - user = relationship("User", back_populates="cached_products") diff --git a/web-python/routes/__init__.py b/web-python/routes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/web-python/routes/auth.py b/web-python/routes/auth.py deleted file mode 100644 index ca04473..0000000 --- a/web-python/routes/auth.py +++ /dev/null @@ -1,122 +0,0 @@ -import uuid - -from fastapi import APIRouter, Request, Depends -from fastapi.responses import RedirectResponse -from web.templates_env import templates -from sqlalchemy.orm import Session - -from web.auth import hash_password, verify_password, get_current_user -from web.config import settings -from web.database import get_db -from web.models import User -from web.schemas import validate_registration, validate_login - -router = APIRouter() - - -@router.get("/register") -def register_form(request: Request, user: User | None = Depends(get_current_user)): - if user: - return RedirectResponse("/profile", 303) - return templates.TemplateResponse("register.html", {"request": request, "user": None}) - - -@router.post("/register") -async def register_submit(request: Request, db: Session = Depends(get_db)): - form = await request.form() - data = dict(form) - - errors = validate_registration(data) - - if not errors: - existing = db.query(User).filter( - (User.email == data["email"].strip()) | (User.phone == data["phone"].strip()) - ).first() - if existing: - if existing.email == data["email"].strip(): - errors.append("Пользователь с таким email уже существует") - else: - errors.append("Пользователь с таким телефоном уже существует") - - if errors: - return templates.TemplateResponse("register.html", { - "request": request, "user": None, "errors": errors, "form": data, - }) - - token = uuid.uuid4().hex - user = User( - first_name=data["first_name"].strip(), - last_name=data["last_name"].strip(), - email=data["email"].strip(), - phone=data["phone"].strip(), - password_hash=hash_password(data["password"]), - email_confirm_token=token, - ) - db.add(user) - db.commit() - - confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}" - print("=" * 40) - print("ПОДТВЕРЖДЕНИЕ EMAIL") - print(f"Пользователь: {user.email}") - print(f"Ссылка: {confirm_url}") - print("=" * 40) - - return templates.TemplateResponse("confirm_email.html", {"request": request, "user": None}) - - -@router.get("/confirm-email") -def confirm_email(request: Request, token: str, db: Session = Depends(get_db)): - user = db.query(User).filter(User.email_confirm_token == token).first() - if not user: - return templates.TemplateResponse("message.html", { - "request": request, "user": None, - "title": "Ошибка", "message": "Неверная или устаревшая ссылка.", - }) - - user.is_email_confirmed = True - user.email_confirm_token = None - db.commit() - - return templates.TemplateResponse("email_confirmed.html", {"request": request, "user": None}) - - -@router.get("/login") -def login_form(request: Request, user: User | None = Depends(get_current_user)): - if user: - return RedirectResponse("/profile", 303) - return templates.TemplateResponse("login.html", {"request": request, "user": None}) - - -@router.post("/login") -async def login_submit(request: Request, db: Session = Depends(get_db)): - form = await request.form() - data = dict(form) - - errors = validate_login(data) - if errors: - return templates.TemplateResponse("login.html", { - "request": request, "user": None, "errors": errors, "form": data, - }) - - user = db.query(User).filter(User.email == data["email"].strip()).first() - if not user or not verify_password(data["password"], user.password_hash): - return templates.TemplateResponse("login.html", { - "request": request, "user": None, - "errors": ["Неверный email или пароль"], "form": data, - }) - - if not user.is_email_confirmed: - return templates.TemplateResponse("login.html", { - "request": request, "user": None, - "errors": ["Пожалуйста, подтвердите ваш email"], "form": data, - }) - - request.session["user_id"] = user.id - return RedirectResponse("/profile", 303) - - -@router.get("/logout") -def logout(request: Request): - request.session.clear() - return RedirectResponse("/login", 303) diff --git a/web-python/routes/catalog.py b/web-python/routes/catalog.py deleted file mode 100644 index a60911b..0000000 --- a/web-python/routes/catalog.py +++ /dev/null @@ -1,308 +0,0 @@ -import csv -import io - -from fastapi import APIRouter, Request, Depends -from fastapi.responses import RedirectResponse, StreamingResponse -from web.templates_env import templates -from sqlalchemy.orm import Session - -from web.auth import get_current_user -from web.database import get_db -from web.evotor_api import refresh_catalog_cache -from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct - -router = APIRouter(prefix="/catalog") - - -def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig: - config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first() - if not config: - config = SyncConfig(user_id=user_id, is_enabled=False) - db.add(config) - db.commit() - db.refresh(config) - return config - - -def _filter_map(config: SyncConfig) -> dict: - """Returns {entity_id: filter_mode} for quick lookup.""" - return {f.entity_id: f.filter_mode for f in config.filters} - - -def _filter_label(mode: str | None) -> str: - if mode == "include": - return "include" - if mode == "exclude": - return "exclude" - return "none" - - -@router.get("") -async def catalog_stores( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() - if not evotor: - return templates.TemplateResponse("catalog_stores.html", { - "request": request, "user": user, - "evotor": None, "stores": [], "filter_map": {}, "fetched_at": None, - }) - - stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all() - - # Auto-refresh if cache is empty - if not stores: - await refresh_catalog_cache(user.id, evotor.access_token, db) - stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all() - - config = _get_or_create_sync_config(db, user.id) - fmap = _filter_map(config) - fetched_at = stores[0].fetched_at if stores else None - - return templates.TemplateResponse("catalog_stores.html", { - "request": request, - "user": user, - "evotor": evotor, - "stores": stores, - "filter_map": fmap, - "fetched_at": fetched_at, - }) - - -@router.get("/groups") -def catalog_groups( - request: Request, - store_id: str, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - store = db.query(CachedStore).filter( - CachedStore.user_id == user.id, - CachedStore.evotor_id == store_id, - ).first() - if not store: - return RedirectResponse("/catalog", 303) - - groups = db.query(CachedGroup).filter( - CachedGroup.user_id == user.id, - CachedGroup.store_evotor_id == store_id, - ).order_by(CachedGroup.name).all() - - # Count products per group - product_counts = {} - for g in groups: - product_counts[g.evotor_id] = db.query(CachedProduct).filter( - CachedProduct.user_id == user.id, - CachedProduct.group_evotor_id == g.evotor_id, - ).count() - - config = _get_or_create_sync_config(db, user.id) - fmap = _filter_map(config) - - return templates.TemplateResponse("catalog_groups.html", { - "request": request, - "user": user, - "store": store, - "groups": groups, - "product_counts": product_counts, - "filter_map": fmap, - }) - - -@router.get("/products") -def catalog_products( - request: Request, - store_id: str, - group_id: str | None = None, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - store = db.query(CachedStore).filter( - CachedStore.user_id == user.id, - CachedStore.evotor_id == store_id, - ).first() - if not store: - return RedirectResponse("/catalog", 303) - - group = None - query = db.query(CachedProduct).filter( - CachedProduct.user_id == user.id, - CachedProduct.store_evotor_id == store_id, - ) - if group_id: - group = db.query(CachedGroup).filter( - CachedGroup.user_id == user.id, - CachedGroup.evotor_id == group_id, - ).first() - query = query.filter(CachedProduct.group_evotor_id == group_id) - - products = query.order_by(CachedProduct.name).all() - - config = _get_or_create_sync_config(db, user.id) - fmap = _filter_map(config) - - return templates.TemplateResponse("catalog_products.html", { - "request": request, - "user": user, - "store": store, - "group": group, - "products": products, - "filter_map": fmap, - }) - - -@router.post("/filter") -async def catalog_filter( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - form = await request.form() - entity_type = form.get("entity_type") - entity_id = form.get("entity_id") - entity_name = form.get("entity_name") - filter_mode = form.get("filter_mode") # "include", "exclude", "none" - parent_entity_id = form.get("parent_entity_id") or None - redirect_to = form.get("redirect_to", "/catalog") - - config = _get_or_create_sync_config(db, user.id) - - existing = db.query(SyncFilter).filter( - SyncFilter.sync_config_id == config.id, - SyncFilter.entity_type == entity_type, - SyncFilter.entity_id == entity_id, - ).first() - - if filter_mode == "none": - if existing: - db.delete(existing) - elif existing: - existing.filter_mode = filter_mode - existing.entity_name = entity_name - else: - db.add(SyncFilter( - sync_config_id=config.id, - entity_type=entity_type, - entity_id=entity_id, - entity_name=entity_name, - filter_mode=filter_mode, - parent_entity_id=parent_entity_id, - )) - db.commit() - - return RedirectResponse(redirect_to, 303) - - -@router.post("/refresh") -async def catalog_refresh( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() - if evotor: - await refresh_catalog_cache(user.id, evotor.access_token, db) - - return RedirectResponse("/catalog", 303) - - -@router.get("/export") -def catalog_export( - request: Request, - type: str, - store_id: str | None = None, - group_id: str | None = None, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - config = _get_or_create_sync_config(db, user.id) - fmap = _filter_map(config) - - def filter_label(eid): - m = fmap.get(eid) - if m == "include": - return "Включено" - if m == "exclude": - return "Исключено" - return "Нет правила" - - output = io.StringIO() - output.write("\ufeff") # UTF-8 BOM for Excel - writer = csv.writer(output) - - from datetime import date - today = date.today().strftime("%Y%m%d") - - if type == "stores": - writer.writerow(["Название", "Адрес", "ID", "Фильтр"]) - stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all() - for s in stores: - writer.writerow([s.name, s.address or "", s.evotor_id, filter_label(s.evotor_id)]) - filename = f"stores_{today}.csv" - - elif type == "groups": - writer.writerow(["Магазин", "Название", "ID", "Фильтр"]) - q = db.query(CachedGroup, CachedStore).join( - CachedStore, - (CachedStore.evotor_id == CachedGroup.store_evotor_id) & (CachedStore.user_id == user.id) - ).filter(CachedGroup.user_id == user.id) - if store_id: - q = q.filter(CachedGroup.store_evotor_id == store_id) - for g, s in q.order_by(CachedGroup.name).all(): - writer.writerow([s.name, g.name, g.evotor_id, filter_label(g.evotor_id)]) - filename = f"groups_{today}.csv" - - else: # products - writer.writerow(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"]) - q = db.query(CachedProduct, CachedStore, CachedGroup).join( - CachedStore, - (CachedStore.evotor_id == CachedProduct.store_evotor_id) & (CachedStore.user_id == user.id) - ).outerjoin( - CachedGroup, - (CachedGroup.evotor_id == CachedProduct.group_evotor_id) & (CachedGroup.user_id == user.id) - ).filter(CachedProduct.user_id == user.id) - if store_id: - q = q.filter(CachedProduct.store_evotor_id == store_id) - if group_id: - q = q.filter(CachedProduct.group_evotor_id == group_id) - for p, s, g in q.order_by(CachedProduct.name).all(): - writer.writerow([ - s.name, - g.name if g else "", - p.name, - p.article_number or "", - p.price or "", - p.quantity or "", - p.measure_name or "", - "Да" if p.allow_to_sell else ("Нет" if p.allow_to_sell is not None else ""), - p.evotor_id, - filter_label(p.evotor_id), - ]) - filename = f"products_{today}.csv" - - output.seek(0) - return StreamingResponse( - iter([output.getvalue()]), - media_type="text/csv; charset=utf-8", - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) diff --git a/web-python/routes/connections.py b/web-python/routes/connections.py deleted file mode 100644 index 3cb238e..0000000 --- a/web-python/routes/connections.py +++ /dev/null @@ -1,125 +0,0 @@ -from fastapi import APIRouter, Request, Depends -from fastapi.responses import RedirectResponse -from web.templates_env import templates -from sqlalchemy.orm import Session - -from web.auth import get_current_user -from web.database import get_db -from web.models import User, EvotorConnection, VkConnection - -router = APIRouter() - -SERVICE_TYPES = [ - { - "type": "evotor", - "name": "Эвотор", - "icon": "bi-shop", - "description": "Подключите кассу Эвотор для синхронизации каталога товаров.", - "configure_url": "/evotor", - "connect_url": "/evotor", - }, - { - "type": "vk", - "name": "ВКонтакте", - "icon": "bi-bag", - "description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.", - "configure_url": "/vk", - "connect_url": "/vk", - }, -] - - -def _get_connection(svc_type: str, evotor, vk): - if svc_type == "evotor": - return evotor - if svc_type == "vk": - return vk - return None - - -def _get_details(svc_type: str, conn): - if conn is None: - return None - if svc_type == "evotor": - return conn.store_name - if svc_type == "vk": - return f"{conn.first_name} {conn.last_name}".strip() if conn.first_name else None - return None - - -@router.get("/connections") -def connections_page( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() - vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() - - connected = [] - for svc in SERVICE_TYPES: - conn = _get_connection(svc["type"], evotor, vk) - if conn is not None: - connected.append({ - **svc, - "is_online": conn.is_online, - "last_checked_at": conn.last_checked_at, - "details": _get_details(svc["type"], conn), - }) - - return templates.TemplateResponse("connections.html", { - "request": request, - "user": user, - "connections": connected, - }) - - -@router.get("/connections/add") -def connections_add_page( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() - vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() - - available = [ - svc for svc in SERVICE_TYPES - if _get_connection(svc["type"], evotor, vk) is None - ] - - return templates.TemplateResponse("connections_add.html", { - "request": request, - "user": user, - "available": available, - }) - - -@router.post("/connections/delete") -async def connections_delete( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - svc_type = request.query_params.get("type") - if svc_type == "evotor": - conn = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() - elif svc_type == "vk": - conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() - else: - conn = None - - if conn: - db.delete(conn) - db.commit() - - return RedirectResponse("/connections", 303) diff --git a/web-python/routes/evotor.py b/web-python/routes/evotor.py deleted file mode 100644 index eb01a00..0000000 --- a/web-python/routes/evotor.py +++ /dev/null @@ -1,193 +0,0 @@ -import logging -import httpx - -from datetime import datetime -from fastapi import APIRouter, Request, Depends, HTTPException -from fastapi.responses import RedirectResponse, JSONResponse -from web.templates_env import templates -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from web.auth import get_current_user -from web.config import settings -from web.database import get_db -from web.models import User, EvotorConnection - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/evotor") - -EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}" -EVOTOR_STORES_URL = "https://api.evotor.ru/stores" - - -@router.get("") -def evotor_page( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() - error = request.query_params.get("error") - app_url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID) if settings.EVOTOR_APP_ID else None - return templates.TemplateResponse("evotor.html", { - "request": request, - "user": user, - "connection": connection, - "error": error, - "app_url": app_url, - }) - - -class EvotorTokenPayload(BaseModel): - userId: str - token: str - - -@router.post("/callback") -async def evotor_callback( - request: Request, - payload: EvotorTokenPayload, - db: Session = Depends(get_db), -): - """ - Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."} here - after the user authorizes the app in their Evotor account. - """ - # Verify the Authorization header matches our configured webhook secret - if settings.EVOTOR_WEBHOOK_SECRET: - auth_header = request.headers.get("Authorization", "") - expected = f"Bearer {settings.EVOTOR_WEBHOOK_SECRET}" - if auth_header != expected: - logger.warning("Evotor webhook: invalid Authorization header") - raise HTTPException(status_code=401, detail="Unauthorized") - - now = datetime.utcnow() - - # Fetch store info using the received token - store_id = None - store_name = None - try: - async with httpx.AsyncClient() as client: - stores_response = await client.get( - EVOTOR_STORES_URL, - headers={"Authorization": f"Bearer {payload.token}"}, - timeout=15, - ) - if stores_response.status_code == 200: - stores = stores_response.json() - items = stores.get("items", stores) if isinstance(stores, dict) else stores - if items: - store_id = items[0].get("uuid") or items[0].get("id") - store_name = items[0].get("name") - except Exception: - pass # Store info is optional - - # Upsert by evotor_user_id (user_id stays NULL until /evotor/link is called) - connection = db.query(EvotorConnection).filter( - EvotorConnection.evotor_user_id == payload.userId - ).first() - - if connection: - connection.access_token = payload.token - connection.store_id = store_id - connection.store_name = store_name - connection.is_online = True - connection.last_checked_at = now - connection.updated_at = now - else: - connection = EvotorConnection( - evotor_user_id=payload.userId, - access_token=payload.token, - store_id=store_id, - store_name=store_name, - is_online=True, - last_checked_at=now, - ) - db.add(connection) - - db.commit() - logger.info("Evotor webhook: saved token for evotor_user_id=%s", payload.userId) - - return JSONResponse({"status": "ok"}) - - -@router.post("/token") -async def evotor_token_manual( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - """Allow user to manually paste their Evotor token.""" - if not user: - return RedirectResponse("/login", 303) - - form = await request.form() - token = (form.get("token") or "").strip() - if not token: - return RedirectResponse("/evotor?error=empty_token", 303) - - now = datetime.utcnow() - - # Fetch store info - store_id = None - store_name = None - try: - async with httpx.AsyncClient() as client: - stores_response = await client.get( - EVOTOR_STORES_URL, - headers={"Authorization": f"Bearer {token}"}, - timeout=15, - ) - if stores_response.status_code == 200: - stores = stores_response.json() - items = stores.get("items", stores) if isinstance(stores, dict) else stores - if items: - store_id = items[0].get("uuid") or items[0].get("id") - store_name = items[0].get("name") - elif stores_response.status_code == 401: - return RedirectResponse("/evotor?error=invalid_token", 303) - except Exception: - pass - - connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() - if connection: - connection.access_token = token - connection.store_id = store_id - connection.store_name = store_name - connection.is_online = True - connection.last_checked_at = now - connection.updated_at = now - else: - connection = EvotorConnection( - user_id=user.id, - access_token=token, - store_id=store_id, - store_name=store_name, - is_online=True, - last_checked_at=now, - ) - db.add(connection) - db.commit() - - return RedirectResponse("/connections", 303) - - -@router.post("/disconnect") -async def evotor_disconnect( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() - if connection: - db.delete(connection) - db.commit() - - return RedirectResponse("/connections", 303) diff --git a/web-python/routes/profile.py b/web-python/routes/profile.py deleted file mode 100644 index 9e813be..0000000 --- a/web-python/routes/profile.py +++ /dev/null @@ -1,144 +0,0 @@ -from fastapi import APIRouter, Request, Depends -from fastapi.responses import RedirectResponse -from web.templates_env import templates -from sqlalchemy.orm import Session - -from web.auth import get_current_user, verify_password, hash_password -from web.database import get_db -from web.models import User -from web.schemas import validate_profile, validate_reset_password - -router = APIRouter() - - -# VIEW PROFILE -@router.get("/profile") -def profile_view(request: Request, user: User | None = Depends(get_current_user)): - if not user: - return RedirectResponse("/login", 303) - return templates.TemplateResponse("profile_view.html", {"request": request, "user": user}) - - -# EDIT PROFILE -@router.get("/profile/edit") -def profile_edit_form(request: Request, user: User | None = Depends(get_current_user)): - if not user: - return RedirectResponse("/login", 303) - return templates.TemplateResponse("profile_edit.html", {"request": request, "user": user}) - - -@router.post("/profile/edit") -async def profile_edit_submit( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - form = await request.form() - data = dict(form) - - errors = validate_profile(data) - - if not errors: - existing = db.query(User).filter( - User.phone == data["phone"].strip(), User.id != user.id - ).first() - if existing: - errors.append("Пользователь с таким телефоном уже существует") - - if errors: - return templates.TemplateResponse("profile_edit.html", { - "request": request, "user": user, "errors": errors, "form": data, - }) - - user.first_name = data["first_name"].strip() - user.last_name = data["last_name"].strip() - user.phone = data["phone"].strip() - db.commit() - - return templates.TemplateResponse("profile_edit.html", { - "request": request, "user": user, "success": "Профиль обновлен", - }) - - -# CHANGE PASSWORD -@router.get("/profile/change-password") -def change_password_form(request: Request, user: User | None = Depends(get_current_user)): - if not user: - return RedirectResponse("/login", 303) - return templates.TemplateResponse("profile_change_password.html", {"request": request, "user": user}) - - -@router.post("/profile/change-password") -async def change_password_submit( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - form = await request.form() - data = dict(form) - - errors = [] - current_password = data.get("current_password", "") - if not current_password: - errors.append("Введите текущий пароль") - elif not verify_password(current_password, user.password_hash): - errors.append("Неверный текущий пароль") - - password_errors = validate_reset_password(data) - errors.extend(password_errors) - - if errors: - return templates.TemplateResponse("profile_change_password.html", { - "request": request, "user": user, "errors": errors, - }) - - user.password_hash = hash_password(data["password"]) - db.commit() - - return templates.TemplateResponse("profile_change_password.html", { - "request": request, "user": user, "success": "Пароль изменен", - }) - - -# DELETE ACCOUNT -@router.get("/profile/delete") -def delete_account_form(request: Request, user: User | None = Depends(get_current_user)): - if not user: - return RedirectResponse("/login", 303) - return templates.TemplateResponse("profile_delete.html", {"request": request, "user": user}) - - -@router.post("/profile/delete") -async def delete_account_submit( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - form = await request.form() - data = dict(form) - - password = data.get("password", "") - if not password: - return templates.TemplateResponse("profile_delete.html", { - "request": request, "user": user, "errors": ["Введите пароль для подтверждения"], - }) - - if not verify_password(password, user.password_hash): - return templates.TemplateResponse("profile_delete.html", { - "request": request, "user": user, "errors": ["Неверный пароль"], - }) - - db.delete(user) - db.commit() - request.session.clear() - - return RedirectResponse("/", 303) diff --git a/web-python/routes/reset.py b/web-python/routes/reset.py deleted file mode 100644 index 7d290db..0000000 --- a/web-python/routes/reset.py +++ /dev/null @@ -1,107 +0,0 @@ -import uuid -from datetime import datetime, timedelta, timezone - -from fastapi import APIRouter, Request, Depends -from fastapi.responses import RedirectResponse -from web.templates_env import templates -from sqlalchemy.orm import Session - -from web.auth import hash_password -from web.config import settings -from web.database import get_db -from web.models import User -from web.schemas import validate_reset_password - -router = APIRouter() - - -@router.get("/forgot-password") -def forgot_form(request: Request): - return templates.TemplateResponse("forgot_password.html", {"request": request, "user": None}) - - -@router.post("/forgot-password") -async def forgot_submit(request: Request, db: Session = Depends(get_db)): - form = await request.form() - email = form.get("email", "").strip() - - if email: - user = db.query(User).filter(User.email == email).first() - if user: - token = uuid.uuid4().hex - user.password_reset_token = token - user.password_reset_expires = datetime.now(timezone.utc) + timedelta( - minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES - ) - db.commit() - - reset_url = f"{settings.BASE_URL}/reset-password?token={token}" - print("=" * 40) - print("СБРОС ПАРОЛЯ") - print(f"Пользователь: {user.email}") - print(f"Ссылка: {reset_url}") - print(f"Действительна: {settings.PASSWORD_RESET_EXPIRE_MINUTES} мин.") - print("=" * 40) - - return templates.TemplateResponse("message.html", { - "request": request, "user": None, - "title": "Сброс пароля", - "message": "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.", - }) - - -@router.get("/reset-password") -def reset_form(request: Request, token: str, db: Session = Depends(get_db)): - user = db.query(User).filter(User.password_reset_token == token).first() - if not user or not user.password_reset_expires: - return templates.TemplateResponse("message.html", { - "request": request, "user": None, - "title": "Ошибка", "message": "Неверная или устаревшая ссылка.", - }) - - if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc): - return templates.TemplateResponse("message.html", { - "request": request, "user": None, - "title": "Ошибка", "message": "Срок действия ссылки истек.", - }) - - return templates.TemplateResponse("reset_password.html", { - "request": request, "user": None, "token": token, - }) - - -@router.post("/reset-password") -async def reset_submit(request: Request, token: str, db: Session = Depends(get_db)): - user = db.query(User).filter(User.password_reset_token == token).first() - if not user or not user.password_reset_expires: - return templates.TemplateResponse("message.html", { - "request": request, "user": None, - "title": "Ошибка", "message": "Неверная или устаревшая ссылка.", - }) - - if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc): - return templates.TemplateResponse("message.html", { - "request": request, "user": None, - "title": "Ошибка", "message": "Срок действия ссылки истек.", - }) - - form = await request.form() - data = dict(form) - errors = validate_reset_password(data) - - if errors: - return templates.TemplateResponse("reset_password.html", { - "request": request, "user": None, "token": token, "errors": errors, - }) - - user.password_hash = hash_password(data["password"]) - user.password_reset_token = None - user.password_reset_expires = None - db.commit() - - return templates.TemplateResponse("message.html", { - "request": request, "user": None, - "title": "Пароль изменен", - "message": "Ваш пароль успешно изменен. Теперь вы можете войти.", - "link": "/login", "link_text": "Войти", - }) diff --git a/web-python/routes/sync.py b/web-python/routes/sync.py deleted file mode 100644 index b2930c8..0000000 --- a/web-python/routes/sync.py +++ /dev/null @@ -1,101 +0,0 @@ -from datetime import datetime - -from fastapi import APIRouter, Request, Depends -from fastapi.responses import RedirectResponse -from web.templates_env import templates -from sqlalchemy.orm import Session - -from web.auth import get_current_user -from web.database import get_db -from web.models import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter - -router = APIRouter(prefix="/sync") - - -def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig: - config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first() - if not config: - config = SyncConfig(user_id=user_id, is_enabled=False) - db.add(config) - db.commit() - db.refresh(config) - return config - - -def _filter_summary(config: SyncConfig) -> dict: - stores = [f for f in config.filters if f.entity_type == "store"] - groups = [f for f in config.filters if f.entity_type == "group"] - products = [f for f in config.filters if f.entity_type == "product"] - return { - "stores": len(stores), - "groups": len(groups), - "products": len(products), - "total": len(config.filters), - } - - -@router.get("") -def sync_page( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() - vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() - config = _get_or_create_sync_config(db, user.id) - summary = _filter_summary(config) - - if config.confirmed_at and config.is_enabled: - status = "active" - elif config.confirmed_at and not config.is_enabled: - status = "paused" - elif summary["total"] > 0: - status = "pending" - else: - status = "unconfigured" - - return templates.TemplateResponse("sync.html", { - "request": request, - "user": user, - "evotor": evotor, - "vk": vk, - "config": config, - "summary": summary, - "status": status, - }) - - -@router.post("/toggle") -def sync_toggle( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - config = _get_or_create_sync_config(db, user.id) - config.is_enabled = not config.is_enabled - db.commit() - - return RedirectResponse("/sync", 303) - - -@router.post("/confirm") -def sync_confirm( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - config = _get_or_create_sync_config(db, user.id) - if config.is_enabled and len(config.filters) > 0: - config.confirmed_at = datetime.utcnow() - db.commit() - - return RedirectResponse("/sync", 303) diff --git a/web-python/routes/vk.py b/web-python/routes/vk.py deleted file mode 100644 index 2ce93c8..0000000 --- a/web-python/routes/vk.py +++ /dev/null @@ -1,168 +0,0 @@ -from datetime import datetime -from urllib.parse import urlencode - -import httpx - -from fastapi import APIRouter, Request, Depends -from fastapi.responses import RedirectResponse -from web.templates_env import templates -from sqlalchemy.orm import Session - -from web.auth import get_current_user -from web.config import settings -from web.database import get_db -from web.models import User, VkConnection - -router = APIRouter(prefix="/vk") - -VK_API_URL = "https://api.vk.com/method" -VK_OAUTH_URL = "https://oauth.vk.com/authorize" - - -async def _fetch_group_info(token: str) -> tuple[str | None, str | None]: - """Returns (group_id, group_name) for the first admin group, or (None, None).""" - try: - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{VK_API_URL}/groups.get", - params={ - "access_token": token, - "v": settings.VK_API_VERSION, - "filter": "admin", - "extended": 1, - "count": 1, - }, - timeout=15, - ) - if resp.status_code == 200: - data = resp.json() - if "error" not in data: - items = data.get("response", {}).get("items", []) - if items: - return str(items[0].get("id", "")), items[0].get("name") - except Exception: - pass - return None, None - - -def _save_connection(db: Session, user_id: int, token: str, - group_id: str | None, group_name: str | None) -> None: - now = datetime.utcnow() - connection = db.query(VkConnection).filter(VkConnection.user_id == user_id).first() - if connection: - connection.access_token = token - connection.vk_user_id = group_id - connection.first_name = group_name - connection.last_name = None - connection.is_online = True - connection.last_checked_at = now - else: - db.add(VkConnection( - user_id=user_id, - access_token=token, - vk_user_id=group_id, - first_name=group_name, - last_name=None, - is_online=True, - last_checked_at=now, - )) - db.commit() - - -@router.get("") -def vk_page( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() - error = request.query_params.get("error") - return templates.TemplateResponse("vk.html", { - "request": request, - "user": user, - "connection": connection, - "error": error, - "vk_client_id": settings.VK_CLIENT_ID, - "callback_url": f"{settings.BASE_URL}/vk/callback", - }) - - -@router.get("/connect") -def vk_connect( - request: Request, - user: User | None = Depends(get_current_user), -): - """Redirect to VK OAuth authorization page.""" - if not user: - return RedirectResponse("/login", 303) - - if not settings.VK_CLIENT_ID: - return RedirectResponse("/vk?error=no_client_id", 303) - - params = urlencode({ - "client_id": settings.VK_CLIENT_ID, - "scope": "market,groups", - "redirect_uri": f"{settings.BASE_URL}/vk/callback", - "display": "page", - "response_type": "token", - "v": settings.VK_API_VERSION, - }) - return RedirectResponse(f"{VK_OAUTH_URL}?{params}", 302) - - -@router.get("/callback") -def vk_callback( - request: Request, - user: User | None = Depends(get_current_user), -): - """Landing page after VK OAuth. JS reads the token from the URL fragment and POSTs it.""" - if not user: - return RedirectResponse("/login", 303) - - return templates.TemplateResponse("vk_callback.html", { - "request": request, - "user": user, - }) - - -@router.post("/token") -async def vk_token( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - """Save a VK user access token (from manual entry or OAuth callback).""" - if not user: - return RedirectResponse("/login", 303) - - form = await request.form() - token = (form.get("token") or "").strip() - if not token: - return RedirectResponse("/vk?error=empty_token", 303) - - group_id, group_name = await _fetch_group_info(token) - if not group_id: - return RedirectResponse("/vk?error=invalid_token", 303) - - _save_connection(db, user.id, token, group_id, group_name) - return RedirectResponse("/connections", 303) - - -@router.post("/disconnect") -async def vk_disconnect( - request: Request, - db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), -): - if not user: - return RedirectResponse("/login", 303) - - connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() - if connection: - db.delete(connection) - db.commit() - - return RedirectResponse("/connections", 303) diff --git a/web-python/schemas.py b/web-python/schemas.py deleted file mode 100644 index cb1cc70..0000000 --- a/web-python/schemas.py +++ /dev/null @@ -1,52 +0,0 @@ -import re - - -def validate_registration(data: dict) -> list[str]: - errors = [] - if not data.get("first_name", "").strip(): - errors.append("Введите имя") - if not data.get("last_name", "").strip(): - errors.append("Введите фамилию") - email = data.get("email", "").strip() - if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email): - errors.append("Введите корректный email") - phone = data.get("phone", "").strip() - if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone): - errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX") - password = data.get("password", "") - if len(password) < 8: - errors.append("Пароль должен быть не менее 8 символов") - if password != data.get("password_confirm", ""): - errors.append("Пароли не совпадают") - return errors - - -def validate_login(data: dict) -> list[str]: - errors = [] - if not data.get("email", "").strip(): - errors.append("Введите email") - if not data.get("password", ""): - errors.append("Введите пароль") - return errors - - -def validate_reset_password(data: dict) -> list[str]: - errors = [] - password = data.get("password", "") - if len(password) < 8: - errors.append("Пароль должен быть не менее 8 символов") - if password != data.get("password_confirm", ""): - errors.append("Пароли не совпадают") - return errors - - -def validate_profile(data: dict) -> list[str]: - errors = [] - if not data.get("first_name", "").strip(): - errors.append("Введите имя") - if not data.get("last_name", "").strip(): - errors.append("Введите фамилию") - phone = data.get("phone", "").strip() - if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone): - errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX") - return errors diff --git a/web-python/static/style.css b/web-python/static/style.css deleted file mode 100644 index 6270d70..0000000 --- a/web-python/static/style.css +++ /dev/null @@ -1,39 +0,0 @@ -/* Brand overrides */ -:root { - --bs-primary: #F05023; - --bs-primary-rgb: 240, 80, 35; - --bs-link-color: #0986E2; - --bs-link-hover-color: #0670c0; -} - -.brand-logo { - font-size: 22px; - font-weight: 700; - color: #F05023 !important; -} - -.brand-border { - border-color: #F05023 !important; -} - -.btn-primary { - --bs-btn-bg: #F05023; - --bs-btn-border-color: #F05023; - --bs-btn-hover-bg: #d44420; - --bs-btn-hover-border-color: #d44420; - --bs-btn-active-bg: #c03d1c; - --bs-btn-active-border-color: #c03d1c; -} - -.btn-secondary { - --bs-btn-bg: #0986E2; - --bs-btn-border-color: #0986E2; - --bs-btn-hover-bg: #0770c0; - --bs-btn-hover-border-color: #0770c0; - --bs-btn-active-bg: #065fa3; - --bs-btn-active-border-color: #065fa3; -} - -.nav-link:hover { - color: #F05023 !important; -} diff --git a/web-python/sync_engine.py b/web-python/sync_engine.py deleted file mode 100644 index 8b40f84..0000000 --- a/web-python/sync_engine.py +++ /dev/null @@ -1,485 +0,0 @@ -""" -Sync engine: syncs Evotor products to VK market for all enabled users. -Runs as a background asyncio loop inside the web app. -""" - -import asyncio -import logging -from datetime import datetime - -import httpx -from sqlalchemy.orm import Session - -from web.database import SessionLocal -from web.models import CachedProduct, EvotorConnection, VkConnection, SyncConfig, SyncFilter - -logger = logging.getLogger("uvicorn.error") - -VK_API_HOST = "https://api.vk.ru/method" -VK_API_VERSION = "5.199" -EVOTOR_API_BASE = "https://api.evotor.ru" -VK_CATEGORY_ID = 40932 -VK_STOCK_AMOUNT = 1000 -WEIGHT_PRICE_MULTIPLIER = 10 -WEIGHT_MEASURES = {"г", "г.", "грамм", "граммов", "гр", "гр."} - - -def _is_weight_measure(measure: str | None) -> bool: - if not measure: - return False - return measure.strip().lower() in WEIGHT_MEASURES - - -def _normalize_name(name: str) -> str: - return name.strip().replace(";", ",") - - -def _calc_price(price_kopecks, measure: str | None) -> tuple[int, str]: - """Returns (price_in_kopecks_for_vk, price_info_label).""" - base = int(price_kopecks or 0) - if _is_weight_measure(measure): - return base * WEIGHT_PRICE_MULTIPLIER, f"{WEIGHT_PRICE_MULTIPLIER}{measure}" - return base, measure or "" - - -def _build_description(name: str, price_info: str, extra_desc: str | None) -> str: - desc = f"{name} (цена за {price_info}.)\n\n" - if extra_desc: - desc += extra_desc - return desc - - -def _get_included_store_ids(filters: list) -> list[str]: - return [f.entity_id for f in filters if f.entity_type == "store" and f.filter_mode == "include"] - - -def _is_group_included(group_id: str | None, filters: list) -> bool: - """Returns True if the group should be synced based on filters.""" - group_filters = {f.entity_id: f.filter_mode for f in filters if f.entity_type == "group"} - if not group_filters: - return True # no group filters → include all - mode = group_filters.get(group_id) - if mode == "exclude": - return False - if mode == "include": - return True - # Not mentioned — include if there are only excludes, exclude if there are only includes - has_includes = any(v == "include" for v in group_filters.values()) - return not has_includes - - -def _is_product_included(product_id: str, filters: list) -> bool: - product_filters = {f.entity_id: f.filter_mode for f in filters if f.entity_type == "product"} - if not product_filters: - return True - mode = product_filters.get(product_id) - if mode == "exclude": - return False - if mode == "include": - return True - has_includes = any(v == "include" for v in product_filters.values()) - return not has_includes - - -# --------------------------------------------------------------------------- -# Evotor API helpers -# --------------------------------------------------------------------------- - -async def _evo_fetch_products(token: str, store_id: str) -> list[dict]: - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{EVOTOR_API_BASE}/stores/{store_id}/products", - headers={"Authorization": f"Bearer {token}"}, - timeout=30, - ) - if resp.status_code in (402, 404): - return [] - resp.raise_for_status() - data = resp.json() - return data.get("items", data) if isinstance(data, dict) else data - - -async def _evo_fetch_groups(token: str, store_id: str) -> list[dict]: - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups", - headers={"Authorization": f"Bearer {token}"}, - timeout=30, - ) - if resp.status_code in (402, 404): - return [] - resp.raise_for_status() - data = resp.json() - return data.get("items", data) if isinstance(data, dict) else data - - -# --------------------------------------------------------------------------- -# VK API helpers -# --------------------------------------------------------------------------- - -def _vk_params(token: str, **extra) -> dict: - return {"access_token": token, "v": VK_API_VERSION, **extra} - - -async def _vk_get_albums(token: str, owner_id: str) -> list[dict]: - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{VK_API_HOST}/market.getAlbums", - params=_vk_params(token, owner_id=owner_id, count=200), - timeout=30, - ) - resp.raise_for_status() - data = resp.json() - return data.get("response", {}).get("items", []) - - -async def _vk_create_album(token: str, owner_id: str, title: str) -> int | None: - async with httpx.AsyncClient() as client: - resp = await client.post( - f"{VK_API_HOST}/market.addAlbum", - data=_vk_params(token, owner_id=owner_id, title=title), - timeout=30, - ) - resp.raise_for_status() - data = resp.json() - if "error" in data: - logger.error("VK create album error: %s", data["error"]) - return None - return data.get("response", {}).get("market_album_id") - - -async def _vk_get_products(token: str, owner_id: str) -> list[dict]: - """Fetch all VK market items (handles pagination).""" - items = [] - offset = 0 - count = 200 - async with httpx.AsyncClient() as client: - while True: - resp = await client.get( - f"{VK_API_HOST}/market.get", - params=_vk_params(token, owner_id=owner_id, extended=1, - with_disabled=1, count=count, offset=offset), - timeout=30, - ) - resp.raise_for_status() - data = resp.json() - batch = data.get("response", {}).get("items", []) - items.extend(batch) - if len(batch) < count: - break - offset += count - return items - - -async def _vk_upload_photo(token: str, group_id: str, photo_path: str) -> str | None: - """Upload a photo and return photo_id.""" - async with httpx.AsyncClient() as client: - # Get upload URL - resp = await client.get( - f"{VK_API_HOST}/market.getProductPhotoUploadServer", - params=_vk_params(token, group_id=group_id), - timeout=30, - ) - resp.raise_for_status() - data = resp.json() - if "error" in data: - logger.error("VK get upload URL error: %s", data["error"]) - return None - upload_url = data.get("response", {}).get("upload_url") - if not upload_url: - return None - - # Upload photo - with open(photo_path, "rb") as f: - upload_resp = await client.post(upload_url, files={"file": f}, timeout=60) - upload_resp.raise_for_status() - upload_obj = upload_resp.json() - - # Save photo - save_resp = await client.post( - f"{VK_API_HOST}/market.saveProductPhoto", - data=_vk_params(token, upload_response=upload_resp.text), - timeout=30, - ) - save_resp.raise_for_status() - save_data = save_resp.json() - if "error" in save_data: - logger.error("VK save photo error: %s", save_data["error"]) - return None - return save_data.get("response", {}).get("photo_id") - - -async def _vk_create_product( - token: str, owner_id: str, name: str, description: str, - price: int, stock_amount: int, photo_id: str, album_id: int | None, -) -> int | None: - params = _vk_params( - token, - owner_id=owner_id, - name=name, - description=description, - category_id=VK_CATEGORY_ID, - price=price, - main_photo_id=photo_id, - stock_amount=stock_amount, - ) - async with httpx.AsyncClient() as client: - resp = await client.post(f"{VK_API_HOST}/market.add", data=params, timeout=30) - resp.raise_for_status() - data = resp.json() - if "error" in data: - logger.error("VK create product error: %s", data["error"]) - return None - product_id = data.get("response", {}).get("market_item_id") - if product_id and album_id: - await client.get( - f"{VK_API_HOST}/market.addToAlbum", - params=_vk_params(token, owner_id=owner_id, - item_ids=product_id, album_ids=album_id), - timeout=30, - ) - return product_id - - -async def _vk_edit_product( - token: str, owner_id: str, item_id: int, name: str, - description: str, price: int, stock_amount: int, -) -> None: - params = _vk_params( - token, - owner_id=owner_id, - item_id=item_id, - name=name, - description=description, - category_id=VK_CATEGORY_ID, - price=price, - stock_amount=stock_amount, - ) - async with httpx.AsyncClient() as client: - resp = await client.post(f"{VK_API_HOST}/market.edit", data=params, timeout=30) - resp.raise_for_status() - data = resp.json() - if "error" in data: - logger.error("VK edit product error: %s", data["error"]) - - -async def _vk_delete_product(token: str, owner_id: str, item_id: int) -> None: - async with httpx.AsyncClient() as client: - resp = await client.post( - f"{VK_API_HOST}/market.delete", - data=_vk_params(token, owner_id=owner_id, item_id=item_id), - timeout=30, - ) - resp.raise_for_status() - - -# --------------------------------------------------------------------------- -# Main sync logic per user -# --------------------------------------------------------------------------- - -def _stamp_synced(db: Session, user_id: int, evo_id: str, now: datetime) -> None: - db.query(CachedProduct).filter( - CachedProduct.user_id == user_id, - CachedProduct.evotor_id == evo_id, - ).update({"synced_at": now}) - db.commit() - - -async def sync_user( - user_id: int, - evo_token: str, - vk_token: str, - vk_group_id: str, - filters: list, - photo_path: str, - db: Session, -) -> None: - owner_id = f"-{vk_group_id}" - now = datetime.utcnow() - logger.info("Sync start: user_id=%d vk_group=%s", user_id, vk_group_id) - - store_ids = _get_included_store_ids(filters) - if not store_ids: - logger.info("Sync skip: user_id=%d — no stores included in filters", user_id) - return - - # Collect all Evotor products and groups across included stores - evo_products: list[dict] = [] - groups_by_id: dict[str, dict] = {} - - for store_id in store_ids: - raw_groups = await _evo_fetch_groups(evo_token, store_id) - for g in raw_groups: - gid = g.get("uuid") or g.get("id") - if gid: - groups_by_id[gid] = g - - raw_products = await _evo_fetch_products(evo_token, store_id) - for p in raw_products: - pid = p.get("uuid") or p.get("id") - gid = p.get("parentUuid") or p.get("parent_id") - if not _is_group_included(gid, filters): - continue - if not _is_product_included(pid, filters): - continue - evo_products.append(p) - - # Build evo product lookup by normalized name - # {normalized_name: product_dict} - evo_by_name: dict[str, dict] = {} - for p in evo_products: - raw_name = (p.get("name") or "").strip() - norm = _normalize_name(raw_name) - evo_by_name[norm] = p - - # Fetch VK state - vk_products = await _vk_get_products(vk_token, owner_id) - vk_albums = await _vk_get_albums(vk_token, owner_id) - vk_album_by_title: dict[str, dict] = {a["title"]: a for a in vk_albums} - - # Ensure albums exist for all included groups - for gid, group in groups_by_id.items(): - if not _is_group_included(gid, filters): - continue - title = group.get("name", "") - if title and title not in vk_album_by_title: - new_album_id = await _vk_create_album(vk_token, owner_id, title) - if new_album_id: - vk_album_by_title[title] = {"id": new_album_id, "title": title} - logger.info("Created VK album '%s' for user_id=%d", title, user_id) - - # Build VK product lookup by normalized name - # {normalized_name: [vk_item, ...]} - vk_by_name: dict[str, list[dict]] = {} - for item in vk_products: - norm = _normalize_name(item.get("title", "")) - vk_by_name.setdefault(norm, []).append(item) - - # --- UPDATE / CREATE --- - for norm_name, evo_p in evo_by_name.items(): - evo_id = evo_p.get("uuid") or evo_p.get("id") - raw_name = (evo_p.get("name") or "").strip() - name_for_vk = _normalize_name(raw_name) - measure = evo_p.get("measureName") or evo_p.get("measure_name") - raw_price = evo_p.get("price") or 0 - price, price_info = _calc_price(raw_price, measure) - allow_to_sell = evo_p.get("allowToSell") if evo_p.get("allowToSell") is not None else evo_p.get("allow_to_sell") - stock_amount = VK_STOCK_AMOUNT if allow_to_sell else 0 - extra_desc = evo_p.get("description") or "" - description = _build_description(raw_name, price_info, extra_desc).strip() - - gid = evo_p.get("parentUuid") or evo_p.get("parent_id") - group_name = groups_by_id.get(gid, {}).get("name") if gid else None - album = vk_album_by_title.get(group_name) if group_name else None - album_id = album["id"] if album else None - - if norm_name in vk_by_name: - # Update existing (use first match) - vk_item = vk_by_name[norm_name][0] - vk_id = vk_item["id"] - orig_price = vk_item.get("price", {}).get("amount", 0) - orig_price_int = int(orig_price) if orig_price else 0 - orig_desc = (vk_item.get("description") or "").strip() - orig_stock = vk_item.get("stock_amount", 0) - - price_changed = price != orig_price_int - desc_changed = description != orig_desc - stock_changed = stock_amount != orig_stock - - if price_changed or desc_changed or stock_changed: - logger.info( - "Updating VK product '%s' user_id=%d (price=%s desc=%s stock=%s)", - name_for_vk, user_id, price_changed, desc_changed, stock_changed, - ) - await _vk_edit_product( - vk_token, owner_id, vk_id, name_for_vk, description, price, stock_amount, - ) - _stamp_synced(db, user_id, evo_id, now) - else: - # Create new (only if allow_to_sell) - if not allow_to_sell: - continue - photo_id = await _vk_upload_photo(vk_token, vk_group_id, photo_path) - if not photo_id: - logger.error("Skipping product '%s' — photo upload failed", name_for_vk) - continue - logger.info("Creating VK product '%s' user_id=%d", name_for_vk, user_id) - created = await _vk_create_product( - vk_token, owner_id, name_for_vk, description, - price, stock_amount, photo_id, album_id, - ) - if created: - _stamp_synced(db, user_id, evo_id, now) - - # --- DELETE products in VK that are no longer in Evo --- - for norm_name, vk_items in vk_by_name.items(): - if norm_name in evo_by_name: - # Delete duplicates (keep first) - for dup in vk_items[1:]: - logger.info("Deleting duplicate VK product '%s' id=%d user_id=%d", - norm_name, dup["id"], user_id) - await _vk_delete_product(vk_token, owner_id, dup["id"]) - else: - # Delete all — product removed from Evo - for item in vk_items: - logger.info("Deleting removed product '%s' id=%d user_id=%d", - norm_name, item["id"], user_id) - await _vk_delete_product(vk_token, owner_id, item["id"]) - - logger.info("Sync complete: user_id=%d", user_id) - - -# --------------------------------------------------------------------------- -# Background loop -# --------------------------------------------------------------------------- - -async def run_sync() -> None: - from web.config import settings - - db = SessionLocal() - try: - configs = db.query(SyncConfig).filter( - SyncConfig.is_enabled == True, - SyncConfig.confirmed_at != None, - ).all() - - for config in configs: - user_id = config.user_id - evo = db.query(EvotorConnection).filter( - EvotorConnection.user_id == user_id - ).first() - vk = db.query(VkConnection).filter( - VkConnection.user_id == user_id - ).first() - - if not evo or not vk: - continue - if not evo.access_token or not vk.access_token: - continue - if not vk.vk_user_id: - continue - - try: - await sync_user( - user_id=user_id, - evo_token=evo.access_token, - vk_token=vk.access_token, - vk_group_id=vk.vk_user_id, - filters=config.filters, - photo_path=settings.VK_DEFAULT_PHOTO_PATH, - db=db, - ) - except Exception: - logger.exception("Sync failed for user_id=%d", user_id) - - except Exception: - logger.exception("Error in sync runner") - db.rollback() - finally: - db.close() - - -async def sync_loop(interval: int) -> None: - while True: - await run_sync() - await asyncio.sleep(interval) diff --git a/web-python/templates/base.html b/web-python/templates/base.html deleted file mode 100644 index 9f81b48..0000000 --- a/web-python/templates/base.html +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - {% block title %}ЭВОСИНК{% endblock %} - - - - - - - -
- {% if errors %} -
- {% for error in errors %} -

{{ error }}

- {% endfor %} -
- {% endif %} - - {% if success %} -
-

{{ success }}

-
- {% endif %} - - {% block content %}{% endblock %} -
- - {% if jivosite_widget_id %} - - {% endif %} - - - - - - diff --git a/web-python/templates/catalog_groups.html b/web-python/templates/catalog_groups.html deleted file mode 100644 index bb0b3ce..0000000 --- a/web-python/templates/catalog_groups.html +++ /dev/null @@ -1,108 +0,0 @@ -{% extends "base.html" %} -{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %} - -{% block content %} - - -
-

Группы товаров

- - Экспорт CSV - -
- -{% if not groups %} -
- -

Группы не найдены в этом магазине.

- - Посмотреть все товары магазина - -
-{% else %} -
- - - - - - - - - - - {% for group in groups %} - {% set mode = filter_map.get(group.evotor_id) %} - - - - - - - {% endfor %} - -
НазваниеКол-во товаровФильтр
{{ group.name }}{{ product_counts.get(group.evotor_id, 0) }} - {% if mode == "include" %} - ✓ Включено - {% elif mode == "exclude" %} - ✗ Исключено - {% else %} - — Нет правила - {% endif %} - -
- - - - -
-
-
-{% endif %} -{% endblock %} diff --git a/web-python/templates/catalog_products.html b/web-python/templates/catalog_products.html deleted file mode 100644 index cf9df49..0000000 --- a/web-python/templates/catalog_products.html +++ /dev/null @@ -1,133 +0,0 @@ -{% extends "base.html" %} -{% block title %}Товары — ЭВОСИНК{% endblock %} - -{% block content %} - - -
-

Товары{% if group %}: {{ group.name }}{% endif %}

- - Экспорт CSV - -
- -{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %} - -{% if not products %} -
- -

Товары не найдены.

-
-{% else %} -
- - - - - - - - - - - - - - - - {% for product in products %} - {% set mode = filter_map.get(product.evotor_id) %} - - - - - - - - - - - - {% endfor %} - -
НазваниеАртикулЦенаКол-воЕд. изм.В продажеСинхронизированФильтр
{{ product.name }}{{ product.article_number or "—" }}{% if product.price %}{{ "%.2f"|format(product.price) }} ₽{% else %}—{% endif %}{% if product.quantity is not none %}{{ product.quantity }}{% else %}—{% endif %}{{ product.measure_name or "—" }} - {% if product.allow_to_sell is none %} - - {% elif product.allow_to_sell %} - - {% else %} - - {% endif %} - - {% if product.synced_at %} - - - - {% else %} - - {% endif %} - - {% if mode == "include" %} - ✓ Включено - {% elif mode == "exclude" %} - ✗ Исключено - {% else %} - — Нет правила - {% endif %} - - -
-
-{% endif %} -{% endblock %} diff --git a/web-python/templates/catalog_stores.html b/web-python/templates/catalog_stores.html deleted file mode 100644 index 008bda7..0000000 --- a/web-python/templates/catalog_stores.html +++ /dev/null @@ -1,113 +0,0 @@ -{% extends "base.html" %} -{% block title %}Каталог — ЭВОСИНК{% endblock %} - -{% block content %} -
-

Каталог

-
- {% if evotor %} -
- -
- - Экспорт CSV - - {% endif %} -
-
- -{% if not evotor %} -
- - Эвотор не подключён. Подключить Эвотор -
-{% elif not stores %} -
- -

Магазины не найдены в вашем аккаунте Эвотор.

-
-{% else %} -{% if fetched_at %} -

Последнее обновление: {{ fetched_at.strftime("%d.%m.%Y %H:%M") }}

-{% endif %} - -
- - - - - - - - - - - {% for store in stores %} - {% set mode = filter_map.get(store.evotor_id) %} - - - - - - - {% endfor %} - -
НазваниеАдресФильтр
{{ store.name }}{{ store.address or "—" }} - {% if mode == "include" %} - ✓ Включено - {% elif mode == "exclude" %} - ✗ Исключено - {% else %} - — Нет правила - {% endif %} - -
- - - - -
-
-
-{% endif %} -{% endblock %} diff --git a/web-python/templates/confirm_email.html b/web-python/templates/confirm_email.html deleted file mode 100644 index a3a7ed7..0000000 --- a/web-python/templates/confirm_email.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} -{% block title %}Подтверждение email — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
- -

Подтвердите ваш email

-

Проверьте почту и нажмите на ссылку для подтверждения.

-
-
-
-
-{% endblock %} diff --git a/web-python/templates/connections.html b/web-python/templates/connections.html deleted file mode 100644 index 5dc3b32..0000000 --- a/web-python/templates/connections.html +++ /dev/null @@ -1,67 +0,0 @@ -{% extends "base.html" %} -{% block title %}Подключения — ЭВОСИНК{% endblock %} - -{% block content %} -
-

Подключения

- - Добавить - -
- -{% if connections %} -
- {% for conn in connections %} -
-
-
-
- -
-
{{ conn.name }}
- {% if conn.details %} - {{ conn.details }} - {% endif %} -
-
- {% if conn.is_online %} - - {% else %} - - {% endif %} -
-
- -
- Настроить -
- -
-
-
- -
-
- {% endfor %} -
- -{% else %} -
- -

Нет подключённых сервисов

- - Добавить подключение - -
-{% endif %} -{% endblock %} diff --git a/web-python/templates/connections_add.html b/web-python/templates/connections_add.html deleted file mode 100644 index f182729..0000000 --- a/web-python/templates/connections_add.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.html" %} -{% block title %}Добавить подключение — ЭВОСИНК{% endblock %} - -{% block content %} -
- -

Добавить подключение

-
- -{% if available %} -
- {% for svc in available %} -
-
-
-
- -
{{ svc.name }}
-
-

{{ svc.description }}

- - Подключить - -
-
-
- {% endfor %} -
- -{% else %} -
- -

Все доступные сервисы подключены

- - Вернуться к подключениям - -
-{% endif %} -{% endblock %} diff --git a/web-python/templates/email_confirmed.html b/web-python/templates/email_confirmed.html deleted file mode 100644 index 6075299..0000000 --- a/web-python/templates/email_confirmed.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} -{% block title %}Email подтвержден — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
- -

Email подтвержден!

-

Ваш email успешно подтвержден. Теперь вы можете войти в систему.

- Войти -
-
-
-
-{% endblock %} diff --git a/web-python/templates/evotor.html b/web-python/templates/evotor.html deleted file mode 100644 index 03b3ff8..0000000 --- a/web-python/templates/evotor.html +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "base.html" %} -{% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %} - -{% block content %} -
-
- - {% if error %} -
- {% if error == "invalid_token" %} - Токен недействителен. Проверьте правильность и попробуйте снова. - {% elif error == "empty_token" %} - Введите токен. - {% else %} - Произошла ошибка при подключении: {{ error }} - {% endif %} -
- {% endif %} - -
-
-

Подключение Эвотор

-
- - {% if connection %} - {# ── CONNECTED STATE ── #} -
    -
  • - Статус - Подключено -
  • - {% if connection.store_name %} -
  • - Магазин - {{ connection.store_name }} -
  • - {% endif %} - {% if connection.store_id %} -
  • - ID магазина - {{ connection.store_id }} -
  • - {% endif %} -
  • - Подключено - {{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }} -
  • -
- -
-
- -
-
- - {% else %} - {# ── NOT CONNECTED STATE ── #} -
-

- Для подключения вам нужно установить приложение ЭвоСинк в личном кабинете Эвотор - и скопировать токен доступа из его настроек. -

- -
    - {% if app_url %} -
  1. Откройте приложение ЭвоСинк в магазине Эвотор и установите его.
  2. - {% else %} -
  3. Найдите приложение ЭвоСинк в магазине Эвотор и установите его.
  4. - {% endif %} -
  5. Перейдите в раздел Приложения → ЭвоСинк → Настройки.
  6. -
  7. Скопируйте токен доступа и вставьте его в поле ниже.
  8. -
- -
-
- - -
-
- -
-
-
- {% endif %} - -
- - - -
-
-{% endblock %} diff --git a/web-python/templates/forgot_password.html b/web-python/templates/forgot_password.html deleted file mode 100644 index 6f91f6c..0000000 --- a/web-python/templates/forgot_password.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} -{% block title %}Забыли пароль — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Забыли пароль?

-

Введите email, указанный при регистрации.

-
-
- - -
-
- -
-
- -
-
-
-
-{% endblock %} diff --git a/web-python/templates/login.html b/web-python/templates/login.html deleted file mode 100644 index cc705b3..0000000 --- a/web-python/templates/login.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% block title %}Вход — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Вход

-
-
- - -
-
- - -
-
- -
-
- -
-
-
-
-{% endblock %} diff --git a/web-python/templates/message.html b/web-python/templates/message.html deleted file mode 100644 index 7b7d85f..0000000 --- a/web-python/templates/message.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ title }} — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

{{ title }}

-

{{ message }}

- {% if link %} - {{ link_text }} - {% endif %} -
-
-
-
-{% endblock %} diff --git a/web-python/templates/profile_change_password.html b/web-python/templates/profile_change_password.html deleted file mode 100644 index 20928a7..0000000 --- a/web-python/templates/profile_change_password.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "base.html" %} -{% block title %}Изменить пароль — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Изменить пароль

-
-
- {% if success %} -
{{ success }}
- {% endif %} - - {% if errors %} -
- {% for error in errors %} -
{{ error }}
- {% endfor %} -
- {% endif %} - -
-
- - -
-
- - -
-
- - -
-
- - Отмена -
-
-
-
-
-
-{% endblock %} diff --git a/web-python/templates/profile_delete.html b/web-python/templates/profile_delete.html deleted file mode 100644 index bb1fa3e..0000000 --- a/web-python/templates/profile_delete.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "base.html" %} -{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Удалить аккаунт

-
-
-
- - Внимание! Это действие необратимо. Все ваши данные будут удалены. -
- - {% if errors %} -
- {% for error in errors %} -
{{ error }}
- {% endfor %} -
- {% endif %} - -
-
- - -
-
- - Отмена -
-
-
-
-
-
-{% endblock %} diff --git a/web-python/templates/profile_edit.html b/web-python/templates/profile_edit.html deleted file mode 100644 index a198504..0000000 --- a/web-python/templates/profile_edit.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} -{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Редактировать профиль

-
-
- {% if success %} -
{{ success }}
- {% endif %} - - {% if errors %} -
- {% for error in errors %} -
{{ error }}
- {% endfor %} -
- {% endif %} - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - Отмена -
-
-
-
-
-
-{% endblock %} diff --git a/web-python/templates/profile_view.html b/web-python/templates/profile_view.html deleted file mode 100644 index d41f33a..0000000 --- a/web-python/templates/profile_view.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "base.html" %} -{% block title %}Личный кабинет — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Личный кабинет

-
-
    -
  • - Имя - {{ user.first_name }} -
  • -
  • - Фамилия - {{ user.last_name }} -
  • -
  • - Email - {{ user.email }} -
  • -
  • - Телефон - {{ user.phone }} -
  • -
- -
-
-
-{% endblock %} diff --git a/web-python/templates/register.html b/web-python/templates/register.html deleted file mode 100644 index 1ea4f0c..0000000 --- a/web-python/templates/register.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "base.html" %} -{% block title %}Регистрация — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Регистрация

-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- -
-
-
-
-{% endblock %} diff --git a/web-python/templates/reset_password.html b/web-python/templates/reset_password.html deleted file mode 100644 index 8fb40bf..0000000 --- a/web-python/templates/reset_password.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} -{% block title %}Новый пароль — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Новый пароль

-
-
- - -
-
- - -
-
- -
-
-
-
-
-
-{% endblock %} diff --git a/web-python/templates/sync.html b/web-python/templates/sync.html deleted file mode 100644 index 4874567..0000000 --- a/web-python/templates/sync.html +++ /dev/null @@ -1,110 +0,0 @@ -{% extends "base.html" %} -{% block title %}Синхронизация — ЭВОСИНК{% endblock %} - -{% block content %} -

Синхронизация

- -{% if not evotor %} -
- - Эвотор не подключён. Подключить Эвотор -
-{% endif %} - -{% if not vk %} -
- - ВКонтакте не подключён. Подключить ВКонтакте -
-{% endif %} - -
- - {# ── Status card ── #} -
-
-
-
Статус
- -
- {% if status == "active" %} - Активна - {% elif status == "paused" %} - Приостановлена - {% elif status == "pending" %} - Ожидает подтверждения - {% else %} - Не настроено - {% endif %} -
- - {% if config.confirmed_at %} -

- Запущена: {{ config.confirmed_at.strftime("%d.%m.%Y %H:%M") }} -

- {% endif %} - -
- {# Toggle enable/disable #} -
- {% if config.is_enabled %} - - {% else %} - - {% endif %} -
- - {# Confirm button #} - {% if config.is_enabled and summary.total > 0 %} -
- -
- {% endif %} -
- - {% if config.is_enabled and summary.total == 0 %} -

- Настройте фильтры, чтобы подтвердить запуск. -

- {% endif %} -
-
-
- - {# ── Filters card ── #} -
-
-
-
Фильтры
- - {% if summary.total > 0 %} -
    - {% if summary.stores > 0 %} -
  • Магазины: {{ summary.stores }} правил
  • - {% endif %} - {% if summary.groups > 0 %} -
  • Группы: {{ summary.groups }} правил
  • - {% endif %} - {% if summary.products > 0 %} -
  • Товары: {{ summary.products }} правил
  • - {% endif %} -
- {% else %} -

Фильтры не настроены — будут синхронизированы все товары.

- {% endif %} - - - Настроить фильтры - -
-
-
- -
-{% endblock %} diff --git a/web-python/templates/vk.html b/web-python/templates/vk.html deleted file mode 100644 index 621c749..0000000 --- a/web-python/templates/vk.html +++ /dev/null @@ -1,109 +0,0 @@ -{% extends "base.html" %} -{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %} - -{% block content %} -
-
- - {% if error %} -
- {% if error == "invalid_token" %} - Токен недействителен или у него нет прав администратора сообщества. - {% elif error == "empty_token" %} - Введите токен. - {% elif error == "no_client_id" %} - Автоматическое подключение не настроено. Введите токен вручную. - {% else %} - Произошла ошибка при подключении: {{ error }} - {% endif %} -
- {% endif %} - -
-
-

Подключение ВКонтакте

-
- - {% if connection %} - {# ── CONNECTED STATE ── #} -
    -
  • - Статус - Подключено -
  • - {% if connection.first_name %} -
  • - Сообщество - {{ connection.first_name }} -
  • - {% endif %} - {% if connection.vk_user_id %} -
  • - ID сообщества - {{ connection.vk_user_id }} -
  • - {% endif %} -
  • - Подключено - {{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }} -
  • -
- -
-
- -
-
- - {% else %} - {# ── NOT CONNECTED STATE ── #} -
- {% if vk_client_id %} -

- Нажмите кнопку ниже, чтобы авторизоваться через ВКонтакте и выдать доступ к управлению товарами вашего сообщества. -

- -
-

Или введите токен вручную:

- {% else %} -

- Для синхронизации товаров необходим токен пользователя ВКонтакте - с правами на управление товарами сообщества. -

- {% endif %} - -
-
- - -
-
- -
-
-
- {% endif %} - -
- - - -
-
-{% endblock %} diff --git a/web-python/templates/vk_callback.html b/web-python/templates/vk_callback.html deleted file mode 100644 index 5ace86c..0000000 --- a/web-python/templates/vk_callback.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends "base.html" %} -{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-
-
-

Подключение ВКонтакте…

-
-
- -

Не удалось получить токен от ВКонтакте.

- Попробовать снова -
-
-
-
-
- -
- -
- - -{% endblock %} diff --git a/web-python/templates_env.py b/web-python/templates_env.py deleted file mode 100644 index 4c7e8d2..0000000 --- a/web-python/templates_env.py +++ /dev/null @@ -1,6 +0,0 @@ -from fastapi.templating import Jinja2Templates - -from web.config import settings - -templates = Jinja2Templates(directory="web/templates") -templates.env.globals["jivosite_widget_id"] = settings.JIVOSITE_WIDGET_ID diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index b947077..0000000 --- a/web/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/web/drizzle.config.ts b/web/drizzle.config.ts deleted file mode 100644 index bf772f9..0000000 --- a/web/drizzle.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - schema: "./src/db/schema.ts", - out: "./drizzle", - dialect: "mysql", - dbCredentials: { - url: process.env.DATABASE_URL ?? "mysql://evosync:evosync@localhost:3306/evosync", - }, -}); diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index 14a695c..0000000 --- a/web/package-lock.json +++ /dev/null @@ -1,2001 +0,0 @@ -{ - "name": "evosync-web", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "evosync-web", - "version": "1.0.0", - "dependencies": { - "@hono/node-server": "^1.13.7", - "bcryptjs": "^2.4.3", - "drizzle-orm": "^0.41.0", - "hono": "^4.7.4", - "hono-sessions": "^0.5.5", - "mysql2": "^3.14.0", - "nunjucks": "^3.2.4" - }, - "devDependencies": { - "@types/bcryptjs": "^2.4.6", - "@types/node": "^22.13.13", - "@types/nunjucks": "^3.2.6", - "drizzle-kit": "^0.30.4", - "tsx": "^4.19.3", - "typescript": "^5.8.2" - } - }, - "node_modules/@drizzle-team/brocli": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", - "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@esbuild-kit/core-utils": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", - "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.18.20", - "source-map-support": "^0.5.21" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", - "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.3.2", - "get-tsconfig": "^4.7.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@petamoriken/float16": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", - "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/nunjucks": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.2.6.tgz", - "integrity": "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/a-sync-waterfall": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", - "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", - "license": "MIT" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "license": "MIT" - }, - "node_modules/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/bcryptjs": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", - "license": "MIT" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/drizzle-kit": { - "version": "0.30.6", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.6.tgz", - "integrity": "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@drizzle-team/brocli": "^0.10.2", - "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.19.7", - "esbuild-register": "^3.5.0", - "gel": "^2.0.0" - }, - "bin": { - "drizzle-kit": "bin.cjs" - } - }, - "node_modules/drizzle-orm": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.41.0.tgz", - "integrity": "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gel": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/gel/-/gel-2.2.0.tgz", - "integrity": "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@petamoriken/float16": "^3.8.7", - "debug": "^4.3.4", - "env-paths": "^3.0.0", - "semver": "^7.6.2", - "shell-quote": "^1.8.1", - "which": "^4.0.0" - }, - "bin": { - "gel": "dist/cli.mjs" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "license": "MIT", - "dependencies": { - "is-property": "^1.0.2" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/hono": { - "version": "4.12.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", - "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/hono-sessions": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/hono-sessions/-/hono-sessions-0.5.8.tgz", - "integrity": "sha512-RTPVnjVaB+JcENp+gKi7olhJhsxRa2gaB6cpLtt7uZf51+JeZH3FNealY9nIBxle+SwKtAQJkAjdcOmYGShaIg==", - "license": "MIT", - "dependencies": { - "hono": "^4.0.0", - "iron-webcrypto": "0.10.1" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iron-webcrypto": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-0.10.1.tgz", - "integrity": "sha512-QGOS8MRMnj/UiOa+aMIgfyHcvkhqNUsUxb1XzskENvbo+rEfp6TOwqd1KPuDzXC4OnGHcMSVxDGRoilqB8ViqA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/brc-dd" - } - }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "devOptional": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru.min": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", - "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/mysql2": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", - "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", - "license": "MIT", - "dependencies": { - "aws-ssl-profiles": "^1.1.2", - "denque": "^2.1.0", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.2", - "long": "^5.3.2", - "lru.min": "^1.1.4", - "named-placeholders": "^1.1.6", - "sql-escaper": "^1.3.3" - }, - "engines": { - "node": ">= 8.0" - }, - "peerDependencies": { - "@types/node": ">= 8" - } - }, - "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "license": "MIT", - "dependencies": { - "lru.min": "^1.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/nunjucks": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", - "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", - "license": "BSD-2-Clause", - "dependencies": { - "a-sync-waterfall": "^1.0.0", - "asap": "^2.0.3", - "commander": "^5.1.0" - }, - "bin": { - "nunjucks-precompile": "bin/precompile" - }, - "engines": { - "node": ">= 6.9.0" - }, - "peerDependencies": { - "chokidar": "^3.3.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sql-escaper": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", - "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=2.0.0", - "node": ">=12.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" - } - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - } - } -} diff --git a/web/package.json b/web/package.json deleted file mode 100644 index bdcac38..0000000 --- a/web/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "evosync-web", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js" - }, - "dependencies": { - "@hono/node-server": "^1.13.7", - "bcryptjs": "^2.4.3", - "drizzle-orm": "^0.41.0", - "hono": "^4.7.4", - "hono-sessions": "^0.5.5", - "mysql2": "^3.14.0", - "nunjucks": "^3.2.4" - }, - "devDependencies": { - "@types/bcryptjs": "^2.4.6", - "@types/node": "^22.13.13", - "@types/nunjucks": "^3.2.6", - "drizzle-kit": "^0.30.4", - "tsx": "^4.19.3", - "typescript": "^5.8.2" - } -} diff --git a/web/src/config.ts b/web/src/config.ts deleted file mode 100644 index 44cfbc4..0000000 --- a/web/src/config.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const config = { - DATABASE_URL: process.env.DATABASE_URL ?? "mysql://evosync:evosync@localhost:3306/evosync", - SECRET_KEY: process.env.SECRET_KEY ?? "change-me-in-production", - BASE_URL: process.env.BASE_URL ?? "http://localhost:8080", - PORT: parseInt(process.env.PORT ?? "3000", 10), - - PASSWORD_RESET_EXPIRE_MINUTES: parseInt(process.env.PASSWORD_RESET_EXPIRE_MINUTES ?? "60", 10), - - EVOTOR_APP_ID: process.env.EVOTOR_APP_ID ?? "", - EVOTOR_WEBHOOK_SECRET: process.env.EVOTOR_WEBHOOK_SECRET ?? "", - - JIVOSITE_WIDGET_ID: process.env.JIVOSITE_WIDGET_ID ?? "", - - HEALTH_CHECK_INTERVAL_SECONDS: parseInt(process.env.HEALTH_CHECK_INTERVAL_SECONDS ?? "600", 10), - CATALOG_REFRESH_INTERVAL_SECONDS: parseInt(process.env.CATALOG_REFRESH_INTERVAL_SECONDS ?? "3600", 10), - SYNC_INTERVAL_SECONDS: parseInt(process.env.SYNC_INTERVAL_SECONDS ?? "3600", 10), - - VK_DEFAULT_PHOTO_PATH: process.env.VK_DEFAULT_PHOTO_PATH ?? "/app/default_product.png", - VK_CLIENT_ID: process.env.VK_CLIENT_ID ?? "", - VK_CLIENT_SECRET: process.env.VK_CLIENT_SECRET ?? "", - VK_API_VERSION: process.env.VK_API_VERSION ?? "5.131", -} as const; diff --git a/web/src/db/client.ts b/web/src/db/client.ts deleted file mode 100644 index 739e8ec..0000000 --- a/web/src/db/client.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { drizzle } from "drizzle-orm/mysql2"; -import mysql from "mysql2/promise"; -import { config } from "../config.js"; -import * as schema from "./schema.js"; - -const pool = mysql.createPool({ - uri: config.DATABASE_URL, - waitForConnections: true, - connectionLimit: 10, - decimalNumbers: true, -}); - -export const db = drizzle(pool, { schema, mode: "default" }); diff --git a/web/src/db/schema.ts b/web/src/db/schema.ts deleted file mode 100644 index 051e6f0..0000000 --- a/web/src/db/schema.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - mysqlTable, - int, - varchar, - text, - boolean, - datetime, - decimal, - uniqueIndex, - index, -} from "drizzle-orm/mysql-core"; -import { sql } from "drizzle-orm"; - -export const users = mysqlTable("users", { - id: int("id").autoincrement().primaryKey(), - first_name: varchar("first_name", { length: 100 }).notNull(), - last_name: varchar("last_name", { length: 100 }).notNull(), - email: varchar("email", { length: 255 }).notNull(), - phone: varchar("phone", { length: 20 }).notNull(), - password_hash: varchar("password_hash", { length: 255 }).notNull(), - is_email_confirmed: boolean("is_email_confirmed").notNull().default(false), - email_confirm_token: varchar("email_confirm_token", { length: 255 }), - password_reset_token: varchar("password_reset_token", { length: 255 }), - password_reset_expires: datetime("password_reset_expires"), - created_at: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), - updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}, (t) => [ - uniqueIndex("ix_users_email").on(t.email), - uniqueIndex("ix_users_phone").on(t.phone), -]); - -export const evotor_connections = mysqlTable("evotor_connections", { - id: int("id").autoincrement().primaryKey(), - user_id: int("user_id").references(() => users.id, { onDelete: "cascade" }), - evotor_user_id: varchar("evotor_user_id", { length: 255 }), - access_token: text("access_token").notNull(), - store_id: varchar("store_id", { length: 255 }), - store_name: varchar("store_name", { length: 255 }), - refresh_token: text("refresh_token"), - token_expires_at: datetime("token_expires_at"), - is_online: boolean("is_online").notNull().default(false), - last_checked_at: datetime("last_checked_at"), - connected_at: datetime("connected_at").notNull().default(sql`CURRENT_TIMESTAMP`), - updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}, (t) => [ - uniqueIndex("ix_evotor_connections_user_id").on(t.user_id), - uniqueIndex("ix_evotor_connections_evotor_user_id").on(t.evotor_user_id), -]); - -export const vk_connections = mysqlTable("vk_connections", { - id: int("id").autoincrement().primaryKey(), - user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - access_token: text("access_token").notNull(), - vk_user_id: varchar("vk_user_id", { length: 50 }), - first_name: varchar("first_name", { length: 255 }), - last_name: varchar("last_name", { length: 255 }), - is_online: boolean("is_online").notNull().default(false), - last_checked_at: datetime("last_checked_at"), - connected_at: datetime("connected_at").notNull().default(sql`CURRENT_TIMESTAMP`), - updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}, (t) => [ - uniqueIndex("ix_vk_connections_user_id").on(t.user_id), -]); - -export const sync_configs = mysqlTable("sync_configs", { - id: int("id").autoincrement().primaryKey(), - user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - is_enabled: boolean("is_enabled").notNull().default(false), - confirmed_at: datetime("confirmed_at"), - created_at: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), - updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}, (t) => [ - uniqueIndex("ix_sync_configs_user_id").on(t.user_id), -]); - -export const sync_filters = mysqlTable("sync_filters", { - id: int("id").autoincrement().primaryKey(), - sync_config_id: int("sync_config_id").notNull().references(() => sync_configs.id, { onDelete: "cascade" }), - entity_type: varchar("entity_type", { length: 20 }).notNull(), - entity_id: varchar("entity_id", { length: 255 }).notNull(), - entity_name: varchar("entity_name", { length: 255 }), - filter_mode: varchar("filter_mode", { length: 10 }).notNull(), - parent_entity_id: varchar("parent_entity_id", { length: 255 }), - created_at: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}, (t) => [ - uniqueIndex("uq_sync_filters_config_type_entity").on(t.sync_config_id, t.entity_type, t.entity_id), -]); - -export const cached_stores = mysqlTable("cached_stores", { - id: int("id").autoincrement().primaryKey(), - user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - evotor_id: varchar("evotor_id", { length: 255 }).notNull(), - name: varchar("name", { length: 255 }).notNull(), - address: varchar("address", { length: 500 }), - fetched_at: datetime("fetched_at").notNull(), -}, (t) => [ - uniqueIndex("uq_cached_stores_user_evotor").on(t.user_id, t.evotor_id), - index("ix_cached_stores_user_id").on(t.user_id), -]); - -export const cached_groups = mysqlTable("cached_groups", { - id: int("id").autoincrement().primaryKey(), - user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - evotor_id: varchar("evotor_id", { length: 255 }).notNull(), - store_evotor_id: varchar("store_evotor_id", { length: 255 }).notNull(), - name: varchar("name", { length: 255 }).notNull(), - fetched_at: datetime("fetched_at").notNull(), -}, (t) => [ - uniqueIndex("uq_cached_groups_user_evotor").on(t.user_id, t.evotor_id), - index("ix_cached_groups_user_store").on(t.user_id, t.store_evotor_id), -]); - -export const cached_products = mysqlTable("cached_products", { - id: int("id").autoincrement().primaryKey(), - user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - evotor_id: varchar("evotor_id", { length: 255 }).notNull(), - store_evotor_id: varchar("store_evotor_id", { length: 255 }).notNull(), - group_evotor_id: varchar("group_evotor_id", { length: 255 }), - name: varchar("name", { length: 255 }).notNull(), - price: decimal("price", { precision: 12, scale: 2 }), - quantity: decimal("quantity", { precision: 12, scale: 3 }), - measure_name: varchar("measure_name", { length: 20 }), - article_number: varchar("article_number", { length: 100 }), - allow_to_sell: boolean("allow_to_sell"), - fetched_at: datetime("fetched_at").notNull(), - synced_at: datetime("synced_at"), -}, (t) => [ - uniqueIndex("uq_cached_products_user_evotor").on(t.user_id, t.evotor_id), - index("ix_cached_products_user_store_group").on(t.user_id, t.store_evotor_id, t.group_evotor_id), -]); - -// Convenience types -export type User = typeof users.$inferSelect; -export type EvotorConnection = typeof evotor_connections.$inferSelect; -export type VkConnection = typeof vk_connections.$inferSelect; -export type SyncConfig = typeof sync_configs.$inferSelect; -export type SyncFilter = typeof sync_filters.$inferSelect; -export type CachedStore = typeof cached_stores.$inferSelect; -export type CachedGroup = typeof cached_groups.$inferSelect; -export type CachedProduct = typeof cached_products.$inferSelect; diff --git a/web/src/index.ts b/web/src/index.ts deleted file mode 100644 index c5c81f2..0000000 --- a/web/src/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { serve } from "@hono/node-server"; -import { serveStatic } from "@hono/node-server/serve-static"; -import { Hono } from "hono"; -import { sessionMiddleware, CookieStore } from "hono-sessions"; -import path from "path"; -import { fileURLToPath } from "url"; - -import { config } from "./config.js"; -import { getSessionUserId, type AppEnv } from "./lib/auth.js"; -import { db } from "./db/client.js"; -import { users } from "./db/schema.js"; -import { eq } from "drizzle-orm"; -import { startHealthCheckLoop } from "./lib/healthChecker.js"; -import { startSyncLoop } from "./lib/syncEngine.js"; - -import { authRouter } from "./routes/auth.js"; -import { resetRouter } from "./routes/reset.js"; -import { profileRouter } from "./routes/profile.js"; -import { connectionsRouter } from "./routes/connections.js"; -import { evotorRouter } from "./routes/evotor.js"; -import { vkRouter } from "./routes/vk.js"; -import { catalogRouter } from "./routes/catalog.js"; -import { syncRouter } from "./routes/sync.js"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const app = new Hono(); - -// Session middleware -const store = new CookieStore(); -app.use("*", sessionMiddleware({ - store, - encryptionKey: config.SECRET_KEY.padEnd(32, "0").slice(0, 32), - expireAfterSeconds: 86400 * 30, - cookieOptions: { - sameSite: "Lax", - path: "/", - httpOnly: true, - secure: process.env.NODE_ENV === "production", - }, - sessionCookieName: "session", -})); - -// Static files -app.use("/static/*", serveStatic({ root: path.join(__dirname, "../") })); - -// Routes -app.route("/", authRouter); -app.route("/", resetRouter); -app.route("/", profileRouter); -app.route("/", connectionsRouter); -app.route("/", evotorRouter); -app.route("/", vkRouter); -app.route("/", catalogRouter); -app.route("/", syncRouter); - -// Home redirect -app.get("/", async (c) => { - const userId = getSessionUserId(c); - if (userId) { - const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - if (user) return c.redirect("/profile", 302); - } - return c.redirect("/login", 302); -}); - -serve({ fetch: app.fetch, port: config.PORT }, () => { - console.log(`Server running on port ${config.PORT}`); - startHealthCheckLoop(config.HEALTH_CHECK_INTERVAL_SECONDS * 1000); - startSyncLoop(config.SYNC_INTERVAL_SECONDS * 1000); -}); diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts deleted file mode 100644 index 5e40d77..0000000 --- a/web/src/lib/auth.ts +++ /dev/null @@ -1,21 +0,0 @@ -import bcrypt from "bcryptjs"; -import type { Context } from "hono"; -import type { Session } from "hono-sessions"; - -// Hono env type that includes our session variable -export type AppEnv = { Variables: { session: Session } }; - -export function hashPassword(password: string): Promise { - return bcrypt.hash(password, 12); -} - -export function verifyPassword(plain: string, hashed: string): Promise { - return bcrypt.compare(plain, hashed); -} - -export function getSessionUserId(c: Context): number | null { - const session = c.get("session"); - if (!session) return null; - const id = session.get("user_id"); - return typeof id === "number" ? id : null; -} diff --git a/web/src/lib/evotorApi.ts b/web/src/lib/evotorApi.ts deleted file mode 100644 index e644044..0000000 --- a/web/src/lib/evotorApi.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { db } from "../db/client.js"; -import { cached_stores, cached_groups, cached_products } from "../db/schema.js"; -import { eq } from "drizzle-orm"; - -const EVOTOR_API_BASE = "https://api.evotor.ru"; - -async function fetchJson(url: string, token: string, allowStatuses: number[] = []): Promise { - const resp = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(15000), - }); - if (allowStatuses.includes(resp.status)) return null; - if (!resp.ok) throw new Error(`Evotor API error ${resp.status}: ${url}`); - return resp.json(); -} - -export async function fetchStores(token: string): Promise> { - const data = await fetchJson(`${EVOTOR_API_BASE}/stores`, token) as unknown; - const items = (typeof data === "object" && data !== null && "items" in data) - ? (data as { items: unknown[] }).items - : (Array.isArray(data) ? data : []); - return (items as Array>).map((s) => ({ - id: (s.uuid as string) ?? (s.id as string), - name: (s.name as string) ?? "", - address: s.address as string | undefined, - })); -} - -export async function fetchGroups(token: string, storeId: string): Promise> { - const data = await fetchJson(`${EVOTOR_API_BASE}/stores/${storeId}/product-groups`, token, [402]); - if (!data) return []; - const items = (typeof data === "object" && data !== null && "items" in data) - ? (data as { items: unknown[] }).items - : (Array.isArray(data) ? data : []); - return (items as Array>).map((g) => ({ - id: (g.uuid as string) ?? (g.id as string), - name: (g.name as string) ?? "", - })); -} - -export async function fetchProducts(token: string, storeId: string): Promise>> { - const data = await fetchJson(`${EVOTOR_API_BASE}/stores/${storeId}/products`, token, [402]); - if (!data) return []; - const items = (typeof data === "object" && data !== null && "items" in data) - ? (data as { items: unknown[] }).items - : (Array.isArray(data) ? data : []); - return (items as Array>).map((p) => ({ - id: (p.uuid as string) ?? (p.id as string), - name: (p.name as string) ?? "", - parent_id: (p.parentUuid as string) ?? (p.parent_id as string) ?? null, - price: (p.price as number) ?? null, - quantity: (p.quantity as number) ?? null, - measure_name: (p.measureName as string) ?? (p.measure_name as string) ?? null, - article_number: (p.code as string) ?? (p.article_number as string) ?? null, - allow_to_sell: p.allowToSell !== undefined ? (p.allowToSell as boolean) : (p.allow_to_sell as boolean | null) ?? null, - })); -} - -export async function refreshCatalogCache(userId: number, accessToken: string): Promise { - const now = new Date(); - - await db.delete(cached_products).where(eq(cached_products.user_id, userId)); - await db.delete(cached_groups).where(eq(cached_groups.user_id, userId)); - await db.delete(cached_stores).where(eq(cached_stores.user_id, userId)); - - const stores = await fetchStores(accessToken); - for (const store of stores) { - await db.insert(cached_stores).values({ - user_id: userId, - evotor_id: store.id, - name: store.name, - address: store.address ?? null, - fetched_at: now, - }); - } - - for (const store of stores) { - const groups = await fetchGroups(accessToken, store.id); - for (const group of groups) { - await db.insert(cached_groups).values({ - user_id: userId, - evotor_id: group.id, - store_evotor_id: store.id, - name: group.name, - fetched_at: now, - }); - } - - const products = await fetchProducts(accessToken, store.id); - for (const product of products) { - await db.insert(cached_products).values({ - user_id: userId, - evotor_id: product.id as string, - store_evotor_id: store.id, - group_evotor_id: (product.parent_id as string | null) ?? null, - name: product.name as string, - price: product.price !== null ? String(product.price) : null, - quantity: product.quantity !== null ? String(product.quantity) : null, - measure_name: (product.measure_name as string | null) ?? null, - article_number: (product.article_number as string | null) ?? null, - allow_to_sell: (product.allow_to_sell as boolean | null) ?? null, - fetched_at: now, - }); - } - } -} diff --git a/web/src/lib/healthChecker.ts b/web/src/lib/healthChecker.ts deleted file mode 100644 index 1f1fd5d..0000000 --- a/web/src/lib/healthChecker.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { db } from "../db/client.js"; -import { evotor_connections, vk_connections, cached_stores } from "../db/schema.js"; -import { eq } from "drizzle-orm"; -import { refreshCatalogCache } from "./evotorApi.js"; -import { config } from "../config.js"; - -const EVOTOR_STORES_URL = "https://api.evotor.ru/stores"; -const EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"; -const VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById"; -const REFRESH_BEFORE_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours - -async function checkEvotorConnection(token: string): Promise { - try { - const resp = await fetch(EVOTOR_STORES_URL, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(15000), - }); - return resp.ok; - } catch { - return false; - } -} - -async function checkVkConnection(token: string): Promise { - try { - const params = new URLSearchParams({ access_token: token, v: "5.131" }); - const resp = await fetch(`${VK_GROUPS_GET_URL}?${params}`, { signal: AbortSignal.timeout(10000) }); - if (!resp.ok) return false; - const data = await resp.json() as { error?: unknown }; - return !data.error; - } catch { - return false; - } -} - -async function refreshEvotorToken(conn: typeof evotor_connections.$inferSelect): Promise<{ - access_token: string; refresh_token?: string; expires_in?: number; -} | null> { - if (!conn.refresh_token) return null; - try { - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: conn.refresh_token, - }); - const resp = await fetch(EVOTOR_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - signal: AbortSignal.timeout(15000), - }); - if (!resp.ok) return null; - const data = await resp.json() as { access_token?: string; refresh_token?: string; expires_in?: number }; - return data.access_token ? data as { access_token: string; refresh_token?: string; expires_in?: number } : null; - } catch { - return null; - } -} - -export async function runHealthChecks(): Promise { - const now = new Date(); - console.log("[health] running health checks"); - - try { - const evotorConns = await db.select().from(evotor_connections); - for (const conn of evotorConns) { - let token = conn.access_token; - - // Proactive refresh if token expires soon - const needsRefresh = conn.refresh_token && - conn.token_expires_at && - conn.token_expires_at.getTime() - now.getTime() < REFRESH_BEFORE_EXPIRY_MS; - - if (needsRefresh) { - const tokenData = await refreshEvotorToken(conn); - if (tokenData) { - token = tokenData.access_token; - const expires = tokenData.expires_in ? new Date(now.getTime() + tokenData.expires_in * 1000) : null; - await db.update(evotor_connections) - .set({ access_token: token, refresh_token: tokenData.refresh_token ?? conn.refresh_token, token_expires_at: expires }) - .where(eq(evotor_connections.id, conn.id)); - } - } - - let isOnline = await checkEvotorConnection(token); - - // If offline and not yet tried refresh, attempt it now - if (!isOnline && conn.refresh_token && !needsRefresh) { - const tokenData = await refreshEvotorToken(conn); - if (tokenData) { - token = tokenData.access_token; - const expires = tokenData.expires_in ? new Date(now.getTime() + tokenData.expires_in * 1000) : null; - await db.update(evotor_connections) - .set({ access_token: token, refresh_token: tokenData.refresh_token ?? conn.refresh_token, token_expires_at: expires }) - .where(eq(evotor_connections.id, conn.id)); - isOnline = await checkEvotorConnection(token); - } - } - - await db.update(evotor_connections) - .set({ is_online: isOnline, last_checked_at: now }) - .where(eq(evotor_connections.id, conn.id)); - } - - const vkConns = await db.select().from(vk_connections); - for (const conn of vkConns) { - const isOnline = await checkVkConnection(conn.access_token); - await db.update(vk_connections) - .set({ is_online: isOnline, last_checked_at: now }) - .where(eq(vk_connections.id, conn.id)); - } - - // Refresh catalog cache for online Evotor connections - let refreshed = 0; - for (const conn of evotorConns) { - if (!conn.is_online || !conn.user_id) continue; - const [cached] = await db.select().from(cached_stores).where(eq(cached_stores.user_id, conn.user_id)).limit(1); - const ageSeconds = cached ? (now.getTime() - cached.fetched_at.getTime()) / 1000 : Infinity; - if (!cached || ageSeconds >= config.CATALOG_REFRESH_INTERVAL_SECONDS) { - try { - await refreshCatalogCache(conn.user_id, conn.access_token); - refreshed++; - } catch (err) { - console.error(`[health] catalog refresh failed for user_id=${conn.user_id}:`, err); - } - } - } - - console.log(`[health] done: ${evotorConns.length} evotor, ${vkConns.length} vk, ${refreshed} catalogs refreshed`); - } catch (err) { - console.error("[health] error:", err); - } -} - -export function startHealthCheckLoop(intervalMs: number): NodeJS.Timeout { - return setInterval(() => { - runHealthChecks().catch((err) => console.error("[health] uncaught error:", err)); - }, intervalMs); -} diff --git a/web/src/lib/render.ts b/web/src/lib/render.ts deleted file mode 100644 index ffe0b69..0000000 --- a/web/src/lib/render.ts +++ /dev/null @@ -1,39 +0,0 @@ -import nunjucks from "nunjucks"; -import path from "path"; -import { config } from "../config.js"; - -// In dev (tsx): templates are in src/templates/ -// In prod (node dist/): Dockerfile copies src/templates/ to dist/templates/ -const isDev = process.env.NODE_ENV !== "production"; -const templatesDir = isDev - ? path.join(process.cwd(), "src", "templates") - : path.join(process.cwd(), "dist", "templates"); - -const env = nunjucks.configure(templatesDir, { - autoescape: true, - noCache: process.env.NODE_ENV !== "production", -}); - -env.addGlobal("jivosite_widget_id", config.JIVOSITE_WIDGET_ID); - -// Format a JS Date to Russian date string -env.addFilter("datefmt", (d: Date | null | undefined, fmt?: string) => { - if (!d) return ""; - const dd = String(d.getDate()).padStart(2, "0"); - const mm = String(d.getMonth() + 1).padStart(2, "0"); - const yyyy = d.getFullYear(); - const hh = String(d.getHours()).padStart(2, "0"); - const min = String(d.getMinutes()).padStart(2, "0"); - if (fmt === "%Y%m%d") return `${yyyy}${mm}${dd}`; - return `${dd}.${mm}.${yyyy} ${hh}:${min}`; -}); - -// Format decimal price to 2 decimal places -env.addFilter("price", (v: number | string | null | undefined) => { - if (v == null) return ""; - return Number(v).toFixed(2); -}); - -export function render(template: string, ctx: Record = {}): string { - return env.render(template, ctx); -} diff --git a/web/src/lib/syncEngine.ts b/web/src/lib/syncEngine.ts deleted file mode 100644 index 02ef2b3..0000000 --- a/web/src/lib/syncEngine.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { db } from "../db/client.js"; -import { evotor_connections, vk_connections, sync_configs, sync_filters, cached_products } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { config } from "../config.js"; -import type { SyncFilter } from "../db/schema.js"; - -const VK_API_HOST = "https://api.vk.ru/method"; -const VK_API_VERSION = "5.199"; -const EVOTOR_API_BASE = "https://api.evotor.ru"; -const VK_CATEGORY_ID = 40932; -const VK_STOCK_AMOUNT = 1000; -const WEIGHT_PRICE_MULTIPLIER = 10; -const WEIGHT_MEASURES = new Set(["г", "г.", "грамм", "граммов", "гр", "гр."]); - -function isWeightMeasure(measure: string | null | undefined): boolean { - return !!measure && WEIGHT_MEASURES.has(measure.trim().toLowerCase()); -} - -function normalizeName(name: string): string { - return name.trim().replace(/;/g, ","); -} - -function calcPrice(priceKopecks: number | null, measure: string | null): [number, string] { - const base = Math.round(Number(priceKopecks) || 0); - if (isWeightMeasure(measure)) { - return [base * WEIGHT_PRICE_MULTIPLIER, `${WEIGHT_PRICE_MULTIPLIER}${measure}`]; - } - return [base, measure ?? ""]; -} - -function buildDescription(name: string, priceInfo: string, extraDesc: string | null): string { - let desc = `${name} (цена за ${priceInfo}.)\n\n`; - if (extraDesc) desc += extraDesc; - return desc.trim(); -} - -function getIncludedStoreIds(filters: SyncFilter[]): string[] { - return filters.filter((f) => f.entity_type === "store" && f.filter_mode === "include").map((f) => f.entity_id); -} - -function isGroupIncluded(groupId: string | null, filters: SyncFilter[]): boolean { - const groupFilters = Object.fromEntries( - filters.filter((f) => f.entity_type === "group").map((f) => [f.entity_id, f.filter_mode]) - ); - if (Object.keys(groupFilters).length === 0) return true; - const mode = groupId ? groupFilters[groupId] : undefined; - if (mode === "exclude") return false; - if (mode === "include") return true; - return !Object.values(groupFilters).some((v) => v === "include"); -} - -function isProductIncluded(productId: string, filters: SyncFilter[]): boolean { - const productFilters = Object.fromEntries( - filters.filter((f) => f.entity_type === "product").map((f) => [f.entity_id, f.filter_mode]) - ); - if (Object.keys(productFilters).length === 0) return true; - const mode = productFilters[productId]; - if (mode === "exclude") return false; - if (mode === "include") return true; - return !Object.values(productFilters).some((v) => v === "include"); -} - -// --------------------------------------------------------------------------- -// Evotor API helpers -// --------------------------------------------------------------------------- - -async function evoFetchProducts(token: string, storeId: string): Promise>> { - const resp = await fetch(`${EVOTOR_API_BASE}/stores/${storeId}/products`, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(30000), - }); - if ([402, 404].includes(resp.status)) return []; - if (!resp.ok) throw new Error(`Evotor products ${resp.status}`); - const data = await resp.json() as { items?: unknown[] } | unknown[]; - return (Array.isArray(data) ? data : (data as { items?: unknown[] }).items ?? []) as Array>; -} - -async function evoFetchGroups(token: string, storeId: string): Promise>> { - const resp = await fetch(`${EVOTOR_API_BASE}/stores/${storeId}/product-groups`, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(30000), - }); - if ([402, 404].includes(resp.status)) return []; - if (!resp.ok) throw new Error(`Evotor groups ${resp.status}`); - const data = await resp.json() as { items?: unknown[] } | unknown[]; - return (Array.isArray(data) ? data : (data as { items?: unknown[] }).items ?? []) as Array>; -} - -// --------------------------------------------------------------------------- -// VK API helpers -// --------------------------------------------------------------------------- - -function vkParams(token: string, extra: Record): URLSearchParams { - const p: Record = { access_token: token, v: VK_API_VERSION }; - for (const [k, v] of Object.entries(extra)) p[k] = String(v); - return new URLSearchParams(p); -} - -async function vkGet(token: string, method: string, params: Record): Promise { - const resp = await fetch(`${VK_API_HOST}/${method}?${vkParams(token, params)}`, { - signal: AbortSignal.timeout(30000), - }); - if (!resp.ok) throw new Error(`VK ${method} ${resp.status}`); - return resp.json(); -} - -async function vkPost(token: string, method: string, params: Record): Promise { - const resp = await fetch(`${VK_API_HOST}/${method}`, { - method: "POST", - body: vkParams(token, params), - signal: AbortSignal.timeout(30000), - }); - if (!resp.ok) throw new Error(`VK ${method} ${resp.status}`); - return resp.json(); -} - -async function vkGetAlbums(token: string, ownerId: string): Promise>> { - const data = await vkGet(token, "market.getAlbums", { owner_id: ownerId, count: 200 }) as { response?: { items?: unknown[] } }; - return (data.response?.items ?? []) as Array>; -} - -async function vkCreateAlbum(token: string, ownerId: string, title: string): Promise { - const data = await vkPost(token, "market.addAlbum", { owner_id: ownerId, title }) as { error?: unknown; response?: { market_album_id?: number } }; - if (data.error) { console.error("[sync] VK create album error:", data.error); return null; } - return data.response?.market_album_id ?? null; -} - -async function vkGetProducts(token: string, ownerId: string): Promise>> { - const items: Array> = []; - let offset = 0; - const count = 200; - while (true) { - const data = await vkGet(token, "market.get", { owner_id: ownerId, extended: 1, with_disabled: 1, count, offset }) as { response?: { items?: unknown[] } }; - const batch = (data.response?.items ?? []) as Array>; - items.push(...batch); - if (batch.length < count) break; - offset += count; - } - return items; -} - -async function vkUploadPhoto(token: string, groupId: string, photoPath: string): Promise { - try { - const serverData = await vkGet(token, "market.getProductPhotoUploadServer", { group_id: groupId }) as { error?: unknown; response?: { upload_url?: string } }; - if (serverData.error) return null; - const uploadUrl = serverData.response?.upload_url; - if (!uploadUrl) return null; - - const fs = await import("fs"); - const form = new FormData(); - form.append("file", new Blob([fs.readFileSync(photoPath)]), "photo.jpg"); - - const uploadResp = await fetch(uploadUrl, { method: "POST", body: form as BodyInit, signal: AbortSignal.timeout(60000) }); - if (!uploadResp.ok) return null; - const uploadText = await uploadResp.text(); - - const saveData = await vkPost(token, "market.saveProductPhoto", { upload_response: uploadText }) as { error?: unknown; response?: { photo_id?: string } }; - if (saveData.error) return null; - return saveData.response?.photo_id ?? null; - } catch (err) { - console.error("[sync] photo upload error:", err); - return null; - } -} - -async function vkCreateProduct( - token: string, ownerId: string, name: string, description: string, - price: number, stockAmount: number, photoId: string, albumId: number | null, -): Promise { - const data = await vkPost(token, "market.add", { - owner_id: ownerId, name, description, category_id: VK_CATEGORY_ID, - price, main_photo_id: photoId, stock_amount: stockAmount, - }) as { error?: unknown; response?: { market_item_id?: number } }; - if (data.error) { console.error("[sync] VK create product error:", data.error); return null; } - const productId = data.response?.market_item_id ?? null; - if (productId && albumId) { - await vkGet(token, "market.addToAlbum", { owner_id: ownerId, item_ids: productId, album_ids: albumId }).catch(() => {}); - } - return productId; -} - -async function vkEditProduct( - token: string, ownerId: string, itemId: number, name: string, - description: string, price: number, stockAmount: number, -): Promise { - const data = await vkPost(token, "market.edit", { - owner_id: ownerId, item_id: itemId, name, description, category_id: VK_CATEGORY_ID, price, stock_amount: stockAmount, - }) as { error?: unknown }; - if (data.error) console.error("[sync] VK edit product error:", data.error); -} - -async function vkDeleteProduct(token: string, ownerId: string, itemId: number): Promise { - await vkPost(token, "market.delete", { owner_id: ownerId, item_id: itemId }).catch(() => {}); -} - -// --------------------------------------------------------------------------- -// Main sync logic per user -// --------------------------------------------------------------------------- - -async function stampSynced(userId: number, evoId: string, now: Date): Promise { - await db.update(cached_products) - .set({ synced_at: now }) - .where(and(eq(cached_products.user_id, userId), eq(cached_products.evotor_id, evoId))); -} - -async function syncUser( - userId: number, - evoToken: string, - vkToken: string, - vkGroupId: string, - filters: SyncFilter[], - photoPath: string, -): Promise { - const ownerId = `-${vkGroupId}`; - const now = new Date(); - console.log(`[sync] start user_id=${userId} vk_group=${vkGroupId}`); - - const storeIds = getIncludedStoreIds(filters); - if (!storeIds.length) { - console.log(`[sync] skip user_id=${userId} — no stores in filters`); - return; - } - - const evoProducts: Array> = []; - const groupsById: Record> = {}; - - for (const storeId of storeIds) { - const rawGroups = await evoFetchGroups(evoToken, storeId); - for (const g of rawGroups) { - const gid = (g.uuid ?? g.id) as string; - if (gid) groupsById[gid] = g; - } - const rawProducts = await evoFetchProducts(evoToken, storeId); - for (const p of rawProducts) { - const pid = (p.uuid ?? p.id) as string; - const gid = (p.parentUuid ?? p.parent_id) as string | null; - if (!isGroupIncluded(gid, filters)) continue; - if (!isProductIncluded(pid, filters)) continue; - evoProducts.push(p); - } - } - - const evoByName: Record> = {}; - for (const p of evoProducts) { - const norm = normalizeName(((p.name as string) ?? "").trim()); - evoByName[norm] = p; - } - - const vkProducts = await vkGetProducts(vkToken, ownerId); - const vkAlbums = await vkGetAlbums(vkToken, ownerId); - const vkAlbumByTitle: Record> = {}; - for (const a of vkAlbums) vkAlbumByTitle[a.title as string] = a; - - // Ensure albums exist for included groups - for (const [gid, group] of Object.entries(groupsById)) { - if (!isGroupIncluded(gid, filters)) continue; - const title = (group.name as string) ?? ""; - if (title && !vkAlbumByTitle[title]) { - const newId = await vkCreateAlbum(vkToken, ownerId, title); - if (newId) { - vkAlbumByTitle[title] = { id: newId, title }; - console.log(`[sync] created VK album '${title}' user_id=${userId}`); - } - } - } - - const vkByName: Record>> = {}; - for (const item of vkProducts) { - const norm = normalizeName((item.title as string) ?? ""); - (vkByName[norm] ??= []).push(item); - } - - // Update / create - for (const [normName, evoP] of Object.entries(evoByName)) { - const evoId = (evoP.uuid ?? evoP.id) as string; - const rawName = ((evoP.name as string) ?? "").trim(); - const nameForVk = normalizeName(rawName); - const measure = (evoP.measureName ?? evoP.measure_name) as string | null; - const rawPrice = (evoP.price as number) ?? 0; - const [price, priceInfo] = calcPrice(rawPrice, measure); - const allowToSell = (evoP.allowToSell !== undefined ? evoP.allowToSell : evoP.allow_to_sell) as boolean | null; - const stockAmount = allowToSell ? VK_STOCK_AMOUNT : 0; - const extraDesc = (evoP.description as string) ?? ""; - const description = buildDescription(rawName, priceInfo, extraDesc); - - const gid = (evoP.parentUuid ?? evoP.parent_id) as string | null; - const groupName = gid ? (groupsById[gid]?.name as string) ?? null : null; - const album = groupName ? vkAlbumByTitle[groupName] : null; - const albumId = album ? (album.id as number) : null; - - if (vkByName[normName]) { - const vkItem = vkByName[normName][0]; - const vkId = vkItem.id as number; - const origPrice = parseInt(String((vkItem.price as Record)?.amount ?? 0)); - const origDesc = ((vkItem.description as string) ?? "").trim(); - const origStock = (vkItem.stock_amount as number) ?? 0; - - if (price !== origPrice || description !== origDesc || stockAmount !== origStock) { - console.log(`[sync] updating '${nameForVk}' user_id=${userId}`); - await vkEditProduct(vkToken, ownerId, vkId, nameForVk, description, price, stockAmount); - } - await stampSynced(userId, evoId, now); - } else { - if (!allowToSell) continue; - const photoId = await vkUploadPhoto(vkToken, vkGroupId, photoPath); - if (!photoId) { console.error(`[sync] skip '${nameForVk}' — photo upload failed`); continue; } - console.log(`[sync] creating '${nameForVk}' user_id=${userId}`); - const created = await vkCreateProduct(vkToken, ownerId, nameForVk, description, price, stockAmount, photoId, albumId); - if (created) await stampSynced(userId, evoId, now); - } - } - - // Delete VK products not in Evotor - for (const [normName, vkItems] of Object.entries(vkByName)) { - if (evoByName[normName]) { - for (const dup of vkItems.slice(1)) { - console.log(`[sync] deleting duplicate '${normName}' id=${dup.id} user_id=${userId}`); - await vkDeleteProduct(vkToken, ownerId, dup.id as number); - } - } else { - for (const item of vkItems) { - console.log(`[sync] deleting removed '${normName}' id=${item.id} user_id=${userId}`); - await vkDeleteProduct(vkToken, ownerId, item.id as number); - } - } - } - - console.log(`[sync] complete user_id=${userId}`); -} - -export async function runSync(): Promise { - try { - const configs = await db.select().from(sync_configs) - .where(and(eq(sync_configs.is_enabled, true))); - - for (const cfg of configs) { - if (!cfg.confirmed_at) continue; - - const [evo] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, cfg.user_id)).limit(1); - const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, cfg.user_id)).limit(1); - - if (!evo?.access_token || !vk?.access_token || !vk.vk_user_id) continue; - - const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, cfg.id)); - - try { - await syncUser(cfg.user_id, evo.access_token, vk.access_token, vk.vk_user_id, filters, config.VK_DEFAULT_PHOTO_PATH); - } catch (err) { - console.error(`[sync] failed for user_id=${cfg.user_id}:`, err); - } - } - } catch (err) { - console.error("[sync] runner error:", err); - } -} - -export function startSyncLoop(intervalMs: number): NodeJS.Timeout { - return setInterval(() => { - runSync().catch((err) => console.error("[sync] uncaught error:", err)); - }, intervalMs); -} diff --git a/web/src/lib/validate.ts b/web/src/lib/validate.ts deleted file mode 100644 index 8ddebb4..0000000 --- a/web/src/lib/validate.ts +++ /dev/null @@ -1,40 +0,0 @@ -const PHONE_RE = /^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/; -const EMAIL_RE = /^[^@]+@[^@]+\.[^@]+$/; - -export function validateRegistration(data: Record): string[] { - const errors: string[] = []; - if (!data.first_name?.trim()) errors.push("Введите имя"); - if (!data.last_name?.trim()) errors.push("Введите фамилию"); - const email = data.email?.trim() ?? ""; - if (!email || !EMAIL_RE.test(email)) errors.push("Введите корректный email"); - const phone = data.phone?.trim() ?? ""; - if (!phone || !PHONE_RE.test(phone)) errors.push("Введите телефон в формате +7 (XXX) XXX-XX-XX"); - const password = data.password ?? ""; - if (password.length < 8) errors.push("Пароль должен быть не менее 8 символов"); - if (password !== data.password_confirm) errors.push("Пароли не совпадают"); - return errors; -} - -export function validateLogin(data: Record): string[] { - const errors: string[] = []; - if (!data.email?.trim()) errors.push("Введите email"); - if (!data.password) errors.push("Введите пароль"); - return errors; -} - -export function validateResetPassword(data: Record): string[] { - const errors: string[] = []; - const password = data.password ?? ""; - if (password.length < 8) errors.push("Пароль должен быть не менее 8 символов"); - if (password !== data.password_confirm) errors.push("Пароли не совпадают"); - return errors; -} - -export function validateProfile(data: Record): string[] { - const errors: string[] = []; - if (!data.first_name?.trim()) errors.push("Введите имя"); - if (!data.last_name?.trim()) errors.push("Введите фамилию"); - const phone = data.phone?.trim() ?? ""; - if (!phone || !PHONE_RE.test(phone)) errors.push("Введите телефон в формате +7 (XXX) XXX-XX-XX"); - return errors; -} diff --git a/web/src/routes/auth.ts b/web/src/routes/auth.ts deleted file mode 100644 index 5ca67c6..0000000 --- a/web/src/routes/auth.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Hono } from "hono"; -import { getCookie } from "hono/cookie"; -import { db } from "../db/client.js"; -import { users } from "../db/schema.js"; -import { eq, or } from "drizzle-orm"; -import { hashPassword, verifyPassword, getSessionUserId, type AppEnv } from "../lib/auth.js"; -import { validateRegistration, validateLogin } from "../lib/validate.js"; -import { render } from "../lib/render.js"; -import { config } from "../config.js"; -import { randomUUID } from "crypto"; - -export const authRouter = new Hono(); - -authRouter.get("/register", async (c) => { - const userId = getSessionUserId(c); - if (userId) return c.redirect("/profile", 303); - return c.html(render("register.njk", { user: null })); -}); - -authRouter.post("/register", async (c) => { - const form = await c.req.formData(); - const data: Record = {}; - form.forEach((v, k) => { data[k] = String(v); }); - - const errors = validateRegistration(data); - - if (!errors.length) { - const existing = await db.select().from(users).where( - or(eq(users.email, data.email.trim()), eq(users.phone, data.phone.trim())) - ).limit(1); - if (existing.length) { - if (existing[0].email === data.email.trim()) { - errors.push("Пользователь с таким email уже существует"); - } else { - errors.push("Пользователь с таким телефоном уже существует"); - } - } - } - - if (errors.length) { - return c.html(render("register.njk", { user: null, errors, form: data })); - } - - const token = randomUUID().replace(/-/g, ""); - await db.insert(users).values({ - first_name: data.first_name.trim(), - last_name: data.last_name.trim(), - email: data.email.trim(), - phone: data.phone.trim(), - password_hash: await hashPassword(data.password), - email_confirm_token: token, - }); - - const confirmUrl = `${config.BASE_URL}/confirm-email?token=${token}`; - console.log("=".repeat(40)); - console.log("ПОДТВЕРЖДЕНИЕ EMAIL"); - console.log(`Пользователь: ${data.email.trim()}`); - console.log(`Ссылка: ${confirmUrl}`); - console.log("=".repeat(40)); - - return c.html(render("confirm_email.njk", { user: null })); -}); - -authRouter.get("/confirm-email", async (c) => { - const token = c.req.query("token"); - if (!token) { - return c.html(render("message.njk", { - user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.", - })); - } - - const [user] = await db.select().from(users).where(eq(users.email_confirm_token, token)).limit(1); - if (!user) { - return c.html(render("message.njk", { - user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.", - })); - } - - await db.update(users) - .set({ is_email_confirmed: true, email_confirm_token: null }) - .where(eq(users.id, user.id)); - - return c.html(render("email_confirmed.njk", { user: null })); -}); - -authRouter.get("/login", async (c) => { - const userId = getSessionUserId(c); - if (userId) return c.redirect("/profile", 303); - return c.html(render("login.njk", { user: null })); -}); - -authRouter.post("/login", async (c) => { - const form = await c.req.formData(); - const data: Record = {}; - form.forEach((v, k) => { data[k] = String(v); }); - - const errors = validateLogin(data); - if (errors.length) { - return c.html(render("login.njk", { user: null, errors, form: data })); - } - - const [user] = await db.select().from(users).where(eq(users.email, data.email.trim())).limit(1); - if (!user || !(await verifyPassword(data.password, user.password_hash))) { - return c.html(render("login.njk", { - user: null, errors: ["Неверный email или пароль"], form: data, - })); - } - - if (!user.is_email_confirmed) { - return c.html(render("login.njk", { - user: null, errors: ["Пожалуйста, подтвердите ваш email"], form: data, - })); - } - - const session = c.get("session") as { set: (k: string, v: unknown) => void }; - session.set("user_id", user.id); - return c.redirect("/profile", 303); -}); - -authRouter.get("/logout", (c) => { - const session = c.get("session") as { deleteSession: () => void }; - session.deleteSession(); - return c.redirect("/login", 303); -}); diff --git a/web/src/routes/catalog.ts b/web/src/routes/catalog.ts deleted file mode 100644 index 0ee4f9a..0000000 --- a/web/src/routes/catalog.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { Hono } from "hono"; -import { db } from "../db/client.js"; -import { users, evotor_connections, sync_configs, sync_filters, cached_stores, cached_groups, cached_products } from "../db/schema.js"; -import { eq, and, sql } from "drizzle-orm"; -import { getSessionUserId, type AppEnv } from "../lib/auth.js"; -import { render } from "../lib/render.js"; -import { refreshCatalogCache } from "../lib/evotorApi.js"; - -export const catalogRouter = new Hono(); - -async function requireUser(c: Parameters[0]) { - const id = getSessionUserId(c); - if (!id) return null; - const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); - return user ?? null; -} - -async function getOrCreateSyncConfig(userId: number) { - const [existing] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1); - if (existing) return existing; - await db.insert(sync_configs).values({ user_id: userId, is_enabled: false }); - const [created] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1); - return created!; -} - -async function getFilterMap(configId: number): Promise> { - const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, configId)); - return Object.fromEntries(filters.map((f) => [f.entity_id, f.filter_mode])); -} - -catalogRouter.get("/catalog", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1); - if (!evotor) { - return c.html(render("catalog_stores.njk", { user, evotor: null, stores: [], filter_map: {}, fetched_at: null })); - } - - let stores = await db.select().from(cached_stores) - .where(eq(cached_stores.user_id, user.id)) - .orderBy(cached_stores.name); - - if (!stores.length) { - await refreshCatalogCache(user.id, evotor.access_token); - stores = await db.select().from(cached_stores) - .where(eq(cached_stores.user_id, user.id)) - .orderBy(cached_stores.name); - } - - const config = await getOrCreateSyncConfig(user.id); - const filter_map = await getFilterMap(config.id); - const fetched_at = stores[0]?.fetched_at ?? null; - - return c.html(render("catalog_stores.njk", { user, evotor, stores, filter_map, fetched_at })); -}); - -catalogRouter.get("/catalog/groups", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const store_id = c.req.query("store_id") ?? ""; - const [store] = await db.select().from(cached_stores) - .where(and(eq(cached_stores.user_id, user.id), eq(cached_stores.evotor_id, store_id))).limit(1); - if (!store) return c.redirect("/catalog", 303); - - const groups = await db.select().from(cached_groups) - .where(and(eq(cached_groups.user_id, user.id), eq(cached_groups.store_evotor_id, store_id))) - .orderBy(cached_groups.name); - - const product_counts: Record = {}; - for (const g of groups) { - const [row] = await db.select({ count: sql`count(*)` }).from(cached_products) - .where(and(eq(cached_products.user_id, user.id), eq(cached_products.group_evotor_id, g.evotor_id))); - product_counts[g.evotor_id] = Number(row?.count ?? 0); - } - - const config = await getOrCreateSyncConfig(user.id); - const filter_map = await getFilterMap(config.id); - - return c.html(render("catalog_groups.njk", { user, store, groups, product_counts, filter_map })); -}); - -catalogRouter.get("/catalog/products", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const store_id = c.req.query("store_id") ?? ""; - const group_id = c.req.query("group_id") ?? null; - - const [store] = await db.select().from(cached_stores) - .where(and(eq(cached_stores.user_id, user.id), eq(cached_stores.evotor_id, store_id))).limit(1); - if (!store) return c.redirect("/catalog", 303); - - let group = null; - let products; - - if (group_id) { - [group] = await db.select().from(cached_groups) - .where(and(eq(cached_groups.user_id, user.id), eq(cached_groups.evotor_id, group_id))).limit(1); - products = await db.select().from(cached_products) - .where(and( - eq(cached_products.user_id, user.id), - eq(cached_products.store_evotor_id, store_id), - eq(cached_products.group_evotor_id, group_id), - )) - .orderBy(cached_products.name); - } else { - products = await db.select().from(cached_products) - .where(and(eq(cached_products.user_id, user.id), eq(cached_products.store_evotor_id, store_id))) - .orderBy(cached_products.name); - } - - const config = await getOrCreateSyncConfig(user.id); - const filter_map = await getFilterMap(config.id); - - return c.html(render("catalog_products.njk", { user, store, group: group ?? null, products, filter_map })); -}); - -catalogRouter.post("/catalog/filter", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const form = await c.req.formData(); - const entity_type = String(form.get("entity_type") ?? ""); - const entity_id = String(form.get("entity_id") ?? ""); - const entity_name = String(form.get("entity_name") ?? "") || null; - const filter_mode = String(form.get("filter_mode") ?? ""); - const parent_entity_id = String(form.get("parent_entity_id") ?? "") || null; - const redirect_to = String(form.get("redirect_to") ?? "/catalog"); - - const config = await getOrCreateSyncConfig(user.id); - - const [existing] = await db.select().from(sync_filters) - .where(and( - eq(sync_filters.sync_config_id, config.id), - eq(sync_filters.entity_type, entity_type), - eq(sync_filters.entity_id, entity_id), - )).limit(1); - - if (filter_mode === "none") { - if (existing) await db.delete(sync_filters).where(eq(sync_filters.id, existing.id)); - } else if (existing) { - await db.update(sync_filters) - .set({ filter_mode, entity_name }) - .where(eq(sync_filters.id, existing.id)); - } else { - await db.insert(sync_filters).values({ - sync_config_id: config.id, - entity_type, - entity_id, - entity_name, - filter_mode, - parent_entity_id, - }); - } - - return c.redirect(redirect_to, 303); -}); - -catalogRouter.post("/catalog/refresh", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1); - if (evotor) await refreshCatalogCache(user.id, evotor.access_token); - - return c.redirect("/catalog", 303); -}); - -catalogRouter.get("/catalog/export", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const type = c.req.query("type") ?? "products"; - const store_id = c.req.query("store_id") ?? null; - const group_id = c.req.query("group_id") ?? null; - - const syncConfig = await getOrCreateSyncConfig(user.id); - const fmap = await getFilterMap(syncConfig.id); - - const filterLabel = (eid: string) => { - const m = fmap[eid]; - if (m === "include") return "Включено"; - if (m === "exclude") return "Исключено"; - return "Нет правила"; - }; - - const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); - let filename: string; - const rows: string[][] = []; - - const BOM = "\uFEFF"; - - if (type === "stores") { - filename = `stores_${today}.csv`; - rows.push(["Название", "Адрес", "ID", "Фильтр"]); - const stores = await db.select().from(cached_stores) - .where(eq(cached_stores.user_id, user.id)) - .orderBy(cached_stores.name); - for (const s of stores) { - rows.push([s.name, s.address ?? "", s.evotor_id, filterLabel(s.evotor_id)]); - } - } else if (type === "groups") { - filename = `groups_${today}.csv`; - rows.push(["Магазин", "Название", "ID", "Фильтр"]); - const storeMap: Record = {}; - const allStores = await db.select().from(cached_stores).where(eq(cached_stores.user_id, user.id)); - for (const s of allStores) storeMap[s.evotor_id] = s.name; - - const q = db.select().from(cached_groups).where( - store_id - ? and(eq(cached_groups.user_id, user.id), eq(cached_groups.store_evotor_id, store_id)) - : eq(cached_groups.user_id, user.id) - ).orderBy(cached_groups.name); - const groups = await q; - for (const g of groups) { - rows.push([storeMap[g.store_evotor_id] ?? "", g.name, g.evotor_id, filterLabel(g.evotor_id)]); - } - } else { - filename = `products_${today}.csv`; - rows.push(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"]); - const storeMap: Record = {}; - const groupMap: Record = {}; - const allStores = await db.select().from(cached_stores).where(eq(cached_stores.user_id, user.id)); - for (const s of allStores) storeMap[s.evotor_id] = s.name; - const allGroups = await db.select().from(cached_groups).where(eq(cached_groups.user_id, user.id)); - for (const g of allGroups) groupMap[g.evotor_id] = g.name; - - const conditions = [eq(cached_products.user_id, user.id)]; - if (store_id) conditions.push(eq(cached_products.store_evotor_id, store_id)); - if (group_id) conditions.push(eq(cached_products.group_evotor_id, group_id)); - const products = await db.select().from(cached_products).where(and(...conditions)).orderBy(cached_products.name); - for (const p of products) { - rows.push([ - storeMap[p.store_evotor_id] ?? "", - p.group_evotor_id ? (groupMap[p.group_evotor_id] ?? "") : "", - p.name, - p.article_number ?? "", - p.price ? String(p.price) : "", - p.quantity ? String(p.quantity) : "", - p.measure_name ?? "", - p.allow_to_sell === true ? "Да" : p.allow_to_sell === false ? "Нет" : "", - p.evotor_id, - filterLabel(p.evotor_id), - ]); - } - } - - const csv = BOM + rows.map((row) => - row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",") - ).join("\r\n"); - - return new Response(csv, { - headers: { - "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); -}); diff --git a/web/src/routes/connections.ts b/web/src/routes/connections.ts deleted file mode 100644 index 2b89f5f..0000000 --- a/web/src/routes/connections.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Hono } from "hono"; -import { db } from "../db/client.js"; -import { users, evotor_connections, vk_connections } from "../db/schema.js"; -import { eq } from "drizzle-orm"; -import { getSessionUserId, type AppEnv } from "../lib/auth.js"; -import { render } from "../lib/render.js"; - -export const connectionsRouter = new Hono(); - -const SERVICE_TYPES = [ - { - type: "evotor", - name: "Эвотор", - icon: "bi-shop", - description: "Подключите кассу Эвотор для синхронизации каталога товаров.", - configure_url: "/evotor", - connect_url: "/evotor", - }, - { - type: "vk", - name: "ВКонтакте", - icon: "bi-bag", - description: "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.", - configure_url: "/vk", - connect_url: "/vk", - }, -]; - -async function requireUser(c: Parameters[0]) { - const id = getSessionUserId(c); - if (!id) return null; - const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); - return user ?? null; -} - -connectionsRouter.get("/connections", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1); - const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1); - - const connections = SERVICE_TYPES - .map((svc) => { - const conn = svc.type === "evotor" ? evotor : vk; - if (!conn) return null; - const details = svc.type === "evotor" - ? (evotor?.store_name ?? null) - : (vk?.first_name ? `${vk.first_name} ${vk.last_name ?? ""}`.trim() : null); - return { ...svc, is_online: conn.is_online, last_checked_at: conn.last_checked_at, details }; - }) - .filter(Boolean); - - return c.html(render("connections.njk", { user, connections })); -}); - -connectionsRouter.get("/connections/add", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1); - const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1); - - const available = SERVICE_TYPES.filter((svc) => { - return svc.type === "evotor" ? !evotor : !vk; - }); - - return c.html(render("connections_add.njk", { user, available })); -}); - -connectionsRouter.post("/connections/delete", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const svcType = c.req.query("type"); - if (svcType === "evotor") { - await db.delete(evotor_connections).where(eq(evotor_connections.user_id, user.id)); - } else if (svcType === "vk") { - await db.delete(vk_connections).where(eq(vk_connections.user_id, user.id)); - } - - return c.redirect("/connections", 303); -}); diff --git a/web/src/routes/evotor.ts b/web/src/routes/evotor.ts deleted file mode 100644 index 709c7a9..0000000 --- a/web/src/routes/evotor.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Hono } from "hono"; -import { db } from "../db/client.js"; -import { users, evotor_connections } from "../db/schema.js"; -import { eq } from "drizzle-orm"; -import { getSessionUserId, type AppEnv } from "../lib/auth.js"; -import { render } from "../lib/render.js"; -import { config } from "../config.js"; - -export const evotorRouter = new Hono(); - -const EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"; -const EVOTOR_STORES_URL = "https://api.evotor.ru/stores"; - -async function requireUser(c: Parameters[0]) { - const id = getSessionUserId(c); - if (!id) return null; - const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); - return user ?? null; -} - -async function fetchStoreInfo(token: string): Promise<{ store_id: string | null; store_name: string | null }> { - try { - const resp = await fetch(EVOTOR_STORES_URL, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(15000), - }); - if (resp.ok) { - const data = await resp.json() as unknown; - const stores = (typeof data === "object" && data !== null && "items" in data) - ? (data as { items: unknown[] }).items - : (Array.isArray(data) ? data : []); - if (Array.isArray(stores) && stores.length > 0) { - const s = stores[0] as Record; - return { - store_id: (s.uuid as string | null) ?? (s.id as string | null) ?? null, - store_name: (s.name as string | null) ?? null, - }; - } - } - return { store_id: null, store_name: null }; - } catch { - return { store_id: null, store_name: null }; - } -} - -evotorRouter.get("/evotor", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const [connection] = await db.select().from(evotor_connections) - .where(eq(evotor_connections.user_id, user.id)).limit(1); - const error = c.req.query("error") ?? null; - const app_url = config.EVOTOR_APP_ID - ? EVOTOR_APP_URL.replace("{app_id}", config.EVOTOR_APP_ID) - : null; - - return c.html(render("evotor.njk", { user, connection: connection ?? null, error, app_url })); -}); - -// Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."} -evotorRouter.post("/evotor/callback", async (c) => { - if (config.EVOTOR_WEBHOOK_SECRET) { - const authHeader = c.req.header("Authorization") ?? ""; - if (authHeader !== `Bearer ${config.EVOTOR_WEBHOOK_SECRET}`) { - return c.json({ error: "Unauthorized" }, 401); - } - } - - const payload = await c.req.json() as { userId?: string; token?: string }; - if (!payload.userId || !payload.token) { - return c.json({ error: "Invalid payload" }, 400); - } - - const now = new Date(); - const { store_id, store_name } = await fetchStoreInfo(payload.token); - - const [existing] = await db.select().from(evotor_connections) - .where(eq(evotor_connections.evotor_user_id, payload.userId)).limit(1); - - if (existing) { - await db.update(evotor_connections) - .set({ access_token: payload.token, store_id, store_name, is_online: true, last_checked_at: now, updated_at: now }) - .where(eq(evotor_connections.id, existing.id)); - } else { - await db.insert(evotor_connections).values({ - evotor_user_id: payload.userId, - access_token: payload.token, - store_id, - store_name, - is_online: true, - last_checked_at: now, - }); - } - - return c.json({ status: "ok" }); -}); - -evotorRouter.post("/evotor/token", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const form = await c.req.formData(); - const token = String(form.get("token") ?? "").trim(); - if (!token) return c.redirect("/evotor?error=empty_token", 303); - - const now = new Date(); - - // Validate token by fetching stores - let storeInfo: { store_id: string | null; store_name: string | null }; - try { - const resp = await fetch(EVOTOR_STORES_URL, { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(15000), - }); - if (resp.status === 401) return c.redirect("/evotor?error=invalid_token", 303); - storeInfo = await fetchStoreInfo(token); - } catch { - storeInfo = { store_id: null, store_name: null }; - } - - const [existing] = await db.select().from(evotor_connections) - .where(eq(evotor_connections.user_id, user.id)).limit(1); - - if (existing) { - await db.update(evotor_connections) - .set({ access_token: token, store_id: storeInfo.store_id, store_name: storeInfo.store_name, is_online: true, last_checked_at: now, updated_at: now }) - .where(eq(evotor_connections.id, existing.id)); - } else { - await db.insert(evotor_connections).values({ - user_id: user.id, - access_token: token, - store_id: storeInfo.store_id, - store_name: storeInfo.store_name, - is_online: true, - last_checked_at: now, - }); - } - - return c.redirect("/connections", 303); -}); - -evotorRouter.post("/evotor/disconnect", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - await db.delete(evotor_connections).where(eq(evotor_connections.user_id, user.id)); - return c.redirect("/connections", 303); -}); diff --git a/web/src/routes/profile.ts b/web/src/routes/profile.ts deleted file mode 100644 index 9ca0a1e..0000000 --- a/web/src/routes/profile.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Hono } from "hono"; -import { db } from "../db/client.js"; -import { users } from "../db/schema.js"; -import { eq, and, ne } from "drizzle-orm"; -import { hashPassword, verifyPassword, getSessionUserId, type AppEnv } from "../lib/auth.js"; -import { validateProfile, validateResetPassword } from "../lib/validate.js"; -import { render } from "../lib/render.js"; - -export const profileRouter = new Hono(); - -async function requireUser(c: Parameters[0]) { - const id = getSessionUserId(c); - if (!id) return null; - const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); - return user ?? null; -} - -profileRouter.get("/profile", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - return c.html(render("profile_view.njk", { user })); -}); - -profileRouter.get("/profile/edit", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - return c.html(render("profile_edit.njk", { user })); -}); - -profileRouter.post("/profile/edit", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const form = await c.req.formData(); - const data: Record = {}; - form.forEach((v, k) => { data[k] = String(v); }); - - const errors = validateProfile(data); - if (!errors.length) { - const existing = await db.select().from(users).where( - and(eq(users.phone, data.phone.trim()), ne(users.id, user.id)) - ).limit(1); - if (existing.length) errors.push("Пользователь с таким телефоном уже существует"); - } - - if (errors.length) { - return c.html(render("profile_edit.njk", { user, errors, form: data })); - } - - await db.update(users) - .set({ first_name: data.first_name.trim(), last_name: data.last_name.trim(), phone: data.phone.trim() }) - .where(eq(users.id, user.id)); - - const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1); - return c.html(render("profile_edit.njk", { user: updated, success: "Профиль обновлен" })); -}); - -profileRouter.get("/profile/change-password", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - return c.html(render("profile_change_password.njk", { user })); -}); - -profileRouter.post("/profile/change-password", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const form = await c.req.formData(); - const data: Record = {}; - form.forEach((v, k) => { data[k] = String(v); }); - - const errors: string[] = []; - const currentPassword = data.current_password ?? ""; - if (!currentPassword) { - errors.push("Введите текущий пароль"); - } else if (!(await verifyPassword(currentPassword, user.password_hash))) { - errors.push("Неверный текущий пароль"); - } - errors.push(...validateResetPassword(data)); - - if (errors.length) { - return c.html(render("profile_change_password.njk", { user, errors })); - } - - await db.update(users) - .set({ password_hash: await hashPassword(data.password) }) - .where(eq(users.id, user.id)); - - return c.html(render("profile_change_password.njk", { user, success: "Пароль изменен" })); -}); - -profileRouter.get("/profile/delete", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - return c.html(render("profile_delete.njk", { user })); -}); - -profileRouter.post("/profile/delete", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const form = await c.req.formData(); - const password = String(form.get("password") ?? ""); - - if (!password) { - return c.html(render("profile_delete.njk", { user, errors: ["Введите пароль для подтверждения"] })); - } - if (!(await verifyPassword(password, user.password_hash))) { - return c.html(render("profile_delete.njk", { user, errors: ["Неверный пароль"] })); - } - - await db.delete(users).where(eq(users.id, user.id)); - const session = c.get("session") as { deleteSession: () => void }; - session.deleteSession(); - return c.redirect("/", 303); -}); diff --git a/web/src/routes/reset.ts b/web/src/routes/reset.ts deleted file mode 100644 index ca77620..0000000 --- a/web/src/routes/reset.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Hono } from "hono"; -import { db } from "../db/client.js"; -import { users } from "../db/schema.js"; -import { eq } from "drizzle-orm"; -import { hashPassword, type AppEnv } from "../lib/auth.js"; -import { validateResetPassword } from "../lib/validate.js"; -import { render } from "../lib/render.js"; -import { config } from "../config.js"; -import { randomUUID } from "crypto"; - -export const resetRouter = new Hono(); - -resetRouter.get("/forgot-password", (c) => { - return c.html(render("forgot_password.njk", { user: null })); -}); - -resetRouter.post("/forgot-password", async (c) => { - const form = await c.req.formData(); - const email = (String(form.get("email") ?? "")).trim(); - - if (email) { - const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1); - if (user) { - const token = randomUUID().replace(/-/g, ""); - const expires = new Date(Date.now() + config.PASSWORD_RESET_EXPIRE_MINUTES * 60 * 1000); - await db.update(users) - .set({ password_reset_token: token, password_reset_expires: expires }) - .where(eq(users.id, user.id)); - - const resetUrl = `${config.BASE_URL}/reset-password?token=${token}`; - console.log("=".repeat(40)); - console.log("СБРОС ПАРОЛЯ"); - console.log(`Пользователь: ${user.email}`); - console.log(`Ссылка: ${resetUrl}`); - console.log(`Действительна: ${config.PASSWORD_RESET_EXPIRE_MINUTES} мин.`); - console.log("=".repeat(40)); - } - } - - return c.html(render("message.njk", { - user: null, - title: "Сброс пароля", - message: "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.", - })); -}); - -resetRouter.get("/reset-password", async (c) => { - const token = c.req.query("token") ?? ""; - const [user] = await db.select().from(users).where(eq(users.password_reset_token, token)).limit(1); - - if (!user || !user.password_reset_expires) { - return c.html(render("message.njk", { - user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.", - })); - } - if (new Date() > user.password_reset_expires) { - return c.html(render("message.njk", { - user: null, title: "Ошибка", message: "Срок действия ссылки истек.", - })); - } - - return c.html(render("reset_password.njk", { user: null, token })); -}); - -resetRouter.post("/reset-password", async (c) => { - const token = c.req.query("token") ?? ""; - const [user] = await db.select().from(users).where(eq(users.password_reset_token, token)).limit(1); - - if (!user || !user.password_reset_expires) { - return c.html(render("message.njk", { - user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.", - })); - } - if (new Date() > user.password_reset_expires) { - return c.html(render("message.njk", { - user: null, title: "Ошибка", message: "Срок действия ссылки истек.", - })); - } - - const form = await c.req.formData(); - const data: Record = {}; - form.forEach((v, k) => { data[k] = String(v); }); - const errors = validateResetPassword(data); - - if (errors.length) { - return c.html(render("reset_password.njk", { user: null, token, errors })); - } - - await db.update(users) - .set({ password_hash: await hashPassword(data.password), password_reset_token: null, password_reset_expires: null }) - .where(eq(users.id, user.id)); - - return c.html(render("message.njk", { - user: null, - title: "Пароль изменен", - message: "Ваш пароль успешно изменен. Теперь вы можете войти.", - link: "/login", - link_text: "Войти", - })); -}); diff --git a/web/src/routes/sync.ts b/web/src/routes/sync.ts deleted file mode 100644 index ab8266b..0000000 --- a/web/src/routes/sync.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Hono } from "hono"; -import { db } from "../db/client.js"; -import { users, evotor_connections, vk_connections, sync_configs, sync_filters } from "../db/schema.js"; -import { eq } from "drizzle-orm"; -import { getSessionUserId, type AppEnv } from "../lib/auth.js"; -import { render } from "../lib/render.js"; - -export const syncRouter = new Hono(); - -async function requireUser(c: Parameters[0]) { - const id = getSessionUserId(c); - if (!id) return null; - const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); - return user ?? null; -} - -async function getOrCreateSyncConfig(userId: number) { - const [existing] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1); - if (existing) return existing; - await db.insert(sync_configs).values({ user_id: userId, is_enabled: false }); - const [created] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1); - return created!; -} - -syncRouter.get("/sync", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1); - const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1); - const config = await getOrCreateSyncConfig(user.id); - const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, config.id)); - - const summary = { - stores: filters.filter((f) => f.entity_type === "store").length, - groups: filters.filter((f) => f.entity_type === "group").length, - products: filters.filter((f) => f.entity_type === "product").length, - total: filters.length, - }; - - let status: string; - if (config.confirmed_at && config.is_enabled) { - status = "active"; - } else if (config.confirmed_at && !config.is_enabled) { - status = "paused"; - } else if (summary.total > 0) { - status = "pending"; - } else { - status = "unconfigured"; - } - - return c.html(render("sync.njk", { - user, - evotor: evotor ?? null, - vk: vk ?? null, - config, - summary, - status, - })); -}); - -syncRouter.post("/sync/toggle", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const config = await getOrCreateSyncConfig(user.id); - await db.update(sync_configs) - .set({ is_enabled: !config.is_enabled }) - .where(eq(sync_configs.id, config.id)); - - return c.redirect("/sync", 303); -}); - -syncRouter.post("/sync/confirm", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const config = await getOrCreateSyncConfig(user.id); - const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, config.id)); - - if (config.is_enabled && filters.length > 0) { - await db.update(sync_configs) - .set({ confirmed_at: new Date() }) - .where(eq(sync_configs.id, config.id)); - } - - return c.redirect("/sync", 303); -}); diff --git a/web/src/routes/vk.ts b/web/src/routes/vk.ts deleted file mode 100644 index e2ac49d..0000000 --- a/web/src/routes/vk.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Hono } from "hono"; -import { db } from "../db/client.js"; -import { users, vk_connections } from "../db/schema.js"; -import { eq } from "drizzle-orm"; -import { getSessionUserId, type AppEnv } from "../lib/auth.js"; -import { render } from "../lib/render.js"; -import { config } from "../config.js"; - -export const vkRouter = new Hono(); - -const VK_API_URL = "https://api.vk.com/method"; -const VK_OAUTH_URL = "https://oauth.vk.com/authorize"; - -async function requireUser(c: Parameters[0]) { - const id = getSessionUserId(c); - if (!id) return null; - const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1); - return user ?? null; -} - -async function fetchGroupInfo(token: string): Promise<{ groupId: string | null; groupName: string | null }> { - try { - const params = new URLSearchParams({ - access_token: token, - v: config.VK_API_VERSION, - filter: "admin", - extended: "1", - count: "1", - }); - const resp = await fetch(`${VK_API_URL}/groups.get?${params}`, { - signal: AbortSignal.timeout(15000), - }); - if (!resp.ok) return { groupId: null, groupName: null }; - const data = await resp.json() as { error?: unknown; response?: { items?: Array<{ id: number; name: string }> } }; - if (data.error) return { groupId: null, groupName: null }; - const items = data.response?.items ?? []; - if (items.length === 0) return { groupId: null, groupName: null }; - return { groupId: String(items[0].id), groupName: items[0].name }; - } catch { - return { groupId: null, groupName: null }; - } -} - -vkRouter.get("/vk", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const [connection] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1); - const error = c.req.query("error") ?? null; - - return c.html(render("vk.njk", { - user, - connection: connection ?? null, - error, - vk_client_id: config.VK_CLIENT_ID, - callback_url: `${config.BASE_URL}/vk/callback`, - })); -}); - -vkRouter.get("/vk/connect", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - if (!config.VK_CLIENT_ID) return c.redirect("/vk?error=no_client_id", 303); - - const params = new URLSearchParams({ - client_id: config.VK_CLIENT_ID, - scope: "market,groups", - redirect_uri: `${config.BASE_URL}/vk/callback`, - display: "page", - response_type: "token", - v: config.VK_API_VERSION, - }); - return c.redirect(`${VK_OAUTH_URL}?${params}`, 302); -}); - -vkRouter.get("/vk/callback", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - return c.html(render("vk_callback.njk", { user })); -}); - -vkRouter.post("/vk/token", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - - const form = await c.req.formData(); - const token = String(form.get("token") ?? "").trim(); - if (!token) return c.redirect("/vk?error=empty_token", 303); - - const { groupId, groupName } = await fetchGroupInfo(token); - if (!groupId) return c.redirect("/vk?error=invalid_token", 303); - - const now = new Date(); - const [existing] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1); - - if (existing) { - await db.update(vk_connections) - .set({ access_token: token, vk_user_id: groupId, first_name: groupName, last_name: null, is_online: true, last_checked_at: now, updated_at: now }) - .where(eq(vk_connections.id, existing.id)); - } else { - await db.insert(vk_connections).values({ - user_id: user.id, - access_token: token, - vk_user_id: groupId, - first_name: groupName, - last_name: null, - is_online: true, - last_checked_at: now, - }); - } - - return c.redirect("/connections", 303); -}); - -vkRouter.post("/vk/disconnect", async (c) => { - const user = await requireUser(c); - if (!user) return c.redirect("/login", 303); - await db.delete(vk_connections).where(eq(vk_connections.user_id, user.id)); - return c.redirect("/connections", 303); -}); diff --git a/web/src/templates/base.njk b/web/src/templates/base.njk deleted file mode 100644 index 711abe3..0000000 --- a/web/src/templates/base.njk +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - {% block title %}ЭВОСИНК{% endblock %} - - - - - - - -
- {% if errors %} - - {% endif %} - - {% if success %} - - {% endif %} - - {% block content %}{% endblock %} -
- - {% if jivosite_widget_id %} - - {% endif %} - - - - - diff --git a/web/src/templates/catalog_groups.njk b/web/src/templates/catalog_groups.njk deleted file mode 100644 index ccb5011..0000000 --- a/web/src/templates/catalog_groups.njk +++ /dev/null @@ -1,119 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %} - -{% block content %} - - -
-

Группы товаров

- - Экспорт CSV - -
- -{% if not groups %} -
- -

Группы не найдены в этом магазине.

- - Посмотреть все товары магазина - -
- -{% else %} -
- - - - - - - - - - - {% for group in groups %} - {% set mode = filter_map[group.evotor_id] %} - - - - - - - {% endfor %} - -
НазваниеКол-во товаровФильтр
{{ group.name }}{{ product_counts[group.evotor_id] or 0 }} - {% if mode == "include" %} - ✓ Включено - {% elif mode == "exclude" %} - ✗ Исключено - {% else %} - — Нет правила - {% endif %} - -
- - - - -
-
-
-{% endif %} - - -{% endblock %} diff --git a/web/src/templates/catalog_products.njk b/web/src/templates/catalog_products.njk deleted file mode 100644 index bf2f1d0..0000000 --- a/web/src/templates/catalog_products.njk +++ /dev/null @@ -1,144 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Товары — ЭВОСИНК{% endblock %} - -{% block content %} - - -
-

Товары{% if group %}: {{ group.name }}{% endif %}

- - Экспорт CSV - -
- -{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %} - -{% if not products %} -
- -

Товары не найдены.

-
- -{% else %} -
- - - - - - - - - - - - - - - - {% for product in products %} - {% set mode = filter_map[product.evotor_id] %} - - - - - - - - - - - - {% endfor %} - -
НазваниеАртикулЦенаКол-воЕд. изм.В продажеСинхронизированФильтр
{{ product.name }}{{ product.article_number or "—" }}{% if product.price %}{{ product.price | price }} ₽{% else %}—{% endif %}{% if product.quantity != null %}{{ product.quantity }}{% else %}—{% endif %}{{ product.measure_name or "—" }} - {% if product.allow_to_sell == null %} - - {% elif product.allow_to_sell %} - - {% else %} - - {% endif %} - - {% if product.synced_at %} - - - - {% else %} - - {% endif %} - - {% if mode == "include" %} - ✓ Включено - {% elif mode == "exclude" %} - ✗ Исключено - {% else %} - — Нет правила - {% endif %} - - -
-
-{% endif %} - - -{% endblock %} diff --git a/web/src/templates/catalog_stores.njk b/web/src/templates/catalog_stores.njk deleted file mode 100644 index a181e8f..0000000 --- a/web/src/templates/catalog_stores.njk +++ /dev/null @@ -1,127 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Каталог — ЭВОСИНК{% endblock %} - -{% block content %} -
-

Каталог

-
- {% if evotor %} -
- -
- - Экспорт CSV - - {% endif %} -
-
- -{% if not evotor %} - - -{% elif not stores %} -
- -

Магазины не найдены в вашем аккаунте Эвотор.

-
- -{% else %} -{% if fetched_at %} -

Последнее обновление: {{ fetched_at | datefmt }}

-{% endif %} - -
- - - - - - - - - - - {% for store in stores %} - {% set mode = filter_map[store.evotor_id] %} - - - - - - - {% endfor %} - -
НазваниеАдресФильтр
{{ store.name }}{{ store.address or "—" }} - {% if mode == "include" %} - ✓ Включено - {% elif mode == "exclude" %} - ✗ Исключено - {% else %} - — Нет правила - {% endif %} - -
- - - - -
-
-
-{% endif %} - - -{% endblock %} diff --git a/web/src/templates/confirm_email.njk b/web/src/templates/confirm_email.njk deleted file mode 100644 index ca271ca..0000000 --- a/web/src/templates/confirm_email.njk +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Подтверждение email — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
- -

Подтвердите ваш email

-

Проверьте почту и нажмите на ссылку для подтверждения.

-
-
-
-
-{% endblock %} diff --git a/web/src/templates/connections.njk b/web/src/templates/connections.njk deleted file mode 100644 index 5fb0b42..0000000 --- a/web/src/templates/connections.njk +++ /dev/null @@ -1,63 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Подключения — ЭВОСИНК{% endblock %} - -{% block content %} -
-

Подключения

- - Добавить - -
- -{% if connections %} -
- {% for conn in connections %} -
-
-
-
- -
- {{ conn.name }} - {% if conn.details %} -
{{ conn.details }} - {% endif %} -
- {% if conn.is_online %} - - {% else %} - - {% endif %} -
-
- Настроить -
- -
-
-
-
- {% if conn.last_checked_at %} - Проверено: {{ conn.last_checked_at | datefmt }} - {% else %} - Статус ещё не проверялся - {% endif %} -
-
-
- {% endfor %} -
- -{% else %} -
- -

Нет подключённых сервисов

- - Добавить подключение - -
-{% endif %} -{% endblock %} diff --git a/web/src/templates/connections_add.njk b/web/src/templates/connections_add.njk deleted file mode 100644 index 587de4f..0000000 --- a/web/src/templates/connections_add.njk +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Добавить подключение — ЭВОСИНК{% endblock %} - -{% block content %} -
- -

Добавить подключение

-
- -{% if available %} -
- {% for svc in available %} -
- -
- {% endfor %} -
- -{% else %} -
- -

Все доступные сервисы подключены

- - Вернуться к подключениям - -
-{% endif %} -{% endblock %} diff --git a/web/src/templates/email_confirmed.njk b/web/src/templates/email_confirmed.njk deleted file mode 100644 index 4ca8d1c..0000000 --- a/web/src/templates/email_confirmed.njk +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Email подтвержден — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
- -

Email подтвержден!

-

Ваш email успешно подтвержден. Теперь вы можете войти в систему.

- Войти -
-
-
-
-{% endblock %} diff --git a/web/src/templates/evotor.njk b/web/src/templates/evotor.njk deleted file mode 100644 index ee501a7..0000000 --- a/web/src/templates/evotor.njk +++ /dev/null @@ -1,95 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %} - -{% block content %} -
-
- - {% if error %} - - {% endif %} - -
-
-

Подключение Эвотор

-
- - {% if connection %} -
    -
  • - Статус - Подключено -
  • - {% if connection.store_name %} -
  • - Магазин - {{ connection.store_name }} -
  • - {% endif %} - {% if connection.store_id %} -
  • - ID магазина - {{ connection.store_id }} -
  • - {% endif %} -
  • - Подключено - {{ connection.connected_at | datefmt }} -
  • -
-
-

Обновить токен (Личный кабинет Эвотор → Приложения → ЭвоСинк → Настройки):

-
-
- - -
-
-
-
-
- -
-
- - {% else %} -
-

- Для подключения вам нужно установить приложение ЭвоСинк в личном кабинете Эвотор - и скопировать токен доступа из его настроек. -

-
    - {% if app_url %} -
  1. Откройте приложение ЭвоСинк в магазине Эвотор и установите его.
  2. - {% else %} -
  3. Найдите приложение ЭвоСинк в магазине Эвотор и установите его.
  4. - {% endif %} -
  5. Перейдите в раздел Приложения → ЭвоСинк → Настройки.
  6. -
  7. Скопируйте токен доступа и вставьте его в поле ниже.
  8. -
-
- - -
-
- {% endif %} -
- - -
-
-{% endblock %} diff --git a/web/src/templates/forgot_password.njk b/web/src/templates/forgot_password.njk deleted file mode 100644 index d2add56..0000000 --- a/web/src/templates/forgot_password.njk +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Забыли пароль — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Забыли пароль?

-

Введите email, указанный при регистрации.

-
- - -
- -
-
-
-
-{% endblock %} diff --git a/web/src/templates/login.njk b/web/src/templates/login.njk deleted file mode 100644 index 3233874..0000000 --- a/web/src/templates/login.njk +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Вход — ЭВОСИНК{% endblock %} - -{% block content %} -
-
- -
-
-{% endblock %} diff --git a/web/src/templates/message.njk b/web/src/templates/message.njk deleted file mode 100644 index 9062ebb..0000000 --- a/web/src/templates/message.njk +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.njk" %} -{% block title %}{{ title }} — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

{{ title }}

-

{{ message }}

- {% if link %} - {{ link_text }} - {% endif %} -
-
-
-
-{% endblock %} diff --git a/web/src/templates/profile_change_password.njk b/web/src/templates/profile_change_password.njk deleted file mode 100644 index fd2e7ef..0000000 --- a/web/src/templates/profile_change_password.njk +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Изменить пароль — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Изменить пароль

-
-
-
- - - -
- - Отмена -
-
-
-
-
-
-{% endblock %} diff --git a/web/src/templates/profile_delete.njk b/web/src/templates/profile_delete.njk deleted file mode 100644 index 7ca2337..0000000 --- a/web/src/templates/profile_delete.njk +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Удалить аккаунт

-
-
- -
- -
- - Отмена -
-
-
-
-
-
-{% endblock %} diff --git a/web/src/templates/profile_edit.njk b/web/src/templates/profile_edit.njk deleted file mode 100644 index 2963e92..0000000 --- a/web/src/templates/profile_edit.njk +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Редактировать профиль

-
-
-
-
-
- -
-
- -
-
- - -
- - Отмена -
-
-
-
-
-
-{% endblock %} diff --git a/web/src/templates/profile_view.njk b/web/src/templates/profile_view.njk deleted file mode 100644 index d86664c..0000000 --- a/web/src/templates/profile_view.njk +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Личный кабинет — ЭВОСИНК{% endblock %} - -{% block content %} -
-
- -
-
-{% endblock %} diff --git a/web/src/templates/register.njk b/web/src/templates/register.njk deleted file mode 100644 index 95addfc..0000000 --- a/web/src/templates/register.njk +++ /dev/null @@ -1,44 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Регистрация — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Регистрация

-
-
-
- -
-
- -
-
- - - - - -
- -
-
-
-
-{% endblock %} diff --git a/web/src/templates/reset_password.njk b/web/src/templates/reset_password.njk deleted file mode 100644 index fc28a43..0000000 --- a/web/src/templates/reset_password.njk +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Новый пароль — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-

Новый пароль

-
- - - -
-
-
-
-
-{% endblock %} diff --git a/web/src/templates/sync.njk b/web/src/templates/sync.njk deleted file mode 100644 index f95ca72..0000000 --- a/web/src/templates/sync.njk +++ /dev/null @@ -1,101 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Синхронизация — ЭВОСИНК{% endblock %} - -{% block content %} -

Синхронизация

- -{% if not evotor %} - -{% endif %} - -{% if not vk %} - -{% endif %} - -
-
-
-
-
Статус
-
- {% if status == "active" %} - Активна - {% elif status == "paused" %} - Приостановлена - {% elif status == "pending" %} - Ожидает подтверждения - {% else %} - Не настроено - {% endif %} -
- - {% if config.confirmed_at %} -

- Запущена: {{ config.confirmed_at | datefmt }} -

- {% endif %} - -
-
- {% if config.is_enabled %} - - {% else %} - - {% endif %} -
- - {% if config.is_enabled and summary.total > 0 %} -
- -
- {% endif %} -
- - {% if config.is_enabled and summary.total == 0 %} -

- Настройте фильтры, чтобы подтвердить запуск. -

- {% endif %} -
-
-
- -
-
-
-
Фильтры
- {% if summary.total > 0 %} -
    - {% if summary.stores > 0 %} -
  • Магазины: {{ summary.stores }} правил
  • - {% endif %} - {% if summary.groups > 0 %} -
  • Группы: {{ summary.groups }} правил
  • - {% endif %} - {% if summary.products > 0 %} -
  • Товары: {{ summary.products }} правил
  • - {% endif %} -
- {% else %} -

Фильтры не настроены — будут синхронизированы все товары.

- {% endif %} - - Настроить фильтры - -
-
-
-
-{% endblock %} diff --git a/web/src/templates/vk.njk b/web/src/templates/vk.njk deleted file mode 100644 index 18ae97c..0000000 --- a/web/src/templates/vk.njk +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %} - -{% block content %} -
-
- - {% if error %} - - {% endif %} - -
-
-

Подключение ВКонтакте

-
- - {% if connection %} -
    -
  • - Статус - Подключено -
  • - {% if connection.first_name %} -
  • - Сообщество - {{ connection.first_name }} -
  • - {% endif %} - {% if connection.vk_user_id %} -
  • - ID сообщества - {{ connection.vk_user_id }} -
  • - {% endif %} -
  • - Подключено - {{ connection.connected_at | datefmt }} -
  • -
-
-

Обновить токен пользователя:

-
-
- - -
-
-
-
-
- -
-
- - {% else %} -
- {% if vk_client_id %} -

- Нажмите кнопку ниже, чтобы авторизоваться через ВКонтакте и выдать доступ к управлению товарами вашего сообщества. -

- - Подключить ВКонтакте - -
-

Или введите токен вручную:

- {% else %} -

- Для синхронизации товаров необходим токен пользователя ВКонтакте - с правами на управление товарами сообщества. -

- {% endif %} - -
- - {% if vk_client_id %} - - {% else %} - - {% endif %} -
-
- {% endif %} -
- - -
-
-{% endblock %} diff --git a/web/src/templates/vk_callback.njk b/web/src/templates/vk_callback.njk deleted file mode 100644 index 19113aa..0000000 --- a/web/src/templates/vk_callback.njk +++ /dev/null @@ -1,47 +0,0 @@ -{% extends "base.njk" %} -{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %} - -{% block content %} -
-
-
-
-
-
-

Подключение ВКонтакте…

-
-
- -

Не удалось получить токен от ВКонтакте.

- Попробовать снова -
-
-
-
-
- -
- -
- - -{% endblock %} diff --git a/web/static/style.css b/web/static/style.css deleted file mode 100644 index c530339..0000000 --- a/web/static/style.css +++ /dev/null @@ -1,431 +0,0 @@ -/* Brand colors */ -:root { - --pico-primary: #F05023; - --pico-primary-hover: #d44420; - --pico-primary-focus: rgba(240, 80, 35, 0.25); - --pico-primary-inverse: #fff; - --brand-primary: #F05023; - --brand-secondary: #0986E2; - --brand-secondary-hover: #0770c0; -} - -/* Header / nav */ -.site-header { - background: #fff; - border-bottom: 2px solid var(--brand-primary); - padding: 0; - margin-bottom: 0; -} - -.site-header nav { - display: flex; - align-items: center; - justify-content: space-between; - padding-top: 0.75rem; - padding-bottom: 0.75rem; - gap: 1rem; -} - -.site-header nav > ul { - margin: 0; - padding: 0; - list-style: none; - display: flex; - align-items: center; - gap: 0.25rem; -} - -.brand-logo { - font-size: 1.3rem; - font-weight: 700; - color: var(--brand-primary) !important; - text-decoration: none; -} - -.nav-links { - flex: 1; - justify-content: flex-end; -} - -.nav-links a { - color: var(--pico-color); - text-decoration: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; -} - -.nav-links a:hover { - color: var(--brand-primary); -} - -.nav-links a.secondary { - color: var(--pico-muted-color); -} - -.mobile-menu { - display: none; -} - -.mobile-menu summary { - padding: 0.25rem 0.5rem; - font-size: 1.25rem; -} - -.mobile-menu > ul { - position: absolute; - right: 1rem; - background: var(--pico-background-color); - border: 1px solid var(--pico-border-color); - border-radius: var(--pico-border-radius); - padding: 0.5rem 0; - list-style: none; - margin: 0; - z-index: 100; - min-width: 180px; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); -} - -.mobile-menu > ul li a { - display: block; - padding: 0.5rem 1rem; - text-decoration: none; - color: var(--pico-color); -} - -.mobile-menu > ul li a:hover { - background: var(--pico-muted-background-color); -} - -@media (max-width: 768px) { - .nav-links { display: none; } - .mobile-menu { display: block; } -} - -/* Page spacing */ -.py-4 { - padding-top: 1.5rem; - padding-bottom: 1.5rem; -} - -/* Alerts */ -.alert { - border-radius: var(--pico-border-radius); - padding: 0.75rem 1rem; - margin-bottom: 1rem; -} - -.alert p { margin: 0; } -.alert p + p { margin-top: 0.25rem; } - -.alert-danger { - background: #fef2f2; - border: 1px solid #fecaca; - color: #b91c1c; -} - -.alert-success { - background: #f0fdf4; - border: 1px solid #bbf7d0; - color: #15803d; -} - -.alert-warning { - background: #fffbeb; - border: 1px solid #fde68a; - color: #b45309; -} - -/* Cards (using
) */ -article.card { - margin: 0; - padding: 0; - overflow: hidden; -} - -article.card > header { - padding: 0.75rem 1rem; - background: var(--pico-muted-background-color); - border-bottom: 1px solid var(--pico-border-color); - margin: 0; -} - -article.card > header h1, -article.card > header h2, -article.card > header h5 { - margin: 0; - font-size: 1rem; - font-weight: 600; -} - -article.card > .card-body { - padding: 1.25rem; -} - -article.card > footer { - padding: 0.75rem 1rem; - background: var(--pico-muted-background-color); - border-top: 1px solid var(--pico-border-color); - margin: 0; -} - -/* List groups */ -.list-group { - list-style: none; - padding: 0; - margin: 0; -} - -.list-group-item { - padding: 0.6rem 1rem; - border-bottom: 1px solid var(--pico-border-color); - display: flex; - justify-content: space-between; - align-items: center; -} - -.list-group-item:last-child { border-bottom: none; } - -/* Badges */ -.badge { - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.2rem 0.5rem; - border-radius: 999px; - font-size: 0.75rem; - font-weight: 600; - line-height: 1; -} - -.badge-success { background: #dcfce7; color: #15803d; } -.badge-danger { background: #fee2e2; color: #b91c1c; } -.badge-warning { background: #fef3c7; color: #b45309; } -.badge-secondary { background: var(--pico-muted-background-color); color: var(--pico-muted-color); } -.badge-light { background: var(--pico-muted-background-color); color: var(--pico-muted-color); border: 1px solid var(--pico-border-color); } - -/* Buttons */ -button.secondary, a[role="button"].secondary { - --pico-background-color: var(--brand-secondary); - --pico-border-color: var(--brand-secondary); - --pico-color: #fff; -} - -button.outline.danger, a[role="button"].outline.danger { - --pico-color: #dc2626; - --pico-border-color: #dc2626; -} - -button.danger, a[role="button"].danger { - --pico-background-color: #dc2626; - --pico-border-color: #dc2626; - --pico-color: #fff; -} - -button.sm, a[role="button"].sm { - padding: 0.25rem 0.6rem; - font-size: 0.875rem; -} - -/* Layout helpers */ -.row { - display: flex; - flex-wrap: wrap; - gap: 1rem; -} - -.col { flex: 1 1 0; } -.col-auto { flex: 0 0 auto; } - -.col-sm-6 { flex: 0 0 calc(50% - 0.5rem); min-width: 200px; } -.col-sm-10 { flex: 0 0 calc(83.33% - 0.5rem); } -.col-md-6 { flex: 0 0 calc(50% - 0.5rem); } -.col-md-7 { flex: 0 0 calc(58.33% - 0.5rem); } -.col-lg-4 { flex: 0 0 calc(33.33% - 0.67rem); } -.col-lg-5 { flex: 0 0 calc(41.67% - 0.5rem); } -.col-lg-6 { flex: 0 0 calc(50% - 0.5rem); } -.col-12 { flex: 0 0 100%; } -.col-md-6-auto { flex: 0 0 calc(50% - 0.5rem); } - -@media (max-width: 768px) { - .col-sm-6, .col-md-6, .col-md-7, .col-lg-4, .col-lg-5, .col-lg-6, .col-md-6-auto { flex: 0 0 100%; } -} - -.justify-center { justify-content: center; } -.justify-between { justify-content: space-between; } -.justify-end { justify-content: flex-end; } -.align-center { align-items: center; } -.flex-wrap { flex-wrap: wrap; } -.flex-col { flex-direction: column; } -.flex-1 { flex: 1; } -.flex-fill { flex: 1 1 0; } - -.gap-1 { gap: 0.25rem; } -.gap-2 { gap: 0.5rem; } -.gap-3 { gap: 0.75rem; } - -.mt-2 { margin-top: 0.5rem; } -.mt-3 { margin-top: 0.75rem; } -.mt-4 { margin-top: 1.5rem; } -.mt-5 { margin-top: 3rem; } -.mb-0 { margin-bottom: 0; } -.mb-1 { margin-bottom: 0.25rem; } -.mb-2 { margin-bottom: 0.5rem; } -.mb-3 { margin-bottom: 0.75rem; } -.mb-4 { margin-bottom: 1.5rem; } -.ms-auto { margin-left: auto; } -.me-1 { margin-right: 0.25rem; } -.me-2 { margin-right: 0.5rem; } -.me-3 { margin-right: 0.75rem; } - -.d-flex { display: flex; } -.d-grid { display: grid; } -.d-none { display: none; } -.d-block { display: block; } - -.text-center { text-align: center; } -.text-end { text-align: right; } -.text-muted { color: var(--pico-muted-color); } -.small { font-size: 0.875rem; } -.fs-1 { font-size: 2rem; } -.fs-2 { font-size: 1.5rem; } -.fs-5 { font-size: 1.15rem; } -.fs-6 { font-size: 0.875rem; } - -.text-success { color: #15803d; } -.text-danger { color: #dc2626; } -.text-warning { color: #b45309; } -.text-primary { color: var(--brand-primary); } -.text-secondary { color: var(--brand-secondary); } -.text-white { color: #fff; } - -.bg-danger-header { - background: #dc2626; - color: #fff; -} - -.font-monospace { font-family: monospace; } -.w-100 { width: 100%; } -.h-100 { height: 100%; } - -/* Table */ -.table-scroll { - overflow-x: auto; -} - -table.align-middle td, -table.align-middle th { - vertical-align: middle; -} - -/* Breadcrumb */ -.breadcrumb { - display: flex; - align-items: center; - gap: 0.25rem; - list-style: none; - padding: 0; - margin: 0 0 1rem; - font-size: 0.9rem; - color: var(--pico-muted-color); -} - -.breadcrumb-item + .breadcrumb-item::before { - content: "/"; - margin-right: 0.25rem; - color: var(--pico-muted-color); -} - -.breadcrumb-item.active { color: var(--pico-color); } - -/* Dropdown */ -.dropdown { - position: relative; - display: inline-block; -} - -.dropdown-menu { - display: none; - position: absolute; - right: 0; - top: calc(100% + 4px); - background: var(--pico-background-color); - border: 1px solid var(--pico-border-color); - border-radius: var(--pico-border-radius); - box-shadow: 0 4px 12px rgba(0,0,0,0.12); - z-index: 200; - min-width: 220px; - padding: 0.25rem 0; - list-style: none; - margin: 0; -} - -.dropdown.open .dropdown-menu { display: block; } - -.dropdown-item { - display: block; - width: 100%; - padding: 0.45rem 1rem; - background: none; - border: none; - text-align: left; - cursor: pointer; - color: var(--pico-color); - font-size: 0.9rem; - text-decoration: none; -} - -.dropdown-item:hover { - background: var(--pico-muted-background-color); -} - -.dropdown-item.muted { color: var(--pico-muted-color); } - -.dropdown-divider { - border: none; - border-top: 1px solid var(--pico-border-color); - margin: 0.25rem 0; -} - -/* Spinner */ -.spinner { - display: inline-block; - width: 2rem; - height: 2rem; - border: 3px solid var(--pico-muted-background-color); - border-top-color: var(--brand-primary); - border-radius: 50%; - animation: spin 0.75s linear infinite; -} - -@keyframes spin { to { transform: rotate(360deg); } } - -/* Input group */ -.input-group { - display: flex; - gap: 0; -} - -.input-group input { - border-radius: var(--pico-border-radius) 0 0 var(--pico-border-radius); - margin: 0; - flex: 1; -} - -.input-group button { - border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0; - margin: 0; - white-space: nowrap; -} - -/* Empty state */ -.empty-state { - text-align: center; - padding: 3rem 1rem; - color: var(--pico-muted-color); -} - -.empty-state .empty-icon { - font-size: 3rem; - display: block; - margin-bottom: 0.75rem; -} diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index 787655d..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "resolveJsonModule": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -}