diff --git a/CLAUDE.md b/CLAUDE.md index afb00d7..a9588b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ Federated content-addressed DAG execution engine for distributed media processing with ActivityPub ownership and provenance tracking. +## Deployment + +- **Do NOT push** until explicitly told to. Pushes reload code to dev automatically. + ## Project Structure ``` @@ -54,6 +58,9 @@ docker build -f l2/Dockerfile -t l2-server:latest . - **Celery Tasks**: In `l1/tasks/`, decorated with `@app.task` - **S-Expression Effects**: Composable effect language in `l1/sexp_effects/` - **Storage**: Local filesystem, S3, or IPFS backends (`storage_providers.py`) +- **Inter-Service Reads**: `fetch_data()` → GET `/internal/data/{query}` (HMAC-signed) +- **Inter-Service Actions**: `call_action()` → POST `/internal/actions/{name}` (HMAC-signed) +- **Inter-Service AP Inbox**: `send_internal_activity()` → POST `/internal/inbox` (HMAC-signed, AP-shaped activities for cross-service writes with denormalized data) ## Auth diff --git a/cart/alembic/versions/0002_denormalize_product_data.py b/cart/alembic/versions/0002_denormalize_product_data.py new file mode 100644 index 0000000..d9396b0 --- /dev/null +++ b/cart/alembic/versions/0002_denormalize_product_data.py @@ -0,0 +1,48 @@ +"""Denormalize product and marketplace data onto cart_items and order_items. + +Revision ID: cart_0002 +Revises: cart_0001 +Create Date: 2026-02-26 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "cart_0002" +down_revision = "cart_0001" +branch_labels = None +depends_on = None + + +def upgrade(): + # -- cart_items: denormalized product data -- + op.add_column("cart_items", sa.Column("product_title", sa.String(512), nullable=True)) + op.add_column("cart_items", sa.Column("product_slug", sa.String(512), nullable=True)) + op.add_column("cart_items", sa.Column("product_image", sa.Text, nullable=True)) + op.add_column("cart_items", sa.Column("product_brand", sa.String(255), nullable=True)) + op.add_column("cart_items", sa.Column("product_regular_price", sa.Numeric(12, 2), nullable=True)) + op.add_column("cart_items", sa.Column("product_special_price", sa.Numeric(12, 2), nullable=True)) + op.add_column("cart_items", sa.Column("product_price_currency", sa.String(16), nullable=True)) + + # -- cart_items: denormalized marketplace data -- + op.add_column("cart_items", sa.Column("market_place_name", sa.String(255), nullable=True)) + op.add_column("cart_items", sa.Column("market_place_container_id", sa.Integer, nullable=True)) + + # -- order_items: denormalized product fields -- + op.add_column("order_items", sa.Column("product_slug", sa.String(512), nullable=True)) + op.add_column("order_items", sa.Column("product_image", sa.Text, nullable=True)) + + +def downgrade(): + op.drop_column("order_items", "product_image") + op.drop_column("order_items", "product_slug") + + op.drop_column("cart_items", "market_place_container_id") + op.drop_column("cart_items", "market_place_name") + op.drop_column("cart_items", "product_price_currency") + op.drop_column("cart_items", "product_special_price") + op.drop_column("cart_items", "product_regular_price") + op.drop_column("cart_items", "product_brand") + op.drop_column("cart_items", "product_image") + op.drop_column("cart_items", "product_slug") + op.drop_column("cart_items", "product_title") diff --git a/cart/app.py b/cart/app.py index f02ffc3..8643f7b 100644 --- a/cart/app.py +++ b/cart/app.py @@ -19,6 +19,7 @@ from bp import ( register_fragments, register_actions, register_data, + register_inbox, ) from bp.cart.services import ( get_cart, @@ -143,6 +144,7 @@ def create_app() -> "Quart": app.register_blueprint(register_fragments()) app.register_blueprint(register_actions()) app.register_blueprint(register_data()) + app.register_blueprint(register_inbox()) # --- Page slug hydration (follows events/market app pattern) --- diff --git a/cart/bp/__init__.py b/cart/bp/__init__.py index 43a64c1..72d4f84 100644 --- a/cart/bp/__init__.py +++ b/cart/bp/__init__.py @@ -6,3 +6,4 @@ from .orders.routes import register as register_orders from .fragments import register_fragments from .actions import register_actions from .data import register_data +from .inbox import register_inbox diff --git a/cart/bp/cart/global_routes.py b/cart/bp/cart/global_routes.py index ad528af..dd24665 100644 --- a/cart/bp/cart/global_routes.py +++ b/cart/bp/cart/global_routes.py @@ -7,7 +7,6 @@ from sqlalchemy import select from shared.models.market import CartItem from shared.models.order import Order -from shared.models.market_place import MarketPlace from shared.infrastructure.actions import call_action from .services import ( current_cart_identity, @@ -265,16 +264,14 @@ def register(url_prefix: str) -> Blueprint: required=False) if raw_pc else None if post: g.page_slug = post["slug"] - result = await g.s.execute( - select(MarketPlace).where( - MarketPlace.container_type == "page", - MarketPlace.container_id == post["id"], - MarketPlace.deleted_at.is_(None), - ).limit(1) - ) - mp = result.scalar_one_or_none() - if mp: - g.market_slug = mp.slug + # Fetch marketplace slug from market service + mps = await fetch_data( + "market", "marketplaces-for-container", + params={"type": "page", "id": post["id"]}, + required=False, + ) or [] + if mps: + g.market_slug = mps[0].get("slug") if order.sumup_checkout_id: try: diff --git a/cart/bp/cart/services/checkout.py b/cart/bp/cart/services/checkout.py index 7d9f3e9..1063175 100644 --- a/cart/bp/cart/services/checkout.py +++ b/cart/bp/cart/services/checkout.py @@ -9,9 +9,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from shared.models.market import Product, CartItem +from shared.models.market import CartItem from shared.models.order import Order, OrderItem -from shared.models.market_place import MarketPlace from shared.config import config from shared.contracts.dtos import CalendarEntryDTO from shared.events import emit_activity @@ -24,17 +23,24 @@ async def find_or_create_cart_item( product_id: int, user_id: Optional[int], session_id: Optional[str], + *, + product_title: str | None = None, + product_slug: str | None = None, + product_image: str | None = None, + product_brand: str | None = None, + product_regular_price: float | None = None, + product_special_price: float | None = None, + product_price_currency: str | None = None, + market_place_id: int | None = None, + market_place_name: str | None = None, + market_place_container_id: int | None = None, ) -> Optional[CartItem]: """ Find an existing cart item for this product/identity, or create a new one. - Returns None if the product doesn't exist. + Returns None if product data is missing. Increments quantity if item already exists. """ - # Make sure product exists - product = await session.scalar( - select(Product).where(Product.id == product_id) - ) - if not product: + if not product_id: return None # Look for existing cart item @@ -56,8 +62,18 @@ async def find_or_create_cart_item( cart_item = CartItem( user_id=user_id, session_id=session_id, - product_id=product.id, + product_id=product_id, quantity=1, + market_place_id=market_place_id, + product_title=product_title, + product_slug=product_slug, + product_image=product_image, + product_brand=product_brand, + product_regular_price=product_regular_price, + product_special_price=product_special_price, + product_price_currency=product_price_currency, + market_place_name=market_place_name, + market_place_container_id=market_place_container_id, ) session.add(cart_item) return cart_item @@ -76,12 +92,10 @@ async def resolve_page_config( """ post_ids: set[int] = set() - # From cart items via market_place + # From cart items via denormalized market_place_container_id for ci in cart: - if ci.market_place_id: - mp = await session.get(MarketPlace, ci.market_place_id) - if mp: - post_ids.add(mp.container_id) + if ci.market_place_container_id: + post_ids.add(ci.market_place_container_id) # From calendar entries via calendar for entry in calendar_entries: @@ -130,8 +144,7 @@ async def create_order_from_cart( cart_total = product_total + calendar_total + ticket_total # Determine currency from first product - first_product = cart[0].product if cart else None - currency = (first_product.regular_price_currency if first_product else None) or "GBP" + currency = (cart[0].product_price_currency if cart else None) or "GBP" # Create order order = Order( @@ -146,11 +159,13 @@ async def create_order_from_cart( # Create order items from cart for ci in cart: - price = ci.product.special_price or ci.product.regular_price or 0 + price = ci.product_special_price or ci.product_regular_price or 0 oi = OrderItem( order=order, - product_id=ci.product.id, - product_title=ci.product.title, + product_id=ci.product_id, + product_title=ci.product_title, + product_slug=ci.product_slug, + product_image=ci.product_image, quantity=ci.quantity, unit_price=price, currency=currency, @@ -188,7 +203,7 @@ async def create_order_from_cart( def build_sumup_description(cart: list[CartItem], order_id: int, *, ticket_count: int = 0) -> str: """Build a human-readable description for SumUp checkout.""" - titles = [ci.product.title for ci in cart if ci.product and ci.product.title] + titles = [ci.product_title for ci in cart if ci.product_title] item_count = sum(ci.quantity for ci in cart) parts = [] @@ -240,11 +255,11 @@ def validate_webhook_secret(token: Optional[str]) -> bool: async def get_order_with_details(session: AsyncSession, order_id: int) -> Optional[Order]: - """Fetch an order with items and calendar entries eagerly loaded.""" + """Fetch an order with items eagerly loaded.""" result = await session.execute( select(Order) .options( - selectinload(Order.items).selectinload(OrderItem.product), + selectinload(Order.items), ) .where(Order.id == order_id) ) diff --git a/cart/bp/cart/services/clear_cart_for_order.py b/cart/bp/cart/services/clear_cart_for_order.py index 3643839..11736ee 100644 --- a/cart/bp/cart/services/clear_cart_for_order.py +++ b/cart/bp/cart/services/clear_cart_for_order.py @@ -1,7 +1,6 @@ -from sqlalchemy import update, func, select +from sqlalchemy import update, func from shared.models.market import CartItem -from shared.models.market_place import MarketPlace from shared.models.order import Order @@ -23,12 +22,7 @@ async def clear_cart_for_order(session, order: Order, *, page_post_id: int | Non return if page_post_id is not None: - mp_ids = select(MarketPlace.id).where( - MarketPlace.container_type == "page", - MarketPlace.container_id == page_post_id, - MarketPlace.deleted_at.is_(None), - ).scalar_subquery() - filters.append(CartItem.market_place_id.in_(mp_ids)) + filters.append(CartItem.market_place_container_id == page_post_id) await session.execute( update(CartItem) diff --git a/cart/bp/cart/services/get_cart.py b/cart/bp/cart/services/get_cart.py index ad1c0ce..ef50025 100644 --- a/cart/bp/cart/services/get_cart.py +++ b/cart/bp/cart/services/get_cart.py @@ -1,25 +1,55 @@ +from types import SimpleNamespace + from sqlalchemy import select -from sqlalchemy.orm import selectinload from shared.models.market import CartItem from .identity import current_cart_identity -async def get_cart(session): - ident = current_cart_identity() - - filters = [CartItem.deleted_at.is_(None)] - if ident["user_id"] is not None: - filters.append(CartItem.user_id == ident["user_id"]) - else: - filters.append(CartItem.session_id == ident["session_id"]) - result = await session.execute( - select(CartItem) - .where(*filters) - .order_by(CartItem.created_at.desc()) - .options( - selectinload(CartItem.product), - selectinload(CartItem.market_place), - ) - ) - return result.scalars().all() +def _attach_product_namespace(ci: CartItem) -> None: + """Build a SimpleNamespace 'product' from denormalized columns for template compat.""" + ci.product = SimpleNamespace( + id=ci.product_id, + title=ci.product_title, + slug=ci.product_slug, + image=ci.product_image, + brand=ci.product_brand, + regular_price=ci.product_regular_price, + special_price=ci.product_special_price, + regular_price_currency=ci.product_price_currency, + ) + + +def _attach_market_place_namespace(ci: CartItem) -> None: + """Build a SimpleNamespace 'market_place' from denormalized columns.""" + if ci.market_place_id: + ci.market_place = SimpleNamespace( + id=ci.market_place_id, + name=ci.market_place_name, + container_id=ci.market_place_container_id, + ) + else: + ci.market_place = None + + +async def get_cart(session): + ident = current_cart_identity() + + filters = [CartItem.deleted_at.is_(None)] + if ident["user_id"] is not None: + filters.append(CartItem.user_id == ident["user_id"]) + else: + filters.append(CartItem.session_id == ident["session_id"]) + + result = await session.execute( + select(CartItem) + .where(*filters) + .order_by(CartItem.created_at.desc()) + ) + items = list(result.scalars().all()) + + for ci in items: + _attach_product_namespace(ci) + _attach_market_place_namespace(ci) + + return items diff --git a/cart/bp/cart/services/page_cart.py b/cart/bp/cart/services/page_cart.py index afb92cd..cf20f8a 100644 --- a/cart/bp/cart/services/page_cart.py +++ b/cart/bp/cart/services/page_cart.py @@ -2,7 +2,7 @@ Page-scoped cart queries. Groups cart items and calendar entries by their owning page (Post), -determined via CartItem.market_place.container_id and CalendarEntry.calendar.container_id +determined via CartItem.market_place_container_id (where container_type == "page"). """ from __future__ import annotations @@ -12,24 +12,21 @@ from collections import defaultdict from types import SimpleNamespace from sqlalchemy import select -from sqlalchemy.orm import selectinload from shared.models.market import CartItem -from shared.models.market_place import MarketPlace from shared.infrastructure.data_client import fetch_data from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, PostDTO, dto_from_dict from .identity import current_cart_identity +from .get_cart import _attach_product_namespace, _attach_market_place_namespace async def get_cart_for_page(session, post_id: int) -> list[CartItem]: - """Return cart items scoped to a specific page (via MarketPlace.container_id).""" + """Return cart items scoped to a specific page (via denormalized market_place_container_id).""" ident = current_cart_identity() filters = [ CartItem.deleted_at.is_(None), - MarketPlace.container_type == "page", - MarketPlace.container_id == post_id, - MarketPlace.deleted_at.is_(None), + CartItem.market_place_container_id == post_id, ] if ident["user_id"] is not None: filters.append(CartItem.user_id == ident["user_id"]) @@ -38,15 +35,16 @@ async def get_cart_for_page(session, post_id: int) -> list[CartItem]: result = await session.execute( select(CartItem) - .join(MarketPlace, CartItem.market_place_id == MarketPlace.id) .where(*filters) .order_by(CartItem.created_at.desc()) - .options( - selectinload(CartItem.product), - selectinload(CartItem.market_place), - ) ) - return result.scalars().all() + items = list(result.scalars().all()) + + for ci in items: + _attach_product_namespace(ci) + _attach_market_place_namespace(ci) + + return items async def get_calendar_entries_for_page(session, post_id: int): diff --git a/cart/bp/cart/services/total.py b/cart/bp/cart/services/total.py index 8dcdaf9..151fa06 100644 --- a/cart/bp/cart/services/total.py +++ b/cart/bp/cart/services/total.py @@ -4,10 +4,9 @@ from decimal import Decimal def total(cart): return sum( ( - Decimal(str(item.product.special_price or item.product.regular_price)) + Decimal(str(item.product_special_price or item.product_regular_price)) * item.quantity ) for item in cart - if (item.product.special_price or item.product.regular_price) is not None + if (item.product_special_price or item.product_regular_price) is not None ) - \ No newline at end of file diff --git a/cart/bp/data/routes.py b/cart/bp/data/routes.py index af5fce7..d401c15 100644 --- a/cart/bp/data/routes.py +++ b/cart/bp/data/routes.py @@ -83,7 +83,6 @@ def register() -> Blueprint: # --- cart-items (product slugs + quantities for template rendering) --- async def _cart_items(): from sqlalchemy import select - from sqlalchemy.orm import selectinload from shared.models.market import CartItem user_id = request.args.get("user_id", type=int) @@ -98,13 +97,13 @@ def register() -> Blueprint: return [] result = await g.s.execute( - select(CartItem).where(*filters).options(selectinload(CartItem.product)) + select(CartItem).where(*filters) ) items = result.scalars().all() return [ { "product_id": item.product_id, - "product_slug": item.product.slug if item.product else None, + "product_slug": item.product_slug, "quantity": item.quantity, } for item in items diff --git a/cart/bp/inbox/__init__.py b/cart/bp/inbox/__init__.py new file mode 100644 index 0000000..16e431f --- /dev/null +++ b/cart/bp/inbox/__init__.py @@ -0,0 +1 @@ +from .routes import register as register_inbox diff --git a/cart/bp/inbox/routes.py b/cart/bp/inbox/routes.py new file mode 100644 index 0000000..9fa9b67 --- /dev/null +++ b/cart/bp/inbox/routes.py @@ -0,0 +1,161 @@ +"""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 diff --git a/cart/bp/order/routes.py b/cart/bp/order/routes.py index 5432ce0..2452679 100644 --- a/cart/bp/order/routes.py +++ b/cart/bp/order/routes.py @@ -5,7 +5,6 @@ from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy.orm import selectinload -from shared.models.market import Product from shared.models.order import Order, OrderItem from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.config import config @@ -49,7 +48,7 @@ def register() -> Blueprint: result = await g.s.execute( select(Order) .options( - selectinload(Order.items).selectinload(OrderItem.product) + selectinload(Order.items) ) .where(Order.id == order_id, owner) ) diff --git a/cart/bp/orders/routes.py b/cart/bp/orders/routes.py index 2e1579b..a6fbd8a 100644 --- a/cart/bp/orders/routes.py +++ b/cart/bp/orders/routes.py @@ -5,7 +5,6 @@ from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy.orm import selectinload -from shared.models.market import Product from shared.models.order import Order, OrderItem from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.config import config @@ -86,16 +85,11 @@ def register(url_prefix: str) -> Blueprint: exists( select(1) .select_from(OrderItem) - .join(Product, Product.id == OrderItem.product_id) .where( OrderItem.order_id == Order.id, or_( OrderItem.product_title.ilike(term), - Product.title.ilike(term), - Product.description_short.ilike(term), - Product.description_html.ilike(term), - Product.slug.ilike(term), - Product.brand.ilike(term), + OrderItem.product_slug.ilike(term), ), ) ) diff --git a/cart/templates/_types/order/_items.html b/cart/templates/_types/order/_items.html index 27b2a9f..92d674d 100644 --- a/cart/templates/_types/order/_items.html +++ b/cart/templates/_types/order/_items.html @@ -7,13 +7,13 @@