""" 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. POST /user/install — Evotor notifies about app install or uninstall for a user. """ import json import logging import secrets from datetime import datetime, timezone, 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 hash_password, 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( or_( EvotorConnection.evotor_user_id == evotor_user_id, EvotorConnection.user_id == user_id, ) ).first() now = datetime.now(timezone.utc).replace(tzinfo=None) if conn: conn.api_token = api_token if user_id is not None: conn.user_id = user_id if evotor_user_id: conn.evotor_user_id = evotor_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")) # Evotor sends fields both at top level and inside customField email = (body.get("email") or custom.get("email") or "").strip().lower() or None phone = (body.get("phone_number") or body.get("phone") or custom.get("phone_number") or custom.get("phone") or "").strip() or None first_name = (body.get("first_name") or body.get("firstName") or custom.get("first_name") or custom.get("firstName") or "").strip() or None last_name = (body.get("second_name") or body.get("last_name") or body.get("lastName") or custom.get("second_name") or custom.get("last_name") or custom.get("lastName") or "").strip() or None middle_name = (body.get("middle_name") or custom.get("middle_name") or "").strip() or None password = (body.get("password") or custom.get("password") 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.now(timezone.utc).replace(tzinfo=None) if user: # Link Evotor to existing user; update name fields if Evotor provided them user.evotor_user_id = evotor_user_id user.evotor_meta = body if first_name: user.first_name = first_name if last_name: user.last_name = last_name if middle_name: user.middle_name = middle_name if password: user.password_hash = hash_password(password) if user.status == UserStatusEnum.pending: user.status = UserStatusEnum.active db.flush() else: # Create new user; active immediately if password provided, else pending user = User( first_name=first_name or "", last_name=last_name or "", middle_name=middle_name, email=email or f"{evotor_user_id}@evotor.invalid", phone=phone or None, password_hash=hash_password(password) if password else None, role=UserRoleEnum.user, status=UserStatusEnum.active if password else UserStatusEnum.pending, evotor_user_id=evotor_user_id, evotor_meta=body, created_at=now, updated_at=now, ) db.add(user) db.flush() # get user.id api_token = _upsert_evotor_connection(db, user.id, evotor_user_id) if not password: # No password provided — send invite link so user can set one invite_token = secrets.token_urlsafe(32) user.invite_token = invite_token user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS) db.commit() if email: invite_url = f"{settings.BASE_URL}/invite?token={invite_token}" html = ( f"
Здравствуйте!
" f"Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:
" f'' 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) else: db.commit() 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) logger.info("user/verify body=%s", {k: v for k, v in body.items() if k != "password"}) evotor_user_id: str = body.get("userId", "") username: str = body.get("username", "").strip() phone: str = body.get("phone", "").strip() password: str = body.get("password", "") login = username or phone if not password: return JSONResponse({"error": "password required"}, status_code=400) # 1. Match by evotor_user_id (most reliable — Evotor always sends userId) user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first() if evotor_user_id else None # 2. Fall back to email or phone if not user and login: user = db.query(User).filter( or_(User.email == login, User.phone == login) ).first() if not user: return JSONResponse({"error": "Неверные данные"}, status_code=401) if user.status == UserStatusEnum.suspended: return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403) if not user.password_hash: # First auth with password — save it and activate the account user.password_hash = hash_password(password) if user.status == UserStatusEnum.pending: user.status = UserStatusEnum.active elif 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.now(timezone.utc).replace(tzinfo=None) 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({}) @router.post("/user/install") async def user_install(request: Request, db: Session = Depends(get_db)): """Handle app install / uninstall events from Evotor.""" 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) # userId is nested inside "data"; type is e.g. "ApplicationInstalled" / "ApplicationUninstalled" data: dict = body.get("data", {}) evotor_user_id: str = data.get("userId", "") or body.get("userId", "") event_type: str = body.get("type", "").lower() # "applicationinstalled" / "applicationuninstalled" if not evotor_user_id: logger.warning("user/install missing userId, body=%s", body) return JSONResponse({"error": "userId required"}, status_code=400) logger.info("user/install event type=%s userId=%s", event_type, evotor_user_id) user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first() if not user: # Unknown user — nothing to act on, but acknowledge the event return JSONResponse({}) now = datetime.now(timezone.utc).replace(tzinfo=None) if event_type == "applicationuninstalled": user.status = UserStatusEnum.suspended user.updated_at = now conn = db.query(EvotorConnection).filter( EvotorConnection.evotor_user_id == evotor_user_id ).first() if conn: conn.is_online = False conn.updated_at = now db.commit() logger.info("user suspended on uninstall: userId=%s", evotor_user_id) elif event_type == "applicationinstalled": if user.status == UserStatusEnum.suspended: user.status = UserStatusEnum.active user.updated_at = now db.commit() logger.info("user reactivated on reinstall: userId=%s", evotor_user_id) return JSONResponse({})