""" Evotor webhook endpoints. POST /user/create — Evotor creates a new subscriber; we create/link a local user and return a token. POST /user/verify — Evotor verifies credentials for a user trying to log in via the Evotor interface. POST /user/token — Evotor sends us its own API token for the user. """ import json import logging import secrets from datetime import datetime, timedelta from typing import Any from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from sqlalchemy import or_ from sqlalchemy.orm import Session from web.auth.password import verify_password from web.config import settings from web.database import get_db from web.models.connections import EvotorConnection from web.models.user import User, UserRoleEnum, UserStatusEnum from web.notifications.tasks import send_email_task logger = logging.getLogger(__name__) router = APIRouter() EVOTOR_STORES_URL = "https://api.evotor.ru/stores" def _verify_secret(request: Request) -> bool: secret = settings.EVOTOR_WEBHOOK_SECRET if not secret: return True # dev mode: no secret configured auth = request.headers.get("Authorization", "") return auth == f"Bearer {secret}" def _parse_custom_fields(raw: Any) -> dict: """Extract known fields from Evotor customField (may be JSON string or dict).""" if raw is None: return {} if isinstance(raw, dict): return raw if isinstance(raw, str): try: parsed = json.loads(raw) if isinstance(parsed, dict): return parsed except (json.JSONDecodeError, ValueError): pass return {} def _upsert_evotor_connection( db: Session, user_id: int | None, evotor_user_id: str, access_token: str | None = None, ) -> str: """Create or update an evotor_connections row; always regenerates api_token.""" api_token = secrets.token_urlsafe(32) conn = db.query(EvotorConnection).filter( EvotorConnection.evotor_user_id == evotor_user_id ).first() now = datetime.utcnow() if conn: conn.api_token = api_token if user_id is not None: conn.user_id = user_id if access_token: conn.access_token = access_token conn.updated_at = now else: conn = EvotorConnection( user_id=user_id, evotor_user_id=evotor_user_id, access_token=access_token or "", api_token=api_token, connected_at=now, updated_at=now, ) db.add(conn) db.flush() return api_token @router.post("/user/create") async def user_create(request: Request, db: Session = Depends(get_db)): if not _verify_secret(request): return JSONResponse({"error": "Unauthorized"}, status_code=401) try: body = await request.json() except Exception: return JSONResponse({"error": "Invalid JSON"}, status_code=400) evotor_user_id: str = body.get("userId", "") if not evotor_user_id: return JSONResponse({"error": "userId required"}, status_code=400) custom = _parse_custom_fields(body.get("customField")) email = (custom.get("email") or "").strip().lower() or None phone = (custom.get("phone") or "").strip() or None first_name = (custom.get("first_name") or custom.get("firstName") or "").strip() or None last_name = (custom.get("last_name") or custom.get("lastName") or "").strip() or None # Try to find existing user user: User | None = None # 1. By evotor_user_id user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first() # 2. By email if user is None and email: user = db.query(User).filter(User.email == email).first() # 3. By phone if user is None and phone: user = db.query(User).filter(User.phone == phone).first() now = datetime.utcnow() if user: # Link Evotor to existing user user.evotor_user_id = evotor_user_id user.evotor_meta = custom or body if user.status == UserStatusEnum.pending: user.status = UserStatusEnum.active db.flush() else: # Create new pending user user = User( first_name=first_name or "", last_name=last_name or "", email=email or f"{evotor_user_id}@evotor.placeholder", phone=phone or "", password_hash=None, role=UserRoleEnum.user, status=UserStatusEnum.pending, evotor_user_id=evotor_user_id, evotor_meta=custom or body, created_at=now, updated_at=now, ) db.add(user) db.flush() # get user.id # Generate invite invite_token = secrets.token_urlsafe(32) user.invite_token = invite_token user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS) api_token = _upsert_evotor_connection(db, user.id, evotor_user_id) db.commit() # Send invite email if we have a real email address if email: invite_url = f"{settings.BASE_URL}/invite?token={invite_token}" html = ( f"

Здравствуйте!

" f"

Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:

" f'

{invite_url}

' f"

Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.

" ) send_email_task.delay(email, "Приглашение в ЭВОСИНК", html) else: logger.info("No email for evotor_user_id=%s, invite URL: %s/invite?token=%s", evotor_user_id, settings.BASE_URL, invite_token) return JSONResponse({"userId": evotor_user_id, "token": api_token}) @router.post("/user/verify") async def user_verify(request: Request, db: Session = Depends(get_db)): if not _verify_secret(request): return JSONResponse({"error": "Unauthorized"}, status_code=401) try: body = await request.json() except Exception: return JSONResponse({"error": "Invalid JSON"}, status_code=400) evotor_user_id: str = body.get("userId", "") username: str = body.get("username", "").strip() password: str = body.get("password", "") if not username or not password: return JSONResponse({"error": "username and password required"}, status_code=400) # username is email or phone user = db.query(User).filter( or_(User.email == username, User.phone == username) ).first() if not user or not user.password_hash: return JSONResponse({"error": "Неверные данные"}, status_code=401) if user.status == UserStatusEnum.suspended: return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403) if not verify_password(password, user.password_hash): return JSONResponse({"error": "Неверные данные"}, status_code=401) # Get or create connection to retrieve api_token conn = db.query(EvotorConnection).filter( EvotorConnection.evotor_user_id == (user.evotor_user_id or evotor_user_id) ).first() if not conn: # Auto-link: create connection with Evotor userId from request if evotor_user_id and not user.evotor_user_id: user.evotor_user_id = evotor_user_id db.flush() api_token = _upsert_evotor_connection(db, user.id, evotor_user_id or (user.evotor_user_id or "")) db.commit() else: api_token = conn.api_token or secrets.token_urlsafe(32) if not conn.api_token: conn.api_token = api_token db.commit() return JSONResponse({"userId": user.evotor_user_id or evotor_user_id, "token": api_token}) @router.post("/user/token") async def user_token(request: Request, db: Session = Depends(get_db)): if not _verify_secret(request): return JSONResponse({"error": "Unauthorized"}, status_code=401) try: body = await request.json() except Exception: return JSONResponse({"error": "Invalid JSON"}, status_code=400) evotor_user_id: str = body.get("userId", "") evotor_token: str = body.get("token", "") if not evotor_user_id or not evotor_token: return JSONResponse({"error": "userId and token required"}, status_code=400) user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first() if not user: return JSONResponse({"error": "User not found"}, status_code=404) conn = db.query(EvotorConnection).filter( EvotorConnection.evotor_user_id == evotor_user_id ).first() now = datetime.utcnow() if conn: conn.access_token = evotor_token conn.is_online = True conn.last_checked_at = now conn.updated_at = now else: conn = EvotorConnection( user_id=user.id, evotor_user_id=evotor_user_id, access_token=evotor_token, api_token=secrets.token_urlsafe(32), is_online=True, last_checked_at=now, connected_at=now, updated_at=now, ) db.add(conn) db.commit() return JSONResponse({})