"""MercadoLibre external marketplace REST endpoints. Routes: Config GET /pos/api/marketplace-ext/config POST /pos/api/marketplace-ext/connect DELETE /pos/api/marketplace-ext/connect GET /pos/api/marketplace-ext/categories Listings GET /pos/api/marketplace-ext/listings POST /pos/api/marketplace-ext/listings POST /pos/api/marketplace-ext/listings/import-existing POST /pos/api/marketplace-ext/listings/backfill-inventory POST /pos/api/marketplace-ext/listings//sync POST /pos/api/marketplace-ext/listings//pause POST /pos/api/marketplace-ext/listings//activate DELETE /pos/api/marketplace-ext/listings/ Questions GET /pos/api/marketplace-ext/questions POST /pos/api/marketplace-ext/questions/sync POST /pos/api/marketplace-ext/questions//answer GET /pos/api/marketplace-ext/questions//suggestion (AI, A7) Orders GET /pos/api/marketplace-ext/orders GET /pos/api/marketplace-ext/orders/ POST /pos/api/marketplace-ext/orders//convert Webhook (public) POST /pos/api/marketplace-ext/webhook/meli """ import urllib.parse from flask import Blueprint, request, jsonify, g from middleware import require_auth, has_permission from tenant_db import get_tenant_conn, get_master_conn from services import marketplace_external_service as meli_svc def _get_public_base_url() -> str: """Build the tenant's public base URL from request headers (handles reverse proxy).""" proto = request.headers.get("X-Forwarded-Proto", request.scheme) host = request.headers.get("X-Forwarded-Host", request.host) # Cloudflare specific header cf_visitor = request.headers.get("CF-Visitor") if cf_visitor and '"scheme":"https"' in cf_visitor: proto = "https" # Force https for production domain if we detect http behind a TLS terminator if proto == "http" and ("nexusautoparts.com.mx" in host or request.headers.get("X-Forwarded-Ssl") == "on"): proto = "https" return f"{proto}://{host}/" from services.meli_service import MeliService, MeliAuthError marketplace_ext_bp = Blueprint( "marketplace_ext", __name__, url_prefix="/pos/api/marketplace-ext" ) # ─── Helpers ─────────────────────────────────────────────────────────────── def _require_meli_manage(): if not has_permission("marketplace.manage"): return jsonify({"error": "Missing permission: marketplace.manage"}), 403 return None def _safe_int(value, default): """Parse an int from a query param, falling back to a default instead of 500.""" try: return int(value) except (TypeError, ValueError): return default def _get_meli_config(tenant_id): """Return the tenant's ML config dict, or None if the tenant is unknown.""" try: conn = get_tenant_conn(tenant_id) except Exception: return None try: return meli_svc.get_meli_config(conn) except Exception: return None finally: conn.close() def _resolve_tenant_by_meli_user(meli_user_id): """Find the tenant whose tenant_config.meli_user_id matches. tenant_config lives in each tenant DB (not in master), so we iterate the active tenants from master and probe each one. """ if not meli_user_id: return None mconn = get_master_conn() try: mcur = mconn.cursor() mcur.execute("SELECT id FROM tenants WHERE is_active = true ORDER BY id") tenant_ids = [r[0] for r in mcur.fetchall()] mcur.close() except Exception: return None finally: mconn.close() target = str(meli_user_id) for tid in tenant_ids: try: conn = get_tenant_conn(tid) except Exception: continue try: cur = conn.cursor() cur.execute( "SELECT 1 FROM tenant_config WHERE key = 'meli_user_id' AND value = %s LIMIT 1", (target,), ) found = cur.fetchone() is not None cur.close() if found: return tid except Exception: continue finally: conn.close() return None # ═══════════════════════════════════════════════════════════════════════════ # CONFIG # ═══════════════════════════════════════════════════════════════════════════ def _meli_auth_error(e): """Respuesta 401 para MeliAuthError: propaga el detalle accionable (p. ej. credenciales de app rechazadas) en vez del mensaje genérico.""" detail = str(e) if ('credenciales' in detail.lower() or 'refresh failed' in detail.lower() or 'invalid_client' in detail.lower()): return jsonify({'error': detail}), 401 return jsonify({'error': 'La sesión de MercadoLibre expiró. Reconecta la cuenta en la pestaña de configuración.'}), 401 @marketplace_ext_bp.route("/config", methods=["GET"]) @require_auth() def get_config(): conn = get_tenant_conn(g.tenant_id) try: cfg = meli_svc.get_meli_config(conn) # Never return tokens to frontend safe = { k: v for k, v in cfg.items() if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret") } safe["connected"] = bool(cfg.get("meli_access_token")) safe["has_refresh_token"] = bool(cfg.get("meli_refresh_token")) return jsonify(safe) finally: conn.close() @marketplace_ext_bp.route("/connect/init", methods=["POST"]) @require_auth() def init_meli_connect(): """Store client credentials server-side and return the MercadoLibre auth URL. The frontend no longer keeps the client_secret in localStorage. """ err = _require_meli_manage() if err: return err data = request.get_json() or {} client_id = data.get("client_id", "").strip() client_secret = data.get("client_secret", "").strip() category = data.get("category", "").strip() shipping = data.get("shipping", "").strip() if not client_id or not client_secret: return jsonify({"error": "client_id and client_secret required"}), 400 base = _get_public_base_url().rstrip("/") redirect_uri = f"{base}/pos/marketplace-external/callback" conn = get_tenant_conn(g.tenant_id) try: meli_svc.save_meli_config(conn, { "meli_client_id": client_id, "meli_client_secret": client_secret, "meli_default_category_id": category, "meli_shipping_mode": shipping, }) finally: conn.close() auth_url = ( "https://auth.mercadolibre.com.mx/authorization?response_type=code" f"&client_id={urllib.parse.quote(client_id)}" f"&redirect_uri={urllib.parse.quote(redirect_uri)}" "&scope=read+write+offline_access" ) return jsonify({"auth_url": auth_url, "redirect_uri": redirect_uri}) @marketplace_ext_bp.route("/connect", methods=["POST"]) @require_auth() def connect_meli(): err = _require_meli_manage() if err: return err data = request.get_json() or {} code = data.get("code") redirect_uri = data.get("redirect_uri", "") if not code: return jsonify({"error": "code required"}), 400 conn = get_tenant_conn(g.tenant_id) try: cfg = meli_svc.get_meli_config(conn) client_id = data.get("client_id") or cfg.get("meli_client_id") client_secret = data.get("client_secret") or cfg.get("meli_client_secret") finally: conn.close() if not client_id or not client_secret: return jsonify({"error": "ML credentials not configured"}), 400 try: token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri) except MeliAuthError as e: return jsonify({"error": str(e)}), 400 access_token = token_data.get("access_token") refresh_token = token_data.get("refresh_token") user_id = token_data.get("user_id") # Validate token by fetching user svc = MeliService(access_token) try: user = svc.get_user() except MeliAuthError as e: return jsonify({"error": f"Invalid token: {e}"}), 400 conn = get_tenant_conn(g.tenant_id) try: meli_svc.save_meli_config(conn, { "meli_access_token": access_token, "meli_refresh_token": refresh_token, "meli_user_id": str(user_id or user.get("id")), "meli_site_id": user.get("site_id", "MLM"), "meli_enabled": "true", "meli_client_id": client_id, "meli_client_secret": client_secret, }) resp = { "ok": True, "user_id": user_id or user.get("id"), "nickname": user.get("nickname"), "site_id": user.get("site_id"), } if not refresh_token: # The ML app lacks offline_access scope: the connection will die # in ~6h and cannot be auto-renewed. Warn so it can be fixed. resp["warning"] = "no_refresh_token" return jsonify(resp) finally: conn.close() @marketplace_ext_bp.route("/connect", methods=["DELETE"]) @require_auth() def disconnect_meli(): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: meli_svc.delete_meli_config(conn) return jsonify({"ok": True}) finally: conn.close() @marketplace_ext_bp.route("/categories", methods=["GET"]) @require_auth() def search_categories(): q = request.args.get("q", "") site_id = request.args.get("site_id", "MLM") if not q or len(q) < 2: return jsonify({"categories": []}) conn = get_tenant_conn(g.tenant_id) try: cfg = meli_svc.get_meli_config(conn) svc = meli_svc._get_meli_service(cfg, conn) if not svc: return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400 result = svc.search_categories(site_id, q) return jsonify({"categories": result}) except MeliAuthError as e: return _meli_auth_error(e) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() # ═══════════════════════════════════════════════════════════════════════════ # LISTINGS # ═══════════════════════════════════════════════════════════════════════════ @marketplace_ext_bp.route("/listings", methods=["GET"]) @require_auth() def list_listings(): page = max(1, _safe_int(request.args.get("page"), 1)) per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200) status = request.args.get("status") conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status) return jsonify(result) except MeliAuthError as e: return _meli_auth_error(e) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings", methods=["POST"]) @require_auth() def create_listings(): err = _require_meli_manage() if err: return err data = request.get_json() or {} inventory_ids = data.get("inventory_ids", []) category_id = data.get("category_id") listing_type = data.get("listing_type", "gold_special") shipping_mode = data.get("shipping_mode", "me2") custom_data = data.get("custom_data", {}) if not inventory_ids: return jsonify({"error": "inventory_ids required"}), 400 if not category_id: return jsonify({"error": "category_id required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.publish_items( conn, inventory_ids=inventory_ids, meli_category_id=category_id, listing_type_id=listing_type, shipping_mode=shipping_mode, custom_data=custom_data, base_url=_get_public_base_url(), use_llm_description=bool(data.get("use_llm_description", False)), ) return jsonify(result), 201 except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings/import-existing", methods=["POST"]) @require_auth() def import_existing_listings(): """Import all existing MercadoLibre listings for the connected seller.""" err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.import_existing_listings(conn) return jsonify(result), 200 except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings/backfill-inventory", methods=["POST"]) @require_auth() def backfill_listing_inventory(): """Map already-imported listings to local inventory (populate inventory_id). Body: {"dry_run": bool (default false), "limit": int|null, "only_unmapped": bool (default true)}. """ err = _require_meli_manage() if err: return err data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.backfill_listing_inventory( conn, limit=_safe_int(data.get("limit"), None) if data.get("limit") else None, only_unmapped=bool(data.get("only_unmapped", True)), dry_run=bool(data.get("dry_run", False)), ) return jsonify(result), 200 except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/inventory-check", methods=["POST"]) @require_auth() def inventory_check(): """Check local pre-flight status for ML publishing (duplicates, stock, price, image).""" data = request.get_json() or {} inventory_ids = data.get("inventory_ids", []) if not inventory_ids: return jsonify({"error": "inventory_ids required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.check_inventory_ml_status(conn, inventory_ids, base_url=_get_public_base_url()) return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/sync-stock", methods=["POST"]) @require_auth() def sync_stock_to_meli(): """Process pending stock updates to MercadoLibre.""" err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: # Manual run from the UI: bypass the meli_auto_sync_stock_price gate. result = meli_svc.process_meli_sync_queue(conn, force=True) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/categories//attributes", methods=["GET"]) @require_auth() def category_attributes(category_id): """Get required attributes for a MercadoLibre category.""" conn = get_tenant_conn(g.tenant_id) try: cfg = meli_svc.get_meli_config(conn) svc = meli_svc._get_meli_service(cfg, conn) if not svc: return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400 attrs = svc.get_category_attributes(category_id) # Filter to required attributes only for the UI required = [a for a in attrs if a.get("tags", {}).get("required")] return jsonify({"attributes": required, "all": attrs}) except MeliAuthError as e: return _meli_auth_error(e) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listing-prices", methods=["GET"]) @require_auth() def listing_prices(): """Fee estimator: return MeLi fees per listing type for a price + category.""" price = request.args.get("price", type=float) category_id = request.args.get("category_id", "") site_id = request.args.get("site_id", "MLM") if not price or price <= 0 or not category_id: return jsonify({"error": "price y category_id son requeridos"}), 400 conn = get_tenant_conn(g.tenant_id) try: cfg = meli_svc.get_meli_config(conn) svc = meli_svc._get_meli_service(cfg, conn) if not svc: return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400 result = svc.get_listing_prices(site_id, price, category_id) return jsonify({"listing_prices": result}) except MeliAuthError as e: return _meli_auth_error(e) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings/validate", methods=["POST"]) @require_auth() def validate_listings(): """Validate items payload against ML /items/validate without creating them.""" err = _require_meli_manage() if err: return err data = request.get_json() or {} inventory_ids = data.get("inventory_ids", []) category_id = data.get("category_id") listing_type = data.get("listing_type", "gold_special") shipping_mode = data.get("shipping_mode", "me2") custom_data = data.get("custom_data", {}) if not inventory_ids: return jsonify({"error": "inventory_ids required"}), 400 if not category_id: return jsonify({"error": "category_id required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.validate_items( conn, inventory_ids=inventory_ids, meli_category_id=category_id, listing_type_id=listing_type, shipping_mode=shipping_mode, custom_data=custom_data, base_url=_get_public_base_url(), use_llm_description=bool(data.get("use_llm_description", False)), ) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings/async", methods=["POST"]) @require_auth() def create_listings_async(): """Enqueue ML publishing as a Celery background task.""" err = _require_meli_manage() if err: return err data = request.get_json() or {} inventory_ids = data.get("inventory_ids", []) category_id = data.get("category_id") listing_type = data.get("listing_type", "gold_special") shipping_mode = data.get("shipping_mode", "me2") custom_data = data.get("custom_data", {}) if not inventory_ids: return jsonify({"error": "inventory_ids required"}), 400 if not category_id: return jsonify({"error": "category_id required"}), 400 try: from tasks import publish_meli_items_task task = publish_meli_items_task.delay( g.tenant_id, inventory_ids=inventory_ids, category_id=category_id, listing_type=listing_type, shipping_mode=shipping_mode, custom_data=custom_data, base_url=_get_public_base_url(), chunk_size=_safe_int(data.get("chunk_size"), 10), use_llm_description=bool(data.get("use_llm_description", False)), ) return jsonify({"task_id": task.id, "status": "queued"}), 202 except Exception as e: return jsonify({"error": str(e)}), 500 @marketplace_ext_bp.route("/listings/async/", methods=["GET"]) @require_auth() def get_async_listing_status(task_id): """Get status of an async ML publishing task.""" try: from celery.result import AsyncResult from celery_app import celery as celery_app result = AsyncResult(task_id, app=celery_app) if result.ready(): return jsonify({"status": "done", "result": result.result or {}}) return jsonify({"status": "pending"}) except Exception as e: return jsonify({"error": str(e)}), 500 @marketplace_ext_bp.route("/listings//sync", methods=["POST"]) @require_auth() def sync_listing(listing_id): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.sync_listing(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings//pause", methods=["POST"]) @require_auth() def pause_listing(listing_id): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.pause_listing(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings//activate", methods=["POST"]) @require_auth() def activate_listing(listing_id): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.activate_listing(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings/", methods=["DELETE"]) @require_auth() def delete_listing(listing_id): err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.close_listing(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/listings//permanent", methods=["DELETE"]) @require_auth() def delete_listing_permanent(listing_id): """Hard-delete a closed listing from the local DB.""" err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.delete_listing_permanently(conn, listing_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() # ═══════════════════════════════════════════════════════════════════════════ # QUESTIONS & ANSWERS # ═══════════════════════════════════════════════════════════════════════════ @marketplace_ext_bp.route("/questions", methods=["GET"]) @require_auth() def list_questions(): """List questions from local DB. Query param: ?status=unanswered""" status = request.args.get("status") conn = get_tenant_conn(g.tenant_id) try: items = meli_svc.list_local_questions(conn, status=status) return jsonify({"items": items}) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/questions/sync", methods=["POST"]) @require_auth() def sync_questions(): """Force sync questions from ML for all active listings.""" err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.sync_questions(conn) return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/questions//answer", methods=["POST"]) @require_auth() def answer_question(question_id): """Answer a buyer question via ML API.""" err = _require_meli_manage() if err: return err data = request.get_json() or {} text = data.get("text", "").strip() if not text: return jsonify({"error": "Answer text is required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.answer_question(conn, question_id, text) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/questions//suggestion", methods=["GET"]) @require_auth() def question_suggestion(question_id): """AI intent classification + suggested answer for a buyer question. Query param: ?use_llm=0 to skip the LLM fallback and use templates only. The answer is only a suggestion; auto-answering is opt-in per tenant via tenant_config `meli_auto_answer_questions`. """ err = _require_meli_manage() if err: return err use_llm = request.args.get("use_llm", "1").lower() not in ("0", "false", "no") conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.suggest_question_answer(conn, question_id, use_llm=use_llm) result["auto_answer_enabled"] = bool( meli_svc.get_meli_config(conn).get("meli_auto_answer_questions") ) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 404 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() # ═══════════════════════════════════════════════════════════════════════════ # ORDERS # ═══════════════════════════════════════════════════════════════════════════ @marketplace_ext_bp.route("/orders/sync", methods=["POST"]) @require_auth() def sync_orders(): """Manually trigger sync of MercadoLibre orders.""" err = _require_meli_manage() if err: return err conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.fetch_and_save_orders(conn) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/orders", methods=["GET"]) @require_auth() def list_orders(): page = max(1, _safe_int(request.args.get("page"), 1)) per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200) status = request.args.get("status") conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.get_orders(conn, page=page, per_page=per_page, status=status) return jsonify(result) finally: conn.close() @marketplace_ext_bp.route("/orders/", methods=["GET"]) @require_auth() def get_order(order_id): conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.get_order_detail(conn, order_id) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 404 finally: conn.close() @marketplace_ext_bp.route("/orders//convert", methods=["POST"]) @require_auth("pos.sell") def convert_order(order_id): data = request.get_json() or {} register_id = data.get("register_id") # Mirrors the counter-sale "Facturar CFDI" checkbox; when omitted the sale # is only invoiced if the tenant has meli_auto_invoice = true. generate_cfdi = bool(data.get("generate_cfdi", False)) conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.convert_order_to_sale( conn, order_id, employee_id=g.employee_id, register_id=register_id, generate_cfdi=generate_cfdi, ) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() @marketplace_ext_bp.route("/orders//status", methods=["POST"]) @require_auth() def update_order_status_route(order_id): data = request.get_json() or {} new_status = data.get("status") if not new_status: return jsonify({"error": "status required"}), 400 conn = get_tenant_conn(g.tenant_id) try: result = meli_svc.update_order_status(conn, order_id, new_status) return jsonify(result) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 finally: conn.close() # ═══════════════════════════════════════════════════════════════════════════ # WEBHOOK (public — no auth) # ═══════════════════════════════════════════════════════════════════════════ @marketplace_ext_bp.route("/webhook/meli", methods=["POST"]) def meli_webhook(): """Receive MercadoLibre notifications. ML sends a lightweight payload with topic + resource URL. We resolve the tenant from ML's user_id, validate the X-Signature header when present, ack immediately and enqueue Celery to process the specific resource. """ raw_body = request.get_data() data = request.get_json(force=True, silent=True) or {} topic = data.get("topic", "") resource = data.get("resource", "") user_id = data.get("user_id") # tenant_config lives in each tenant DB, not in master tenant_id = _resolve_tenant_by_meli_user(user_id) if not tenant_id: print(f"[ML Webhook] No tenant found for meli_user_id={user_id}") return jsonify({"error": "unknown tenant"}), 403 cfg = _get_meli_config(tenant_id) or {} secret = cfg.get("meli_client_secret") signature = request.headers.get("X-Signature") if signature: if not secret or not MeliService.validate_webhook_signature(secret, raw_body, signature): print(f"[ML Webhook] Invalid X-Signature for tenant {tenant_id}") return jsonify({"error": "invalid signature"}), 401 else: # ML only signs notifications when signature validation is enabled on # the app; without the header there is nothing to verify. print(f"[ML Webhook] Notification without X-Signature (tenant {tenant_id}, topic={topic})") if topic: try: from tasks import process_meli_webhook_task process_meli_webhook_task.delay(tenant_id, topic, resource) except Exception as e: print(f"[ML Webhook] Failed to enqueue task: {e}") return jsonify({"ok": True})