Files
rose-ash/cart/bp/inbox/routes.py
giles 81112c716b Decouple cart/market DBs: denormalize product data, AP internal inbox, OAuth scraper auth
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>
2026-02-26 14:49:04 +00:00

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