# Split Cart into Microservices ## Context The cart app currently owns too much: CartItem, Order/OrderItem, PageConfig, ContainerRelation, plus all checkout/payment logic. We're splitting it into 4 pieces: 1. **Relations service** — internal only, owns ContainerRelation 2. **Likes service** — internal only, unified generic likes replacing ProductLike + PostLike 3. **PageConfig → blog** — move to blog (which already owns pages) 4. **Orders service** — public (orders.rose-ash.com), owns Order/OrderItem + SumUp checkout After the split, cart becomes a thin CartItem CRUD + inbox service. --- ## Phase 1: Relations Service (internal only) ### 1.1 Scaffold `relations/` Create minimal internal-only app (no templates, no context_fn): | File | Notes | |------|-------| | `relations/__init__.py` | Empty | | `relations/path_setup.py` | Copy from cart | | `relations/app.py` | `create_base_app("relations")`, register data + actions BPs only | | `relations/services/__init__.py` | Empty `register_domain_services()` | | `relations/models/__init__.py` | `from shared.models.container_relation import ContainerRelation` | | `relations/bp/__init__.py` | Export `register_data`, `register_actions` | | `relations/bp/data/routes.py` | Move `get-children` handler from `cart/bp/data/routes.py:175-198` | | `relations/bp/actions/routes.py` | Move `attach-child` + `detach-child` from `cart/bp/actions/routes.py:112-153` | | `relations/alembic.ini` | Copy from cart, adjust path | | `relations/alembic/env.py` | MODELS=`["shared.models.container_relation"]`, TABLES=`{"container_relations"}` | | `relations/alembic/versions/0001_initial.py` | Create `container_relations` table | | `relations/Dockerfile` | Follow cart pattern, `COPY relations/ ./` | | `relations/entrypoint.sh` | Standard pattern, db=`db_relations` | ### 1.2 Retarget callers (`"cart"` → `"relations"`) | File | Lines | Change | |------|-------|--------| | `events/bp/calendars/services/calendars.py` | 74, 111, 121 | `call_action("cart", ...)` → `call_action("relations", ...)` | | `blog/bp/menu_items/services/menu_items.py` | 83, 137, 141, 157 | Same | | `shared/services/market_impl.py` | 96, 109, 133 | Same | ### 1.3 Clean up cart - Remove `get-children` from `cart/bp/data/routes.py:175-198` - Remove `attach-child`, `detach-child` from `cart/bp/actions/routes.py:112-153` - Remove `"shared.models.container_relation"` and `"container_relations"` from `cart/alembic/env.py` --- ## Phase 2: Likes Service (internal only) ### 2.1 New unified model Single `likes` table in `db_likes`: ```python class Like(Base): __tablename__ = "likes" id: Mapped[int] (pk) user_id: Mapped[int] (not null, indexed) target_type: Mapped[str] (String 32, not null) # "product" or "post" target_slug: Mapped[str | None] (String 255) # for products target_id: Mapped[int | None] (Integer) # for posts created_at, updated_at, deleted_at UniqueConstraint("user_id", "target_type", "target_slug") UniqueConstraint("user_id", "target_type", "target_id") Index("ix_likes_target", "target_type", "target_slug") ``` Products use `target_type="product"`, `target_slug=slug`. Posts use `target_type="post"`, `target_id=post.id`. ### 2.2 Scaffold `likes/` | File | Notes | |------|-------| | `likes/__init__.py` | Empty | | `likes/path_setup.py` | Standard | | `likes/app.py` | Internal-only, `create_base_app("likes")`, data + actions BPs | | `likes/services/__init__.py` | Empty `register_domain_services()` | | `likes/models/__init__.py` | Import Like | | `likes/models/like.py` | Generic Like model (above) | | `likes/bp/__init__.py` | Export register functions | | `likes/bp/data/routes.py` | `is-liked`, `liked-slugs`, `liked-ids` | | `likes/bp/actions/routes.py` | `toggle` action | | `likes/alembic.ini` | Standard | | `likes/alembic/env.py` | MODELS=`["likes.models.like"]`, TABLES=`{"likes"}` | | `likes/alembic/versions/0001_initial.py` | Create `likes` table | | `likes/Dockerfile` | Standard pattern | | `likes/entrypoint.sh` | Standard, db=`db_likes` | ### 2.3 Data endpoints (`likes/bp/data/routes.py`) - `is-liked`: params `user_id, target_type, target_slug/target_id` → `{"liked": bool}` - `liked-slugs`: params `user_id, target_type` → `["slug1", "slug2"]` - `liked-ids`: params `user_id, target_type` → `[1, 2, 3]` ### 2.4 Action endpoints (`likes/bp/actions/routes.py`) - `toggle`: payload `{user_id, target_type, target_slug?, target_id?}` → `{"liked": bool}` ### 2.5 Retarget market app **`market/bp/product/routes.py`** (like_toggle, ~line 119): Replace `toggle_product_like(g.s, user_id, product_slug)` with: ```python result = await call_action("likes", "toggle", payload={ "user_id": user_id, "target_type": "product", "target_slug": product_slug }) liked = result["liked"] ``` **`market/bp/browse/services/db_backend.py`** (most complex): - `db_product_full` / `db_product_full_id`: Replace `ProductLike` subquery with `fetch_data("likes", "is-liked", ...)`. Annotate `is_liked` after query. - `db_products_nocounts` / `db_products_counts`: Fetch `liked_slugs` once via `fetch_data("likes", "liked-slugs", ...)`, filter `Product.slug.in_(liked_slugs)` for `?liked=true`, annotate `is_liked` post-query. **Delete**: `toggle_product_like` from `market/bp/product/services/product_operations.py` ### 2.6 Retarget blog app **`blog/bp/post/routes.py`** (like_toggle): Replace `toggle_post_like(g.s, user_id, post_id)` with `call_action("likes", "toggle", payload={...})`. **Delete**: `toggle_post_like` from `blog/bp/post/services/post_operations.py` ### 2.7 Remove old like models - Remove `ProductLike` from `shared/models/market.py` (lines 118-131) + `Product.likes` relationship (lines 110-114) - Remove `PostLike` from `shared/models/ghost_content.py` + `Post.likes` relationship - Remove `product_likes` from market alembic TABLES - Remove `post_likes` from blog alembic TABLES --- ## Phase 3: PageConfig → Blog ### 3.1 Replace blog proxy endpoints with direct DB queries **`blog/bp/data/routes.py`** (lines 77-102): Replace the 3 proxy handlers that currently call `fetch_data("cart", ...)` with direct DB queries. Copy logic from `cart/bp/data/routes.py`: - `page-config` (cart lines 114-134) - `page-config-by-id` (cart lines 136-149) - `page-configs-batch` (cart lines 151-172) - `page-config-ensure` (cart lines 49-81) — add new Also add the `_page_config_dict` helper (cart lines 203-213). ### 3.2 Move action to blog **`blog/bp/actions/routes.py`** (~line 40): Replace `call_action("cart", "update-page-config", ...)` proxy with direct handler. Copy logic from `cart/bp/actions/routes.py:51-110`. ### 3.3 Blog callers become local | File | Current | After | |------|---------|-------| | `blog/bp/post/admin/routes.py:34` | `fetch_data("cart", "page-config", ...)` | Direct DB query (blog now owns table) | | `blog/bp/post/admin/routes.py:87,132` | `call_action("cart", "update-page-config", ...)` | Direct call to local handler | | `blog/bp/post/services/markets.py:44` | `fetch_data("cart", "page-config", ...)` | Direct DB query | | `blog/bp/blog/ghost_db.py:295` | `fetch_data("cart", "page-configs-batch", ...)` | Direct DB query | ### 3.4 Retarget cross-service callers (`"cart"` → `"blog"`) | File | Change | |------|--------| | `cart/bp/cart/services/page_cart.py:181` | `fetch_data("cart", "page-configs-batch", ...)` → `fetch_data("blog", "page-configs-batch", ...)` | | `cart/bp/cart/global_routes.py:274` | `fetch_data("cart", "page-config-by-id", ...)` → `fetch_data("blog", "page-config-by-id", ...)` | (Note: `checkout.py:117` and `cart/app.py:177` already target `"blog"`) ### 3.5 Update blog alembic **`blog/alembic/env.py`**: Add `"shared.models.page_config"` to MODELS and `"page_configs"` to TABLES. ### 3.6 Clean up cart - Remove all `page-config*` handlers from `cart/bp/data/routes.py` (lines 49-172) - Remove `update-page-config` from `cart/bp/actions/routes.py` (lines 50-110) - Remove `"shared.models.page_config"` and `"page_configs"` from `cart/alembic/env.py` --- ## Phase 4: Orders Service (public, orders.rose-ash.com) ### 4.1 Scaffold `orders/` | File | Notes | |------|-------| | `orders/__init__.py` | Empty | | `orders/path_setup.py` | Standard | | `orders/app.py` | Public app with `context_fn`, templates, fragments, page slug hydration | | `orders/services/__init__.py` | `register_domain_services()` | | `orders/models/__init__.py` | `from shared.models.order import Order, OrderItem` | | `orders/bp/__init__.py` | Export all BPs | | `orders/bp/order/` | Move from `cart/bp/order/` (single order: detail, pay, recheck) | | `orders/bp/orders/` | Move from `cart/bp/orders/` (order list + pagination) | | `orders/bp/checkout/routes.py` | Webhook + return routes from `cart/bp/cart/global_routes.py` | | `orders/bp/data/routes.py` | Minimal | | `orders/bp/actions/routes.py` | `create-order` action (called by cart during checkout) | | `orders/bp/fragments/routes.py` | `account-nav-item` fragment (orders link) | | `orders/templates/` | Move `_types/order/`, `_types/orders/`, checkout templates from cart | | `orders/alembic.ini` | Standard | | `orders/alembic/env.py` | MODELS=`["shared.models.order"]`, TABLES=`{"orders", "order_items"}` | | `orders/alembic/versions/0001_initial.py` | Create `orders` + `order_items` tables | | `orders/Dockerfile` | Standard, public-facing | | `orders/entrypoint.sh` | Standard, db=`db_orders` | ### 4.2 Move checkout services to orders **Move to `orders/services/`:** - `checkout.py` — from `cart/bp/cart/services/checkout.py` (move: `create_order_from_cart`, `resolve_page_config`, `build_sumup_*`, `get_order_with_details`. Keep `find_or_create_cart_item` in cart.) - `check_sumup_status.py` — from `cart/bp/cart/services/check_sumup_status.py` **`clear_cart_for_order`** stays in cart as new action: - Add `clear-cart-for-order` to `cart/bp/actions/routes.py` - Orders calls `call_action("cart", "clear-cart-for-order", payload={user_id, session_id, page_post_id})` ### 4.3 `create-order` action endpoint (`orders/bp/actions/routes.py`) Cart's `POST /checkout/` calls this: ``` Payload: {cart_items: [{product_id, product_title, product_slug, product_image, product_special_price, product_regular_price, product_price_currency, quantity, market_place_container_id}], calendar_entries, tickets, user_id, session_id, product_total, calendar_total, ticket_total, page_post_id, redirect_url, webhook_base_url} Returns: {order_id, sumup_hosted_url, page_config_id, sumup_reference, description} ``` ### 4.4 Refactor cart's checkout route `cart/bp/cart/global_routes.py` `POST /checkout/`: 1. Load local cart data (get_cart, calendar entries, tickets, totals) 2. Serialize cart items to dicts 3. `result = await call_action("orders", "create-order", payload={...})` 4. Redirect to `result["sumup_hosted_url"]` Same for page-scoped checkout in `cart/bp/cart/page_routes.py`. ### 4.5 Move webhook + return routes to orders - `POST /checkout/webhook//` → `orders/bp/checkout/routes.py` - `GET /checkout/return//` → `orders/bp/checkout/routes.py` - SumUp redirect/webhook URLs must now point to orders.rose-ash.com ### 4.6 Move order list/detail routes - `cart/bp/order/` → `orders/bp/order/` - `cart/bp/orders/` → `orders/bp/orders/` ### 4.7 Move startup reconciliation `_reconcile_pending_orders` from `cart/app.py:209-265` → `orders/app.py` ### 4.8 Clean up cart - Remove `cart/bp/order/`, `cart/bp/orders/` - Remove checkout webhook/return from `cart/bp/cart/global_routes.py` - Remove `_reconcile_pending_orders` from `cart/app.py` - Remove order templates from `cart/templates/` - Remove `"shared.models.order"` and `"orders", "order_items"` from `cart/alembic/env.py` --- ## Phase 5: Infrastructure (applies to all new services) ### 5.1 docker-compose.yml Add 3 new services (relations, likes, orders) with own DATABASE_URL (db_relations, db_likes, db_orders), own REDIS_URL (Redis DB 7, 8, 9). Add to `x-app-env`: ```yaml INTERNAL_URL_RELATIONS: http://relations:8000 INTERNAL_URL_LIKES: http://likes:8000 INTERNAL_URL_ORDERS: http://orders:8000 APP_URL_ORDERS: https://orders.rose-ash.com ``` ### 5.2 docker-compose.dev.yml Add all 3 services with dev volumes (ports 8008, 8009, 8010). Add to `x-sibling-models` for all 3 new services. ### 5.3 deploy.sh Add `relations likes orders` to APPS list. ### 5.4 Caddyfile (`/root/caddy/Caddyfile`) Add only orders (public): ``` orders.rose-ash.com { reverse_proxy rose-ash-dev-orders-1:8000 } ``` ### 5.5 shared/infrastructure/factory.py Add to model import loop: `"relations.models", "likes.models", "orders.models"` ### 5.6 shared/infrastructure/urls.py Add `orders_url(path)` helper. ### 5.7 All existing Dockerfiles Add sibling model COPY lines for the 3 new services to every existing Dockerfile (blog, market, cart, events, federation, account). ### 5.8 CLAUDE.md Update project structure and add notes about the new services. --- ## Data Migration (one-time, run before code switch) 1. `container_relations` from `db_cart` → `db_relations` 2. `product_likes` from `db_market` + `post_likes` from `db_blog` → `db_likes.likes` 3. `page_configs` from `db_cart` → `db_blog` 4. `orders` + `order_items` from `db_cart` → `db_orders` Use `pg_dump`/`pg_restore` or direct SQL for migration. --- ## Post-Split Cart State After all 4 phases, cart owns only: - **Model**: CartItem (table in db_cart) - **Alembic**: `cart_items` only - **Data endpoints**: `cart-summary`, `cart-items` - **Action endpoints**: `adopt-cart-for-user`, `clear-cart-for-order` (new) - **Inbox handlers**: Add/Remove/Update `rose:CartItem` - **Public routes**: cart overview, page cart, add-to-cart, quantity, delete - **Fragments**: `cart-mini` - **Checkout**: POST /checkout/ (creates order via `call_action("orders", "create-order")`, redirects to SumUp) --- ## Verification 1. **Relations**: Blog attach/detach marketplace to page; events attach/detach calendar 2. **Likes**: Toggle product like on market page; toggle post like on blog; `?liked=true` filter 3. **PageConfig**: Blog admin page config update; cart checkout resolves page config from blog 4. **Orders**: Add to cart → checkout → SumUp redirect → webhook → order paid; order list/detail on orders.rose-ash.com 5. No remaining `call_action("cart", "attach-child|detach-child|update-page-config")` 6. No remaining `fetch_data("cart", "page-config*|get-children")` 7. Cart alembic only manages `cart_items` table