Split cart into 4 microservices: relations, likes, orders, page-config→blog

Phase 1 - Relations service (internal): owns ContainerRelation, exposes
get-children data + attach/detach-child actions. Retargeted events, blog,
market callers from cart to relations.

Phase 2 - Likes service (internal): unified Like model replaces ProductLike
and PostLike with generic target_type/target_slug/target_id. Exposes
is-liked, liked-slugs, liked-ids data + toggle action.

Phase 3 - PageConfig → blog: moved ownership to blog with direct DB queries,
removed proxy endpoints from cart.

Phase 4 - Orders service (public): owns Order/OrderItem + SumUp checkout
flow. Cart checkout now delegates to orders via create-order action.
Webhook/return routes and reconciliation moved to orders.

Phase 5 - Infrastructure: docker-compose, deploy.sh, Dockerfiles updated
for all 3 new services. Added orders_url helper and factory model imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 09:03:33 +00:00
parent 76a9436ea1
commit fa431ee13e
125 changed files with 3459 additions and 860 deletions

View File

@@ -22,23 +22,27 @@ def register():
@require_admin
async def admin(slug: str):
from shared.browser.app.utils.htmx import is_htmx_request
from shared.infrastructure.data_client import fetch_data
from sqlalchemy import select
from shared.models.page_config import PageConfig
# Load features for page admin (page_configs lives in db_cart)
# Load features for page admin (page_configs now lives in db_blog)
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
raw_pc = await fetch_data("cart", "page-config",
params={"container_type": "page", "container_id": post["id"]},
required=False)
if raw_pc:
features = raw_pc.get("features") or {}
sumup_configured = bool(raw_pc.get("sumup_api_key"))
sumup_merchant_code = raw_pc.get("sumup_merchant_code") or ""
sumup_checkout_prefix = raw_pc.get("sumup_checkout_prefix") or ""
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
ctx = {
"features": features,
@@ -84,7 +88,7 @@ def register():
return jsonify({"error": "Expected JSON object with feature flags."}), 400
# Update via cart action (page_configs lives in db_cart)
result = await call_action("cart", "update-page-config", payload={
result = await call_action("blog", "update-page-config", payload={
"container_type": "page",
"container_id": post_id,
"features": body,
@@ -129,7 +133,7 @@ def register():
if api_key:
payload["sumup_api_key"] = api_key
result = await call_action("cart", "update-page-config", payload=payload)
result = await call_action("blog", "update-page-config", payload=payload)
features = result.get("features", {})
html = await render_template(

View File

@@ -11,8 +11,8 @@ from quart import (
request,
)
from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from shared.infrastructure.data_client import fetch_data
from shared.infrastructure.actions import call_action
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
@@ -144,11 +144,10 @@ def register():
post_id = g.post_data["post"]["id"]
user_id = g.user.id
liked, error = await toggle_post_like(g.s, user_id, post_id)
if error:
resp = make_response(error, 404)
return resp
result = await call_action("likes", "toggle", payload={
"user_id": user_id, "target_type": "post", "target_id": post_id,
})
liked = result["liked"]
html = await render_template(
"_types/browse/like/button.html",

View File

@@ -41,7 +41,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
if not post.is_page:
raise MarketError("Markets can only be created on pages, not posts.")
raw_pc = await fetch_data("cart", "page-config",
raw_pc = await fetch_data("blog", "page-config",
params={"container_type": "page", "container_id": post_id},
required=False)
if raw_pc is None or not (raw_pc.get("features") or {}).get("market"):

View File

@@ -1,6 +1,5 @@
from ...blog.ghost_db import DBClient # adjust import path
from sqlalchemy import select
from models.ghost_content import PostLike
from shared.infrastructure.data_client import fetch_data
from quart import g
async def post_data(slug, session, include_drafts=False):
@@ -15,14 +14,10 @@ async def post_data(slug, session, include_drafts=False):
# Check if current user has liked this post
is_liked = False
if g.user:
liked_record = await session.scalar(
select(PostLike).where(
PostLike.user_id == g.user.id,
PostLike.post_id == post["id"],
PostLike.deleted_at.is_(None),
)
)
is_liked = liked_record is not None
liked_data = await fetch_data("likes", "is-liked", params={
"user_id": g.user.id, "target_type": "post", "target_id": post["id"],
}, required=False)
is_liked = (liked_data or {}).get("liked", False)
# Add is_liked to post dict
post["is_liked"] = is_liked

View File

@@ -1,58 +0,0 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.ghost_content import Post, PostLike
async def toggle_post_like(
session: AsyncSession,
user_id: int,
post_id: int,
) -> tuple[bool, Optional[str]]:
"""
Toggle a post like for a given user using soft deletes.
Returns (liked_state, error_message).
- If error_message is not None, an error occurred.
- liked_state indicates whether post is now liked (True) or unliked (False).
"""
# Verify post exists
post_exists = await session.scalar(
select(Post.id).where(Post.id == post_id, Post.deleted_at.is_(None))
)
if not post_exists:
return False, "Post not found"
# Check if like exists (not deleted)
existing = await session.scalar(
select(PostLike).where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
)
if existing:
# Unlike: soft delete the like
await session.execute(
update(PostLike)
.where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
.values(deleted_at=func.now())
)
return False, None
else:
# Like: add a new like
new_like = PostLike(
user_id=user_id,
post_id=post_id,
)
session.add(new_like)
return True, None