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 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-17 19:33:32 +03:00
parent db0c1cbed3
commit 854c912a88
100 changed files with 5770 additions and 39 deletions

2
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

View File

View File

@@ -1,23 +0,0 @@
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from passlib.context import CryptContext
from web.database import get_db
from web.models import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
user_id = request.session.get("user_id")
if not user_id:
return None
return db.query(User).filter(User.id == user_id).first()

View File

@@ -1,34 +0,0 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync"
SECRET_KEY: str = "change-me-in-production"
BASE_URL: str = "http://localhost:8080"
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
EVOTOR_APP_ID: str = ""
EVOTOR_WEBHOOK_SECRET: str = ""
JIVOSITE_WIDGET_ID: str = ""
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
SYNC_INTERVAL_SECONDS: int = 3600
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
VK_CLIENT_ID: str = ""
VK_CLIENT_SECRET: str = ""
VK_API_VERSION: str = "5.131"
# Docker compose vars (ignored in app, kept for env compatibility)
DB_ROOT_PASSWORD: str = ""
DB_NAME: str = ""
DB_USER: str = ""
DB_PASSWORD: str = ""
model_config = {"env_file": ".env", "case_sensitive": False}
settings = Settings()

View File

@@ -1,19 +0,0 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from web.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

10
web/drizzle.config.ts Normal file
View File

@@ -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",
},
});

View File

@@ -1,119 +0,0 @@
from datetime import datetime
import httpx
from sqlalchemy.orm import Session
EVOTOR_API_BASE = "https://api.evotor.ru"
async def fetch_stores(access_token: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores",
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
return [
{
"id": s.get("uuid") or s.get("id"),
"name": s.get("name"),
"address": s.get("address"),
}
for s in items
]
async def fetch_groups(access_token: str, store_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups",
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
if resp.status_code == 402:
return []
resp.raise_for_status()
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
return [{"id": g.get("uuid") or g.get("id"), "name": g.get("name")} for g in items]
async def fetch_products(access_token: str, store_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores/{store_id}/products",
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
if resp.status_code == 402:
return []
resp.raise_for_status()
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
return [
{
"id": p.get("uuid") or p.get("id"),
"name": p.get("name"),
"parent_id": p.get("parentUuid") or p.get("parent_id"),
"price": p.get("price"),
"quantity": p.get("quantity"),
"measure_name": p.get("measureName") or p.get("measure_name"),
"article_number": p.get("code") or p.get("article_number"),
"allow_to_sell": p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell"),
}
for p in items
]
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session) -> None:
from web.models import CachedStore, CachedGroup, CachedProduct
now = datetime.utcnow()
# Delete old cache for user
db.query(CachedProduct).filter(CachedProduct.user_id == user_id).delete()
db.query(CachedGroup).filter(CachedGroup.user_id == user_id).delete()
db.query(CachedStore).filter(CachedStore.user_id == user_id).delete()
db.commit()
stores = await fetch_stores(access_token)
for store in stores:
db.add(CachedStore(
user_id=user_id,
evotor_id=store["id"],
name=store["name"] or "",
address=store.get("address"),
fetched_at=now,
))
db.commit()
for store in stores:
groups = await fetch_groups(access_token, store["id"])
for group in groups:
db.add(CachedGroup(
user_id=user_id,
evotor_id=group["id"],
store_evotor_id=store["id"],
name=group["name"] or "",
fetched_at=now,
))
products = await fetch_products(access_token, store["id"])
for product in products:
db.add(CachedProduct(
user_id=user_id,
evotor_id=product["id"],
store_evotor_id=store["id"],
group_evotor_id=product.get("parent_id"),
name=product["name"] or "",
price=product.get("price"),
quantity=product.get("quantity"),
measure_name=product.get("measure_name"),
article_number=product.get("article_number"),
allow_to_sell=product.get("allow_to_sell"),
fetched_at=now,
))
db.commit()

View File

@@ -1,152 +0,0 @@
import asyncio
import logging
from datetime import datetime, timedelta
import httpx
from web.database import SessionLocal
from web.models import EvotorConnection, VkConnection, CachedStore
logger = logging.getLogger("uvicorn.error")
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById"
VK_API_VERSION = "5.131"
# Refresh Evotor token if it expires within this window
REFRESH_BEFORE_EXPIRY = timedelta(hours=2)
async def _refresh_evotor_token(conn: EvotorConnection) -> str | None:
"""Attempt to refresh the Evotor access token. Returns new access token or None."""
from web.config import settings
if not conn.refresh_token:
return None
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
EVOTOR_TOKEN_URL,
data={
"grant_type": "refresh_token",
"refresh_token": conn.refresh_token,
},
auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET),
timeout=15,
)
if resp.status_code != 200:
return None
data = resp.json()
return data if data.get("access_token") else None
except Exception:
return None
async def check_evotor_connection(access_token: str) -> bool:
try:
async with httpx.AsyncClient() as client:
response = await client.get(
EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
return response.status_code == 200
except Exception:
return False
async def check_vk_connection(access_token: str) -> bool:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
VK_GROUPS_GET_URL,
params={"access_token": access_token, "v": VK_API_VERSION},
timeout=10,
)
if resp.status_code != 200:
return False
data = resp.json()
return "error" not in data
except Exception:
return False
async def run_health_checks() -> None:
db = SessionLocal()
try:
now = datetime.utcnow()
evotor_connections = db.query(EvotorConnection).all()
for conn in evotor_connections:
# Proactively refresh if token expires soon
needs_refresh = (
conn.refresh_token and
conn.token_expires_at and
conn.token_expires_at - now < REFRESH_BEFORE_EXPIRY
)
if needs_refresh:
token_data = await _refresh_evotor_token(conn)
if token_data:
conn.access_token = token_data["access_token"]
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
expires_in = token_data.get("expires_in")
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
logger.info("Refreshed Evotor token for user_id=%d", conn.user_id)
is_online = await check_evotor_connection(conn.access_token)
# If offline and not yet tried refresh, attempt it now
if not is_online and conn.refresh_token and not needs_refresh:
token_data = await _refresh_evotor_token(conn)
if token_data:
conn.access_token = token_data["access_token"]
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
expires_in = token_data.get("expires_in")
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
is_online = await check_evotor_connection(conn.access_token)
if is_online:
logger.info("Evotor token refreshed after failed check for user_id=%d", conn.user_id)
conn.is_online = is_online
conn.last_checked_at = now
vk_connections = db.query(VkConnection).all()
for conn in vk_connections:
conn.is_online = await check_vk_connection(conn.access_token)
conn.last_checked_at = now
db.commit()
# Refresh catalog cache for online Evotor connections
from web.config import settings
refreshed_catalog = 0
for conn in evotor_connections:
if not conn.is_online:
continue
cached = db.query(CachedStore).filter(CachedStore.user_id == conn.user_id).first()
cache_age = (now - cached.fetched_at).total_seconds() if cached else None
if cached is None or cache_age >= settings.CATALOG_REFRESH_INTERVAL_SECONDS:
try:
from web.evotor_api import refresh_catalog_cache
await refresh_catalog_cache(conn.user_id, conn.access_token, db)
refreshed_catalog += 1
except Exception:
logger.exception("Failed to refresh catalog cache for user_id=%d", conn.user_id)
logger.info(
"Health checks completed: %d Evotor, %d VK, %d catalogs refreshed",
len(evotor_connections),
len(vk_connections),
refreshed_catalog,
)
except Exception:
logger.exception("Error during health checks")
db.rollback()
finally:
db.close()
async def health_check_loop(interval: int) -> None:
while True:
await run_health_checks()
await asyncio.sleep(interval)

View File

@@ -1,53 +0,0 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from web.auth import get_current_user
from web.config import settings
from web.health_checker import health_check_loop
from web.sync_engine import sync_loop
from web.models import User
from web.routes import auth, profile, reset, evotor, vk, sync, catalog
from web.routes import connections
@asynccontextmanager
async def lifespan(app: FastAPI):
tasks = [
asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS)),
asyncio.create_task(sync_loop(settings.SYNC_INTERVAL_SECONDS)),
]
yield
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
app.mount("/static", StaticFiles(directory="web/static"), name="static")
app.include_router(auth.router)
app.include_router(profile.router)
app.include_router(reset.router)
app.include_router(evotor.router)
app.include_router(connections.router)
app.include_router(vk.router)
app.include_router(sync.router)
app.include_router(catalog.router)
@app.get("/")
def home(request: Request, user: User | None = Depends(get_current_user)):
if user:
return RedirectResponse("/profile", 302)
return RedirectResponse("/login", 302)

View File

@@ -1 +0,0 @@
Generic single-database configuration.

View File

@@ -1,54 +0,0 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from web.config import settings
from web.database import Base
from web.models import User, EvotorConnection # noqa: F401 — register models with Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1,60 +0,0 @@
"""initial
Revision ID: 2c15000e752b
Revises:
Create Date: 2026-03-06 09:07:16.180639
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2c15000e752b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("first_name", sa.String(length=100), nullable=False),
sa.Column("last_name", sa.String(length=100), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("phone", sa.String(length=20), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("is_email_confirmed", sa.Boolean(), nullable=False),
sa.Column("email_confirm_token", sa.String(length=255), nullable=True),
sa.Column("password_reset_token", sa.String(length=255), nullable=True),
sa.Column("password_reset_expires", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index(op.f("ix_users_phone"), "users", ["phone"], unique=True)
op.create_table(
"evotor_connections",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("access_token", sa.Text(), nullable=False),
sa.Column("store_id", sa.String(length=255), nullable=True),
sa.Column("store_name", sa.String(length=255), nullable=True),
sa.Column("connected_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id"),
)
def downgrade() -> None:
op.drop_table("evotor_connections")
op.drop_index(op.f("ix_users_phone"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")

View File

@@ -1,26 +0,0 @@
"""add is_online and last_checked_at to evotor_connections
Revision ID: a1b2c3d4e5f6
Revises: 2c15000e752b
Create Date: 2026-03-06 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'a1b2c3d4e5f6'
down_revision = '2c15000e752b'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('evotor_connections',
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'))
op.add_column('evotor_connections',
sa.Column('last_checked_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('evotor_connections', 'last_checked_at')
op.drop_column('evotor_connections', 'is_online')

View File

@@ -1,26 +0,0 @@
"""add synced_at to cached_products
Revision ID: a7b8c9d0e1f2
Revises: f6a7b8c9d0e1
Branch Labels: None
Depends On: None
"""
from alembic import op
import sqlalchemy as sa
revision = "a7b8c9d0e1f2"
down_revision = "f6a7b8c9d0e1"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"cached_products",
sa.Column("synced_at", sa.DateTime(), nullable=True),
)
def downgrade():
op.drop_column("cached_products", "synced_at")

View File

@@ -1,37 +0,0 @@
"""add vk_connections table
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-03-06 00:01:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'b2c3d4e5f6a7'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'vk_connections',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('access_token', sa.Text(), nullable=False),
sa.Column('vk_user_id', sa.String(50), nullable=True),
sa.Column('first_name', sa.String(255), nullable=True),
sa.Column('last_name', sa.String(255), nullable=True),
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('last_checked_at', sa.DateTime(), nullable=True),
sa.Column('connected_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id'),
)
def downgrade() -> None:
op.drop_table('vk_connections')

View File

@@ -1,48 +0,0 @@
"""add sync_configs and sync_filters tables
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-03-06 00:02:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'c3d4e5f6a7b8'
down_revision = 'b2c3d4e5f6a7'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'sync_configs',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id'),
)
op.create_table(
'sync_filters',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('sync_config_id', sa.Integer(), nullable=False),
sa.Column('entity_type', sa.String(20), nullable=False),
sa.Column('entity_id', sa.String(255), nullable=False),
sa.Column('entity_name', sa.String(255), nullable=True),
sa.Column('filter_mode', sa.String(10), nullable=False),
sa.Column('parent_entity_id', sa.String(255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['sync_config_id'], ['sync_configs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('sync_config_id', 'entity_type', 'entity_id', name='uq_sync_filter'),
)
def downgrade() -> None:
op.drop_table('sync_filters')
op.drop_table('sync_configs')

View File

@@ -1,70 +0,0 @@
"""add catalog cache tables
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-03-06 00:03:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'd4e5f6a7b8c9'
down_revision = 'c3d4e5f6a7b8'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'cached_stores',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('evotor_id', sa.String(255), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('address', sa.String(500), nullable=True),
sa.Column('fetched_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_stores'),
)
op.create_index('ix_cached_stores_user_id', 'cached_stores', ['user_id'])
op.create_table(
'cached_groups',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('evotor_id', sa.String(255), nullable=False),
sa.Column('store_evotor_id', sa.String(255), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('fetched_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_groups'),
)
op.create_index('ix_cached_groups_user_store', 'cached_groups', ['user_id', 'store_evotor_id'])
op.create_table(
'cached_products',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('evotor_id', sa.String(255), nullable=False),
sa.Column('store_evotor_id', sa.String(255), nullable=False),
sa.Column('group_evotor_id', sa.String(255), nullable=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('price', sa.Numeric(12, 2), nullable=True),
sa.Column('quantity', sa.Numeric(12, 3), nullable=True),
sa.Column('measure_name', sa.String(20), nullable=True),
sa.Column('article_number', sa.String(100), nullable=True),
sa.Column('allow_to_sell', sa.Boolean(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_products'),
)
op.create_index('ix_cached_products_user_store_group', 'cached_products', ['user_id', 'store_evotor_id', 'group_evotor_id'])
def downgrade() -> None:
op.drop_table('cached_products')
op.drop_table('cached_groups')
op.drop_table('cached_stores')

View File

@@ -1,24 +0,0 @@
"""add refresh_token and token_expires_at to evotor_connections
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-03-06 00:04:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'e5f6a7b8c9d0'
down_revision = 'd4e5f6a7b8c9'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('evotor_connections', sa.Column('refresh_token', sa.Text(), nullable=True))
op.add_column('evotor_connections', sa.Column('token_expires_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('evotor_connections', 'token_expires_at')
op.drop_column('evotor_connections', 'refresh_token')

View File

@@ -1,53 +0,0 @@
"""evotor webhook token flow: add evotor_user_id, make user_id nullable
Revision ID: f6a7b8c9d0e1
Revises: e5f6a7b8c9d0
Branch Labels: None
Depends On: None
"""
from alembic import op
import sqlalchemy as sa
revision = 'f6a7b8c9d0e1'
down_revision = 'e5f6a7b8c9d0'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Check existing columns
columns = [row[0] for row in conn.execute(sa.text(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'evotor_connections'"
))]
if 'evotor_user_id' not in columns:
op.add_column('evotor_connections',
sa.Column('evotor_user_id', sa.String(255), nullable=True))
# Check existing indexes
indexes = [row[2] for row in conn.execute(sa.text(
"SHOW INDEX FROM evotor_connections"
))]
if 'uq_evotor_connections_evotor_user_id' not in indexes:
op.create_unique_constraint('uq_evotor_connections_evotor_user_id',
'evotor_connections', ['evotor_user_id'])
if 'ix_evotor_connections_evotor_user_id' not in indexes:
op.create_index('ix_evotor_connections_evotor_user_id',
'evotor_connections', ['evotor_user_id'])
op.alter_column('evotor_connections', 'user_id',
existing_type=sa.Integer(), nullable=True)
def downgrade() -> None:
op.alter_column('evotor_connections', 'user_id',
existing_type=sa.Integer(), nullable=False)
op.drop_index('ix_evotor_connections_evotor_user_id', 'evotor_connections')
op.drop_constraint('uq_evotor_connections_evotor_user_id', 'evotor_connections')
op.drop_column('evotor_connections', 'evotor_user_id')

View File

@@ -1,159 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, Numeric, Index
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from web.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
email = Column(String(255), unique=True, nullable=False, index=True)
phone = Column(String(20), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
is_email_confirmed = Column(Boolean, default=False, nullable=False)
email_confirm_token = Column(String(255), nullable=True)
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
evotor_connection = relationship("EvotorConnection", back_populates="user", uselist=False)
vk_connection = relationship("VkConnection", back_populates="user", uselist=False)
sync_config = relationship("SyncConfig", back_populates="user", uselist=False)
cached_stores = relationship("CachedStore", back_populates="user", cascade="all, delete-orphan")
cached_groups = relationship("CachedGroup", back_populates="user", cascade="all, delete-orphan")
cached_products = relationship("CachedProduct", back_populates="user", cascade="all, delete-orphan")
class EvotorConnection(Base):
__tablename__ = "evotor_connections"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True)
evotor_user_id = Column(String(255), unique=True, nullable=True, index=True)
access_token = Column(Text, nullable=False)
store_id = Column(String(255), nullable=True)
store_name = Column(String(255), nullable=True)
refresh_token = Column(Text, nullable=True)
token_expires_at = Column(DateTime, nullable=True)
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="evotor_connection")
class VkConnection(Base):
__tablename__ = "vk_connections"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
access_token = Column(Text, nullable=False)
vk_user_id = Column(String(50), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="vk_connection")
class SyncConfig(Base):
__tablename__ = "sync_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
is_enabled = Column(Boolean, default=False, nullable=False)
confirmed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="sync_config")
filters = relationship("SyncFilter", back_populates="sync_config", cascade="all, delete-orphan")
class SyncFilter(Base):
__tablename__ = "sync_filters"
id = Column(Integer, primary_key=True, autoincrement=True)
sync_config_id = Column(Integer, ForeignKey("sync_configs.id", ondelete="CASCADE"), nullable=False)
entity_type = Column(String(20), nullable=False) # "store", "group", "product"
entity_id = Column(String(255), nullable=False)
entity_name = Column(String(255), nullable=True)
filter_mode = Column(String(10), nullable=False) # "include", "exclude"
parent_entity_id = Column(String(255), nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint("sync_config_id", "entity_type", "entity_id"),
)
sync_config = relationship("SyncConfig", back_populates="filters")
class CachedStore(Base):
__tablename__ = "cached_stores"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
evotor_id = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
address = Column(String(500), nullable=True)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "evotor_id"),
Index("ix_cached_stores_user_id", "user_id"),
)
user = relationship("User", back_populates="cached_stores")
class CachedGroup(Base):
__tablename__ = "cached_groups"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
evotor_id = Column(String(255), nullable=False)
store_evotor_id = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "evotor_id"),
Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"),
)
user = relationship("User", back_populates="cached_groups")
class CachedProduct(Base):
__tablename__ = "cached_products"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
evotor_id = Column(String(255), nullable=False)
store_evotor_id = Column(String(255), nullable=False)
group_evotor_id = Column(String(255), nullable=True)
name = Column(String(255), nullable=False)
price = Column(Numeric(12, 2), nullable=True)
quantity = Column(Numeric(12, 3), nullable=True)
measure_name = Column(String(20), nullable=True)
article_number = Column(String(100), nullable=True)
allow_to_sell = Column(Boolean, nullable=True)
fetched_at = Column(DateTime, nullable=False)
synced_at = Column(DateTime, nullable=True)
__table_args__ = (
UniqueConstraint("user_id", "evotor_id"),
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
)
user = relationship("User", back_populates="cached_products")

2001
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
web/package.json Normal file
View File

@@ -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"
}
}

View File

View File

@@ -1,122 +0,0 @@
import uuid
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from web.templates_env import templates
from sqlalchemy.orm import Session
from web.auth import hash_password, verify_password, get_current_user
from web.config import settings
from web.database import get_db
from web.models import User
from web.schemas import validate_registration, validate_login
router = APIRouter()
@router.get("/register")
def register_form(request: Request, user: User | None = Depends(get_current_user)):
if user:
return RedirectResponse("/profile", 303)
return templates.TemplateResponse("register.html", {"request": request, "user": None})
@router.post("/register")
async def register_submit(request: Request, db: Session = Depends(get_db)):
form = await request.form()
data = dict(form)
errors = validate_registration(data)
if not errors:
existing = db.query(User).filter(
(User.email == data["email"].strip()) | (User.phone == data["phone"].strip())
).first()
if existing:
if existing.email == data["email"].strip():
errors.append("Пользователь с таким email уже существует")
else:
errors.append("Пользователь с таким телефоном уже существует")
if errors:
return templates.TemplateResponse("register.html", {
"request": request, "user": None, "errors": errors, "form": data,
})
token = uuid.uuid4().hex
user = User(
first_name=data["first_name"].strip(),
last_name=data["last_name"].strip(),
email=data["email"].strip(),
phone=data["phone"].strip(),
password_hash=hash_password(data["password"]),
email_confirm_token=token,
)
db.add(user)
db.commit()
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
print("=" * 40)
print("ПОДТВЕРЖДЕНИЕ EMAIL")
print(f"Пользователь: {user.email}")
print(f"Ссылка: {confirm_url}")
print("=" * 40)
return templates.TemplateResponse("confirm_email.html", {"request": request, "user": None})
@router.get("/confirm-email")
def confirm_email(request: Request, token: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email_confirm_token == token).first()
if not user:
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
})
user.is_email_confirmed = True
user.email_confirm_token = None
db.commit()
return templates.TemplateResponse("email_confirmed.html", {"request": request, "user": None})
@router.get("/login")
def login_form(request: Request, user: User | None = Depends(get_current_user)):
if user:
return RedirectResponse("/profile", 303)
return templates.TemplateResponse("login.html", {"request": request, "user": None})
@router.post("/login")
async def login_submit(request: Request, db: Session = Depends(get_db)):
form = await request.form()
data = dict(form)
errors = validate_login(data)
if errors:
return templates.TemplateResponse("login.html", {
"request": request, "user": None, "errors": errors, "form": data,
})
user = db.query(User).filter(User.email == data["email"].strip()).first()
if not user or not verify_password(data["password"], user.password_hash):
return templates.TemplateResponse("login.html", {
"request": request, "user": None,
"errors": ["Неверный email или пароль"], "form": data,
})
if not user.is_email_confirmed:
return templates.TemplateResponse("login.html", {
"request": request, "user": None,
"errors": ["Пожалуйста, подтвердите ваш email"], "form": data,
})
request.session["user_id"] = user.id
return RedirectResponse("/profile", 303)
@router.get("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", 303)

View File

@@ -1,308 +0,0 @@
import csv
import io
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse, StreamingResponse
from web.templates_env import templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.database import get_db
from web.evotor_api import refresh_catalog_cache
from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct
router = APIRouter(prefix="/catalog")
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
if not config:
config = SyncConfig(user_id=user_id, is_enabled=False)
db.add(config)
db.commit()
db.refresh(config)
return config
def _filter_map(config: SyncConfig) -> dict:
"""Returns {entity_id: filter_mode} for quick lookup."""
return {f.entity_id: f.filter_mode for f in config.filters}
def _filter_label(mode: str | None) -> str:
if mode == "include":
return "include"
if mode == "exclude":
return "exclude"
return "none"
@router.get("")
async def catalog_stores(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if not evotor:
return templates.TemplateResponse("catalog_stores.html", {
"request": request, "user": user,
"evotor": None, "stores": [], "filter_map": {}, "fetched_at": None,
})
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
# Auto-refresh if cache is empty
if not stores:
await refresh_catalog_cache(user.id, evotor.access_token, db)
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
fetched_at = stores[0].fetched_at if stores else None
return templates.TemplateResponse("catalog_stores.html", {
"request": request,
"user": user,
"evotor": evotor,
"stores": stores,
"filter_map": fmap,
"fetched_at": fetched_at,
})
@router.get("/groups")
def catalog_groups(
request: Request,
store_id: str,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
store = db.query(CachedStore).filter(
CachedStore.user_id == user.id,
CachedStore.evotor_id == store_id,
).first()
if not store:
return RedirectResponse("/catalog", 303)
groups = db.query(CachedGroup).filter(
CachedGroup.user_id == user.id,
CachedGroup.store_evotor_id == store_id,
).order_by(CachedGroup.name).all()
# Count products per group
product_counts = {}
for g in groups:
product_counts[g.evotor_id] = db.query(CachedProduct).filter(
CachedProduct.user_id == user.id,
CachedProduct.group_evotor_id == g.evotor_id,
).count()
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
return templates.TemplateResponse("catalog_groups.html", {
"request": request,
"user": user,
"store": store,
"groups": groups,
"product_counts": product_counts,
"filter_map": fmap,
})
@router.get("/products")
def catalog_products(
request: Request,
store_id: str,
group_id: str | None = None,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
store = db.query(CachedStore).filter(
CachedStore.user_id == user.id,
CachedStore.evotor_id == store_id,
).first()
if not store:
return RedirectResponse("/catalog", 303)
group = None
query = db.query(CachedProduct).filter(
CachedProduct.user_id == user.id,
CachedProduct.store_evotor_id == store_id,
)
if group_id:
group = db.query(CachedGroup).filter(
CachedGroup.user_id == user.id,
CachedGroup.evotor_id == group_id,
).first()
query = query.filter(CachedProduct.group_evotor_id == group_id)
products = query.order_by(CachedProduct.name).all()
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
return templates.TemplateResponse("catalog_products.html", {
"request": request,
"user": user,
"store": store,
"group": group,
"products": products,
"filter_map": fmap,
})
@router.post("/filter")
async def catalog_filter(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
form = await request.form()
entity_type = form.get("entity_type")
entity_id = form.get("entity_id")
entity_name = form.get("entity_name")
filter_mode = form.get("filter_mode") # "include", "exclude", "none"
parent_entity_id = form.get("parent_entity_id") or None
redirect_to = form.get("redirect_to", "/catalog")
config = _get_or_create_sync_config(db, user.id)
existing = db.query(SyncFilter).filter(
SyncFilter.sync_config_id == config.id,
SyncFilter.entity_type == entity_type,
SyncFilter.entity_id == entity_id,
).first()
if filter_mode == "none":
if existing:
db.delete(existing)
elif existing:
existing.filter_mode = filter_mode
existing.entity_name = entity_name
else:
db.add(SyncFilter(
sync_config_id=config.id,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
filter_mode=filter_mode,
parent_entity_id=parent_entity_id,
))
db.commit()
return RedirectResponse(redirect_to, 303)
@router.post("/refresh")
async def catalog_refresh(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if evotor:
await refresh_catalog_cache(user.id, evotor.access_token, db)
return RedirectResponse("/catalog", 303)
@router.get("/export")
def catalog_export(
request: Request,
type: str,
store_id: str | None = None,
group_id: str | None = None,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
def filter_label(eid):
m = fmap.get(eid)
if m == "include":
return "Включено"
if m == "exclude":
return "Исключено"
return "Нет правила"
output = io.StringIO()
output.write("\ufeff") # UTF-8 BOM for Excel
writer = csv.writer(output)
from datetime import date
today = date.today().strftime("%Y%m%d")
if type == "stores":
writer.writerow(["Название", "Адрес", "ID", "Фильтр"])
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
for s in stores:
writer.writerow([s.name, s.address or "", s.evotor_id, filter_label(s.evotor_id)])
filename = f"stores_{today}.csv"
elif type == "groups":
writer.writerow(["Магазин", "Название", "ID", "Фильтр"])
q = db.query(CachedGroup, CachedStore).join(
CachedStore,
(CachedStore.evotor_id == CachedGroup.store_evotor_id) & (CachedStore.user_id == user.id)
).filter(CachedGroup.user_id == user.id)
if store_id:
q = q.filter(CachedGroup.store_evotor_id == store_id)
for g, s in q.order_by(CachedGroup.name).all():
writer.writerow([s.name, g.name, g.evotor_id, filter_label(g.evotor_id)])
filename = f"groups_{today}.csv"
else: # products
writer.writerow(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"])
q = db.query(CachedProduct, CachedStore, CachedGroup).join(
CachedStore,
(CachedStore.evotor_id == CachedProduct.store_evotor_id) & (CachedStore.user_id == user.id)
).outerjoin(
CachedGroup,
(CachedGroup.evotor_id == CachedProduct.group_evotor_id) & (CachedGroup.user_id == user.id)
).filter(CachedProduct.user_id == user.id)
if store_id:
q = q.filter(CachedProduct.store_evotor_id == store_id)
if group_id:
q = q.filter(CachedProduct.group_evotor_id == group_id)
for p, s, g in q.order_by(CachedProduct.name).all():
writer.writerow([
s.name,
g.name if g else "",
p.name,
p.article_number or "",
p.price or "",
p.quantity or "",
p.measure_name or "",
"Да" if p.allow_to_sell else ("Нет" if p.allow_to_sell is not None else ""),
p.evotor_id,
filter_label(p.evotor_id),
])
filename = f"products_{today}.csv"
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)

View File

@@ -1,125 +0,0 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from web.templates_env import templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.database import get_db
from web.models import User, EvotorConnection, VkConnection
router = APIRouter()
SERVICE_TYPES = [
{
"type": "evotor",
"name": "Эвотор",
"icon": "bi-shop",
"description": "Подключите кассу Эвотор для синхронизации каталога товаров.",
"configure_url": "/evotor",
"connect_url": "/evotor",
},
{
"type": "vk",
"name": "ВКонтакте",
"icon": "bi-bag",
"description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
"configure_url": "/vk",
"connect_url": "/vk",
},
]
def _get_connection(svc_type: str, evotor, vk):
if svc_type == "evotor":
return evotor
if svc_type == "vk":
return vk
return None
def _get_details(svc_type: str, conn):
if conn is None:
return None
if svc_type == "evotor":
return conn.store_name
if svc_type == "vk":
return f"{conn.first_name} {conn.last_name}".strip() if conn.first_name else None
return None
@router.get("/connections")
def connections_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
connected = []
for svc in SERVICE_TYPES:
conn = _get_connection(svc["type"], evotor, vk)
if conn is not None:
connected.append({
**svc,
"is_online": conn.is_online,
"last_checked_at": conn.last_checked_at,
"details": _get_details(svc["type"], conn),
})
return templates.TemplateResponse("connections.html", {
"request": request,
"user": user,
"connections": connected,
})
@router.get("/connections/add")
def connections_add_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
available = [
svc for svc in SERVICE_TYPES
if _get_connection(svc["type"], evotor, vk) is None
]
return templates.TemplateResponse("connections_add.html", {
"request": request,
"user": user,
"available": available,
})
@router.post("/connections/delete")
async def connections_delete(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
svc_type = request.query_params.get("type")
if svc_type == "evotor":
conn = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
elif svc_type == "vk":
conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
else:
conn = None
if conn:
db.delete(conn)
db.commit()
return RedirectResponse("/connections", 303)

View File

@@ -1,193 +0,0 @@
import logging
import httpx
from datetime import datetime
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse
from web.templates_env import templates
from pydantic import BaseModel
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.config import settings
from web.database import get_db
from web.models import User, EvotorConnection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/evotor")
EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
@router.get("")
def evotor_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
error = request.query_params.get("error")
app_url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID) if settings.EVOTOR_APP_ID else None
return templates.TemplateResponse("evotor.html", {
"request": request,
"user": user,
"connection": connection,
"error": error,
"app_url": app_url,
})
class EvotorTokenPayload(BaseModel):
userId: str
token: str
@router.post("/callback")
async def evotor_callback(
request: Request,
payload: EvotorTokenPayload,
db: Session = Depends(get_db),
):
"""
Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."} here
after the user authorizes the app in their Evotor account.
"""
# Verify the Authorization header matches our configured webhook secret
if settings.EVOTOR_WEBHOOK_SECRET:
auth_header = request.headers.get("Authorization", "")
expected = f"Bearer {settings.EVOTOR_WEBHOOK_SECRET}"
if auth_header != expected:
logger.warning("Evotor webhook: invalid Authorization header")
raise HTTPException(status_code=401, detail="Unauthorized")
now = datetime.utcnow()
# Fetch store info using the received token
store_id = None
store_name = None
try:
async with httpx.AsyncClient() as client:
stores_response = await client.get(
EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {payload.token}"},
timeout=15,
)
if stores_response.status_code == 200:
stores = stores_response.json()
items = stores.get("items", stores) if isinstance(stores, dict) else stores
if items:
store_id = items[0].get("uuid") or items[0].get("id")
store_name = items[0].get("name")
except Exception:
pass # Store info is optional
# Upsert by evotor_user_id (user_id stays NULL until /evotor/link is called)
connection = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == payload.userId
).first()
if connection:
connection.access_token = payload.token
connection.store_id = store_id
connection.store_name = store_name
connection.is_online = True
connection.last_checked_at = now
connection.updated_at = now
else:
connection = EvotorConnection(
evotor_user_id=payload.userId,
access_token=payload.token,
store_id=store_id,
store_name=store_name,
is_online=True,
last_checked_at=now,
)
db.add(connection)
db.commit()
logger.info("Evotor webhook: saved token for evotor_user_id=%s", payload.userId)
return JSONResponse({"status": "ok"})
@router.post("/token")
async def evotor_token_manual(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
"""Allow user to manually paste their Evotor token."""
if not user:
return RedirectResponse("/login", 303)
form = await request.form()
token = (form.get("token") or "").strip()
if not token:
return RedirectResponse("/evotor?error=empty_token", 303)
now = datetime.utcnow()
# Fetch store info
store_id = None
store_name = None
try:
async with httpx.AsyncClient() as client:
stores_response = await client.get(
EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
if stores_response.status_code == 200:
stores = stores_response.json()
items = stores.get("items", stores) if isinstance(stores, dict) else stores
if items:
store_id = items[0].get("uuid") or items[0].get("id")
store_name = items[0].get("name")
elif stores_response.status_code == 401:
return RedirectResponse("/evotor?error=invalid_token", 303)
except Exception:
pass
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection:
connection.access_token = token
connection.store_id = store_id
connection.store_name = store_name
connection.is_online = True
connection.last_checked_at = now
connection.updated_at = now
else:
connection = EvotorConnection(
user_id=user.id,
access_token=token,
store_id=store_id,
store_name=store_name,
is_online=True,
last_checked_at=now,
)
db.add(connection)
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/disconnect")
async def evotor_disconnect(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection:
db.delete(connection)
db.commit()
return RedirectResponse("/connections", 303)

View File

@@ -1,144 +0,0 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from web.templates_env import templates
from sqlalchemy.orm import Session
from web.auth import get_current_user, verify_password, hash_password
from web.database import get_db
from web.models import User
from web.schemas import validate_profile, validate_reset_password
router = APIRouter()
# VIEW PROFILE
@router.get("/profile")
def profile_view(request: Request, user: User | None = Depends(get_current_user)):
if not user:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("profile_view.html", {"request": request, "user": user})
# EDIT PROFILE
@router.get("/profile/edit")
def profile_edit_form(request: Request, user: User | None = Depends(get_current_user)):
if not user:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("profile_edit.html", {"request": request, "user": user})
@router.post("/profile/edit")
async def profile_edit_submit(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
form = await request.form()
data = dict(form)
errors = validate_profile(data)
if not errors:
existing = db.query(User).filter(
User.phone == data["phone"].strip(), User.id != user.id
).first()
if existing:
errors.append("Пользователь с таким телефоном уже существует")
if errors:
return templates.TemplateResponse("profile_edit.html", {
"request": request, "user": user, "errors": errors, "form": data,
})
user.first_name = data["first_name"].strip()
user.last_name = data["last_name"].strip()
user.phone = data["phone"].strip()
db.commit()
return templates.TemplateResponse("profile_edit.html", {
"request": request, "user": user, "success": "Профиль обновлен",
})
# CHANGE PASSWORD
@router.get("/profile/change-password")
def change_password_form(request: Request, user: User | None = Depends(get_current_user)):
if not user:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("profile_change_password.html", {"request": request, "user": user})
@router.post("/profile/change-password")
async def change_password_submit(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
form = await request.form()
data = dict(form)
errors = []
current_password = data.get("current_password", "")
if not current_password:
errors.append("Введите текущий пароль")
elif not verify_password(current_password, user.password_hash):
errors.append("Неверный текущий пароль")
password_errors = validate_reset_password(data)
errors.extend(password_errors)
if errors:
return templates.TemplateResponse("profile_change_password.html", {
"request": request, "user": user, "errors": errors,
})
user.password_hash = hash_password(data["password"])
db.commit()
return templates.TemplateResponse("profile_change_password.html", {
"request": request, "user": user, "success": "Пароль изменен",
})
# DELETE ACCOUNT
@router.get("/profile/delete")
def delete_account_form(request: Request, user: User | None = Depends(get_current_user)):
if not user:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("profile_delete.html", {"request": request, "user": user})
@router.post("/profile/delete")
async def delete_account_submit(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
form = await request.form()
data = dict(form)
password = data.get("password", "")
if not password:
return templates.TemplateResponse("profile_delete.html", {
"request": request, "user": user, "errors": ["Введите пароль для подтверждения"],
})
if not verify_password(password, user.password_hash):
return templates.TemplateResponse("profile_delete.html", {
"request": request, "user": user, "errors": ["Неверный пароль"],
})
db.delete(user)
db.commit()
request.session.clear()
return RedirectResponse("/", 303)

View File

@@ -1,107 +0,0 @@
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from web.templates_env import templates
from sqlalchemy.orm import Session
from web.auth import hash_password
from web.config import settings
from web.database import get_db
from web.models import User
from web.schemas import validate_reset_password
router = APIRouter()
@router.get("/forgot-password")
def forgot_form(request: Request):
return templates.TemplateResponse("forgot_password.html", {"request": request, "user": None})
@router.post("/forgot-password")
async def forgot_submit(request: Request, db: Session = Depends(get_db)):
form = await request.form()
email = form.get("email", "").strip()
if email:
user = db.query(User).filter(User.email == email).first()
if user:
token = uuid.uuid4().hex
user.password_reset_token = token
user.password_reset_expires = datetime.now(timezone.utc) + timedelta(
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
)
db.commit()
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
print("=" * 40)
print("СБРОС ПАРОЛЯ")
print(f"Пользователь: {user.email}")
print(f"Ссылка: {reset_url}")
print(f"Действительна: {settings.PASSWORD_RESET_EXPIRE_MINUTES} мин.")
print("=" * 40)
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Сброс пароля",
"message": "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
})
@router.get("/reset-password")
def reset_form(request: Request, token: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires:
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
})
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Срок действия ссылки истек.",
})
return templates.TemplateResponse("reset_password.html", {
"request": request, "user": None, "token": token,
})
@router.post("/reset-password")
async def reset_submit(request: Request, token: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires:
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
})
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Срок действия ссылки истек.",
})
form = await request.form()
data = dict(form)
errors = validate_reset_password(data)
if errors:
return templates.TemplateResponse("reset_password.html", {
"request": request, "user": None, "token": token, "errors": errors,
})
user.password_hash = hash_password(data["password"])
user.password_reset_token = None
user.password_reset_expires = None
db.commit()
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Пароль изменен",
"message": "Ваш пароль успешно изменен. Теперь вы можете войти.",
"link": "/login", "link_text": "Войти",
})

View File

@@ -1,101 +0,0 @@
from datetime import datetime
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from web.templates_env import templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.database import get_db
from web.models import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter
router = APIRouter(prefix="/sync")
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
if not config:
config = SyncConfig(user_id=user_id, is_enabled=False)
db.add(config)
db.commit()
db.refresh(config)
return config
def _filter_summary(config: SyncConfig) -> dict:
stores = [f for f in config.filters if f.entity_type == "store"]
groups = [f for f in config.filters if f.entity_type == "group"]
products = [f for f in config.filters if f.entity_type == "product"]
return {
"stores": len(stores),
"groups": len(groups),
"products": len(products),
"total": len(config.filters),
}
@router.get("")
def sync_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
config = _get_or_create_sync_config(db, user.id)
summary = _filter_summary(config)
if config.confirmed_at and config.is_enabled:
status = "active"
elif config.confirmed_at and not config.is_enabled:
status = "paused"
elif summary["total"] > 0:
status = "pending"
else:
status = "unconfigured"
return templates.TemplateResponse("sync.html", {
"request": request,
"user": user,
"evotor": evotor,
"vk": vk,
"config": config,
"summary": summary,
"status": status,
})
@router.post("/toggle")
def sync_toggle(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
config = _get_or_create_sync_config(db, user.id)
config.is_enabled = not config.is_enabled
db.commit()
return RedirectResponse("/sync", 303)
@router.post("/confirm")
def sync_confirm(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
config = _get_or_create_sync_config(db, user.id)
if config.is_enabled and len(config.filters) > 0:
config.confirmed_at = datetime.utcnow()
db.commit()
return RedirectResponse("/sync", 303)

View File

@@ -1,168 +0,0 @@
from datetime import datetime
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from web.templates_env import templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.config import settings
from web.database import get_db
from web.models import User, VkConnection
router = APIRouter(prefix="/vk")
VK_API_URL = "https://api.vk.com/method"
VK_OAUTH_URL = "https://oauth.vk.com/authorize"
async def _fetch_group_info(token: str) -> tuple[str | None, str | None]:
"""Returns (group_id, group_name) for the first admin group, or (None, None)."""
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{VK_API_URL}/groups.get",
params={
"access_token": token,
"v": settings.VK_API_VERSION,
"filter": "admin",
"extended": 1,
"count": 1,
},
timeout=15,
)
if resp.status_code == 200:
data = resp.json()
if "error" not in data:
items = data.get("response", {}).get("items", [])
if items:
return str(items[0].get("id", "")), items[0].get("name")
except Exception:
pass
return None, None
def _save_connection(db: Session, user_id: int, token: str,
group_id: str | None, group_name: str | None) -> None:
now = datetime.utcnow()
connection = db.query(VkConnection).filter(VkConnection.user_id == user_id).first()
if connection:
connection.access_token = token
connection.vk_user_id = group_id
connection.first_name = group_name
connection.last_name = None
connection.is_online = True
connection.last_checked_at = now
else:
db.add(VkConnection(
user_id=user_id,
access_token=token,
vk_user_id=group_id,
first_name=group_name,
last_name=None,
is_online=True,
last_checked_at=now,
))
db.commit()
@router.get("")
def vk_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
error = request.query_params.get("error")
return templates.TemplateResponse("vk.html", {
"request": request,
"user": user,
"connection": connection,
"error": error,
"vk_client_id": settings.VK_CLIENT_ID,
"callback_url": f"{settings.BASE_URL}/vk/callback",
})
@router.get("/connect")
def vk_connect(
request: Request,
user: User | None = Depends(get_current_user),
):
"""Redirect to VK OAuth authorization page."""
if not user:
return RedirectResponse("/login", 303)
if not settings.VK_CLIENT_ID:
return RedirectResponse("/vk?error=no_client_id", 303)
params = urlencode({
"client_id": settings.VK_CLIENT_ID,
"scope": "market,groups",
"redirect_uri": f"{settings.BASE_URL}/vk/callback",
"display": "page",
"response_type": "token",
"v": settings.VK_API_VERSION,
})
return RedirectResponse(f"{VK_OAUTH_URL}?{params}", 302)
@router.get("/callback")
def vk_callback(
request: Request,
user: User | None = Depends(get_current_user),
):
"""Landing page after VK OAuth. JS reads the token from the URL fragment and POSTs it."""
if not user:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("vk_callback.html", {
"request": request,
"user": user,
})
@router.post("/token")
async def vk_token(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
"""Save a VK user access token (from manual entry or OAuth callback)."""
if not user:
return RedirectResponse("/login", 303)
form = await request.form()
token = (form.get("token") or "").strip()
if not token:
return RedirectResponse("/vk?error=empty_token", 303)
group_id, group_name = await _fetch_group_info(token)
if not group_id:
return RedirectResponse("/vk?error=invalid_token", 303)
_save_connection(db, user.id, token, group_id, group_name)
return RedirectResponse("/connections", 303)
@router.post("/disconnect")
async def vk_disconnect(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
if connection:
db.delete(connection)
db.commit()
return RedirectResponse("/connections", 303)

View File

@@ -1,52 +0,0 @@
import re
def validate_registration(data: dict) -> list[str]:
errors = []
if not data.get("first_name", "").strip():
errors.append("Введите имя")
if not data.get("last_name", "").strip():
errors.append("Введите фамилию")
email = data.get("email", "").strip()
if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
errors.append("Введите корректный email")
phone = data.get("phone", "").strip()
if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
password = data.get("password", "")
if len(password) < 8:
errors.append("Пароль должен быть не менее 8 символов")
if password != data.get("password_confirm", ""):
errors.append("Пароли не совпадают")
return errors
def validate_login(data: dict) -> list[str]:
errors = []
if not data.get("email", "").strip():
errors.append("Введите email")
if not data.get("password", ""):
errors.append("Введите пароль")
return errors
def validate_reset_password(data: dict) -> list[str]:
errors = []
password = data.get("password", "")
if len(password) < 8:
errors.append("Пароль должен быть не менее 8 символов")
if password != data.get("password_confirm", ""):
errors.append("Пароли не совпадают")
return errors
def validate_profile(data: dict) -> list[str]:
errors = []
if not data.get("first_name", "").strip():
errors.append("Введите имя")
if not data.get("last_name", "").strip():
errors.append("Введите фамилию")
phone = data.get("phone", "").strip()
if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
return errors

22
web/src/config.ts Normal file
View File

@@ -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;

13
web/src/db/client.ts Normal file
View File

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

140
web/src/db/schema.ts Normal file
View File

@@ -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;

71
web/src/index.ts Normal file
View File

@@ -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<AppEnv>();
// 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);
});

21
web/src/lib/auth.ts Normal file
View File

@@ -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<string> {
return bcrypt.hash(password, 12);
}
export function verifyPassword(plain: string, hashed: string): Promise<boolean> {
return bcrypt.compare(plain, hashed);
}
export function getSessionUserId(c: Context<AppEnv>): number | null {
const session = c.get("session");
if (!session) return null;
const id = session.get("user_id");
return typeof id === "number" ? id : null;
}

106
web/src/lib/evotorApi.ts Normal file
View File

@@ -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<unknown> {
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<Array<{ id: string; name: string; address?: string }>> {
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<Record<string, unknown>>).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<Array<{ id: string; name: string }>> {
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<Record<string, unknown>>).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<Array<Record<string, unknown>>> {
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<Record<string, unknown>>).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<void> {
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,
});
}
}
}

View File

@@ -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<boolean> {
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<boolean> {
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<void> {
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);
}

39
web/src/lib/render.ts Normal file
View File

@@ -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, unknown> = {}): string {
return env.render(template, ctx);
}

361
web/src/lib/syncEngine.ts Normal file
View File

@@ -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<Array<Record<string, unknown>>> {
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<Record<string, unknown>>;
}
async function evoFetchGroups(token: string, storeId: string): Promise<Array<Record<string, unknown>>> {
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<Record<string, unknown>>;
}
// ---------------------------------------------------------------------------
// VK API helpers
// ---------------------------------------------------------------------------
function vkParams(token: string, extra: Record<string, unknown>): URLSearchParams {
const p: Record<string, string> = { 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<string, unknown>): Promise<unknown> {
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<string, unknown>): Promise<unknown> {
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<Array<Record<string, unknown>>> {
const data = await vkGet(token, "market.getAlbums", { owner_id: ownerId, count: 200 }) as { response?: { items?: unknown[] } };
return (data.response?.items ?? []) as Array<Record<string, unknown>>;
}
async function vkCreateAlbum(token: string, ownerId: string, title: string): Promise<number | null> {
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<Array<Record<string, unknown>>> {
const items: Array<Record<string, unknown>> = [];
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<Record<string, unknown>>;
items.push(...batch);
if (batch.length < count) break;
offset += count;
}
return items;
}
async function vkUploadPhoto(token: string, groupId: string, photoPath: string): Promise<string | null> {
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<number | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Record<string, unknown>> = [];
const groupsById: Record<string, Record<string, unknown>> = {};
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<string, Record<string, unknown>> = {};
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<string, Record<string, unknown>> = {};
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<string, Array<Record<string, unknown>>> = {};
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<string, unknown>)?.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<void> {
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);
}

40
web/src/lib/validate.ts Normal file
View File

@@ -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, string>): 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, string>): string[] {
const errors: string[] = [];
if (!data.email?.trim()) errors.push("Введите email");
if (!data.password) errors.push("Введите пароль");
return errors;
}
export function validateResetPassword(data: Record<string, string>): 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, string>): 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;
}

124
web/src/routes/auth.ts Normal file
View File

@@ -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<AppEnv>();
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<string, string> = {};
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<string, string> = {};
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);
});

260
web/src/routes/catalog.ts Normal file
View File

@@ -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<AppEnv>();
async function requireUser(c: Parameters<typeof getSessionUserId>[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<Record<string, string>> {
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<string, number> = {};
for (const g of groups) {
const [row] = await db.select({ count: sql<number>`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<string, string> = {};
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<string, string> = {};
const groupMap: Record<string, string> = {};
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}"`,
},
});
});

View File

@@ -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<AppEnv>();
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<typeof getSessionUserId>[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);
});

147
web/src/routes/evotor.ts Normal file
View File

@@ -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<AppEnv>();
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<typeof getSessionUserId>[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<string, unknown>;
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);
});

116
web/src/routes/profile.ts Normal file
View File

@@ -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<AppEnv>();
async function requireUser(c: Parameters<typeof getSessionUserId>[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<string, string> = {};
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<string, string> = {};
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);
});

100
web/src/routes/reset.ts Normal file
View File

@@ -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<AppEnv>();
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<string, string> = {};
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: "Войти",
}));
});

88
web/src/routes/sync.ts Normal file
View File

@@ -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<AppEnv>();
async function requireUser(c: Parameters<typeof getSessionUserId>[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);
});

120
web/src/routes/vk.ts Normal file
View File

@@ -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<AppEnv>();
const VK_API_URL = "https://api.vk.com/method";
const VK_OAUTH_URL = "https://oauth.vk.com/authorize";
async function requireUser(c: Parameters<typeof getSessionUserId>[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);
});

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="ru" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ЭВОСИНК{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<nav class="container">
<ul>
<li><a href="/" class="brand-logo">ЭВОСИНК</a></li>
</ul>
<ul class="nav-links">
{% if user %}
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог</a></li>
<li><a href="/sync">Синхронизация</a></li>
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
<li><a href="/logout" class="secondary">Выход</a></li>
{% else %}
<li><a href="/login">Вход</a></li>
<li><a href="/register">Регистрация</a></li>
{% endif %}
</ul>
{% if user %}
<details class="mobile-menu">
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
<ul>
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог</a></li>
<li><a href="/sync">Синхронизация</a></li>
<li><a href="/profile">Личный кабинет</a></li>
<li><a href="/logout">Выход</a></li>
</ul>
</details>
{% else %}
<details class="mobile-menu">
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
<ul>
<li><a href="/login">Вход</a></li>
<li><a href="/register">Регистрация</a></li>
</ul>
</details>
{% endif %}
</nav>
</header>
<main class="container py-4">
{% if errors %}
<div role="alert" class="alert alert-danger">
{% for error in errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% if success %}
<div role="alert" class="alert alert-success">
<p>{{ success }}</p>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
{% if jivosite_widget_id %}
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', {
placeholder: '_',
showMaskOnHover: false,
clearMaskOnLostFocus: false
}).mask(phoneInputs);
}
});
</script>
<script>
document.addEventListener('invalid', function(e) {
if (e.target.validity.valueMissing) {
e.target.setCustomValidity('Пожалуйста, заполните это поле');
} else if (e.target.validity.typeMismatch) {
e.target.setCustomValidity('Пожалуйста, введите корректное значение');
}
}, true);
document.addEventListener('input', function(e) {
if (e.target.required) e.target.setCustomValidity('');
}, true);
</script>
</body>
</html>

View File

@@ -1,33 +1,32 @@
{% extends "base.html" %}
{% extends "base.njk" %}
{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
<li class="breadcrumb-item active">{{ store.name }}</li>
</ol>
</nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
<li class="breadcrumb-item active">{{ store.name }}</li>
</ol>
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 mb-0">Группы товаров</h1>
<a href="/catalog/export?type=groups&store_id={{ store.evotor_id }}" class="btn btn-outline-secondary btn-sm">
<div class="d-flex align-center justify-between mb-3">
<h1 style="font-size:1.3rem; margin:0;">Группы товаров</h1>
<a href="/catalog/export?type=groups&store_id={{ store.evotor_id }}" role="button" class="outline secondary sm">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
</div>
{% if not groups %}
<div class="text-center py-5 text-muted">
<i class="bi bi-folder fs-1 mb-3 d-block"></i>
<div class="empty-state">
<i class="bi bi-folder empty-icon"></i>
<p>Группы не найдены в этом магазине.</p>
<a href="/catalog/products?store_id={{ store.evotor_id }}" class="btn btn-outline-primary btn-sm">
<a href="/catalog/products?store_id={{ store.evotor_id }}" role="button" class="outline sm">
Посмотреть все товары магазина
</a>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-light">
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>Название</th>
<th>Кол-во товаров</th>
@@ -37,30 +36,30 @@
</thead>
<tbody>
{% for group in groups %}
{% set mode = filter_map.get(group.evotor_id) %}
{% set mode = filter_map[group.evotor_id] %}
<tr>
<td>{{ group.name }}</td>
<td class="text-muted">{{ product_counts.get(group.evotor_id, 0) }}</td>
<td class="text-muted">{{ product_counts[group.evotor_id] or 0 }}</td>
<td>
{% if mode == "include" %}
<span class="badge bg-success">✓ Включено</span>
<span class="badge badge-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge bg-danger">✗ Исключено</span>
<span class="badge badge-danger">✗ Исключено</span>
{% else %}
<span class="badge bg-light text-muted border">— Нет правила</span>
<span class="badge badge-light">— Нет правила</span>
{% endif %}
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<td>
<div class="d-flex gap-1 justify-end">
<a href="/catalog/products?store_id={{ store.evotor_id }}&group_id={{ group.evotor_id }}"
class="btn btn-outline-secondary btn-sm" title="Товары">
role="button" class="outline secondary sm" title="Товары">
<i class="bi bi-arrow-right"></i>
</a>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
<div class="dropdown" data-dropdown>
<button type="button" class="outline secondary sm" data-dropdown-toggle>
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<ul class="dropdown-menu">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
@@ -92,7 +91,7 @@
<input type="hidden" name="filter_mode" value="none">
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
<button type="submit" class="dropdown-item muted">— Убрать правило</button>
</form>
</li>
</ul>
@@ -105,4 +104,16 @@
</table>
</div>
{% endif %}
<script>
document.querySelectorAll('[data-dropdown]').forEach(function(dd) {
dd.querySelector('[data-dropdown-toggle]').addEventListener('click', function(e) {
e.stopPropagation();
dd.classList.toggle('open');
});
});
document.addEventListener('click', function() {
document.querySelectorAll('[data-dropdown].open').forEach(function(dd) { dd.classList.remove('open'); });
});
</script>
{% endblock %}

View File

@@ -1,23 +1,21 @@
{% extends "base.html" %}
{% extends "base.njk" %}
{% block title %}Товары — ЭВОСИНК{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
<li class="breadcrumb-item"><a href="/catalog/groups?store_id={{ store.evotor_id }}">{{ store.name }}</a></li>
{% if group %}
<li class="breadcrumb-item active">{{ group.name }}</li>
{% else %}
<li class="breadcrumb-item active">Все товары</li>
{% endif %}
</ol>
</nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
<li class="breadcrumb-item"><a href="/catalog/groups?store_id={{ store.evotor_id }}">{{ store.name }}</a></li>
{% if group %}
<li class="breadcrumb-item active">{{ group.name }}</li>
{% else %}
<li class="breadcrumb-item active">Все товары</li>
{% endif %}
</ol>
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 mb-0">Товары{% if group %}: {{ group.name }}{% endif %}</h1>
<div class="d-flex align-center justify-between mb-3">
<h1 style="font-size:1.3rem; margin:0;">Товары{% if group %}: {{ group.name }}{% endif %}</h1>
<a href="/catalog/export?type=products&store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}"
class="btn btn-outline-secondary btn-sm">
role="button" class="outline secondary sm">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
</div>
@@ -25,14 +23,15 @@
{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %}
{% if not products %}
<div class="text-center py-5 text-muted">
<i class="bi bi-box fs-1 mb-3 d-block"></i>
<div class="empty-state">
<i class="bi bi-box empty-icon"></i>
<p>Товары не найдены.</p>
</div>
{% else %}
<div class="table-responsive" style="overflow: visible;">
<table class="table table-striped table-hover align-middle small">
<thead class="table-light">
<div class="table-scroll">
<table class="align-middle small">
<thead>
<tr>
<th>Название</th>
<th>Артикул</th>
@@ -47,15 +46,15 @@
</thead>
<tbody>
{% for product in products %}
{% set mode = filter_map.get(product.evotor_id) %}
{% set mode = filter_map[product.evotor_id] %}
<tr>
<td>{{ product.name }}</td>
<td class="text-muted font-monospace">{{ product.article_number or "—" }}</td>
<td>{% if product.price %}{{ "%.2f"|format(product.price) }} ₽{% else %}—{% endif %}</td>
<td>{% if product.quantity is not none %}{{ product.quantity }}{% else %}—{% endif %}</td>
<td>{% if product.price %}{{ product.price | price }} ₽{% else %}—{% endif %}</td>
<td>{% if product.quantity != null %}{{ product.quantity }}{% else %}—{% endif %}</td>
<td class="text-muted">{{ product.measure_name or "—" }}</td>
<td>
{% if product.allow_to_sell is none %}
{% if product.allow_to_sell == null %}
<span class="text-muted">—</span>
{% elif product.allow_to_sell %}
<i class="bi bi-check-circle-fill text-success"></i>
@@ -65,7 +64,7 @@
</td>
<td>
{% if product.synced_at %}
<span title="{{ product.synced_at.strftime('%d.%m.%Y %H:%M') }}">
<span title="{{ product.synced_at | datefmt }}">
<i class="bi bi-check-circle-fill text-success"></i>
</span>
{% else %}
@@ -74,19 +73,19 @@
</td>
<td>
{% if mode == "include" %}
<span class="badge bg-success">✓ Включено</span>
<span class="badge badge-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge bg-danger">✗ Исключено</span>
<span class="badge badge-danger">✗ Исключено</span>
{% else %}
<span class="badge bg-light text-muted border">— Нет правила</span>
<span class="badge badge-light">— Нет правила</span>
{% endif %}
</td>
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" data-bs-strategy="fixed">
<div class="dropdown" data-dropdown>
<button type="button" class="outline secondary sm" data-dropdown-toggle>
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<ul class="dropdown-menu">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
@@ -118,7 +117,7 @@
<input type="hidden" name="filter_mode" value="none">
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
<button type="submit" class="dropdown-item muted">— Убрать правило</button>
</form>
</li>
</ul>
@@ -130,4 +129,16 @@
</table>
</div>
{% endif %}
<script>
document.querySelectorAll('[data-dropdown]').forEach(function(dd) {
dd.querySelector('[data-dropdown-toggle]').addEventListener('click', function(e) {
e.stopPropagation();
dd.classList.toggle('open');
});
});
document.addEventListener('click', function() {
document.querySelectorAll('[data-dropdown].open').forEach(function(dd) { dd.classList.remove('open'); });
});
</script>
{% endblock %}

View File

@@ -1,17 +1,17 @@
{% extends "base.html" %}
{% extends "base.njk" %}
{% block title %}Каталог — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 mb-0">Каталог</h1>
<div class="d-flex align-center justify-between mb-3">
<h1 style="font-size:1.3rem; margin:0;">Каталог</h1>
<div class="d-flex gap-2">
{% if evotor %}
<form method="post" action="/catalog/refresh">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<button type="submit" class="outline secondary sm">
<i class="bi bi-arrow-clockwise me-1"></i>Обновить
</button>
</form>
<a href="/catalog/export?type=stores" class="btn btn-outline-secondary btn-sm">
<a href="/catalog/export?type=stores" role="button" class="outline secondary sm">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
{% endif %}
@@ -19,23 +19,25 @@
</div>
{% if not evotor %}
<div class="alert alert-warning d-flex align-items-center gap-2">
<div role="alert" class="alert alert-warning d-flex align-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
</div>
{% elif not stores %}
<div class="text-center py-5 text-muted">
<i class="bi bi-shop fs-1 mb-3 d-block"></i>
<div class="empty-state">
<i class="bi bi-shop empty-icon"></i>
<p>Магазины не найдены в вашем аккаунте Эвотор.</p>
</div>
{% else %}
{% if fetched_at %}
<p class="text-muted small mb-3">Последнее обновление: {{ fetched_at.strftime("%d.%m.%Y %H:%M") }}</p>
<p class="text-muted small mb-3">Последнее обновление: {{ fetched_at | datefmt }}</p>
{% endif %}
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-light">
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>Название</th>
<th>Адрес</th>
@@ -45,30 +47,30 @@
</thead>
<tbody>
{% for store in stores %}
{% set mode = filter_map.get(store.evotor_id) %}
{% set mode = filter_map[store.evotor_id] %}
<tr>
<td>{{ store.name }}</td>
<td class="text-muted small">{{ store.address or "—" }}</td>
<td>
{% if mode == "include" %}
<span class="badge bg-success">✓ Включено</span>
<span class="badge badge-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge bg-danger">✗ Исключено</span>
<span class="badge badge-danger">✗ Исключено</span>
{% else %}
<span class="badge bg-light text-muted border">— Нет правила</span>
<span class="badge badge-light">— Нет правила</span>
{% endif %}
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<td>
<div class="d-flex gap-1 justify-end">
<a href="/catalog/groups?store_id={{ store.evotor_id }}"
class="btn btn-outline-secondary btn-sm" title="Группы">
role="button" class="outline secondary sm" title="Группы">
<i class="bi bi-arrow-right"></i>
</a>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
<div class="dropdown" data-dropdown>
<button type="button" class="outline secondary sm" data-dropdown-toggle>
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<ul class="dropdown-menu">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
@@ -97,7 +99,7 @@
<input type="hidden" name="entity_name" value="{{ store.name }}">
<input type="hidden" name="filter_mode" value="none">
<input type="hidden" name="redirect_to" value="/catalog">
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
<button type="submit" class="dropdown-item muted">— Убрать правило</button>
</form>
</li>
</ul>
@@ -110,4 +112,16 @@
</table>
</div>
{% endif %}
<script>
document.querySelectorAll('[data-dropdown]').forEach(function(dd) {
dd.querySelector('[data-dropdown-toggle]').addEventListener('click', function(e) {
e.stopPropagation();
dd.classList.toggle('open');
});
});
document.addEventListener('click', function() {
document.querySelectorAll('[data-dropdown].open').forEach(function(dd) { dd.classList.remove('open'); });
});
</script>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.njk" %}
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<i class="bi bi-envelope-check fs-1 text-primary mb-3 d-block"></i>
<h1 style="font-size:1.3rem;" class="mb-3">Подтвердите ваш email</h1>
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "base.njk" %}
{% block title %}Подключения — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-center justify-between mb-4">
<h1 style="font-size:1.3rem; margin:0;">Подключения</h1>
<a href="/connections/add" role="button" class="sm">
<i class="bi bi-plus-lg me-1"></i>Добавить
</a>
</div>
{% if connections %}
<div class="row gap-3">
{% for conn in connections %}
<div class="col-sm-6 col-lg-4">
<article class="card h-100">
<div class="card-body d-flex flex-col" style="gap: 0.75rem;">
<div class="d-flex align-center" style="gap: 0.75rem;">
<i class="bi {{ conn.icon }} fs-2 text-secondary"></i>
<div class="flex-1">
<strong>{{ conn.name }}</strong>
{% if conn.details %}
<br><small class="text-muted">{{ conn.details }}</small>
{% endif %}
</div>
{% if conn.is_online %}
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
{% else %}
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
{% endif %}
</div>
<div class="d-flex gap-2">
<a href="{{ conn.configure_url }}" role="button" class="outline sm flex-fill">Настроить</a>
<form method="post" action="/connections/delete?type={{ conn.type }}">
<button type="submit" class="outline danger sm"
onclick="return confirm('Вы уверены, что хотите отключить {{ conn.name }}?')">
Отключить
</button>
</form>
</div>
</div>
<footer class="text-muted small">
{% if conn.last_checked_at %}
Проверено: {{ conn.last_checked_at | datefmt }}
{% else %}
Статус ещё не проверялся
{% endif %}
</footer>
</article>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-plug empty-icon"></i>
<p class="mb-3">Нет подключённых сервисов</p>
<a href="/connections/add" role="button">
<i class="bi bi-plus-lg me-1"></i>Добавить подключение
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.njk" %}
{% block title %}Добавить подключение — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-center mb-4" style="gap: 0.75rem;">
<a href="/connections" class="text-muted"><i class="bi bi-arrow-left fs-5"></i></a>
<h1 style="font-size:1.3rem; margin:0;">Добавить подключение</h1>
</div>
{% if available %}
<div class="row gap-3">
{% for svc in available %}
<div class="col-sm-6 col-lg-4">
<article class="card h-100">
<div class="card-body d-flex flex-col" style="gap: 0.75rem;">
<div class="d-flex align-center" style="gap: 0.75rem;">
<i class="bi {{ svc.icon }} fs-2 text-secondary"></i>
<strong>{{ svc.name }}</strong>
</div>
<p class="text-muted small flex-1">{{ svc.description }}</p>
<a href="{{ svc.connect_url }}" role="button" class="sm" style="margin-top: auto;">
Подключить <i class="bi bi-arrow-right ms-auto"></i>
</a>
</div>
</article>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-check-circle empty-icon text-success"></i>
<p class="mb-3">Все доступные сервисы подключены</p>
<a href="/connections" role="button" class="outline secondary sm">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "base.njk" %}
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<i class="bi bi-check-circle fs-1 text-success mb-3 d-block"></i>
<h1 style="font-size:1.3rem;" class="mb-3">Email подтвержден!</h1>
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
<a href="/login" role="button" class="mt-2">Войти</a>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% extends "base.njk" %}
{% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
{% if error %}
<div class="alert alert-danger mt-4">
<div role="alert" class="alert alert-danger mt-4">
{% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
{% elif error == "empty_token" %}
@@ -17,58 +17,55 @@
</div>
{% endif %}
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0">Подключение Эвотор</h1>
</div>
<article class="card mt-4">
<header>
<h1>Подключение Эвотор</h1>
</header>
{% if connection %}
{# ── CONNECTED STATE ── #}
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
<ul class="list-group">
<li class="list-group-item">
<span class="text-muted small">Статус</span>
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
</li>
{% if connection.store_name %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<li class="list-group-item">
<span class="text-muted small">Магазин</span>
<span>{{ connection.store_name }}</span>
</li>
{% endif %}
{% if connection.store_id %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<li class="list-group-item">
<span class="text-muted small">ID магазина</span>
<span class="font-monospace small text-muted">{{ connection.store_id }}</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<li class="list-group-item">
<span class="text-muted small">Подключено</span>
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
<span class="small">{{ connection.connected_at | datefmt }}</span>
</li>
</ul>
<div class="card-footer">
<footer>
<p class="text-muted small mb-2">Обновить токен (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
<form method="post" action="/evotor/token">
<div class="input-group input-group-sm">
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
<div class="input-group">
<input type="text" name="token" class="font-monospace" placeholder="Новый токен" required>
<button type="submit" class="outline secondary">Обновить</button>
</div>
</form>
</div>
<div class="card-body d-grid">
</footer>
<div class="card-body">
<form method="post" action="/evotor/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
<button type="submit" class="outline danger w-100">Отключить аккаунт Эвотор</button>
</form>
</div>
{% else %}
{# ── NOT CONNECTED STATE ── #}
<div class="card-body">
<p class="text-muted mb-3">
Для подключения вам нужно установить приложение <strong>ЭвоСинк</strong> в личном кабинете Эвотор
и скопировать токен доступа из его настроек.
</p>
<ol class="text-muted small mb-4">
{% if app_url %}
<li class="mb-1">Откройте <a href="{{ app_url }}" target="_blank" rel="noopener">приложение ЭвоСинк в магазине Эвотор <i class="bi bi-box-arrow-up-right small"></i></a> и установите его.</li>
@@ -78,27 +75,21 @@
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
</ol>
<form method="post" action="/evotor/token">
<div class="mb-3">
<label class="form-label small text-muted">Токен доступа</label>
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Подключить</button>
</div>
<label class="small text-muted">Токен доступа
<input type="text" name="token" class="font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
</label>
<button type="submit" class="w-100">Подключить</button>
</form>
</div>
{% endif %}
</div>
</article>
<div class="mt-3 text-center">
<a href="/connections" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "base.njk" %}
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 style="font-size:1.3rem;" class="mb-2">Забыли пароль?</h1>
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
<form method="post" action="/forgot-password">
<label for="email">Email
<input type="email" id="email" name="email" required>
</label>
<button type="submit" class="w-100">Отправить ссылку для сброса</button>
</form>
<div class="text-center small mt-3">
<a href="/login">Вернуться ко входу</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.njk" %}
{% block title %}Вход — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 class="mb-4" style="font-size:1.3rem;">Вход</h1>
<form method="post" action="/login">
<label for="email">Email
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label for="password">Пароль
<input type="password" id="password" name="password" required>
</label>
<button type="submit" class="w-100">Войти</button>
</form>
<div class="text-center small mt-3">
<a href="/forgot-password">Забыли пароль?</a><br>
<a href="/register">Зарегистрироваться</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.njk" %}
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<h1 style="font-size:1.3rem;" class="mb-3">{{ title }}</h1>
<p class="text-muted">{{ message }}</p>
{% if link %}
<a href="{{ link }}" role="button" class="mt-2">{{ link_text }}</a>
{% endif %}
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.njk" %}
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<header>
<h1><i class="bi bi-key me-2"></i>Изменить пароль</h1>
</header>
<div class="card-body">
<form method="post" action="/profile/change-password">
<label for="current_password">Текущий пароль
<input type="password" id="current_password" name="current_password" required>
</label>
<label for="password">Новый пароль
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтвердить пароль
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<div class="d-flex gap-2">
<button type="submit">Изменить пароль</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.njk" %}
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4" style="border-color: #dc2626;">
<header class="bg-danger-header">
<h1><i class="bi bi-trash me-2"></i>Удалить аккаунт</h1>
</header>
<div class="card-body">
<div role="alert" class="alert alert-warning mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Внимание!</strong> Это действие необратимо. Все ваши данные будут удалены.
</div>
<form method="post" action="/profile/delete">
<label for="password">Введите пароль для подтверждения
<input type="password" id="password" name="password" required>
</label>
<div class="d-flex gap-2">
<button type="submit" class="danger">
<i class="bi bi-trash me-1"></i>Удалить мой аккаунт
</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "base.njk" %}
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<header>
<h1><i class="bi bi-pencil me-2"></i>Редактировать профиль</h1>
</header>
<div class="card-body">
<form method="post" action="/profile/edit">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя
<input type="text" id="first_name" name="first_name"
value="{{ form.first_name if form else user.first_name }}" required>
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name"
value="{{ form.last_name if form else user.last_name }}" required>
</label>
</div>
</div>
<label>Email
<input type="email" value="{{ user.email }}" disabled>
</label>
<label for="phone">Телефон
<input type="tel" id="phone" name="phone"
value="{{ form.phone if form else user.phone }}" required>
</label>
<div class="d-flex gap-2">
<button type="submit">Сохранить</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -1,46 +1,46 @@
{% extends "base.html" %}
{% extends "base.njk" %}
{% block title %}Личный кабинет — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0"><i class="bi bi-person-circle me-2"></i>Личный кабинет</h1>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between">
<article class="card mt-4">
<header>
<h1><i class="bi bi-person-circle me-2"></i>Личный кабинет</h1>
</header>
<ul class="list-group">
<li class="list-group-item">
<span class="text-muted small">Имя</span>
<span>{{ user.first_name }}</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<li class="list-group-item">
<span class="text-muted small">Фамилия</span>
<span>{{ user.last_name }}</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<li class="list-group-item">
<span class="text-muted small">Email</span>
<span>{{ user.email }}</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<li class="list-group-item">
<span class="text-muted small">Телефон</span>
<span>{{ user.phone }}</span>
</li>
</ul>
<div class="card-body d-grid gap-2">
<a href="/profile/edit" class="btn btn-primary">
<a href="/profile/edit" role="button">
<i class="bi bi-pencil me-1"></i>Редактировать профиль
</a>
<a href="/profile/change-password" class="btn btn-secondary">
<a href="/profile/change-password" role="button" class="secondary">
<i class="bi bi-key me-1"></i>Изменить пароль
</a>
<a href="/logout" class="btn btn-outline-secondary">
<a href="/logout" role="button" class="outline secondary">
<i class="bi bi-box-arrow-right me-1"></i>Выход
</a>
<a href="/profile/delete" class="btn btn-outline-danger btn-sm mt-2">
<a href="/profile/delete" role="button" class="outline danger sm mt-2">
<i class="bi bi-trash me-1"></i>Удалить аккаунт
</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.njk" %}
{% block title %}Регистрация — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<div class="card-body">
<h1 class="mb-4" style="font-size:1.3rem;">Регистрация</h1>
<form method="post" action="/register">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя
<input type="text" id="first_name" name="first_name" value="{{ form.first_name if form else '' }}">
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name" value="{{ form.last_name if form else '' }}">
</label>
</div>
</div>
<label for="email">Email <span class="text-danger">*</span>
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label for="phone">Телефон <span class="text-danger">*</span>
<input type="tel" id="phone" name="phone" value="{{ form.phone if form else '' }}" required>
</label>
<label for="password">Пароль <span class="text-danger">*</span>
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтверждение пароля <span class="text-danger">*</span>
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<button type="submit" class="w-100">Зарегистрироваться</button>
</form>
<div class="text-center small mt-3">
<a href="/login">Уже есть аккаунт? Войти</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "base.njk" %}
{% block title %}Новый пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 style="font-size:1.3rem;" class="mb-4">Новый пароль</h1>
<form method="post" action="/reset-password?token={{ token }}">
<label for="password">Новый пароль
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтверждение пароля
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<button type="submit" class="w-100">Сменить пароль</button>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -1,67 +1,62 @@
{% extends "base.html" %}
{% extends "base.njk" %}
{% block title %}Синхронизация — ЭВОСИНК{% endblock %}
{% block content %}
<h1 class="h4 mb-4">Синхронизация</h1>
<h1 style="font-size:1.3rem;" class="mb-4">Синхронизация</h1>
{% if not evotor %}
<div class="alert alert-warning d-flex align-items-center gap-2">
<div role="alert" class="alert alert-warning d-flex align-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
</div>
{% endif %}
{% if not vk %}
<div class="alert alert-warning d-flex align-items-center gap-2">
<div role="alert" class="alert alert-warning d-flex align-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>ВКонтакте не подключён. <a href="/vk">Подключить ВКонтакте</a></span>
</div>
{% endif %}
<div class="row g-3">
{# ── Status card ── #}
<div class="col-12 col-md-6">
<div class="card shadow-sm h-100">
<div class="row gap-3">
<div class="col-12 col-md-6-auto">
<article class="card h-100">
<div class="card-body">
<h5 class="card-title mb-3">Статус</h5>
<h5 class="mb-3">Статус</h5>
<div class="mb-3">
{% if status == "active" %}
<span class="badge bg-success fs-6"><i class="bi bi-play-fill me-1"></i>Активна</span>
<span class="badge badge-success fs-6"><i class="bi bi-play-fill me-1"></i>Активна</span>
{% elif status == "paused" %}
<span class="badge bg-secondary fs-6"><i class="bi bi-pause-fill me-1"></i>Приостановлена</span>
<span class="badge badge-secondary fs-6"><i class="bi bi-pause-fill me-1"></i>Приостановлена</span>
{% elif status == "pending" %}
<span class="badge bg-warning text-dark fs-6"><i class="bi bi-clock me-1"></i>Ожидает подтверждения</span>
<span class="badge badge-warning fs-6"><i class="bi bi-clock me-1"></i>Ожидает подтверждения</span>
{% else %}
<span class="badge bg-light text-dark border fs-6"><i class="bi bi-gear me-1"></i>Не настроено</span>
<span class="badge badge-light fs-6"><i class="bi bi-gear me-1"></i>Не настроено</span>
{% endif %}
</div>
{% if config.confirmed_at %}
<p class="text-muted small mb-3">
Запущена: {{ config.confirmed_at.strftime("%d.%m.%Y %H:%M") }}
Запущена: {{ config.confirmed_at | datefmt }}
</p>
{% endif %}
<div class="d-flex gap-2 flex-wrap">
{# Toggle enable/disable #}
<form method="post" action="/sync/toggle">
{% if config.is_enabled %}
<button type="submit" class="btn btn-outline-secondary btn-sm">
<button type="submit" class="outline secondary sm">
<i class="bi bi-pause-fill me-1"></i>Приостановить
</button>
{% else %}
<button type="submit" class="btn btn-outline-primary btn-sm" {% if not evotor or not vk %}disabled{% endif %}>
<button type="submit" class="outline sm" {% if not evotor or not vk %}disabled{% endif %}>
<i class="bi bi-play-fill me-1"></i>Включить
</button>
{% endif %}
</form>
{# Confirm button #}
{% if config.is_enabled and summary.total > 0 %}
<form method="post" action="/sync/confirm">
<button type="submit" class="btn btn-success btn-sm">
<button type="submit" class="sm" style="--pico-background-color: #15803d; --pico-border-color: #15803d;">
<i class="bi bi-check-lg me-1"></i>Подтвердить и запустить
</button>
</form>
@@ -74,17 +69,15 @@
</p>
{% endif %}
</div>
</div>
</article>
</div>
{# ── Filters card ── #}
<div class="col-12 col-md-6">
<div class="card shadow-sm h-100">
<div class="col-12 col-md-6-auto">
<article class="card h-100">
<div class="card-body">
<h5 class="card-title mb-3">Фильтры</h5>
<h5 class="mb-3">Фильтры</h5>
{% if summary.total > 0 %}
<ul class="list-unstyled mb-3">
<ul style="list-style: none; padding: 0; margin-bottom: 0.75rem;">
{% if summary.stores > 0 %}
<li class="text-muted small"><i class="bi bi-shop me-1"></i>Магазины: {{ summary.stores }} правил</li>
{% endif %}
@@ -98,13 +91,11 @@
{% else %}
<p class="text-muted small mb-3">Фильтры не настроены — будут синхронизированы все товары.</p>
{% endif %}
<a href="/catalog" class="btn btn-outline-primary btn-sm" {% if not evotor %}disabled{% endif %}>
<a href="/catalog" role="button" class="outline sm" {% if not evotor %}disabled{% endif %}>
<i class="bi bi-sliders me-1"></i>Настроить фильтры
</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% extends "base.njk" %}
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
{% if error %}
<div class="alert alert-danger mt-4">
<div role="alert" class="alert alert-danger mt-4">
{% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен или у него нет прав администратора сообщества.
{% elif error == "empty_token" %}
@@ -19,63 +19,59 @@
</div>
{% endif %}
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0">Подключение ВКонтакте</h1>
</div>
<article class="card mt-4">
<header>
<h1>Подключение ВКонтакте</h1>
</header>
{% if connection %}
{# ── CONNECTED STATE ── #}
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
<ul class="list-group">
<li class="list-group-item">
<span class="text-muted small">Статус</span>
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
</li>
{% if connection.first_name %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<li class="list-group-item">
<span class="text-muted small">Сообщество</span>
<span>{{ connection.first_name }}</span>
</li>
{% endif %}
{% if connection.vk_user_id %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<li class="list-group-item">
<span class="text-muted small">ID сообщества</span>
<span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<li class="list-group-item">
<span class="text-muted small">Подключено</span>
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
<span class="small">{{ connection.connected_at | datefmt }}</span>
</li>
</ul>
<div class="card-footer">
<footer>
<p class="text-muted small mb-2">Обновить токен пользователя:</p>
<form method="post" action="/vk/token">
<div class="input-group input-group-sm">
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен пользователя" required>
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
<div class="input-group">
<input type="text" name="token" class="font-monospace" placeholder="Новый токен пользователя" required>
<button type="submit" class="outline secondary">Обновить</button>
</div>
</form>
</div>
<div class="card-body d-grid">
</footer>
<div class="card-body">
<form method="post" action="/vk/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить ВКонтакте</button>
<button type="submit" class="outline danger w-100">Отключить ВКонтакте</button>
</form>
</div>
{% else %}
{# ── NOT CONNECTED STATE ── #}
<div class="card-body">
{% if vk_client_id %}
<p class="text-muted mb-4">
Нажмите кнопку ниже, чтобы авторизоваться через ВКонтакте и выдать доступ к управлению товарами вашего сообщества.
</p>
<div class="d-grid mb-3">
<a href="/vk/connect" class="btn btn-primary btn-lg">
<i class="bi bi-box-arrow-in-right me-2"></i>Подключить ВКонтакте
</a>
</div>
<hr class="my-4">
<a href="/vk/connect" role="button" class="w-100 mb-3" style="display: block; text-align: center;">
<i class="bi bi-box-arrow-in-right me-2"></i>Подключить ВКонтакте
</a>
<hr>
<p class="text-muted small mb-2">Или введите токен вручную:</p>
{% else %}
<p class="text-muted mb-3">
@@ -85,25 +81,24 @@
{% endif %}
<form method="post" action="/vk/token">
<div class="mb-3">
<label class="form-label small text-muted">Токен пользователя ВКонтакте</label>
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен пользователя" required {% if vk_client_id %}{% else %}autofocus{% endif %}>
</div>
<div class="d-grid">
<button type="submit" class="btn {% if vk_client_id %}btn-outline-secondary{% else %}btn-primary{% endif %}">Подключить</button>
</div>
<label class="small text-muted">Токен пользователя ВКонтакте
<input type="text" name="token" class="font-monospace" placeholder="Вставьте токен пользователя" required {% if not vk_client_id %}autofocus{% endif %}>
</label>
{% if vk_client_id %}
<button type="submit" class="w-100 outline secondary">Подключить</button>
{% else %}
<button type="submit" class="w-100">Подключить</button>
{% endif %}
</form>
</div>
{% endif %}
</div>
</article>
<div class="mt-3 text-center">
<a href="/connections" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,22 +1,22 @@
{% extends "base.html" %}
{% extends "base.njk" %}
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<div class="card shadow-sm mt-4 text-center">
<div class="card-body py-5">
<article class="card mt-4 text-center">
<div class="card-body" style="padding: 2.5rem;">
<div id="state-loading">
<div class="spinner-border text-primary mb-3" role="status"></div>
<div class="spinner mb-3" style="margin: 0 auto;"></div>
<p class="text-muted mb-0">Подключение ВКонтакте…</p>
</div>
<div id="state-error" class="d-none">
<i class="bi bi-exclamation-triangle-fill text-danger fs-1 mb-3 d-block"></i>
<p class="text-muted mb-3" id="error-message">Не удалось получить токен от ВКонтакте.</p>
<a href="/vk" class="btn btn-outline-secondary">Попробовать снова</a>
<a href="/vk" role="button" class="outline secondary">Попробовать снова</a>
</div>
</div>
</div>
</article>
</div>
</div>

View File

@@ -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>) */
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;
}

View File

@@ -1,485 +0,0 @@
"""
Sync engine: syncs Evotor products to VK market for all enabled users.
Runs as a background asyncio loop inside the web app.
"""
import asyncio
import logging
from datetime import datetime
import httpx
from sqlalchemy.orm import Session
from web.database import SessionLocal
from web.models import CachedProduct, EvotorConnection, VkConnection, SyncConfig, SyncFilter
logger = logging.getLogger("uvicorn.error")
VK_API_HOST = "https://api.vk.ru/method"
VK_API_VERSION = "5.199"
EVOTOR_API_BASE = "https://api.evotor.ru"
VK_CATEGORY_ID = 40932
VK_STOCK_AMOUNT = 1000
WEIGHT_PRICE_MULTIPLIER = 10
WEIGHT_MEASURES = {"г", "г.", "грамм", "граммов", "гр", "гр."}
def _is_weight_measure(measure: str | None) -> bool:
if not measure:
return False
return measure.strip().lower() in WEIGHT_MEASURES
def _normalize_name(name: str) -> str:
return name.strip().replace(";", ",")
def _calc_price(price_kopecks, measure: str | None) -> tuple[int, str]:
"""Returns (price_in_kopecks_for_vk, price_info_label)."""
base = int(price_kopecks or 0)
if _is_weight_measure(measure):
return base * WEIGHT_PRICE_MULTIPLIER, f"{WEIGHT_PRICE_MULTIPLIER}{measure}"
return base, measure or ""
def _build_description(name: str, price_info: str, extra_desc: str | None) -> str:
desc = f"{name} (цена за {price_info}.)\n\n"
if extra_desc:
desc += extra_desc
return desc
def _get_included_store_ids(filters: list) -> list[str]:
return [f.entity_id for f in filters if f.entity_type == "store" and f.filter_mode == "include"]
def _is_group_included(group_id: str | None, filters: list) -> bool:
"""Returns True if the group should be synced based on filters."""
group_filters = {f.entity_id: f.filter_mode for f in filters if f.entity_type == "group"}
if not group_filters:
return True # no group filters → include all
mode = group_filters.get(group_id)
if mode == "exclude":
return False
if mode == "include":
return True
# Not mentioned — include if there are only excludes, exclude if there are only includes
has_includes = any(v == "include" for v in group_filters.values())
return not has_includes
def _is_product_included(product_id: str, filters: list) -> bool:
product_filters = {f.entity_id: f.filter_mode for f in filters if f.entity_type == "product"}
if not product_filters:
return True
mode = product_filters.get(product_id)
if mode == "exclude":
return False
if mode == "include":
return True
has_includes = any(v == "include" for v in product_filters.values())
return not has_includes
# ---------------------------------------------------------------------------
# Evotor API helpers
# ---------------------------------------------------------------------------
async def _evo_fetch_products(token: str, store_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores/{store_id}/products",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if resp.status_code in (402, 404):
return []
resp.raise_for_status()
data = resp.json()
return data.get("items", data) if isinstance(data, dict) else data
async def _evo_fetch_groups(token: str, store_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if resp.status_code in (402, 404):
return []
resp.raise_for_status()
data = resp.json()
return data.get("items", data) if isinstance(data, dict) else data
# ---------------------------------------------------------------------------
# VK API helpers
# ---------------------------------------------------------------------------
def _vk_params(token: str, **extra) -> dict:
return {"access_token": token, "v": VK_API_VERSION, **extra}
async def _vk_get_albums(token: str, owner_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{VK_API_HOST}/market.getAlbums",
params=_vk_params(token, owner_id=owner_id, count=200),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return data.get("response", {}).get("items", [])
async def _vk_create_album(token: str, owner_id: str, title: str) -> int | None:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{VK_API_HOST}/market.addAlbum",
data=_vk_params(token, owner_id=owner_id, title=title),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if "error" in data:
logger.error("VK create album error: %s", data["error"])
return None
return data.get("response", {}).get("market_album_id")
async def _vk_get_products(token: str, owner_id: str) -> list[dict]:
"""Fetch all VK market items (handles pagination)."""
items = []
offset = 0
count = 200
async with httpx.AsyncClient() as client:
while True:
resp = await client.get(
f"{VK_API_HOST}/market.get",
params=_vk_params(token, owner_id=owner_id, extended=1,
with_disabled=1, count=count, offset=offset),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
batch = data.get("response", {}).get("items", [])
items.extend(batch)
if len(batch) < count:
break
offset += count
return items
async def _vk_upload_photo(token: str, group_id: str, photo_path: str) -> str | None:
"""Upload a photo and return photo_id."""
async with httpx.AsyncClient() as client:
# Get upload URL
resp = await client.get(
f"{VK_API_HOST}/market.getProductPhotoUploadServer",
params=_vk_params(token, group_id=group_id),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if "error" in data:
logger.error("VK get upload URL error: %s", data["error"])
return None
upload_url = data.get("response", {}).get("upload_url")
if not upload_url:
return None
# Upload photo
with open(photo_path, "rb") as f:
upload_resp = await client.post(upload_url, files={"file": f}, timeout=60)
upload_resp.raise_for_status()
upload_obj = upload_resp.json()
# Save photo
save_resp = await client.post(
f"{VK_API_HOST}/market.saveProductPhoto",
data=_vk_params(token, upload_response=upload_resp.text),
timeout=30,
)
save_resp.raise_for_status()
save_data = save_resp.json()
if "error" in save_data:
logger.error("VK save photo error: %s", save_data["error"])
return None
return save_data.get("response", {}).get("photo_id")
async def _vk_create_product(
token: str, owner_id: str, name: str, description: str,
price: int, stock_amount: int, photo_id: str, album_id: int | None,
) -> int | None:
params = _vk_params(
token,
owner_id=owner_id,
name=name,
description=description,
category_id=VK_CATEGORY_ID,
price=price,
main_photo_id=photo_id,
stock_amount=stock_amount,
)
async with httpx.AsyncClient() as client:
resp = await client.post(f"{VK_API_HOST}/market.add", data=params, timeout=30)
resp.raise_for_status()
data = resp.json()
if "error" in data:
logger.error("VK create product error: %s", data["error"])
return None
product_id = data.get("response", {}).get("market_item_id")
if product_id and album_id:
await client.get(
f"{VK_API_HOST}/market.addToAlbum",
params=_vk_params(token, owner_id=owner_id,
item_ids=product_id, album_ids=album_id),
timeout=30,
)
return product_id
async def _vk_edit_product(
token: str, owner_id: str, item_id: int, name: str,
description: str, price: int, stock_amount: int,
) -> None:
params = _vk_params(
token,
owner_id=owner_id,
item_id=item_id,
name=name,
description=description,
category_id=VK_CATEGORY_ID,
price=price,
stock_amount=stock_amount,
)
async with httpx.AsyncClient() as client:
resp = await client.post(f"{VK_API_HOST}/market.edit", data=params, timeout=30)
resp.raise_for_status()
data = resp.json()
if "error" in data:
logger.error("VK edit product error: %s", data["error"])
async def _vk_delete_product(token: str, owner_id: str, item_id: int) -> None:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{VK_API_HOST}/market.delete",
data=_vk_params(token, owner_id=owner_id, item_id=item_id),
timeout=30,
)
resp.raise_for_status()
# ---------------------------------------------------------------------------
# Main sync logic per user
# ---------------------------------------------------------------------------
def _stamp_synced(db: Session, user_id: int, evo_id: str, now: datetime) -> None:
db.query(CachedProduct).filter(
CachedProduct.user_id == user_id,
CachedProduct.evotor_id == evo_id,
).update({"synced_at": now})
db.commit()
async def sync_user(
user_id: int,
evo_token: str,
vk_token: str,
vk_group_id: str,
filters: list,
photo_path: str,
db: Session,
) -> None:
owner_id = f"-{vk_group_id}"
now = datetime.utcnow()
logger.info("Sync start: user_id=%d vk_group=%s", user_id, vk_group_id)
store_ids = _get_included_store_ids(filters)
if not store_ids:
logger.info("Sync skip: user_id=%d — no stores included in filters", user_id)
return
# Collect all Evotor products and groups across included stores
evo_products: list[dict] = []
groups_by_id: dict[str, dict] = {}
for store_id in store_ids:
raw_groups = await _evo_fetch_groups(evo_token, store_id)
for g in raw_groups:
gid = g.get("uuid") or g.get("id")
if gid:
groups_by_id[gid] = g
raw_products = await _evo_fetch_products(evo_token, store_id)
for p in raw_products:
pid = p.get("uuid") or p.get("id")
gid = p.get("parentUuid") or p.get("parent_id")
if not _is_group_included(gid, filters):
continue
if not _is_product_included(pid, filters):
continue
evo_products.append(p)
# Build evo product lookup by normalized name
# {normalized_name: product_dict}
evo_by_name: dict[str, dict] = {}
for p in evo_products:
raw_name = (p.get("name") or "").strip()
norm = _normalize_name(raw_name)
evo_by_name[norm] = p
# Fetch VK state
vk_products = await _vk_get_products(vk_token, owner_id)
vk_albums = await _vk_get_albums(vk_token, owner_id)
vk_album_by_title: dict[str, dict] = {a["title"]: a for a in vk_albums}
# Ensure albums exist for all included groups
for gid, group in groups_by_id.items():
if not _is_group_included(gid, filters):
continue
title = group.get("name", "")
if title and title not in vk_album_by_title:
new_album_id = await _vk_create_album(vk_token, owner_id, title)
if new_album_id:
vk_album_by_title[title] = {"id": new_album_id, "title": title}
logger.info("Created VK album '%s' for user_id=%d", title, user_id)
# Build VK product lookup by normalized name
# {normalized_name: [vk_item, ...]}
vk_by_name: dict[str, list[dict]] = {}
for item in vk_products:
norm = _normalize_name(item.get("title", ""))
vk_by_name.setdefault(norm, []).append(item)
# --- UPDATE / CREATE ---
for norm_name, evo_p in evo_by_name.items():
evo_id = evo_p.get("uuid") or evo_p.get("id")
raw_name = (evo_p.get("name") or "").strip()
name_for_vk = _normalize_name(raw_name)
measure = evo_p.get("measureName") or evo_p.get("measure_name")
raw_price = evo_p.get("price") or 0
price, price_info = _calc_price(raw_price, measure)
allow_to_sell = evo_p.get("allowToSell") if evo_p.get("allowToSell") is not None else evo_p.get("allow_to_sell")
stock_amount = VK_STOCK_AMOUNT if allow_to_sell else 0
extra_desc = evo_p.get("description") or ""
description = _build_description(raw_name, price_info, extra_desc).strip()
gid = evo_p.get("parentUuid") or evo_p.get("parent_id")
group_name = groups_by_id.get(gid, {}).get("name") if gid else None
album = vk_album_by_title.get(group_name) if group_name else None
album_id = album["id"] if album else None
if norm_name in vk_by_name:
# Update existing (use first match)
vk_item = vk_by_name[norm_name][0]
vk_id = vk_item["id"]
orig_price = vk_item.get("price", {}).get("amount", 0)
orig_price_int = int(orig_price) if orig_price else 0
orig_desc = (vk_item.get("description") or "").strip()
orig_stock = vk_item.get("stock_amount", 0)
price_changed = price != orig_price_int
desc_changed = description != orig_desc
stock_changed = stock_amount != orig_stock
if price_changed or desc_changed or stock_changed:
logger.info(
"Updating VK product '%s' user_id=%d (price=%s desc=%s stock=%s)",
name_for_vk, user_id, price_changed, desc_changed, stock_changed,
)
await _vk_edit_product(
vk_token, owner_id, vk_id, name_for_vk, description, price, stock_amount,
)
_stamp_synced(db, user_id, evo_id, now)
else:
# Create new (only if allow_to_sell)
if not allow_to_sell:
continue
photo_id = await _vk_upload_photo(vk_token, vk_group_id, photo_path)
if not photo_id:
logger.error("Skipping product '%s' — photo upload failed", name_for_vk)
continue
logger.info("Creating VK product '%s' user_id=%d", name_for_vk, user_id)
created = await _vk_create_product(
vk_token, owner_id, name_for_vk, description,
price, stock_amount, photo_id, album_id,
)
if created:
_stamp_synced(db, user_id, evo_id, now)
# --- DELETE products in VK that are no longer in Evo ---
for norm_name, vk_items in vk_by_name.items():
if norm_name in evo_by_name:
# Delete duplicates (keep first)
for dup in vk_items[1:]:
logger.info("Deleting duplicate VK product '%s' id=%d user_id=%d",
norm_name, dup["id"], user_id)
await _vk_delete_product(vk_token, owner_id, dup["id"])
else:
# Delete all — product removed from Evo
for item in vk_items:
logger.info("Deleting removed product '%s' id=%d user_id=%d",
norm_name, item["id"], user_id)
await _vk_delete_product(vk_token, owner_id, item["id"])
logger.info("Sync complete: user_id=%d", user_id)
# ---------------------------------------------------------------------------
# Background loop
# ---------------------------------------------------------------------------
async def run_sync() -> None:
from web.config import settings
db = SessionLocal()
try:
configs = db.query(SyncConfig).filter(
SyncConfig.is_enabled == True,
SyncConfig.confirmed_at != None,
).all()
for config in configs:
user_id = config.user_id
evo = db.query(EvotorConnection).filter(
EvotorConnection.user_id == user_id
).first()
vk = db.query(VkConnection).filter(
VkConnection.user_id == user_id
).first()
if not evo or not vk:
continue
if not evo.access_token or not vk.access_token:
continue
if not vk.vk_user_id:
continue
try:
await sync_user(
user_id=user_id,
evo_token=evo.access_token,
vk_token=vk.access_token,
vk_group_id=vk.vk_user_id,
filters=config.filters,
photo_path=settings.VK_DEFAULT_PHOTO_PATH,
db=db,
)
except Exception:
logger.exception("Sync failed for user_id=%d", user_id)
except Exception:
logger.exception("Error in sync runner")
db.rollback()
finally:
db.close()
async def sync_loop(interval: int) -> None:
while True:
await run_sync()
await asyncio.sleep(interval)

View File

@@ -1,97 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ЭВОСИНК{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
<div class="container">
<a href="/" class="navbar-brand brand-logo">ЭВОСИНК</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{% if user %}
<li class="nav-item">
<a href="/connections" class="nav-link">Подключения</a>
</li>
<li class="nav-item">
<a href="/catalog" class="nav-link">Каталог</a>
</li>
<li class="nav-item">
<a href="/sync" class="nav-link">Синхронизация</a>
</li>
<li class="nav-item">
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>
</li>
<li class="nav-item">
<a href="/logout" class="nav-link text-muted">Выход</a>
</li>
{% else %}
<li class="nav-item">
<a href="/login" class="nav-link">Вход</a>
</li>
<li class="nav-item">
<a href="/register" class="nav-link">Регистрация</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<main class="container py-4">
{% if errors %}
<div class="alert alert-danger">
{% for error in errors %}
<p class="mb-1">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% if success %}
<div class="alert alert-success">
<p class="mb-0">{{ success }}</p>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
{% if jivosite_widget_id %}
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', {
placeholder: '_',
showMaskOnHover: false,
clearMaskOnLostFocus: false
}).mask(phoneInputs);
}
});
</script>
<script>
document.addEventListener('invalid', function(e) {
if (e.target.validity.valueMissing) {
e.target.setCustomValidity('Пожалуйста, заполните это поле');
} else if (e.target.validity.typeMismatch) {
e.target.setCustomValidity('Пожалуйста, введите корректное значение');
}
}, true);
document.addEventListener('input', function(e) {
if (e.target.required) e.target.setCustomValidity('');
}, true);
</script>
</body>
</html>

View File

@@ -1,16 +0,0 @@
{% extends "base.html" %}
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-5 text-center">
<div class="card-body p-5">
<i class="bi bi-envelope-check display-4 text-primary mb-3"></i>
<h1 class="h4 mb-3">Подтвердите ваш email</h1>
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,67 +0,0 @@
{% extends "base.html" %}
{% block title %}Подключения — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-4">
<h1 class="h4 mb-0">Подключения</h1>
<a href="/connections/add" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Добавить
</a>
</div>
{% if connections %}
<div class="row g-3">
{% for conn in connections %}
<div class="col-sm-6 col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<i class="bi {{ conn.icon }} fs-2 me-3 text-secondary"></i>
<div>
<h5 class="mb-0">{{ conn.name }}</h5>
{% if conn.details %}
<small class="text-muted">{{ conn.details }}</small>
{% endif %}
</div>
<div class="ms-auto">
{% if conn.is_online %}
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
{% else %}
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
{% endif %}
</div>
</div>
<div class="d-flex gap-2">
<a href="{{ conn.configure_url }}" class="btn btn-outline-primary btn-sm flex-fill">Настроить</a>
<form method="post" action="/connections/delete?type={{ conn.type }}">
<button type="submit"
class="btn btn-outline-danger btn-sm"
onclick="return confirm('Вы уверены, что хотите отключить {{ conn.name }}?')">
Отключить
</button>
</form>
</div>
</div>
<div class="card-footer text-muted small">
{% if conn.last_checked_at %}
Проверено: {{ conn.last_checked_at.strftime("%d.%m.%Y %H:%M") }}
{% else %}
Статус ещё не проверялся
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-plug fs-1 mb-3 d-block"></i>
<p class="mb-3">Нет подключённых сервисов</p>
<a href="/connections/add" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Добавить подключение
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,39 +0,0 @@
{% extends "base.html" %}
{% block title %}Добавить подключение — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-items-center mb-4">
<a href="/connections" class="text-muted me-3"><i class="bi bi-arrow-left fs-5"></i></a>
<h1 class="h4 mb-0">Добавить подключение</h1>
</div>
{% if available %}
<div class="row g-3">
{% for svc in available %}
<div class="col-sm-6 col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-center mb-3">
<i class="bi {{ svc.icon }} fs-2 me-3 text-secondary"></i>
<h5 class="mb-0">{{ svc.name }}</h5>
</div>
<p class="text-muted small flex-grow-1">{{ svc.description }}</p>
<a href="{{ svc.connect_url }}" class="btn btn-primary btn-sm mt-auto">
Подключить <i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-check-circle fs-1 mb-3 d-block text-success"></i>
<p class="mb-3">Все доступные сервисы подключены</p>
<a href="/connections" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,17 +0,0 @@
{% extends "base.html" %}
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-5 text-center">
<div class="card-body p-5">
<i class="bi bi-check-circle display-4 text-success mb-3"></i>
<h1 class="h4 mb-3">Email подтвержден!</h1>
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
<a href="/login" class="btn btn-primary mt-2">Войти</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,27 +0,0 @@
{% extends "base.html" %}
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<h1 class="card-title h4 mb-2">Забыли пароль?</h1>
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
<form method="post" action="/forgot-password">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" id="email" name="email" class="form-control" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Отправить ссылку для сброса</button>
</div>
</form>
<div class="mt-3 text-center small">
<a href="/login">Вернуться ко входу</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,32 +0,0 @@
{% extends "base.html" %}
{% block title %}Вход — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<h1 class="card-title h4 mb-4">Вход</h1>
<form method="post" action="/login">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" id="email" name="email" class="form-control"
value="{{ form.email if form else '' }}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Войти</button>
</div>
</form>
<div class="mt-3 text-center small">
<a href="/forgot-password">Забыли пароль?</a><br>
<a href="/register">Зарегистрироваться</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,18 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-5 text-center">
<div class="card-body p-5">
<h1 class="h4 mb-3">{{ title }}</h1>
<p class="text-muted">{{ message }}</p>
{% if link %}
<a href="{{ link }}" class="btn btn-primary mt-2">{{ link_text }}</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,46 +0,0 @@
{% extends "base.html" %}
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0"><i class="bi bi-key me-2"></i>Изменить пароль</h1>
</div>
<div class="card-body p-4">
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if errors %}
<div class="alert alert-danger">
{% for error in errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<form method="post" action="/profile/change-password">
<div class="mb-3">
<label for="current_password" class="form-label">Текущий пароль</label>
<input type="password" id="current_password" name="current_password" class="form-control" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Новый пароль</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="mb-4">
<label for="password_confirm" class="form-label">Подтвердить пароль</label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Изменить пароль</button>
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,41 +0,0 @@
{% extends "base.html" %}
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4 border-danger">
<div class="card-header bg-danger text-white">
<h1 class="h5 mb-0"><i class="bi bi-trash me-2"></i>Удалить аккаунт</h1>
</div>
<div class="card-body p-4">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Внимание!</strong> Это действие необратимо. Все ваши данные будут удалены.
</div>
{% if errors %}
<div class="alert alert-danger">
{% for error in errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<form method="post" action="/profile/delete">
<div class="mb-4">
<label for="password" class="form-label">Введите пароль для подтверждения</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>Удалить мой аккаунт
</button>
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0"><i class="bi bi-pencil me-2"></i>Редактировать профиль</h1>
</div>
<div class="card-body p-4">
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if errors %}
<div class="alert alert-danger">
{% for error in errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<form method="post" action="/profile/edit">
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label for="first_name" class="form-label">Имя</label>
<input type="text" id="first_name" name="first_name" class="form-control"
value="{{ form.first_name if form else user.first_name }}" required>
</div>
<div class="col-sm-6">
<label for="last_name" class="form-label">Фамилия</label>
<input type="text" id="last_name" name="last_name" class="form-control"
value="{{ form.last_name if form else user.last_name }}" required>
</div>
</div>
<div class="mb-3">
<label class="form-label text-muted">Email</label>
<input type="email" class="form-control" value="{{ user.email }}" disabled>
</div>
<div class="mb-4">
<label for="phone" class="form-label">Телефон</label>
<input type="tel" id="phone" name="phone" class="form-control"
value="{{ form.phone if form else user.phone }}" required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,52 +0,0 @@
{% extends "base.html" %}
{% block title %}Регистрация — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<h1 class="card-title h4 mb-4">Регистрация</h1>
<form method="post" action="/register">
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label for="first_name" class="form-label">Имя</label>
<input type="text" id="first_name" name="first_name" class="form-control"
value="{{ form.first_name if form else '' }}">
</div>
<div class="col-sm-6">
<label for="last_name" class="form-label">Фамилия</label>
<input type="text" id="last_name" name="last_name" class="form-control"
value="{{ form.last_name if form else '' }}">
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email <span class="text-danger">*</span></label>
<input type="email" id="email" name="email" class="form-control"
value="{{ form.email if form else '' }}" required>
</div>
<div class="mb-3">
<label for="phone" class="form-label">Телефон <span class="text-danger">*</span></label>
<input type="tel" id="phone" name="phone" class="form-control"
value="{{ form.phone if form else '' }}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль <span class="text-danger">*</span></label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="mb-4">
<label for="password_confirm" class="form-label">Подтверждение пароля <span class="text-danger">*</span></label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Зарегистрироваться</button>
</div>
</form>
<div class="mt-3 text-center small">
<a href="/login">Уже есть аккаунт? Войти</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,27 +0,0 @@
{% extends "base.html" %}
{% block title %}Новый пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<h1 class="card-title h4 mb-4">Новый пароль</h1>
<form method="post" action="/reset-password?token={{ token }}">
<div class="mb-3">
<label for="password" class="form-label">Новый пароль</label>
<input type="password" id="password" name="password" class="form-control" required>
</div>
<div class="mb-4">
<label for="password_confirm" class="form-label">Подтверждение пароля</label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Сменить пароль</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,6 +0,0 @@
from fastapi.templating import Jinja2Templates
from web.config import settings
templates = Jinja2Templates(directory="web/templates")
templates.env.globals["jivosite_widget_id"] = settings.JIVOSITE_WIDGET_ID

15
web/tsconfig.json Normal file
View File

@@ -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"]
}