Remove cross-DB relationships (CartItem.product, CartItem.market_place, OrderItem.product) that break with per-service databases. Denormalize product and marketplace fields onto cart_items/order_items at write time. - Add AP internal inbox infrastructure (shared/infrastructure/internal_inbox*) for synchronous inter-service writes via HMAC-authenticated POST - Cart inbox blueprint handles Add/Remove/Update rose:CartItem activities - Market app sends AP activities to cart inbox instead of writing CartItem directly - Cart services use denormalized columns instead of cross-DB hydration/joins - Add marketplaces-by-ids data endpoint to market service - Alembic migration adds denormalized columns to cart_items and order_items - Add OAuth device flow auth to market scraper persist_api (artdag client pattern) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
162 lines
5.6 KiB
Python
162 lines
5.6 KiB
Python
"""Cart internal inbox endpoint.
|
|
|
|
Receives AP-shaped activities from other services via HMAC-authenticated
|
|
POST to ``/internal/inbox``. Routes to handlers registered via the
|
|
internal inbox dispatch infrastructure.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from quart import Blueprint, g, jsonify, request
|
|
from sqlalchemy import select
|
|
|
|
from shared.infrastructure.internal_inbox import dispatch_internal_activity, register_internal_handler
|
|
from shared.infrastructure.internal_inbox_client import INBOX_HEADER
|
|
from shared.models.market import CartItem
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def register() -> Blueprint:
|
|
bp = Blueprint("inbox", __name__, url_prefix="/internal/inbox")
|
|
|
|
@bp.before_request
|
|
async def _require_inbox_header():
|
|
if not request.headers.get(INBOX_HEADER):
|
|
return jsonify({"error": "forbidden"}), 403
|
|
from shared.infrastructure.internal_auth import validate_internal_request
|
|
if not validate_internal_request():
|
|
return jsonify({"error": "forbidden"}), 403
|
|
|
|
@bp.post("")
|
|
async def handle_inbox():
|
|
body = await request.get_json()
|
|
if not body:
|
|
return jsonify({"error": "empty body"}), 400
|
|
try:
|
|
result = await dispatch_internal_activity(g.s, body)
|
|
return jsonify(result)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
except Exception as exc:
|
|
log.exception("Internal inbox dispatch failed")
|
|
return jsonify({"error": str(exc)}), 500
|
|
|
|
# --- Handler: Add rose:CartItem ---
|
|
async def _handle_add_cart_item(session, body: dict) -> dict:
|
|
obj = body["object"]
|
|
user_id = obj.get("user_id")
|
|
session_id = obj.get("session_id")
|
|
product_id = obj["product_id"]
|
|
count = obj.get("quantity", 1)
|
|
market_place_id = obj.get("market_place_id")
|
|
|
|
# Look for existing cart item
|
|
filters = [
|
|
CartItem.deleted_at.is_(None),
|
|
CartItem.product_id == product_id,
|
|
]
|
|
if user_id is not None:
|
|
filters.append(CartItem.user_id == user_id)
|
|
else:
|
|
filters.append(CartItem.session_id == session_id)
|
|
|
|
existing = await session.scalar(select(CartItem).where(*filters))
|
|
|
|
if existing:
|
|
if count > 0:
|
|
existing.quantity = count
|
|
else:
|
|
existing.deleted_at = datetime.now(timezone.utc)
|
|
ci = existing
|
|
else:
|
|
if count <= 0:
|
|
return {"ok": True, "action": "noop"}
|
|
ci = CartItem(
|
|
user_id=user_id,
|
|
session_id=session_id,
|
|
product_id=product_id,
|
|
quantity=count,
|
|
market_place_id=market_place_id,
|
|
# Denormalized product data
|
|
product_title=obj.get("product_title"),
|
|
product_slug=obj.get("product_slug"),
|
|
product_image=obj.get("product_image"),
|
|
product_brand=obj.get("product_brand"),
|
|
product_regular_price=obj.get("product_regular_price"),
|
|
product_special_price=obj.get("product_special_price"),
|
|
product_price_currency=obj.get("product_price_currency"),
|
|
# Denormalized marketplace data
|
|
market_place_name=obj.get("market_place_name"),
|
|
market_place_container_id=obj.get("market_place_container_id"),
|
|
)
|
|
session.add(ci)
|
|
|
|
await session.flush()
|
|
return {
|
|
"ok": True,
|
|
"cart_item_id": ci.id,
|
|
"quantity": ci.quantity,
|
|
}
|
|
|
|
register_internal_handler("Add", "rose:CartItem", _handle_add_cart_item)
|
|
|
|
# --- Handler: Remove rose:CartItem ---
|
|
async def _handle_remove_cart_item(session, body: dict) -> dict:
|
|
obj = body["object"]
|
|
user_id = obj.get("user_id")
|
|
session_id = obj.get("session_id")
|
|
product_id = obj["product_id"]
|
|
|
|
filters = [
|
|
CartItem.deleted_at.is_(None),
|
|
CartItem.product_id == product_id,
|
|
]
|
|
if user_id is not None:
|
|
filters.append(CartItem.user_id == user_id)
|
|
else:
|
|
filters.append(CartItem.session_id == session_id)
|
|
|
|
existing = await session.scalar(select(CartItem).where(*filters))
|
|
if existing:
|
|
existing.deleted_at = datetime.now(timezone.utc)
|
|
await session.flush()
|
|
|
|
return {"ok": True}
|
|
|
|
register_internal_handler("Remove", "rose:CartItem", _handle_remove_cart_item)
|
|
|
|
# --- Handler: Update rose:CartItem ---
|
|
async def _handle_update_cart_item(session, body: dict) -> dict:
|
|
obj = body["object"]
|
|
user_id = obj.get("user_id")
|
|
session_id = obj.get("session_id")
|
|
product_id = obj["product_id"]
|
|
quantity = obj.get("quantity", 1)
|
|
|
|
filters = [
|
|
CartItem.deleted_at.is_(None),
|
|
CartItem.product_id == product_id,
|
|
]
|
|
if user_id is not None:
|
|
filters.append(CartItem.user_id == user_id)
|
|
else:
|
|
filters.append(CartItem.session_id == session_id)
|
|
|
|
existing = await session.scalar(select(CartItem).where(*filters))
|
|
if existing:
|
|
if quantity <= 0:
|
|
existing.deleted_at = datetime.now(timezone.utc)
|
|
else:
|
|
existing.quantity = quantity
|
|
await session.flush()
|
|
return {"ok": True, "quantity": existing.quantity}
|
|
|
|
return {"ok": True, "action": "noop"}
|
|
|
|
register_internal_handler("Update", "rose:CartItem", _handle_update_cart_item)
|
|
|
|
return bp
|