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 %}
-
- {% 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 %}
-
-
- Каталог
- {{ store.name }}
-
-
-
-
-
Группы товаров
-
- Экспорт CSV
-
-
-
-{% if not groups %}
-
-
-
Группы не найдены в этом магазине.
-
- Посмотреть все товары магазина
-
-
-{% else %}
-
-
-
-
- Название
- Кол-во товаров
- Фильтр
-
-
-
-
- {% for group in groups %}
- {% set mode = filter_map.get(group.evotor_id) %}
-
- {{ group.name }}
- {{ product_counts.get(group.evotor_id, 0) }}
-
- {% if mode == "include" %}
- ✓ Включено
- {% elif mode == "exclude" %}
- ✗ Исключено
- {% else %}
- — Нет правила
- {% endif %}
-
-
-
-
-
- {% endfor %}
-
-
-
-{% 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 %}
-
-
- Каталог
- {{ store.name }}
- {% if group %}
- {{ group.name }}
- {% else %}
- Все товары
- {% endif %}
-
-
-
-
-
Товары{% 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) %}
-
- {{ 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 %}
-
-
-
-
-
-
-
-
-
-
- {% endfor %}
-
-
-
-{% 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 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) %}
-
- {{ store.name }}
- {{ store.address or "—" }}
-
- {% if mode == "include" %}
- ✓ Включено
- {% elif mode == "exclude" %}
- ✗ Исключено
- {% else %}
- — Нет правила
- {% endif %}
-
-
-
-
-
- {% endfor %}
-
-
-
-{% 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 %}
-
- {% 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 %}
- Откройте приложение ЭвоСинк в магазине Эвотор и установите его.
- {% else %}
- Найдите приложение ЭвоСинк в магазине Эвотор и установите его.
- {% endif %}
- Перейдите в раздел Приложения → ЭвоСинк → Настройки .
- Скопируйте токен доступа и вставьте его в поле ниже.
-
-
-
-
- {% 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 #}
-
-
- {# 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 %}
-
- {% for error in errors %}
-
{{ error }}
- {% endfor %}
-
- {% 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 %}
-
- Каталог
- {{ store.name }}
-
-
-
-
-{% if not groups %}
-
-
-{% else %}
-
-{% 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 %}
-
- Каталог
- {{ store.name }}
- {% if group %}
- {{ group.name }}
- {% else %}
- Все товары
- {% endif %}
-
-
-
-
-{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %}
-
-{% if not products %}
-
-
-{% else %}
-
-{% 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 not evotor %}
-
-
-{% elif not stores %}
-
-
-
Магазины не найдены в вашем аккаунте Эвотор.
-
-
-{% else %}
-{% if fetched_at %}
-Последнее обновление: {{ fetched_at | datefmt }}
-{% 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 %}
-
-
-
-
-
- {{ svc.name }}
-
-
{{ svc.description }}
-
- Подключить
-
-
-
-
- {% 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 %}
-
- {% if error == "invalid_token" %}
- Токен недействителен. Проверьте правильность и попробуйте снова.
- {% elif error == "empty_token" %}
- Введите токен.
- {% else %}
- Произошла ошибка при подключении: {{ error }}
- {% endif %}
-
- {% 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 %}
- Откройте приложение ЭвоСинк в магазине Эвотор и установите его.
- {% else %}
- Найдите приложение ЭвоСинк в магазине Эвотор и установите его.
- {% endif %}
- Перейдите в раздел Приложения → ЭвоСинк → Настройки .
- Скопируйте токен доступа и вставьте его в поле ниже.
-
-
-
- {% 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 %}
-
-
-
-
-
-
- Имя
- {{ user.first_name }}
-
-
- Фамилия
- {{ user.last_name }}
-
-
- Email
- {{ user.email }}
-
-
- Телефон
- {{ user.phone }}
-
-
-
-
-
-
-{% 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 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 %}
-
- {% if error == "invalid_token" %}
- Токен недействителен или у него нет прав администратора сообщества.
- {% elif error == "empty_token" %}
- Введите токен.
- {% elif error == "no_client_id" %}
- Автоматическое подключение не настроено. Введите токен вручную.
- {% else %}
- Произошла ошибка при подключении: {{ error }}
- {% endif %}
-
- {% 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 %}
-
-
-
- {% 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"]
-}