feat(mall): serve catalog and currency from the live API

Wave 1 of replacing the fixed-data mock adapter. The mall now selects its API
adapter per domain, with catalog and currency served live while auth, cart,
orders, shipments and invoices stay on fixed data.

Backend:
- seed the 6 x 2 x 2 category tree as reference data (migration 0005). The API
  exposes no category write route, so this cannot come from the seed script
- filter public product listing by the category subtree with a recursive CTE,
  matching the mock's existing behaviour instead of exact-match
- add sort=price with order=asc|desc, validated by hand so an unsupported value
  returns the project's ApiError 400 shape rather than axum's own rejection

Mall:
- replace the all-or-nothing mockApi boolean with a liveDomains list composed
  through a typed per-domain pick map
- source home floors, the category menu, search and product detail from the
  catalog API; banners, promos, quick links, store card and comment/coupon
  content stay local display-only content
- drop the brand facet and the sales/comments sorts: no backend model backs them
- fix salesOf/commentCountOf, which parsed digits out of the product id and so
  rendered "NaN sold" for live UUID ids; they now hash the id

Seed: 24 products across 4 shops, idempotent on re-run.

Note: the mall defaults to a live catalog, so pnpm dev:mall now expects the API
to be running; set NUXT_PUBLIC_LIVE_DOMAINS to an empty array for all-mock work.

OpenSpec change: openspec/changes/replace-mock-api-wave-1
This commit is contained in:
2026-09-17 15:15:25 +00:00
parent 44466e5e88
commit e0e833d0e5
20 changed files with 909 additions and 223 deletions
+66 -11
View File
@@ -64,8 +64,50 @@ struct ListQuery {
category_id: Option<Uuid>,
shop_id: Option<Uuid>,
q: Option<String>,
sort: Option<String>,
order: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SortBy {
Newest,
Price,
}
impl ListQuery {
/// Only `price` is a supported sort; anything else is a client error rather
/// than being silently ignored. An absent `sort` keeps newest-first.
fn sort_by(&self) -> ApiResult<SortBy> {
match self.sort.as_deref() {
None => Ok(SortBy::Newest),
Some("price") => Ok(SortBy::Price),
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
}
}
fn ascending(&self) -> ApiResult<bool> {
match self.order.as_deref() {
None | Some("asc") => Ok(true),
Some("desc") => Ok(false),
Some(other) => Err(ApiError::BadRequest(format!("unsupported order: {other}"))),
}
}
}
/// Resolves the requested category to itself plus every descendant, so a parent
/// category lists its children's and grandchildren's products too. Shared by the
/// count and page queries so `total` cannot drift from `items`.
const SUBTREE_CTE: &str = "WITH RECURSIVE subtree AS (
SELECT id FROM categories WHERE id = $1::uuid
UNION ALL
SELECT c.id FROM categories c JOIN subtree st ON c.parent_id = st.id
) ";
/// Lowest active SKU price, used only when sorting by price. Products without a
/// sellable SKU sort last in both directions.
const MIN_PRICE: &str =
"(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)";
/// Public catalog: only published products of active shops.
async fn list_products(
State(state): State<AppState>,
@@ -74,27 +116,39 @@ async fn list_products(
let page = clamp_page(q.page);
let per_page = clamp_per_page(q.per_page);
let pattern = q.q.as_ref().map(|s| format!("%{s}%"));
let total: i64 = sqlx::query_scalar(
"SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id
let sort_by = q.sort_by()?;
let ascending = q.ascending()?;
let total: i64 = sqlx::query_scalar(&format!(
"{SUBTREE_CTE}
SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id
WHERE p.status = 'published' AND s.status = 'active'
AND ($1::uuid IS NULL OR p.category_id = $1)
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
AND ($2::uuid IS NULL OR p.shop_id = $2)
AND ($3::text IS NULL OR p.name::text ILIKE $3)",
)
AND ($3::text IS NULL OR p.name::text ILIKE $3)"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.fetch_one(&state.db)
.await?;
let products = sqlx::query_as::<_, Product>(
"SELECT p.* FROM products p JOIN shops s ON s.id = p.shop_id
// Interpolates only from the validated SortBy/order pair, never from input.
let order_clause = match (sort_by, ascending) {
(SortBy::Newest, _) => "p.created_at DESC".to_string(),
(SortBy::Price, true) => format!("{MIN_PRICE} ASC NULLS LAST, p.created_at DESC"),
(SortBy::Price, false) => format!("{MIN_PRICE} DESC NULLS LAST, p.created_at DESC"),
};
let products = sqlx::query_as::<_, Product>(&format!(
"{SUBTREE_CTE}
SELECT p.* FROM products p JOIN shops s ON s.id = p.shop_id
WHERE p.status = 'published' AND s.status = 'active'
AND ($1::uuid IS NULL OR p.category_id = $1)
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
AND ($2::uuid IS NULL OR p.shop_id = $2)
AND ($3::text IS NULL OR p.name::text ILIKE $3)
ORDER BY p.created_at DESC
LIMIT $4 OFFSET $5",
)
ORDER BY {order_clause}
LIMIT $4 OFFSET $5"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
@@ -102,6 +156,7 @@ async fn list_products(
.bind((page - 1) * per_page)
.fetch_all(&state.db)
.await?;
let items = attach_skus(&state.db, products, true).await?;
Ok(Json(Paged {
items,