- Server sends sexp source text, client (sexp.js) renders everything - SexpExpr marker class for nested sexp composition in serialize() - sexp_page() HTML shell with data-mount="body" for full page loads - sexp_response() returns text/sexp for OOB/partial responses - ~app-body layout component replaces ~app-layout (no raw!) - ~rich-text is the only component using raw! (for CMS HTML content) - Fragment endpoints return text/sexp, auto-wrapped in SexpExpr - All _*_html() helpers converted to _*_sexp() returning sexp source - Head auto-hoist: sexp.js moves meta/title/link/script[ld+json] from rendered body to document.head automatically - Unknown components render warning box instead of crashing page - Component kwargs preserve AST for lazy rendering (fixes <> in kwargs) - Fix unterminated paren in events/sexp/tickets.sexpr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
326 lines
14 KiB
Markdown
326 lines
14 KiB
Markdown
# 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/<order_id>/` → `orders/bp/checkout/routes.py`
|
|
- `GET /checkout/return/<order_id>/` → `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
|