diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 1cb5c88..c88c266 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -86,24 +86,6 @@ pub enum ProductStatus { Unpublished, } -#[derive(Debug, Serialize, sqlx::FromRow)] -pub struct Category { - pub id: Uuid, - pub parent_id: Option, - pub name: serde_json::Value, - pub slug: String, - 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, diff --git a/apps/api/src/modules/brand/dto.rs b/apps/api/src/modules/brand/dto.rs new file mode 100644 index 0000000..07faac0 --- /dev/null +++ b/apps/api/src/modules/brand/dto.rs @@ -0,0 +1,11 @@ +use serde::Serialize; +use uuid::Uuid; + +#[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, +} diff --git a/apps/api/src/modules/brand/handlers.rs b/apps/api/src/modules/brand/handlers.rs new file mode 100644 index 0000000..f0ce29f --- /dev/null +++ b/apps/api/src/modules/brand/handlers.rs @@ -0,0 +1,27 @@ +use axum::{routing::{get, put}, extract::State, Json, Router}; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::state::AppState; + +use super::dto::Brand; +use super::service::{self, BrandInput}; + +pub fn router() -> Router { + Router::new() + .route("/brands", get(list_brands)) + .route("/admin/brands", put(replace_brands)) +} + +async fn list_brands(State(state): State) -> ApiResult>> { + Ok(Json(service::list_brands(&state).await?)) +} + +async fn replace_brands( + State(state): State, + auth: AuthUser, + Json(body): Json>, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::replace_brands(&state, body).await?)) +} diff --git a/apps/api/src/modules/brand/mod.rs b/apps/api/src/modules/brand/mod.rs new file mode 100644 index 0000000..7892b06 --- /dev/null +++ b/apps/api/src/modules/brand/mod.rs @@ -0,0 +1,11 @@ +mod dto; +mod handlers; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/brand/service.rs b/apps/api/src/modules/brand/service.rs new file mode 100644 index 0000000..1866f7e --- /dev/null +++ b/apps/api/src/modules/brand/service.rs @@ -0,0 +1,68 @@ +use crate::error::{unique_conflict, ApiError, ApiResult}; +use crate::state::AppState; + +use super::dto::Brand; + +pub async fn list_brands(state: &AppState) -> ApiResult> { + Ok(sqlx::query_as::<_, Brand>( + "SELECT id, name, slug, position, active FROM brands WHERE active = TRUE ORDER BY position, created_at", + ) + .fetch_all(&state.db) + .await?) +} + +#[derive(serde::Deserialize)] +pub struct BrandInput { + pub slug: String, + pub name: serde_json::Value, + #[serde(default = "default_active")] + pub active: bool, +} + +fn default_active() -> bool { + true +} + +pub async fn replace_brands(state: &AppState, body: Vec) -> ApiResult> { + 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(serde_json::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| unique_conflict(e, format!("duplicate brand slug: {}", brand.slug)))?; + } + tx.commit().await?; + list_brands(state).await +} diff --git a/apps/api/src/modules/category/dto.rs b/apps/api/src/modules/category/dto.rs new file mode 100644 index 0000000..01d5137 --- /dev/null +++ b/apps/api/src/modules/category/dto.rs @@ -0,0 +1,11 @@ +use serde::Serialize; +use uuid::Uuid; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct Category { + pub id: Uuid, + pub parent_id: Option, + pub name: serde_json::Value, + pub slug: String, + pub position: i32, +} diff --git a/apps/api/src/modules/category/handlers.rs b/apps/api/src/modules/category/handlers.rs new file mode 100644 index 0000000..37596dc --- /dev/null +++ b/apps/api/src/modules/category/handlers.rs @@ -0,0 +1,15 @@ +use axum::{extract::State, routing::get, Json, Router}; + +use crate::error::ApiResult; +use crate::state::AppState; + +use super::dto::Category; +use super::service; + +pub fn router() -> Router { + Router::new().route("/categories", get(list_categories)) +} + +async fn list_categories(State(state): State) -> ApiResult>> { + Ok(Json(service::list_categories(&state).await?)) +} diff --git a/apps/api/src/modules/category/mod.rs b/apps/api/src/modules/category/mod.rs new file mode 100644 index 0000000..7892b06 --- /dev/null +++ b/apps/api/src/modules/category/mod.rs @@ -0,0 +1,11 @@ +mod dto; +mod handlers; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/category/service.rs b/apps/api/src/modules/category/service.rs new file mode 100644 index 0000000..a33bbb1 --- /dev/null +++ b/apps/api/src/modules/category/service.rs @@ -0,0 +1,18 @@ +use crate::error::ApiResult; +use crate::state::AppState; + +use super::dto::Category; + +pub(crate) 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 + ) "; + +pub async fn list_categories(state: &AppState) -> ApiResult> { + Ok(sqlx::query_as::<_, Category>( + "SELECT id, parent_id, name, slug, position FROM categories ORDER BY position, slug", + ) + .fetch_all(&state.db) + .await?) +} diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index a553a53..a76c530 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -1,8 +1,9 @@ pub mod account; pub mod address; pub mod billing; +pub mod brand; pub mod cart; -pub mod catalog; +pub mod category; pub mod content; pub mod coupon; pub mod currency; @@ -14,6 +15,7 @@ pub mod health; pub mod identity; pub mod order; pub mod points; +pub mod product; pub mod shop; use axum::Router; @@ -27,7 +29,9 @@ pub fn api_router() -> Router { .merge(address::router()) .merge(identity::router()) .merge(currency::router()) - .merge(catalog::router()) + .merge(product::router()) + .merge(category::router()) + .merge(brand::router()) .merge(content::router()) .merge(cart::router()) .merge(coupon::router()) diff --git a/apps/api/src/modules/catalog/dto.rs b/apps/api/src/modules/product/dto.rs similarity index 100% rename from apps/api/src/modules/catalog/dto.rs rename to apps/api/src/modules/product/dto.rs diff --git a/apps/api/src/modules/catalog/handlers.rs b/apps/api/src/modules/product/handlers.rs similarity index 80% rename from apps/api/src/modules/catalog/handlers.rs rename to apps/api/src/modules/product/handlers.rs index b6c4a0a..058b7e7 100644 --- a/apps/api/src/modules/catalog/handlers.rs +++ b/apps/api/src/modules/product/handlers.rs @@ -1,7 +1,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, - routing::{get, post, put}, + routing::{get, post}, Json, Router, }; use serde::Deserialize; @@ -10,19 +10,16 @@ use uuid::Uuid; use crate::auth::AuthUser; use crate::error::ApiResult; use crate::http::Paged; -use crate::models::{Brand, Category, ProductStatus, Sku}; +use crate::models::{ProductStatus, Sku}; use crate::state::AppState; use super::dto::{ProductBody, ProductWithSkus, PublicListQuery, SkuBody}; -use super::service::{self, BrandInput}; +use super::service; pub fn router() -> Router { Router::new() .route("/products", get(list_products)) .route("/products/{id_or_slug}", get(get_product)) - .route("/categories", get(list_categories)) - .route("/brands", get(list_brands)) - .route("/admin/brands", put(replace_brands)) .route("/shop/products", get(shop_list).post(create_product)) .route("/shop/products/{id}", get(shop_get).put(update_product)) .route("/shop/products/{id}/publish", post(publish)) @@ -44,23 +41,6 @@ async fn get_product( Ok(Json(service::get_public(&state, &id_or_slug).await?)) } -async fn list_categories(State(state): State) -> ApiResult>> { - Ok(Json(service::list_categories(&state).await?)) -} - -async fn list_brands(State(state): State) -> ApiResult>> { - Ok(Json(service::list_brands(&state).await?)) -} - -async fn replace_brands( - State(state): State, - auth: AuthUser, - Json(body): Json>, -) -> ApiResult>> { - auth.require_admin()?; - Ok(Json(service::replace_brands(&state, body).await?)) -} - #[derive(Deserialize)] struct ShopListQuery { page: Option, diff --git a/apps/api/src/modules/catalog/mod.rs b/apps/api/src/modules/product/mod.rs similarity index 100% rename from apps/api/src/modules/catalog/mod.rs rename to apps/api/src/modules/product/mod.rs diff --git a/apps/api/src/modules/catalog/repo.rs b/apps/api/src/modules/product/repo.rs similarity index 100% rename from apps/api/src/modules/catalog/repo.rs rename to apps/api/src/modules/product/repo.rs diff --git a/apps/api/src/modules/catalog/service.rs b/apps/api/src/modules/product/service.rs similarity index 81% rename from apps/api/src/modules/catalog/service.rs rename to apps/api/src/modules/product/service.rs index 8af69bf..62a4f59 100644 --- a/apps/api/src/modules/catalog/service.rs +++ b/apps/api/src/modules/product/service.rs @@ -1,6 +1,7 @@ use crate::error::{unique_conflict, ApiError, ApiResult}; use crate::http::{clamp_page, clamp_per_page, Paged}; -use crate::models::{Brand, Category, Product, ProductStatus, Sku}; +use crate::models::{Product, ProductStatus, Sku}; +use crate::modules::category::service::SUBTREE_CTE; use crate::state::AppState; use uuid::Uuid; @@ -14,12 +15,6 @@ const SOLD_UNITS: &str = "(SELECT COALESCE(SUM(oi.qty), 0) WHERE sk.product_id = p.id AND o.status IN ('paid', 'fulfilling', 'shipped', 'completed'))"; -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 - ) "; - const MIN_PRICE: &str = "(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)"; @@ -111,78 +106,6 @@ pub async fn get_public(state: &AppState, id_or_slug: &str) -> ApiResult ApiResult> { - Ok(sqlx::query_as::<_, Category>( - "SELECT id, parent_id, name, slug, position FROM categories ORDER BY position, slug", - ) - .fetch_all(&state.db) - .await?) -} - -pub async fn list_brands(state: &AppState) -> ApiResult> { - Ok(sqlx::query_as::<_, Brand>( - "SELECT id, name, slug, position, active FROM brands WHERE active = TRUE ORDER BY position, created_at", - ) - .fetch_all(&state.db) - .await?) -} - -#[derive(serde::Deserialize)] -pub struct BrandInput { - pub slug: String, - pub name: serde_json::Value, - #[serde(default = "default_active")] - pub active: bool, -} - -fn default_active() -> bool { - true -} - -pub async fn replace_brands(state: &AppState, body: Vec) -> ApiResult> { - 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(serde_json::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| unique_conflict(e, format!("duplicate brand slug: {}", brand.slug)))?; - } - tx.commit().await?; - list_brands(state).await -} - async fn load_own_product(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { sqlx::query_as::<_, Product>( "SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at diff --git a/openspec/changes/split-catalog-into-product-category-brand/.openspec.yaml b/openspec/changes/split-catalog-into-product-category-brand/.openspec.yaml new file mode 100644 index 0000000..1b9acb7 --- /dev/null +++ b/openspec/changes/split-catalog-into-product-category-brand/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 diff --git a/openspec/changes/split-catalog-into-product-category-brand/design.md b/openspec/changes/split-catalog-into-product-category-brand/design.md new file mode 100644 index 0000000..3fd8cdb --- /dev/null +++ b/openspec/changes/split-catalog-into-product-category-brand/design.md @@ -0,0 +1,40 @@ +## Context + +`apps/api/src/modules/catalog` (dto/handlers/repo/service, 693 lines) currently owns four concepts: `Product`, `Sku`, `Category`, `Brand`. Routes are already flat resource paths (`/api/products`, `/api/categories`, `/api/brands`, `/api/admin/brands`, `/api/shop/products*`) rather than nested under `/api/catalog/*`, so the module boundary is purely internal — no client or `packages/shared` type depends on the Rust module name. `Category` has no other module dependents (checked: `content`, `points`, `group_buying`, `flash_sale` don't reference it). `Brand` is referenced only from `catalog::handlers`/`service`. `Product`/`Sku` are referenced by `favorite`, `flash_sale`, `group_buying` via `crate::models`, so those two stay in the shared `models.rs`. + +## Goals / Non-Goals + +**Goals:** +- Give Product/SKU, Category, and Brand each their own top-level module under `apps/api/src/modules/`, following the existing `dto.rs`/`handlers.rs`/`repo.rs`/`service.rs` shape used by every other module. +- Zero behavior change: identical routes, request/response JSON, SQL, and error codes. +- Keep `Product`/`Sku` in `crate::models` (cross-module dependents); move `Category`/`Brand` out of `models.rs` into their new modules since nothing outside references them. + +**Non-Goals:** +- No API versioning, route renaming, or DB schema/migration changes. +- No behavior changes to sorting, filtering, or the subtree category-matching CTE. +- Not folding Brand into Category — they're kept as separate modules per the chosen split (Option B from exploration), since they have independent admin-replace vs. tree-query shapes. + +## Decisions + +- **Three modules, not two.** Category and Brand are both small, platform-owned lookup tables with no lifecycle, but they don't share logic (recursive CTE vs. flat replace-all transaction) — merging them into one `taxonomy` module would just recreate a smaller version of the same problem. Alternative considered: `product` + `taxonomy` (category+brand together) — rejected per explicit user choice. +- **`Product`/`Sku` stay in `models.rs`; `Category`/`Brand` move out.** Determined by actual cross-module usage (grepped `crate::models::{...}` across all modules): `Sku`/`Product` are imported by `flash_sale`, `group_buying`, `favorite`; `Category`/`Brand` are imported nowhere outside the old `catalog` module. This matches the existing pattern where module-local types (e.g. `Shop`, `Coupon`, `AddressBookEntry`) already live in `models.rs` alongside genuinely shared ones — moving `Category`/`Brand` doesn't fully break that existing pattern, but reduces unnecessary sharing surface for types nothing else touches. +- **`repo.rs`'s `attach_skus` moves to `product/repo.rs` unchanged.** It only touches `Product`/`Sku`/order tables — no category/brand coupling. +- **No spec deltas.** Per `openspec/specs/catalog/spec.md` and `openspec/specs/brand/spec.md`, this change alters no requirement text — those specs describe HTTP-level behavior which is untouched. `docs/tech-specs/rust-api.md` (if it names `modules/catalog`) gets a doc update as part of tasks, not a spec change. + +## Risks / Trade-offs + +- **Import churn** → every file that did `use crate::models::{Brand, Category, Product, ...}` needs updating; mitigated by compiler errors making every missed reference a hard build failure, not a silent bug. +- **Merge/rebase conflicts** for anyone with in-flight branches touching `modules/catalog` → mitigated by doing the move as one atomic commit and merging promptly. +- **sqlx query cache / compile-time verification** (if `sqlx::query!` macros or `.sqlx/` cache are used) → verified `catalog/service.rs` uses `sqlx::query_as::<_, T>(&format!(...))` (runtime-checked, not compile-time macros), so no `cargo sqlx prepare` step is needed. + +## Migration Plan + +1. Create `modules/product`, `modules/category`, `modules/brand` with the moved code (see tasks.md for the file-by-file split). +2. Update `modules/mod.rs` router merge list and `models.rs`. +3. `cargo build` and `cargo test` (integration tests hit HTTP routes, unaffected by internal module names) to confirm zero behavior drift. +4. Delete `modules/catalog/`. +5. Single commit/PR — no incremental rollout needed since this is a same-process, same-deploy internal refactor. Rollback is a plain `git revert`. + +## Open Questions + +- Should `apps/api/tests/catalog.rs` be renamed/split into `product.rs`/`category.rs`/`brand.rs` to mirror the new module boundaries, or left as-is since it already tests via HTTP and doesn't reference Rust module paths? (Default: leave as-is unless the user wants test-file parity too — captured as an optional task.) diff --git a/openspec/changes/split-catalog-into-product-category-brand/proposal.md b/openspec/changes/split-catalog-into-product-category-brand/proposal.md new file mode 100644 index 0000000..f64f430 --- /dev/null +++ b/openspec/changes/split-catalog-into-product-category-brand/proposal.md @@ -0,0 +1,29 @@ +## Why + +`apps/api/src/modules/catalog` currently bundles four distinct concepts — Product, SKU, Category, and Brand — behind one module, one router, and one 392-line service file. Every other module in the codebase (`favorite`, `coupon`, `flash_sale`, `address`, ...) owns exactly one bounded aggregate. `catalog` is the outlier, and it mixes a shop-owned, stateful aggregate (Product/SKU with a publish lifecycle) with platform-owned, admin-curated lookup data (Category tree, Brand list) that has no lifecycle of its own. Splitting now, while the module is still small, avoids compounding the mismatch as more product-side logic (variants, attributes) gets added. + +## What Changes + +- Split `modules/catalog` into three top-level modules: `modules/product` (Product + Sku, publish lifecycle, shop-scoped CRUD), `modules/category` (Category tree, subtree query), and `modules/brand` (Brand list, admin replace-all). +- Move `Category` and `Brand` structs out of the shared `models.rs` into their owning modules' `dto.rs`/models, following the pattern already used by `Shop`, `Coupon`, etc. staying in `models.rs` only where genuinely cross-module (Product/Sku stay in `models.rs` since `flash_sale`/`group_buying`/`favorite` reference them). +- Register the three new modules in `modules/mod.rs`, removing `catalog` from the router merge list. +- No route paths, request/response shapes, or database schema change — `/api/products`, `/api/categories`, `/api/brands`, `/api/admin/brands`, and `/api/shop/products*` are unaffected. +- No SQL query text changes beyond moving them to new files. + +## Capabilities + +No system behavior changes. To keep spec capability boundaries aligned with the new Rust module boundaries, the existing `catalog` capability is re-filed (not behaviorally changed) into two capabilities along the same line the code splits on: + +### New Capabilities +- `product`: product/SKU content, publish lifecycle, shop isolation, SKU pricing, and public product browse/filter/sort — carried over verbatim from `catalog`. +- `category`: the category tree and its use as a browse filter (including subtree matching) — carried over verbatim from `catalog`. + +### Modified Capabilities +- `catalog`: requirements removed (re-filed into `product` and `category` above with identical text/scenarios; no behavior change). `brand` is unaffected and stays a separate capability as it already was. + +## Impact + +- **Code**: `apps/api/src/modules/catalog/*` deleted; replaced by `apps/api/src/modules/product/*`, `apps/api/src/modules/category/*`, `apps/api/src/modules/brand/*`. `apps/api/src/modules/mod.rs` and `apps/api/src/models.rs` updated. +- **Tests**: `apps/api/tests/catalog.rs` stays as-is (integration tests hit HTTP routes, which are unchanged) or is optionally renamed/split to mirror the new module boundaries. +- **API/DB**: none — purely internal Rust restructuring. +- **Frontends / `packages/shared`**: none — no contract change. diff --git a/openspec/changes/split-catalog-into-product-category-brand/specs/catalog/spec.md b/openspec/changes/split-catalog-into-product-category-brand/specs/catalog/spec.md new file mode 100644 index 0000000..a11919f --- /dev/null +++ b/openspec/changes/split-catalog-into-product-category-brand/specs/catalog/spec.md @@ -0,0 +1,77 @@ +## REMOVED Requirements + +### Requirement: Localized product content +Product and category names/descriptions SHALL be stored as JSONB maps keyed by locale (`en`, `zh`). The API MUST return the full map; clients pick the display locale. + +#### Scenario: bilingual round-trip +- **WHEN** a shop owner creates a product with name `{"en": "Mug", "zh": "马克杯"}` +- **THEN** both public detail and shop-admin GET return the identical map + +**Reason**: The `catalog` capability is split so its requirements are filed under the new `product` and `category` capabilities, mirroring the Rust module split (`modules/product`, `modules/category`). No behavior changes. +**Migration**: See `specs/product/spec.md` ("Localized product content") for the identical requirement and scenario. + +### Requirement: Publish lifecycle +Products SHALL have status `draft | published | unpublished`. Only `published` products appear in public list/detail. + +#### Scenario: publish then unpublish +- **WHEN** a product is published +- **THEN** it appears in `GET /api/products` +- **WHEN** it is unpublished +- **THEN** public detail returns 404 and it disappears from listings + +#### Scenario: publish requires sellable SKU +- **WHEN** publishing a product with no active SKU having price > 0 +- **THEN** the API returns 400 + +**Reason**: Re-filed under `product` capability; no behavior change. +**Migration**: See `specs/product/spec.md` ("Publish lifecycle"). + +### Requirement: Shop isolation +Shop-role users SHALL only see and mutate their own shop's products under `/api/shop/products`. + +#### Scenario: cross-shop access denied +- **WHEN** shop owner A requests `/api/shop/products/{id}` of shop B +- **THEN** the API returns 404 + +**Reason**: Re-filed under `product` capability; no behavior change. +**Migration**: See `specs/product/spec.md` ("Shop isolation"). + +### Requirement: SKU pricing +Each SKU SHALL carry `price_minor` (integer minor units) and an ISO `currency` code; stock is a non-negative integer. + +#### Scenario: negative stock rejected +- **WHEN** upserting a SKU with stock < 0 +- **THEN** the API returns 400 + +**Reason**: Re-filed under `product` capability; no behavior change. +**Migration**: See `specs/product/spec.md` ("SKU pricing"). + +### Requirement: Public product browse +Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category **and every category beneath it**, so requesting a parent category returns products assigned to its child and grandchild categories. When `brand_id` is supplied the filter SHALL match that brand and compose with the other filters. The listing SHALL accept an optional `sort` of `price` or `sales`: `price` orders by each product's lowest active SKU price, and `sales` orders by units sold across orders that reached payment, which SHALL also be reported per product as `sold_count`. Any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`. + +#### Scenario: parent category includes descendant products +- **WHEN** a shopper requests products for a category that has child categories holding published products +- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category + +#### Scenario: sort by lowest active SKU price +- **WHEN** a shopper requests the product list with `sort=price` and `order=asc` +- **THEN** products come back ordered by their lowest active SKU price ascending + +#### Scenario: sort by units sold +- **WHEN** a shopper requests the product list with `sort=sales` and `order=desc` +- **THEN** products come back ordered by their `sold_count` descending, and a product with no paid orders reports zero rather than being omitted + +#### Scenario: filter by brand +- **WHEN** a shopper requests products with a `brand_id` alongside a `category_id` +- **THEN** only products matching both filters are returned, and `total` reflects the combined filter + +#### Scenario: unsupported sort is rejected +- **WHEN** a client requests a `sort` value that is neither `price` nor `sales` +- **THEN** the API responds 400 with an `ApiError` body instead of ignoring the parameter + +#### Scenario: unpublished products never appear +- **WHEN** any public listing or filter is applied +- **THEN** products that are not `published`, or whose shop is not active, are absent from both `items` and `total` + +**Reason**: Re-filed under `product` capability (the listing endpoint itself, its lifecycle/shop-isolation/sort/paging contract); the category-subtree matching behavior it depends on is separately documented under the new `category` capability. No behavior change. +**Migration**: See `specs/product/spec.md` ("Public product browse") and `specs/category/spec.md` ("Category subtree browse filtering"). diff --git a/openspec/changes/split-catalog-into-product-category-brand/specs/category/spec.md b/openspec/changes/split-catalog-into-product-category-brand/specs/category/spec.md new file mode 100644 index 0000000..5ddae8b --- /dev/null +++ b/openspec/changes/split-catalog-into-product-category-brand/specs/category/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Localized category content +Category names SHALL be stored as JSONB maps keyed by locale (`en`, `zh`). The API MUST return the full map; clients pick the display locale. + +#### Scenario: bilingual round-trip +- **WHEN** an admin creates a category with name `{"en": "Mugs", "zh": "马克杯"}` +- **THEN** `GET /api/categories` returns the identical map for that category + +### Requirement: Category subtree browse filtering +`GET /api/categories` SHALL return the full category tree as a flat list, each with `id`, `parent_id`, `name`, `slug`, and `position`, ordered by `position` then `slug`. When a product listing is filtered by `category_id`, the filter SHALL match that category **and every category beneath it** in the tree, so requesting a parent category returns products assigned to its child and grandchild categories. + +#### Scenario: parent category includes descendant products +- **WHEN** a shopper requests products for a category that has child categories holding published products +- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category diff --git a/openspec/changes/split-catalog-into-product-category-brand/specs/product/spec.md b/openspec/changes/split-catalog-into-product-category-brand/specs/product/spec.md new file mode 100644 index 0000000..4587c6f --- /dev/null +++ b/openspec/changes/split-catalog-into-product-category-brand/specs/product/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: Localized product content +Product names/descriptions SHALL be stored as JSONB maps keyed by locale (`en`, `zh`). The API MUST return the full map; clients pick the display locale. + +#### Scenario: bilingual round-trip +- **WHEN** a shop owner creates a product with name `{"en": "Mug", "zh": "马克杯"}` +- **THEN** both public detail and shop-admin GET return the identical map + +### Requirement: Publish lifecycle +Products SHALL have status `draft | published | unpublished`. Only `published` products appear in public list/detail. + +#### Scenario: publish then unpublish +- **WHEN** a product is published +- **THEN** it appears in `GET /api/products` +- **WHEN** it is unpublished +- **THEN** public detail returns 404 and it disappears from listings + +#### Scenario: publish requires sellable SKU +- **WHEN** publishing a product with no active SKU having price > 0 +- **THEN** the API returns 400 + +### Requirement: Shop isolation +Shop-role users SHALL only see and mutate their own shop's products under `/api/shop/products`. + +#### Scenario: cross-shop access denied +- **WHEN** shop owner A requests `/api/shop/products/{id}` of shop B +- **THEN** the API returns 404 + +### Requirement: SKU pricing +Each SKU SHALL carry `price_minor` (integer minor units) and an ISO `currency` code; stock is a non-negative integer. + +#### Scenario: negative stock rejected +- **WHEN** upserting a SKU with stock < 0 +- **THEN** the API returns 400 + +### Requirement: Public product browse +Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category and every category beneath it (per the `category` capability's subtree matching). When `brand_id` is supplied the filter SHALL match that brand and compose with the other filters. The listing SHALL accept an optional `sort` of `price` or `sales`: `price` orders by each product's lowest active SKU price, and `sales` orders by units sold across orders that reached payment, which SHALL also be reported per product as `sold_count`. Any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`. + +#### Scenario: parent category includes descendant products +- **WHEN** a shopper requests products for a category that has child categories holding published products +- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category + +#### Scenario: sort by lowest active SKU price +- **WHEN** a shopper requests the product list with `sort=price` and `order=asc` +- **THEN** products come back ordered by their lowest active SKU price ascending + +#### Scenario: sort by units sold +- **WHEN** a shopper requests the product list with `sort=sales` and `order=desc` +- **THEN** products come back ordered by their `sold_count` descending, and a product with no paid orders reports zero rather than being omitted + +#### Scenario: filter by brand +- **WHEN** a shopper requests products with a `brand_id` alongside a `category_id` +- **THEN** only products matching both filters are returned, and `total` reflects the combined filter + +#### Scenario: unsupported sort is rejected +- **WHEN** a client requests a `sort` value that is neither `price` nor `sales` +- **THEN** the API responds 400 with an `ApiError` body instead of ignoring the parameter + +#### Scenario: unpublished products never appear +- **WHEN** any public listing or filter is applied +- **THEN** products that are not `published`, or whose shop is not active, are absent from both `items` and `total` diff --git a/openspec/changes/split-catalog-into-product-category-brand/tasks.md b/openspec/changes/split-catalog-into-product-category-brand/tasks.md new file mode 100644 index 0000000..311c6e6 --- /dev/null +++ b/openspec/changes/split-catalog-into-product-category-brand/tasks.md @@ -0,0 +1,42 @@ +## 1. Create `modules/category` + +- [x] 1.1 Create `apps/api/src/modules/category/mod.rs` (mirror `catalog/mod.rs` shape: `mod dto; mod handlers; pub mod service;` + `router()`). +- [x] 1.2 Move `Category` struct from `models.rs` into `category/dto.rs` (or a `category/models.rs` if the module needs more than one type later). +- [x] 1.3 Move `list_categories` (the `SELECT id, parent_id, name, slug, position FROM categories ORDER BY position, slug` query) from `catalog/service.rs` into `category/service.rs`. +- [x] 1.4 Move the `SUBTREE_CTE` constant (`WITH RECURSIVE subtree AS (...)`) into `category` — expose it as `pub(crate) const SUBTREE_CTE` (or a small helper fn) so `product/service.rs` can still build the category-subtree-filtered product query. +- [x] 1.5 Move the `GET /categories` route + handler from `catalog/handlers.rs` into `category/handlers.rs`. + +## 2. Create `modules/brand` + +- [x] 2.1 Create `apps/api/src/modules/brand/mod.rs` (same shape as above). +- [x] 2.2 Move `Brand` struct from `models.rs` into `brand/dto.rs`. +- [x] 2.3 Move `list_brands`, `BrandInput`, `default_active`, and `replace_brands` (including its slug/bilingual-name validation and the transactional delete+reinsert) from `catalog/service.rs` into `brand/service.rs`. +- [x] 2.4 Move the `GET /brands` and `PUT /admin/brands` routes + handlers (`list_brands`, `replace_brands`) from `catalog/handlers.rs` into `brand/handlers.rs`. + +## 3. Create `modules/product` + +- [x] 3.1 Create `apps/api/src/modules/product/mod.rs` (same shape as above). +- [x] 3.2 Move `product/dto.rs`: `ProductWithSkus`, `PublicListQuery`, `SortBy` (+ `sort_by()`/`ascending()`), `ProductBody`, `SkuBody` from `catalog/dto.rs` unchanged. +- [x] 3.3 Move `product/repo.rs`: `attach_skus` and `SoldRow` from `catalog/repo.rs` unchanged (only touches `Product`/`Sku`/order tables, no category/brand coupling). +- [x] 3.4 Move `product/service.rs`: `list_public`, `get_public`, `load_own_product`, `list_shop_products`, `get_shop_product`, `validate_product_body`, `create_product`, `update_product`, `transition`, `upsert_sku`, plus the `SOLD_UNITS`, `MIN_PRICE`, `PRODUCT_COLS` constants — import `category`'s `SUBTREE_CTE` instead of redefining it locally. +- [x] 3.5 Move `product/handlers.rs`: `/products`, `/products/{id_or_slug}`, `/shop/products`, `/shop/products/{id}`, `/shop/products/{id}/publish`, `/shop/products/{id}/unpublish`, `/shop/products/{id}/skus` routes + handlers, and the `ShopListQuery` struct. +- [x] 3.6 Keep `Product` and `Sku` structs in `crate::models` (do not move) — confirmed cross-module dependents in `favorite`, `flash_sale`, `group_buying`. + +## 4. Wire up and remove the old module + +- [x] 4.1 In `apps/api/src/modules/mod.rs`: replace `pub mod catalog;` with `pub mod product; pub mod category; pub mod brand;`, and replace `.merge(catalog::router())` with `.merge(product::router()).merge(category::router()).merge(brand::router())`. +- [x] 4.2 Update `apps/api/src/models.rs` to remove the (now-moved) `Category` and `Brand` structs. +- [x] 4.3 `rg -n "modules::catalog|catalog::"` across `apps/api/src` and fix any remaining references. +- [x] 4.4 Delete `apps/api/src/modules/catalog/`. + +## 5. Verify + +- [x] 5.1 `cargo build` (or `cargo check`) in `apps/api` — zero errors. +- [x] 5.2 `cargo clippy` — zero new warnings. +- [x] 5.3 `cargo test` — `apps/api/tests/catalog.rs` and the full suite pass unchanged (routes/behavior are identical). +- [x] 5.4 Manually diff route list (`rg "\.route\(" apps/api/src/modules/{product,category,brand}`) against the original `catalog/handlers.rs` router to confirm no route was dropped or duplicated. +- [x] 5.5 `rg -n "modules/catalog|modules::catalog"` across `docs/` (e.g. `docs/tech-specs/rust-api.md`) and update any references to the old module name. + +## 6. Optional test-file parity + +- [ ] 6.1 (Optional, per design.md open question) Rename/split `apps/api/tests/catalog.rs` into `tests/product.rs`, `tests/category.rs`, `tests/brand.rs` to mirror the new module boundaries — only if desired; not required since these are HTTP-level integration tests unaffected by internal module names.