diff --git a/apps/api/migrations/0005_seed_category_tree.sql b/apps/api/migrations/0005_seed_category_tree.sql new file mode 100644 index 0000000..2b218ea --- /dev/null +++ b/apps/api/migrations/0005_seed_category_tree.sql @@ -0,0 +1,95 @@ +-- Reference taxonomy: extend the three top-level categories seeded in +-- 0003_catalog.sql into the full 6 x 2 x 2 tree the storefront is designed +-- around (six pinned sidebar rows, six home floors, two grandchildren per +-- child link). +-- +-- Categories are reference data, not demo data: `GET /categories` is the only +-- category route (there is no write endpoint), so the seed script cannot create +-- them, and shop-admin needs a real tree for its category picker. + +-- New top-level categories (positions 4-6 continue 0003's 1-3). +INSERT INTO categories (parent_id, name, slug, position) +VALUES + (NULL, '{"en": "Computers & Office", "zh": "电脑办公"}', 'computers-office', 4), + (NULL, '{"en": "Beauty & Care", "zh": "美妆个护"}', 'beauty-care', 5), + (NULL, '{"en": "Grocery & Fresh", "zh": "食品生鲜"}', 'grocery-fresh', 6); + +-- Second level: two children under every top-level category. +INSERT INTO categories (parent_id, name, slug, position) +VALUES + ((SELECT id FROM categories WHERE slug = 'electronics'), + '{"en": "Phones & Accessories", "zh": "手机与配件"}', 'electronics-phones', 1), + ((SELECT id FROM categories WHERE slug = 'electronics'), + '{"en": "Audio", "zh": "影音娱乐"}', 'electronics-audio', 2), + ((SELECT id FROM categories WHERE slug = 'fashion'), + '{"en": "Menswear", "zh": "男装"}', 'fashion-menswear', 1), + ((SELECT id FROM categories WHERE slug = 'fashion'), + '{"en": "Bags & Luggage", "zh": "箱包"}', 'fashion-bags', 2), + ((SELECT id FROM categories WHERE slug = 'home-living'), + '{"en": "Kitchen", "zh": "厨房电器"}', 'home-kitchen', 1), + ((SELECT id FROM categories WHERE slug = 'home-living'), + '{"en": "Cleaning", "zh": "清洁电器"}', 'home-cleaning', 2), + ((SELECT id FROM categories WHERE slug = 'computers-office'), + '{"en": "Laptops", "zh": "笔记本电脑"}', 'computers-laptops', 1), + ((SELECT id FROM categories WHERE slug = 'computers-office'), + '{"en": "Peripherals", "zh": "外设产品"}', 'computers-peripherals', 2), + ((SELECT id FROM categories WHERE slug = 'beauty-care'), + '{"en": "Skincare", "zh": "面部护肤"}', 'beauty-skincare', 1), + ((SELECT id FROM categories WHERE slug = 'beauty-care'), + '{"en": "Grooming", "zh": "个人护理"}', 'beauty-grooming', 2), + ((SELECT id FROM categories WHERE slug = 'grocery-fresh'), + '{"en": "Snacks", "zh": "休闲零食"}', 'grocery-snacks', 1), + ((SELECT id FROM categories WHERE slug = 'grocery-fresh'), + '{"en": "Fresh Produce", "zh": "生鲜果蔬"}', 'grocery-produce', 2); + +-- Third level: two grandchildren under every second-level category. +INSERT INTO categories (parent_id, name, slug, position) +VALUES + ((SELECT id FROM categories WHERE slug = 'electronics-phones'), + '{"en": "Flagship Phones", "zh": "旗舰机型"}', 'electronics-flagship', 1), + ((SELECT id FROM categories WHERE slug = 'electronics-phones'), + '{"en": "Budget Phones", "zh": "千元机"}', 'electronics-budget', 2), + ((SELECT id FROM categories WHERE slug = 'electronics-audio'), + '{"en": "Earbuds", "zh": "真无线耳机"}', 'electronics-earbuds', 1), + ((SELECT id FROM categories WHERE slug = 'electronics-audio'), + '{"en": "Speakers", "zh": "蓝牙音箱"}', 'electronics-speakers', 2), + ((SELECT id FROM categories WHERE slug = 'fashion-menswear'), + '{"en": "Jackets", "zh": "夹克外套"}', 'fashion-jackets', 1), + ((SELECT id FROM categories WHERE slug = 'fashion-menswear'), + '{"en": "Shirts", "zh": "衬衫"}', 'fashion-shirts', 2), + ((SELECT id FROM categories WHERE slug = 'fashion-bags'), + '{"en": "Backpacks", "zh": "双肩包"}', 'fashion-backpacks', 1), + ((SELECT id FROM categories WHERE slug = 'fashion-bags'), + '{"en": "Luggage", "zh": "旅行箱"}', 'fashion-luggage', 2), + ((SELECT id FROM categories WHERE slug = 'home-kitchen'), + '{"en": "Cookers", "zh": "电饭煲"}', 'home-cookers', 1), + ((SELECT id FROM categories WHERE slug = 'home-kitchen'), + '{"en": "Blenders", "zh": "破壁机"}', 'home-blenders', 2), + ((SELECT id FROM categories WHERE slug = 'home-cleaning'), + '{"en": "Vacuums", "zh": "吸尘器"}', 'home-vacuums', 1), + ((SELECT id FROM categories WHERE slug = 'home-cleaning'), + '{"en": "Air Purifiers", "zh": "空气净化器"}', 'home-purifiers', 2), + ((SELECT id FROM categories WHERE slug = 'computers-laptops'), + '{"en": "Ultrabooks", "zh": "轻薄本"}', 'computers-ultrabooks', 1), + ((SELECT id FROM categories WHERE slug = 'computers-laptops'), + '{"en": "Gaming Laptops", "zh": "游戏本"}', 'computers-gaming', 2), + ((SELECT id FROM categories WHERE slug = 'computers-peripherals'), + '{"en": "Keyboards", "zh": "键盘"}', 'computers-keyboards', 1), + ((SELECT id FROM categories WHERE slug = 'computers-peripherals'), + '{"en": "Monitors", "zh": "显示器"}', 'computers-monitors', 2), + ((SELECT id FROM categories WHERE slug = 'beauty-skincare'), + '{"en": "Serums", "zh": "精华"}', 'beauty-serums', 1), + ((SELECT id FROM categories WHERE slug = 'beauty-skincare'), + '{"en": "Creams", "zh": "面霜"}', 'beauty-creams', 2), + ((SELECT id FROM categories WHERE slug = 'beauty-grooming'), + '{"en": "Shavers", "zh": "剃须刀"}', 'beauty-shavers', 1), + ((SELECT id FROM categories WHERE slug = 'beauty-grooming'), + '{"en": "Hair Care", "zh": "洗发护发"}', 'beauty-haircare', 2), + ((SELECT id FROM categories WHERE slug = 'grocery-snacks'), + '{"en": "Nuts", "zh": "坚果"}', 'grocery-nuts', 1), + ((SELECT id FROM categories WHERE slug = 'grocery-snacks'), + '{"en": "Chocolate", "zh": "巧克力"}', 'grocery-chocolate', 2), + ((SELECT id FROM categories WHERE slug = 'grocery-produce'), + '{"en": "Fruit", "zh": "水果"}', 'grocery-fruit', 1), + ((SELECT id FROM categories WHERE slug = 'grocery-produce'), + '{"en": "Vegetables", "zh": "蔬菜"}', 'grocery-vegetables', 2); diff --git a/apps/api/src/routes/catalog.rs b/apps/api/src/routes/catalog.rs index ff29672..6b309ca 100644 --- a/apps/api/src/routes/catalog.rs +++ b/apps/api/src/routes/catalog.rs @@ -64,8 +64,50 @@ struct ListQuery { category_id: Option, shop_id: Option, q: Option, + sort: Option, + order: Option, } +#[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 { + 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 { + 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, @@ -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, diff --git a/apps/api/tests/catalog.rs b/apps/api/tests/catalog.rs index 9eee5e7..10f9284 100644 --- a/apps/api/tests/catalog.rs +++ b/apps/api/tests/catalog.rs @@ -1,8 +1,8 @@ mod common; use common::{ - client, create_product_with_sku, create_shop, login_admin, make_shop_owner, register_customer, - spawn_app, + category_id_by_slug, client, create_product_with_sku, create_product_with_sku_in_category, + create_shop, login_admin, make_shop_owner, publish_product, register_customer, spawn_app, }; use serial_test::serial; @@ -180,6 +180,100 @@ async fn currency_conversion_math() { assert_eq!(res.status(), 400); } +#[tokio::test] +#[serial] +async fn category_subtree_listing_and_price_sort() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_id = create_shop(&app, &admin, "shop-browse").await; + let owner = make_shop_owner(&app, &admin, &shop_id).await; + + let electronics = category_id_by_slug(&app, "electronics").await; + let phones = category_id_by_slug(&app, "electronics-phones").await; + let flagship = category_id_by_slug(&app, "electronics-flagship").await; + let audio = category_id_by_slug(&app, "electronics-audio").await; + let fashion = category_id_by_slug(&app, "fashion").await; + + // A grandchild, a sibling branch under the same root, and an unrelated root. + let (leaf, _) = + create_product_with_sku_in_category(&app, &owner, "leaf", 5000, 5, Some(&flagship)).await; + let (sibling, _) = + create_product_with_sku_in_category(&app, &owner, "sib", 1000, 5, Some(&audio)).await; + let (unrelated, _) = + create_product_with_sku_in_category(&app, &owner, "other", 3000, 5, Some(&fashion)).await; + for id in [&leaf, &sibling, &unrelated] { + publish_product(&app, &owner, id).await; + } + + fn listed_ids(body: &serde_json::Value) -> Vec { + body["items"] + .as_array() + .unwrap() + .iter() + .map(|p| p["id"].as_str().unwrap().to_string()) + .collect() + } + + // A root category must include its children's and grandchildren's products. + let res = client() + .get(app.url(&format!( + "/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50" + ))) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let body: serde_json::Value = res.json().await.unwrap(); + let listed = listed_ids(&body); + assert!(listed.contains(&leaf), "grandchild product missing from root listing"); + assert!(listed.contains(&sibling), "child product missing from root listing"); + assert!(!listed.contains(&unrelated), "product from another root category leaked in"); + assert_eq!(body["total"], 2, "total must count the subtree, not only the root"); + + // A mid-level category covers its own subtree and nothing else. + let res = client() + .get(app.url(&format!( + "/api/products?category_id={phones}&shop_id={shop_id}" + ))) + .send() + .await + .unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(listed_ids(&body), vec![leaf.clone()]); + + // Price sort orders by the product's lowest active SKU price. + let res = client() + .get(app.url(&format!( + "/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50&sort=price&order=asc" + ))) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(listed_ids(&body), vec![sibling.clone(), leaf.clone()]); + + let res = client() + .get(app.url(&format!( + "/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50&sort=price&order=desc" + ))) + .send() + .await + .unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(listed_ids(&body), vec![leaf.clone(), sibling.clone()]); + + // An unsupported sort is a client error rather than being silently ignored. + let res = client() + .get(app.url("/api/products?sort=bogus")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(body["error"]["code"], "BAD_REQUEST"); +} + #[tokio::test] #[serial] async fn suspended_shop_hidden_from_public_catalog() { diff --git a/apps/api/tests/common/mod.rs b/apps/api/tests/common/mod.rs index ffc6182..c4ea171 100644 --- a/apps/api/tests/common/mod.rs +++ b/apps/api/tests/common/mod.rs @@ -149,6 +149,18 @@ pub async fn create_product_with_sku( slug: &str, price_minor: i64, stock: i32, +) -> (String, String) { + create_product_with_sku_in_category(app, owner_token, slug, price_minor, stock, None).await +} + +/// Same, but placed in `category_id` so category filtering can be exercised. +pub async fn create_product_with_sku_in_category( + app: &TestApp, + owner_token: &str, + slug: &str, + price_minor: i64, + stock: i32, + category_id: Option<&str>, ) -> (String, String) { let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); let res = client() @@ -158,6 +170,7 @@ pub async fn create_product_with_sku( "slug": slug, "name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")}, "description": {"en": "desc en", "zh": "描述"}, + "category_id": category_id, })) .send() .await @@ -183,6 +196,36 @@ pub async fn create_product_with_sku( (product_id, slug) } +/// Publish a draft product so it appears in the public catalog. +pub async fn publish_product(app: &TestApp, owner_token: &str, product_id: &str) { + let res = client() + .post(app.url(&format!("/api/shop/products/{product_id}/publish"))) + .bearer_auth(owner_token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "publish: {:?}", res.text().await); +} + +/// Look up a seeded reference category id by slug. +pub async fn category_id_by_slug(app: &TestApp, slug: &str) -> String { + let res = client() + .get(app.url("/api/categories")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let cats: serde_json::Value = res.json().await.unwrap(); + cats.as_array() + .unwrap() + .iter() + .find(|c| c["slug"] == slug) + .unwrap_or_else(|| panic!("seeded category {slug} missing"))["id"] + .as_str() + .unwrap() + .to_string() +} + /// Full sellable fixture: shop + owner + published product with one SKU. /// Returns (owner_token, shop_id, product_id, sku_id). pub async fn setup_sellable( diff --git a/apps/mall/components/shell/CategoryMenu.vue b/apps/mall/components/shell/CategoryMenu.vue index 8bcd373..5f7b64c 100644 --- a/apps/mall/components/shell/CategoryMenu.vue +++ b/apps/mall/components/shell/CategoryMenu.vue @@ -1,18 +1,32 @@