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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user