From 854c912a880f4300f19d0f7adbc7ef7e578a9880 Mon Sep 17 00:00:00 2001 From: mguschin Date: Tue, 17 Mar 2026 19:33:32 +0300 Subject: [PATCH] Migrate web app from Python/FastAPI to Node.js/TypeScript Replace the entire Python/FastAPI backend with a Node.js/TypeScript stack: - Framework: Hono + @hono/node-server - Templates: Nunjucks (.njk) replacing Jinja2 (.html) - ORM: Drizzle ORM with mysql2 (same MariaDB schema, no migrations needed) - Sessions: hono-sessions with CookieStore - CSS: Pico CSS v2 replacing Bootstrap 5 (Bootstrap Icons CDN kept) - Dev: tsx watch; Prod: tsc + node dist/index.js Original Python app preserved in web-python/ as backup. Updated Dockerfile.web and docker-compose.yml for Node.js deployment. Co-Authored-By: Claude Opus 4.6 --- Dockerfile.web | 15 +- docker-compose.yml | 10 +- {web => web-python}/__init__.py | 0 {web => web-python}/auth.py | 0 {web => web-python}/config.py | 0 {web => web-python}/database.py | 0 {web => web-python}/evotor_api.py | 0 {web => web-python}/health_checker.py | 0 {web => web-python}/main.py | 0 {web => web-python}/migrations/README | 0 {web => web-python}/migrations/env.py | 0 {web => web-python}/migrations/script.py.mako | 0 .../versions/2c15000e752b_initial.py | 0 ...e_last_checked_at_to_evotor_connections.py | 0 ...d0e1f2_add_synced_at_to_cached_products.py | 0 .../b2c3d4e5f6a7_add_vk_connections_table.py | 0 ...6a7b8_add_sync_configs_and_sync_filters.py | 0 .../d4e5f6a7b8c9_add_catalog_cache_tables.py | 0 ...add_refresh_token_to_evotor_connections.py | 0 .../f6a7b8c9d0e1_evotor_webhook_token_flow.py | 0 {web => web-python}/models.py | 0 {web => web-python}/routes/__init__.py | 0 {web => web-python}/routes/auth.py | 0 {web => web-python}/routes/catalog.py | 0 {web => web-python}/routes/connections.py | 0 {web => web-python}/routes/evotor.py | 0 {web => web-python}/routes/profile.py | 0 {web => web-python}/routes/reset.py | 0 {web => web-python}/routes/sync.py | 0 {web => web-python}/routes/vk.py | 0 {web => web-python}/schemas.py | 0 web-python/static/style.css | 39 + {web => web-python}/sync_engine.py | 0 {web => web-python}/templates/base.html | 0 .../templates/catalog_groups.html | 0 .../templates/catalog_products.html | 0 .../templates/catalog_stores.html | 0 .../templates/confirm_email.html | 0 .../templates/connections.html | 0 .../templates/connections_add.html | 0 .../templates/email_confirmed.html | 0 {web => web-python}/templates/evotor.html | 0 .../templates/forgot_password.html | 0 {web => web-python}/templates/login.html | 0 {web => web-python}/templates/message.html | 0 .../templates/profile_change_password.html | 0 .../templates/profile_delete.html | 0 .../templates/profile_edit.html | 0 .../templates/profile_view.html | 0 {web => web-python}/templates/register.html | 0 .../templates/reset_password.html | 0 {web => web-python}/templates/sync.html | 0 {web => web-python}/templates/vk.html | 0 .../templates/vk_callback.html | 0 {web => web-python}/templates_env.py | 0 web/.gitignore | 2 + web/drizzle.config.ts | 10 + web/package-lock.json | 2001 +++++++++++++++++ web/package.json | 28 + web/src/config.ts | 22 + web/src/db/client.ts | 13 + web/src/db/schema.ts | 140 ++ web/src/index.ts | 71 + web/src/lib/auth.ts | 21 + web/src/lib/evotorApi.ts | 106 + web/src/lib/healthChecker.ts | 138 ++ web/src/lib/render.ts | 39 + web/src/lib/syncEngine.ts | 361 +++ web/src/lib/validate.ts | 40 + web/src/routes/auth.ts | 124 + web/src/routes/catalog.ts | 260 +++ web/src/routes/connections.ts | 83 + web/src/routes/evotor.ts | 147 ++ web/src/routes/profile.ts | 116 + web/src/routes/reset.ts | 100 + web/src/routes/sync.ts | 88 + web/src/routes/vk.ts | 120 + web/src/templates/base.njk | 99 + web/src/templates/catalog_groups.njk | 119 + web/src/templates/catalog_products.njk | 144 ++ web/src/templates/catalog_stores.njk | 127 ++ web/src/templates/confirm_email.njk | 16 + web/src/templates/connections.njk | 63 + web/src/templates/connections_add.njk | 39 + web/src/templates/email_confirmed.njk | 17 + web/src/templates/evotor.njk | 95 + web/src/templates/forgot_password.njk | 24 + web/src/templates/login.njk | 27 + web/src/templates/message.njk | 18 + web/src/templates/profile_change_password.njk | 31 + web/src/templates/profile_delete.njk | 31 + web/src/templates/profile_edit.njk | 43 + web/src/templates/profile_view.njk | 46 + web/src/templates/register.njk | 44 + web/src/templates/reset_password.njk | 23 + web/src/templates/sync.njk | 101 + web/src/templates/vk.njk | 104 + web/src/templates/vk_callback.njk | 47 + web/static/style.css | 442 +++- web/tsconfig.json | 15 + 100 files changed, 5770 insertions(+), 39 deletions(-) rename {web => web-python}/__init__.py (100%) rename {web => web-python}/auth.py (100%) rename {web => web-python}/config.py (100%) rename {web => web-python}/database.py (100%) rename {web => web-python}/evotor_api.py (100%) rename {web => web-python}/health_checker.py (100%) rename {web => web-python}/main.py (100%) rename {web => web-python}/migrations/README (100%) rename {web => web-python}/migrations/env.py (100%) rename {web => web-python}/migrations/script.py.mako (100%) rename {web => web-python}/migrations/versions/2c15000e752b_initial.py (100%) rename {web => web-python}/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py (100%) rename {web => web-python}/migrations/versions/a7b8c9d0e1f2_add_synced_at_to_cached_products.py (100%) rename {web => web-python}/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py (100%) rename {web => web-python}/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py (100%) rename {web => web-python}/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py (100%) rename {web => web-python}/migrations/versions/e5f6a7b8c9d0_add_refresh_token_to_evotor_connections.py (100%) rename {web => web-python}/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py (100%) rename {web => web-python}/models.py (100%) rename {web => web-python}/routes/__init__.py (100%) rename {web => web-python}/routes/auth.py (100%) rename {web => web-python}/routes/catalog.py (100%) rename {web => web-python}/routes/connections.py (100%) rename {web => web-python}/routes/evotor.py (100%) rename {web => web-python}/routes/profile.py (100%) rename {web => web-python}/routes/reset.py (100%) rename {web => web-python}/routes/sync.py (100%) rename {web => web-python}/routes/vk.py (100%) rename {web => web-python}/schemas.py (100%) create mode 100644 web-python/static/style.css rename {web => web-python}/sync_engine.py (100%) rename {web => web-python}/templates/base.html (100%) rename {web => web-python}/templates/catalog_groups.html (100%) rename {web => web-python}/templates/catalog_products.html (100%) rename {web => web-python}/templates/catalog_stores.html (100%) rename {web => web-python}/templates/confirm_email.html (100%) rename {web => web-python}/templates/connections.html (100%) rename {web => web-python}/templates/connections_add.html (100%) rename {web => web-python}/templates/email_confirmed.html (100%) rename {web => web-python}/templates/evotor.html (100%) rename {web => web-python}/templates/forgot_password.html (100%) rename {web => web-python}/templates/login.html (100%) rename {web => web-python}/templates/message.html (100%) rename {web => web-python}/templates/profile_change_password.html (100%) rename {web => web-python}/templates/profile_delete.html (100%) rename {web => web-python}/templates/profile_edit.html (100%) rename {web => web-python}/templates/profile_view.html (100%) rename {web => web-python}/templates/register.html (100%) rename {web => web-python}/templates/reset_password.html (100%) rename {web => web-python}/templates/sync.html (100%) rename {web => web-python}/templates/vk.html (100%) rename {web => web-python}/templates/vk_callback.html (100%) rename {web => web-python}/templates_env.py (100%) create mode 100644 web/.gitignore create mode 100644 web/drizzle.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/config.ts create mode 100644 web/src/db/client.ts create mode 100644 web/src/db/schema.ts create mode 100644 web/src/index.ts create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/lib/evotorApi.ts create mode 100644 web/src/lib/healthChecker.ts create mode 100644 web/src/lib/render.ts create mode 100644 web/src/lib/syncEngine.ts create mode 100644 web/src/lib/validate.ts create mode 100644 web/src/routes/auth.ts create mode 100644 web/src/routes/catalog.ts create mode 100644 web/src/routes/connections.ts create mode 100644 web/src/routes/evotor.ts create mode 100644 web/src/routes/profile.ts create mode 100644 web/src/routes/reset.ts create mode 100644 web/src/routes/sync.ts create mode 100644 web/src/routes/vk.ts create mode 100644 web/src/templates/base.njk create mode 100644 web/src/templates/catalog_groups.njk create mode 100644 web/src/templates/catalog_products.njk create mode 100644 web/src/templates/catalog_stores.njk create mode 100644 web/src/templates/confirm_email.njk create mode 100644 web/src/templates/connections.njk create mode 100644 web/src/templates/connections_add.njk create mode 100644 web/src/templates/email_confirmed.njk create mode 100644 web/src/templates/evotor.njk create mode 100644 web/src/templates/forgot_password.njk create mode 100644 web/src/templates/login.njk create mode 100644 web/src/templates/message.njk create mode 100644 web/src/templates/profile_change_password.njk create mode 100644 web/src/templates/profile_delete.njk create mode 100644 web/src/templates/profile_edit.njk create mode 100644 web/src/templates/profile_view.njk create mode 100644 web/src/templates/register.njk create mode 100644 web/src/templates/reset_password.njk create mode 100644 web/src/templates/sync.njk create mode 100644 web/src/templates/vk.njk create mode 100644 web/src/templates/vk_callback.njk create mode 100644 web/tsconfig.json diff --git a/Dockerfile.web b/Dockerfile.web index 65d67a4..b5dd0ac 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -1,13 +1,10 @@ -FROM python:3.12-slim - +FROM node:20-alpine WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY web/package*.json ./ +RUN npm ci -COPY web/ ./web/ -COPY alembic.ini . -COPY docker-entrypoint.sh . -RUN chmod +x docker-entrypoint.sh +COPY web/ . +RUN npm run build && cp -r src/templates dist/templates && cp -r static dist/static -CMD ["./docker-entrypoint.sh"] +CMD ["node", "dist/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml index c06883e..dd72b5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,19 +6,19 @@ services: context: . dockerfile: Dockerfile.web ports: - - "8080:8000" + - "8080:3000" environment: - - DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:3306/${DB_NAME} + - DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:3306/${DB_NAME} - SECRET_KEY=${SECRET_KEY:-change-me-in-production} - BASE_URL=${BASE_URL:-https://evosync.ru} - EVOTOR_APP_ID=${EVOTOR_APP_ID} - EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET} - VK_CLIENT_ID=${VK_CLIENT_ID} - VK_CLIENT_SECRET=${VK_CLIENT_SECRET} + - JIVOSITE_WIDGET_ID=${JIVOSITE_WIDGET_ID} + - NODE_ENV=production + - VK_DEFAULT_PHOTO_PATH=/app/default_product.png volumes: - - ./web:/app/web - - ./alembic.ini:/app/alembic.ini - - ./docker-entrypoint.sh:/app/docker-entrypoint.sh - ./5393364294319597854.png:/app/default_product.png:ro restart: unless-stopped extra_hosts: diff --git a/web/__init__.py b/web-python/__init__.py similarity index 100% rename from web/__init__.py rename to web-python/__init__.py diff --git a/web/auth.py b/web-python/auth.py similarity index 100% rename from web/auth.py rename to web-python/auth.py diff --git a/web/config.py b/web-python/config.py similarity index 100% rename from web/config.py rename to web-python/config.py diff --git a/web/database.py b/web-python/database.py similarity index 100% rename from web/database.py rename to web-python/database.py diff --git a/web/evotor_api.py b/web-python/evotor_api.py similarity index 100% rename from web/evotor_api.py rename to web-python/evotor_api.py diff --git a/web/health_checker.py b/web-python/health_checker.py similarity index 100% rename from web/health_checker.py rename to web-python/health_checker.py diff --git a/web/main.py b/web-python/main.py similarity index 100% rename from web/main.py rename to web-python/main.py diff --git a/web/migrations/README b/web-python/migrations/README similarity index 100% rename from web/migrations/README rename to web-python/migrations/README diff --git a/web/migrations/env.py b/web-python/migrations/env.py similarity index 100% rename from web/migrations/env.py rename to web-python/migrations/env.py diff --git a/web/migrations/script.py.mako b/web-python/migrations/script.py.mako similarity index 100% rename from web/migrations/script.py.mako rename to web-python/migrations/script.py.mako diff --git a/web/migrations/versions/2c15000e752b_initial.py b/web-python/migrations/versions/2c15000e752b_initial.py similarity index 100% rename from web/migrations/versions/2c15000e752b_initial.py rename to web-python/migrations/versions/2c15000e752b_initial.py diff --git a/web/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 similarity index 100% rename from web/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py rename to web-python/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py diff --git a/web/migrations/versions/a7b8c9d0e1f2_add_synced_at_to_cached_products.py b/web-python/migrations/versions/a7b8c9d0e1f2_add_synced_at_to_cached_products.py similarity index 100% rename from web/migrations/versions/a7b8c9d0e1f2_add_synced_at_to_cached_products.py rename to web-python/migrations/versions/a7b8c9d0e1f2_add_synced_at_to_cached_products.py diff --git a/web/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py b/web-python/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py similarity index 100% rename from web/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py rename to web-python/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py diff --git a/web/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py b/web-python/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py similarity index 100% rename from web/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py rename to web-python/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py diff --git a/web/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py b/web-python/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py similarity index 100% rename from web/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py rename to web-python/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py diff --git a/web/migrations/versions/e5f6a7b8c9d0_add_refresh_token_to_evotor_connections.py b/web-python/migrations/versions/e5f6a7b8c9d0_add_refresh_token_to_evotor_connections.py similarity index 100% rename from web/migrations/versions/e5f6a7b8c9d0_add_refresh_token_to_evotor_connections.py rename to web-python/migrations/versions/e5f6a7b8c9d0_add_refresh_token_to_evotor_connections.py diff --git a/web/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py b/web-python/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py similarity index 100% rename from web/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py rename to web-python/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py diff --git a/web/models.py b/web-python/models.py similarity index 100% rename from web/models.py rename to web-python/models.py diff --git a/web/routes/__init__.py b/web-python/routes/__init__.py similarity index 100% rename from web/routes/__init__.py rename to web-python/routes/__init__.py diff --git a/web/routes/auth.py b/web-python/routes/auth.py similarity index 100% rename from web/routes/auth.py rename to web-python/routes/auth.py diff --git a/web/routes/catalog.py b/web-python/routes/catalog.py similarity index 100% rename from web/routes/catalog.py rename to web-python/routes/catalog.py diff --git a/web/routes/connections.py b/web-python/routes/connections.py similarity index 100% rename from web/routes/connections.py rename to web-python/routes/connections.py diff --git a/web/routes/evotor.py b/web-python/routes/evotor.py similarity index 100% rename from web/routes/evotor.py rename to web-python/routes/evotor.py diff --git a/web/routes/profile.py b/web-python/routes/profile.py similarity index 100% rename from web/routes/profile.py rename to web-python/routes/profile.py diff --git a/web/routes/reset.py b/web-python/routes/reset.py similarity index 100% rename from web/routes/reset.py rename to web-python/routes/reset.py diff --git a/web/routes/sync.py b/web-python/routes/sync.py similarity index 100% rename from web/routes/sync.py rename to web-python/routes/sync.py diff --git a/web/routes/vk.py b/web-python/routes/vk.py similarity index 100% rename from web/routes/vk.py rename to web-python/routes/vk.py diff --git a/web/schemas.py b/web-python/schemas.py similarity index 100% rename from web/schemas.py rename to web-python/schemas.py diff --git a/web-python/static/style.css b/web-python/static/style.css new file mode 100644 index 0000000..6270d70 --- /dev/null +++ b/web-python/static/style.css @@ -0,0 +1,39 @@ +/* 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/sync_engine.py b/web-python/sync_engine.py similarity index 100% rename from web/sync_engine.py rename to web-python/sync_engine.py diff --git a/web/templates/base.html b/web-python/templates/base.html similarity index 100% rename from web/templates/base.html rename to web-python/templates/base.html diff --git a/web/templates/catalog_groups.html b/web-python/templates/catalog_groups.html similarity index 100% rename from web/templates/catalog_groups.html rename to web-python/templates/catalog_groups.html diff --git a/web/templates/catalog_products.html b/web-python/templates/catalog_products.html similarity index 100% rename from web/templates/catalog_products.html rename to web-python/templates/catalog_products.html diff --git a/web/templates/catalog_stores.html b/web-python/templates/catalog_stores.html similarity index 100% rename from web/templates/catalog_stores.html rename to web-python/templates/catalog_stores.html diff --git a/web/templates/confirm_email.html b/web-python/templates/confirm_email.html similarity index 100% rename from web/templates/confirm_email.html rename to web-python/templates/confirm_email.html diff --git a/web/templates/connections.html b/web-python/templates/connections.html similarity index 100% rename from web/templates/connections.html rename to web-python/templates/connections.html diff --git a/web/templates/connections_add.html b/web-python/templates/connections_add.html similarity index 100% rename from web/templates/connections_add.html rename to web-python/templates/connections_add.html diff --git a/web/templates/email_confirmed.html b/web-python/templates/email_confirmed.html similarity index 100% rename from web/templates/email_confirmed.html rename to web-python/templates/email_confirmed.html diff --git a/web/templates/evotor.html b/web-python/templates/evotor.html similarity index 100% rename from web/templates/evotor.html rename to web-python/templates/evotor.html diff --git a/web/templates/forgot_password.html b/web-python/templates/forgot_password.html similarity index 100% rename from web/templates/forgot_password.html rename to web-python/templates/forgot_password.html diff --git a/web/templates/login.html b/web-python/templates/login.html similarity index 100% rename from web/templates/login.html rename to web-python/templates/login.html diff --git a/web/templates/message.html b/web-python/templates/message.html similarity index 100% rename from web/templates/message.html rename to web-python/templates/message.html diff --git a/web/templates/profile_change_password.html b/web-python/templates/profile_change_password.html similarity index 100% rename from web/templates/profile_change_password.html rename to web-python/templates/profile_change_password.html diff --git a/web/templates/profile_delete.html b/web-python/templates/profile_delete.html similarity index 100% rename from web/templates/profile_delete.html rename to web-python/templates/profile_delete.html diff --git a/web/templates/profile_edit.html b/web-python/templates/profile_edit.html similarity index 100% rename from web/templates/profile_edit.html rename to web-python/templates/profile_edit.html diff --git a/web/templates/profile_view.html b/web-python/templates/profile_view.html similarity index 100% rename from web/templates/profile_view.html rename to web-python/templates/profile_view.html diff --git a/web/templates/register.html b/web-python/templates/register.html similarity index 100% rename from web/templates/register.html rename to web-python/templates/register.html diff --git a/web/templates/reset_password.html b/web-python/templates/reset_password.html similarity index 100% rename from web/templates/reset_password.html rename to web-python/templates/reset_password.html diff --git a/web/templates/sync.html b/web-python/templates/sync.html similarity index 100% rename from web/templates/sync.html rename to web-python/templates/sync.html diff --git a/web/templates/vk.html b/web-python/templates/vk.html similarity index 100% rename from web/templates/vk.html rename to web-python/templates/vk.html diff --git a/web/templates/vk_callback.html b/web-python/templates/vk_callback.html similarity index 100% rename from web/templates/vk_callback.html rename to web-python/templates/vk_callback.html diff --git a/web/templates_env.py b/web-python/templates_env.py similarity index 100% rename from web/templates_env.py rename to web-python/templates_env.py diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/web/drizzle.config.ts b/web/drizzle.config.ts new file mode 100644 index 0000000..bf772f9 --- /dev/null +++ b/web/drizzle.config.ts @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..14a695c --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2001 @@ +{ + "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 new file mode 100644 index 0000000..bdcac38 --- /dev/null +++ b/web/package.json @@ -0,0 +1,28 @@ +{ + "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 new file mode 100644 index 0000000..44cfbc4 --- /dev/null +++ b/web/src/config.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..739e8ec --- /dev/null +++ b/web/src/db/client.ts @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..051e6f0 --- /dev/null +++ b/web/src/db/schema.ts @@ -0,0 +1,140 @@ +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 new file mode 100644 index 0000000..c5c81f2 --- /dev/null +++ b/web/src/index.ts @@ -0,0 +1,71 @@ +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 new file mode 100644 index 0000000..5e40d77 --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..e644044 --- /dev/null +++ b/web/src/lib/evotorApi.ts @@ -0,0 +1,106 @@ +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 new file mode 100644 index 0000000..1f1fd5d --- /dev/null +++ b/web/src/lib/healthChecker.ts @@ -0,0 +1,138 @@ +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 new file mode 100644 index 0000000..ffe0b69 --- /dev/null +++ b/web/src/lib/render.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..02ef2b3 --- /dev/null +++ b/web/src/lib/syncEngine.ts @@ -0,0 +1,361 @@ +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 new file mode 100644 index 0000000..8ddebb4 --- /dev/null +++ b/web/src/lib/validate.ts @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..5ca67c6 --- /dev/null +++ b/web/src/routes/auth.ts @@ -0,0 +1,124 @@ +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 new file mode 100644 index 0000000..0ee4f9a --- /dev/null +++ b/web/src/routes/catalog.ts @@ -0,0 +1,260 @@ +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 new file mode 100644 index 0000000..2b89f5f --- /dev/null +++ b/web/src/routes/connections.ts @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..709c7a9 --- /dev/null +++ b/web/src/routes/evotor.ts @@ -0,0 +1,147 @@ +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 new file mode 100644 index 0000000..9ca0a1e --- /dev/null +++ b/web/src/routes/profile.ts @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000..ca77620 --- /dev/null +++ b/web/src/routes/reset.ts @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000..ab8266b --- /dev/null +++ b/web/src/routes/sync.ts @@ -0,0 +1,88 @@ +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 new file mode 100644 index 0000000..e2ac49d --- /dev/null +++ b/web/src/routes/vk.ts @@ -0,0 +1,120 @@ +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 new file mode 100644 index 0000000..711abe3 --- /dev/null +++ b/web/src/templates/base.njk @@ -0,0 +1,99 @@ + + + + + + {% block title %}ЭВОСИНК{% endblock %} + + + + + + + +
+ {% if errors %} + + {% endif %} + + {% if success %} + + {% endif %} + + {% block content %}{% endblock %} +
+ + {% if jivosite_widget_id %} + + {% endif %} + + + + + diff --git a/web/src/templates/catalog_groups.njk b/web/src/templates/catalog_groups.njk new file mode 100644 index 0000000..ccb5011 --- /dev/null +++ b/web/src/templates/catalog_groups.njk @@ -0,0 +1,119 @@ +{% extends "base.njk" %} +{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %} + +{% block content %} + + +
+

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

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

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

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

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

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

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

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

Каталог

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

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

+
+ +{% else %} +{% if fetched_at %} +

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

+{% endif %} + +
+ + + + + + + + + + + {% for store in stores %} + {% set mode = filter_map[store.evotor_id] %} + + + + + + + {% endfor %} + +
НазваниеАдресФильтр
{{ store.name }}{{ store.address or "—" }} + {% if mode == "include" %} + ✓ Включено + {% elif mode == "exclude" %} + ✗ Исключено + {% else %} + — Нет правила + {% endif %} + +
+ + + + +
+
+
+{% endif %} + + +{% endblock %} diff --git a/web/src/templates/confirm_email.njk b/web/src/templates/confirm_email.njk new file mode 100644 index 0000000..ca271ca --- /dev/null +++ b/web/src/templates/confirm_email.njk @@ -0,0 +1,16 @@ +{% 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 new file mode 100644 index 0000000..5fb0b42 --- /dev/null +++ b/web/src/templates/connections.njk @@ -0,0 +1,63 @@ +{% 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 new file mode 100644 index 0000000..587de4f --- /dev/null +++ b/web/src/templates/connections_add.njk @@ -0,0 +1,39 @@ +{% extends "base.njk" %} +{% block title %}Добавить подключение — ЭВОСИНК{% endblock %} + +{% block content %} +
+ +

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

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

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

+ + Вернуться к подключениям + +
+{% endif %} +{% endblock %} diff --git a/web/src/templates/email_confirmed.njk b/web/src/templates/email_confirmed.njk new file mode 100644 index 0000000..4ca8d1c --- /dev/null +++ b/web/src/templates/email_confirmed.njk @@ -0,0 +1,17 @@ +{% 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 new file mode 100644 index 0000000..ee501a7 --- /dev/null +++ b/web/src/templates/evotor.njk @@ -0,0 +1,95 @@ +{% extends "base.njk" %} +{% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+ + {% if error %} + + {% endif %} + +
+
+

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

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

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

+
+
+ + +
+
+
+
+
+ +
+
+ + {% else %} +
+

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

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

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

+

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

+
+ + +
+ +
+
+
+
+{% endblock %} diff --git a/web/src/templates/login.njk b/web/src/templates/login.njk new file mode 100644 index 0000000..3233874 --- /dev/null +++ b/web/src/templates/login.njk @@ -0,0 +1,27 @@ +{% extends "base.njk" %} +{% block title %}Вход — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+ +
+
+{% endblock %} diff --git a/web/src/templates/message.njk b/web/src/templates/message.njk new file mode 100644 index 0000000..9062ebb --- /dev/null +++ b/web/src/templates/message.njk @@ -0,0 +1,18 @@ +{% 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 new file mode 100644 index 0000000..fd2e7ef --- /dev/null +++ b/web/src/templates/profile_change_password.njk @@ -0,0 +1,31 @@ +{% 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 new file mode 100644 index 0000000..7ca2337 --- /dev/null +++ b/web/src/templates/profile_delete.njk @@ -0,0 +1,31 @@ +{% 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 new file mode 100644 index 0000000..2963e92 --- /dev/null +++ b/web/src/templates/profile_edit.njk @@ -0,0 +1,43 @@ +{% 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 new file mode 100644 index 0000000..d86664c --- /dev/null +++ b/web/src/templates/profile_view.njk @@ -0,0 +1,46 @@ +{% extends "base.njk" %} +{% block title %}Личный кабинет — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+ +
+
+{% endblock %} diff --git a/web/src/templates/register.njk b/web/src/templates/register.njk new file mode 100644 index 0000000..95addfc --- /dev/null +++ b/web/src/templates/register.njk @@ -0,0 +1,44 @@ +{% 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 new file mode 100644 index 0000000..fc28a43 --- /dev/null +++ b/web/src/templates/reset_password.njk @@ -0,0 +1,23 @@ +{% extends "base.njk" %} +{% block title %}Новый пароль — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

Новый пароль

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

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

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

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

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

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

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

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

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

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

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

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

+
+
+ + +
+
+
+
+
+ +
+
+ + {% else %} +
+ {% if vk_client_id %} +

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

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

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

+ {% else %} +

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

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

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

+
+
+ +

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

+ Попробовать снова +
+
+
+
+
+ +
+ +
+ + +{% endblock %} diff --git a/web/static/style.css b/web/static/style.css index 6270d70..c530339 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1,39 +1,431 @@ -/* Brand overrides */ +/* Brand colors */ :root { - --bs-primary: #F05023; - --bs-primary-rgb: 240, 80, 35; - --bs-link-color: #0986E2; - --bs-link-hover-color: #0670c0; + --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: 22px; + font-size: 1.3rem; font-weight: 700; - color: #F05023 !important; + color: var(--brand-primary) !important; + text-decoration: none; } -.brand-border { - border-color: #F05023 !important; +.nav-links { + flex: 1; + justify-content: flex-end; } -.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; +.nav-links a { + color: var(--pico-color); + text-decoration: none; + padding: 0.25rem 0.5rem; + border-radius: 4px; } -.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-links a:hover { + color: var(--brand-primary); } -.nav-link:hover { - color: #F05023 !important; +.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 new file mode 100644 index 0000000..787655d --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "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"] +}