feat(mall): restore real brand and sales facets

Wave 6, the last substantive piece of the mock-API migration. Wave 1 removed the
brand facet and the sales/comments sorts for want of a model; sales turn out to
be derivable from order_items and a brand model is a table plus a column.

- a `brands` table with a nullable `products.brand_id` and an ordered admin
  replace, mirroring categories and storefront content; a public `GET /api/brands`
  and a `brand_id` filter on the catalog, which the search page's facet uses
- `sold_count` per product, computed from `order_items` joined to orders that
  reached payment, so an abandoned or cancelled checkout cannot count as a sale.
  It is computed per read rather than stored, so it cannot drift from the orders
  that produced it
- `sort=sales` alongside `sort=price`; anything else is still a 400
- merchants can set a product's brand through the existing product upsert
- the review UI is gone: the card's review figure and the product detail page's
  reviews tab, summary and replies. There is no reviews model, and the mall
  attributed invented comments to named shoppers and showed a "good rate". The
  now-unreferenced fabrication helpers went with it (`salesOf`, `commentCountOf`,
  `commentsFor`, `commentStats`, `salesRankFor`, `productDetail`, `storeDetail`)

Two bugs found by checking rather than trusting: the fixed-data `listProducts`
had silently ignored `brand_id`, `sort` and `order`, so the restored facet
rendered but filtered nothing until the rollback check caught it; and the seed's
brand lookup read back through the shared `r` variable the product loop
reassigns, working once and then throwing.

Verified: 29 backend tests green including a new brand-and-sales case; all three
frontends build; searching filters by brand (24 to 6) and sorts by sales with
counts matching the API; a product page offers detail and after-sale tabs only,
with a real sold count; the fixed-data rollback filters by brand too.

OpenSpec change: openspec/changes/replace-mock-api-wave-6
This commit is contained in:
2026-09-17 17:35:28 +00:00
parent 65cea42da6
commit ce3e8db5b1
20 changed files with 487 additions and 193 deletions
+10
View File
@@ -67,11 +67,21 @@ pub struct Category {
pub position: i32,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Brand {
pub id: Uuid,
pub name: serde_json::Value,
pub slug: String,
pub position: i32,
pub active: bool,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Product {
pub id: Uuid,
pub shop_id: Uuid,
pub category_id: Option<Uuid>,
pub brand_id: Option<Uuid>,
pub slug: String,
pub name: serde_json::Value,
pub description: serde_json::Value,
+101
View File
@@ -0,0 +1,101 @@
use axum::{
extract::State,
routing::{get, put},
Json, Router,
};
use serde::Deserialize;
use serde_json::Value;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult};
use crate::models::{Brand, UserRole};
use crate::state::AppState;
pub fn router(_state: AppState) -> Router<AppState> {
Router::new().route("/brands", get(list_brands))
}
pub fn admin_router(_state: AppState) -> Router<AppState> {
Router::new().route("/admin/brands", put(replace_brands))
}
async fn list_brands(State(state): State<AppState>) -> ApiResult<Json<Vec<Brand>>> {
let brands = sqlx::query_as::<_, Brand>(
"SELECT * FROM brands WHERE active = TRUE ORDER BY position, created_at",
)
.fetch_all(&state.db)
.await?;
Ok(Json(brands))
}
#[derive(Deserialize)]
struct BrandInput {
slug: String,
name: Value,
#[serde(default = "default_active")]
active: bool,
}
fn default_active() -> bool {
true
}
/// Replaces the whole ordered list, as storefront content does: small lists are
/// edited whole, and positions are reindexed from the submitted order.
async fn replace_brands(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<Vec<BrandInput>>,
) -> ApiResult<Json<Vec<Brand>>> {
auth.require(&[UserRole::PlatformAdmin])?;
for brand in &body {
if brand.slug.trim().is_empty()
|| !brand
.slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(ApiError::BadRequest(format!(
"invalid brand slug: {}",
brand.slug
)));
}
let ok = ["en", "zh"].iter().all(|code| {
brand
.name
.get(code)
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty())
});
if !ok {
return Err(ApiError::BadRequest(format!(
"brand {} needs non-empty en and zh names",
brand.slug
)));
}
}
let mut tx = state.db.begin().await?;
sqlx::query("DELETE FROM brands").execute(&mut *tx).await?;
for (i, brand) in body.iter().enumerate() {
sqlx::query(
"INSERT INTO brands (name, slug, position, active) VALUES ($1, $2, $3, $4)",
)
.bind(&brand.name)
.bind(brand.slug.trim())
.bind(i as i32)
.bind(brand.active)
.execute(&mut *tx)
.await
.map_err(|e| match e {
sqlx::Error::Database(d) if d.is_unique_violation() => {
ApiError::BadRequest(format!("duplicate brand slug: {}", brand.slug))
}
other => ApiError::from(other),
})?;
}
tx.commit().await?;
list_brands(State(state)).await
}
+56 -4
View File
@@ -16,6 +16,27 @@ pub struct ProductWithSkus {
#[serde(flatten)]
pub product: Product,
pub skus: Vec<Sku>,
/// Units sold across orders that reached payment. Zero when nothing sold.
pub sold_count: i64,
}
/// Orders that count as a sale: an abandoned or cancelled checkout does not.
const PAID_STATUSES: &str =
"('paid', 'fulfilling', 'shipped', 'completed')";
/// Units sold for one product, as a correlated subquery so it can also drive
/// the sales sort without a second round trip.
const SOLD_UNITS: &str = "(SELECT COALESCE(SUM(oi.qty), 0)
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN skus sk ON sk.id = oi.sku_id
WHERE sk.product_id = p.id
AND o.status IN ('paid', 'fulfilling', 'shipped', 'completed'))";
#[derive(sqlx::FromRow)]
struct SoldRow {
product_id: Uuid,
sold: i64,
}
pub async fn attach_skus(
@@ -41,10 +62,31 @@ pub async fn attach_skus(
.fetch_all(db)
.await?
};
let sold: Vec<SoldRow> = if ids.is_empty() {
Vec::new()
} else {
sqlx::query_as::<_, SoldRow>(&format!(
"SELECT sk.product_id, COALESCE(SUM(oi.qty), 0)::bigint AS sold
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN skus sk ON sk.id = oi.sku_id
WHERE sk.product_id = ANY($1)
AND o.status IN {PAID_STATUSES}
GROUP BY sk.product_id"
))
.bind(&ids)
.fetch_all(db)
.await?
};
Ok(products
.into_iter()
.map(|p| ProductWithSkus {
skus: skus.iter().filter(|s| s.product_id == p.id).cloned().collect(),
sold_count: sold
.iter()
.find(|row| row.product_id == p.id)
.map(|row| row.sold)
.unwrap_or(0),
product: p,
})
.collect())
@@ -62,6 +104,7 @@ struct ListQuery {
page: Option<i64>,
per_page: Option<i64>,
category_id: Option<Uuid>,
brand_id: Option<Uuid>,
shop_id: Option<Uuid>,
q: Option<String>,
sort: Option<String>,
@@ -72,15 +115,18 @@ struct ListQuery {
enum SortBy {
Newest,
Price,
Sales,
}
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.
/// Only `price` and `sales` are supported sorts; 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("sales") => Ok(SortBy::Sales),
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
}
}
@@ -125,11 +171,13 @@ async fn list_products(
WHERE p.status = 'published' AND s.status = 'active'
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)
AND ($4::uuid IS NULL OR p.brand_id = $4)"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.bind(q.brand_id)
.fetch_one(&state.db)
.await?;
@@ -138,6 +186,8 @@ async fn list_products(
(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"),
(SortBy::Sales, true) => format!("{SOLD_UNITS} ASC, p.created_at DESC"),
(SortBy::Sales, false) => format!("{SOLD_UNITS} DESC, p.created_at DESC"),
};
let products = sqlx::query_as::<_, Product>(&format!(
"{SUBTREE_CTE}
@@ -146,12 +196,14 @@ async fn list_products(
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 ($4::uuid IS NULL OR p.brand_id = $4)
ORDER BY {order_clause}
LIMIT $4 OFFSET $5"
LIMIT $5 OFFSET $6"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.bind(q.brand_id)
.bind(per_page)
.bind((page - 1) * per_page)
.fetch_all(&state.db)
+3
View File
@@ -1,5 +1,6 @@
pub mod admin;
pub mod auth;
pub mod brands;
pub mod cart;
pub mod catalog;
pub mod content;
@@ -23,6 +24,8 @@ pub fn api_router(state: AppState) -> Router<AppState> {
.merge(auth::router(state.clone()))
.merge(currency::router(state.clone()))
.merge(catalog::router(state.clone()))
.merge(brands::router(state.clone()))
.merge(brands::admin_router(state.clone()))
.merge(content::router(state.clone()))
.merge(content::admin_router(state.clone()))
.merge(cart::router(state.clone()))
+7 -3
View File
@@ -100,6 +100,7 @@ async fn get_product(
#[derive(Deserialize)]
pub struct ProductBody {
category_id: Option<Uuid>,
brand_id: Option<Uuid>,
slug: String,
name: serde_json::Value,
description: Option<serde_json::Value>,
@@ -132,11 +133,12 @@ async fn create_product(
let shop_id = require_shop(&auth)?;
validate_product_body(&body)?;
let product = sqlx::query_as::<_, Product>(
"INSERT INTO products (shop_id, category_id, slug, name, description, images)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
"INSERT INTO products (shop_id, category_id, brand_id, slug, name, description, images)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
)
.bind(shop_id)
.bind(body.category_id)
.bind(body.brand_id)
.bind(body.slug.trim())
.bind(&body.name)
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
@@ -164,11 +166,13 @@ async fn update_product(
validate_product_body(&body)?;
let product = sqlx::query_as::<_, Product>(
"UPDATE products
SET category_id = $2, slug = $3, name = $4, description = $5, images = $6, updated_at = now()
SET category_id = $2, brand_id = $3, slug = $4, name = $5, description = $6,
images = $7, updated_at = now()
WHERE id = $1 RETURNING *",
)
.bind(id)
.bind(body.category_id)
.bind(body.brand_id)
.bind(body.slug.trim())
.bind(&body.name)
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))