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>
This commit is contained in:
2026-02-26 14:49:04 +00:00
parent cf7fbd8e9b
commit 81112c716b
28 changed files with 739 additions and 186 deletions

View File

@@ -76,4 +76,35 @@ def register() -> Blueprint:
_handlers["products-by-ids"] = _products_by_ids
# --- marketplaces-by-ids ---
async def _marketplaces_by_ids():
"""Return marketplace data for a list of IDs (comma-separated)."""
from sqlalchemy import select
from shared.models.market_place import MarketPlace
ids_raw = request.args.get("ids", "")
try:
ids = [int(x) for x in ids_raw.split(",") if x.strip()]
except ValueError:
return {"error": "ids must be comma-separated integers"}, 400
if not ids:
return []
rows = (await g.s.execute(
select(MarketPlace).where(MarketPlace.id.in_(ids))
)).scalars().all()
return [
{
"id": m.id,
"name": m.name,
"slug": m.slug,
"container_type": m.container_type,
"container_id": m.container_id,
}
for m in rows
]
_handlers["marketplaces-by-ids"] = _marketplaces_by_ids
return bp

View File

@@ -162,29 +162,25 @@ def register():
from bp.cart.services.identity import current_cart_identity
#from bp.cart.routes import view_cart
from models.market import CartItem
from quart import request, url_for
from shared.infrastructure.internal_inbox_client import send_internal_activity
from shared.infrastructure.data_client import fetch_data
@bp.post("/cart/")
@clear_cache(tag="browse", tag_scope="user")
async def cart():
slug = g.product_slug
# make sure product exists (we *allow* deleted_at != None later if you want)
product_id = await g.s.scalar(
select(Product.id).where(
# Load product from local db_market
product = await g.s.scalar(
select(Product).where(
Product.slug == slug,
Product.deleted_at.is_(None),
)
)
product = await g.s.scalar(
select(Product).where(Product.id == product_id)
)
if not product:
return await make_response("Product not found", 404)
# --- NEW: read `count` from body (JSON or form), default to 1 ---
# Read `count` from body (JSON or form), default to 1
count = 1
try:
if request.is_json:
@@ -196,68 +192,72 @@ def register():
if "count" in form:
count = int(form["count"])
except (ValueError, TypeError):
# if parsing fails, just fall back to 1
count = 1
# --- END NEW ---
ident = current_cart_identity()
market = getattr(g, "market", None)
# Load cart items for current user/session
from sqlalchemy.orm import selectinload
cart_filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None:
cart_filters.append(CartItem.user_id == ident["user_id"])
else:
cart_filters.append(CartItem.session_id == ident["session_id"])
cart_result = await g.s.execute(
select(CartItem)
.where(*cart_filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
# Build AP activity with denormalized product data
activity_type = "Add" if count > 0 else "Remove"
activity = {
"type": activity_type,
"object": {
"type": "rose:CartItem",
"user_id": ident["user_id"],
"session_id": ident["session_id"],
"product_id": product.id,
"quantity": count,
"market_place_id": market.id if market else None,
# Denormalized product data
"product_title": product.title,
"product_slug": product.slug,
"product_image": product.image,
"product_brand": product.brand,
"product_regular_price": str(product.regular_price) if product.regular_price is not None else None,
"product_special_price": str(product.special_price) if product.special_price is not None else None,
"product_price_currency": product.regular_price_currency,
# Denormalized marketplace data
"market_place_name": market.name if market else None,
"market_place_container_id": market.container_id if market else None,
},
}
await send_internal_activity("cart", activity)
# Fetch updated cart items from cart service for template rendering
raw_cart = await fetch_data(
"cart", "cart-items",
params={
k: v for k, v in {
"user_id": ident["user_id"],
"session_id": ident["session_id"],
}.items() if v is not None
},
required=False,
) or []
# Build minimal cart list for template (product slug + quantity)
from types import SimpleNamespace
g.cart = [
SimpleNamespace(
product_id=ci["product_id"],
product=SimpleNamespace(slug=ci["product_slug"]),
quantity=ci["quantity"],
)
)
g.cart = list(cart_result.scalars().all())
for ci in raw_cart
]
ci = next(
(item for item in g.cart if item.product_id == product_id),
ci_ns = next(
(item for item in g.cart if item.product_id == product.id),
None,
)
# --- NEW: set quantity based on `count` ---
if ci:
if count > 0:
ci.quantity = count
else:
# count <= 0 → remove from cart entirely
ci.quantity=0
g.cart.remove(ci)
await g.s.delete(ci)
else:
if count > 0:
ci = CartItem(
user_id=ident["user_id"],
session_id=ident["session_id"],
product_id=product.id,
product=product,
quantity=count,
market_place_id=getattr(g, "market", None) and g.market.id,
)
g.cart.append(ci)
g.s.add(ci)
# if count <= 0 and no existing item, do nothing
# --- END NEW ---
# no explicit commit; your session middleware should handle it
# htmx response: OOB-swap mini cart + product buttons
if request.headers.get("HX-Request") == "true":
return await render_template(
"_types/product/_added.html",
cart=g.cart,
item=ci,
item=ci_ns,
)
# normal POST: go to cart page