Archive add-favorites and split-catalog-into-product-category-brand

- Sync favorites capability spec and frontend-mall live-favorites
  requirement into openspec/specs/
- Split catalog spec into new category and product capability specs;
  remove now-empty catalog spec
- Move both completed changes to openspec/changes/archive/
- Ignore .superpowers/ scratch directory
This commit is contained in:
Chengdong Zhang
2026-09-22 16:27:42 +08:00
parent 13cd598a45
commit 4d2ee3b0bf
18 changed files with 83 additions and 4 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-21
@@ -0,0 +1,43 @@
## Context
The buyer center renders product and shop favorites from `MOCK_FAVORITES`; product and store detail pages toggle a local boolean that resets on navigation or reload. Catalog and store data are already live, authentication is customer-scoped, and the fixed-data adapter must continue implementing the full shared client surface.
## Goals / Non-Goals
**Goals:**
- Persist one customer favorite for a product or shop and enforce ownership and uniqueness in Postgres.
- Support efficient buyer-center lists, previews/counts, and detail-page favorite state without browser joins or N+1 API calls.
- Make add/remove safe to retry and keep live and fixed adapters behaviorally compatible.
**Non-Goals:**
- Folders, notes, sharing, alerts, recommendation signals, merchant analytics, or fixture-data migration.
## Decisions
### Model two explicit nullable targets in one table
`favorites` contains `id`, `user_id`, nullable `product_id`, nullable `shop_id`, and `created_at`. A check constraint requires exactly one target. Two partial unique indexes enforce `(user_id, product_id)` and `(user_id, shop_id)` uniqueness; foreign keys cascade on target or user deletion.
This is preferred to a polymorphic `target_type/target_id`, which cannot enforce both target foreign keys. Separate product/shop tables would duplicate ownership, timestamps, repositories, and list composition for no stronger invariant.
### Use idempotent target-resource mutations
Authenticated customer routes are:
- `GET /api/favorites?kind=product|shop&target_id=<optional>&page=&per_page=`
- `PUT /api/favorites/products/{product_id}` and matching `DELETE`
- `PUT /api/favorites/shops/{shop_id}` and matching `DELETE`
`PUT` returns the existing favorite when already present. `DELETE` succeeds with no body even when absent. Target-specific paths prevent malformed mixed-target request bodies and make detail-page retry behavior deterministic.
### Return paginated, hydrated discriminated unions
The shared `Favorite` type is a `product` or `shop` discriminated union containing favorite metadata and a current public target summary. Product summaries include IDs/slug/localized name/image and lowest active SKU price/currency; shop summaries include the public shop profile fields required by the current card. SQL aggregates the product price in the list query, avoiding one API call per favorite.
Only published products belonging to active shops and active shops themselves appear in lists or can be added. An unavailable target's row remains dormant and reappears if the target is republished; physical target deletion cascades the favorite. `total` counts only visible rows.
### Treat favorite state as authenticated UI state
Product and store detail load a filtered favorite query when signed in, preserve the return URL when sign-in is required, and disable the control while a mutation is in flight. Buyer-center pages use paginated favorite responses for tabs, previews, and counts. The `favorites` domain is added to live picks and the fixed adapter implements identical methods over its fixture-backed state.
## Risks / Trade-offs
- Dormant rows are invisible while a target is unavailable. This preserves user intent across temporary unpublishing but means stored-row count can exceed API `total`.
- Hydrated summaries couple this read model to catalog/shop presentation fields; the benefit is bounded query count and a stable buyer-center contract.
- Idempotent delete cannot tell the UI whether a row previously existed; the UI only needs the resulting unfavorited state.
@@ -0,0 +1,27 @@
## Why
Favorites are the last existing mall UI capability that still reads fixed fixtures directly. Persisting product and shop favorites completes the buyer-center journey and makes the product-detail heart survive reloads without touching checkout or payment.
## What Changes
- Add customer-owned product and shop favorites with database-enforced target shape and per-target uniqueness.
- Add authenticated APIs to list, add, and remove each favorite kind; repeated add/remove operations are idempotent.
- Return current target data with each favorite so the Mall does not issue one request per saved item.
- Replace fixture-derived favorites, buyer-center previews/counts, and the product-detail local heart state with the shared live API contract.
- Keep equivalent fixed-data adapter behavior as the rollback implementation.
## Capabilities
### New Capabilities
- `favorites`: Customer ownership, product/shop target integrity, idempotent mutation, and target-aware listing.
### Modified Capabilities
- `frontend-mall`: Product detail and buyer-center favorites use the selected API adapter instead of local state and `MOCK_FAVORITES`.
## Non-goals
Favorite folders, notes, sharing, notifications, ranking, merchant analytics, bulk mutation, and automatic migration of fixture favorites are excluded.
## Impact
Adds one Postgres migration, a Rust favorites module and customer routes, shared types/API methods, a `favorites` live-domain pick, fixed-adapter parity, and Mall updates for product detail and buyer-center pages.
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Customer-owned favorite targets
A favorite SHALL belong to one authenticated customer and reference exactly one product or one shop. The database SHALL enforce target foreign keys and no more than one favorite per customer and target. Customers SHALL never read or mutate another customer's favorites.
#### Scenario: target shape is enforced
- **WHEN** a favorite row would reference both a product and a shop or neither target
- **THEN** the database rejects the row
#### Scenario: ownership filters every operation
- **WHEN** one customer lists or removes favorites
- **THEN** only that customer's rows are read or changed
### Requirement: Idempotent favorite mutation
An authenticated customer SHALL add or remove a published product belonging to an active shop or an active shop as a favorite. Repeating the same add SHALL return the existing favorite without creating a duplicate, and repeating the same remove SHALL succeed with the target still unfavorited. A missing or unavailable target SHALL return 404 on add.
#### Scenario: repeated product add
- **WHEN** a customer adds the same published product twice
- **THEN** both requests succeed and exactly one favorite row exists
#### Scenario: repeated shop removal
- **WHEN** a customer removes the same shop favorite twice
- **THEN** both requests succeed and no favorite row remains
### Requirement: Paginated target-aware favorite listing
An authenticated customer SHALL list favorites filtered by product or shop kind, with optional target ID and pagination. Each result SHALL be a discriminated favorite containing current public target summary data; product summaries SHALL include the current lowest active SKU price and currency. Unpublished products, products of inactive shops, and inactive shops SHALL be absent, and `total` SHALL count only visible results.
#### Scenario: buyer center loads product favorites
- **WHEN** a customer lists product favorites
- **THEN** each visible row contains product card data without additional per-product requests
#### Scenario: unavailable target is hidden
- **WHEN** a favorited product becomes unpublished
- **THEN** it is absent from the favorite list and total until it becomes publicly available again
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Live customer favorites
The mall SHALL use the shared selected API adapter for product and shop favorite state. Product and store detail controls SHALL load persisted state, require customer authentication, prevent duplicate in-flight mutations, and survive reloads. The buyer-center favorites page and dashboard preview/counts SHALL render paginated live favorite results and remove targets through the API instead of reading `MOCK_FAVORITES` or mutating local-only state.
#### Scenario: product favorite survives reload
- **WHEN** a signed-in shopper favorites a product and reloads its detail page
- **THEN** the favorite control remains selected from backend state
#### Scenario: anonymous favorite requires sign-in
- **WHEN** a signed-out shopper uses a product or store favorite control
- **THEN** the mall sends the shopper to sign in with the current detail URL as the return destination
#### Scenario: remove from buyer center
- **WHEN** a shopper removes a product or shop from the favorites page
- **THEN** the API state, visible list, dashboard preview, and visible count reflect the removal without fixture mutation
#### Scenario: fixed adapter remains functional
- **WHEN** the favorites domain is configured to fixed data
- **THEN** detail controls and buyer-center favorite flows behave deterministically through the same shared client methods
@@ -0,0 +1,30 @@
## 1. Persistence and backend contract
- [x] 1.1 Add migration `0015_favorites.sql` with explicit product/shop foreign keys, exactly-one-target check, cascading deletes, customer indexes, and partial unique indexes for each target kind.
- [x] 1.2 Add shared discriminated favorite summary/query types and `listFavorites`, `addProductFavorite`, `removeProductFavorite`, `addShopFavorite`, and `removeShopFavorite` methods to `@vmall/shared`.
- [x] 1.3 Implement `apps/api/src/modules/favorite/` repository, service, DTO, handlers, and module registration with customer-only target-resource routes.
- [x] 1.4 Implement visible-target validation, idempotent upserts/deletes, ownership filtering, pagination totals, optional target filtering, and SQL-hydrated product/shop summaries.
## 2. Backend behavioral proof
- [x] 2.1 Add isolated API integration coverage for exactly-one-target and uniqueness constraints, customer ownership, missing/unavailable targets, repeated add/remove, product/shop listing, target filtering, pagination, and unavailable-target hiding.
- [x] 2.2 Run the focused favorites integration tests and then run `cargo test -p vmall-api` twice to prove list tests remain green against the non-truncated shared test database.
## 3. Adapter and live-domain wiring
- [x] 3.1 Implement the five favorite client methods in `apps/mall/mock/api.ts` with per-session mutable fixture state and the same idempotent/filter/pagination behavior.
- [x] 3.2 Add the `favorites` domain and exact shared-client method picks to Mall API selection and enable it in the default/live runtime configuration.
- [x] 3.3 Add or adjust bilingual favorite loading, mutation, and failure strings through the existing Mall locale source without introducing per-page hard-coded copy.
## 4. Mall favorite surfaces
- [x] 4.1 Replace product-detail local heart state with authenticated filtered lookup and idempotent live add/remove, preserving the detail URL through sign-in and disabling concurrent clicks.
- [x] 4.2 Replace store-detail local favorite state with the same persisted authenticated behavior for shop targets.
- [x] 4.3 Replace `apps/mall/pages/user/favorites.vue` fixture joins and local deletion with paginated product/shop API results and persisted removal.
- [x] 4.4 Replace buyer-dashboard `MOCK_FAVORITES` preview and count derivation with a bounded live product-favorites query and remove all page-level favorites fixture imports.
## 5. Verification and tracker cleanup
- [x] 5.1 Seed or create a deterministic customer product and shop favorite, run the API plus Mall, and browser-smoke add, reload persistence, buyer-center listing/removal, store favorite, and anonymous sign-in redirect.
- [x] 5.2 Build all three frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`.
- [x] 5.3 Mark Favorites implemented in `docs/TBD-marketing.md`, update the README mock boundary, check every OpenSpec task, and run `openspec change validate add-favorites --strict` plus `openspec validate --all --strict`.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-22
@@ -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.)
@@ -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.
@@ -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").
@@ -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
@@ -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`
@@ -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.