Files
vmall/openspec/changes/replace-mock-api-wave-6/tasks.md
T
james ce3e8db5b1 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
2026-09-17 17:35:28 +00:00

4.1 KiB

Tasks

1. Schema and seed

  • 1.1 Add a migration creating brands (bilingual name, slug, position, active) and products.brand_id nullable with ON DELETE SET NULL; verified the table and column exist after cargo run -p vmall-api
  • 1.2 Seed the six demo brands and assign them to the demo products; verified a re-run is idempotent and all 24 products end up with a brand. The seed re-applies each product body on the 409 path so a re-run converges the assignment, and the brand list is captured in a local const — reading it back through the shared r variable worked once and then broke, because the product loop reassigns it

2. Shared contract

  • 2.1 Add Brand and BrandInput, the optional brand_id on the product payload and ProductUpsertBody, brand_id on ProductListQuery, "sales" on its sort, and sold_count on Product; verified all three frontends build
  • 2.2 Add listBrands(), admin.getBrands() / admin.replaceBrands(list) and fixed-data implementations, and register a brands domain in the per-domain switch. Also taught the fixed-data listProducts to pass brand_id, sort and order through — it had silently ignored all three, so the restored facet rendered but filtered nothing until the rollback check caught it

3. Catalog: brands

  • 3.1 Add public GET /api/brands and admin PUT /api/admin/brands (transactional replace, slug and bilingual-name validation); verified a non-admin is refused and a duplicate slug is a 400
  • 3.2 Add the brand_id filter to listProducts, composing with the other filters, and return brand_id on the payload; verified a brand filter narrows 24 products to 6 and composes with the shop filter
  • 3.3 Accept brand_id in the shop product upsert, on both create and update, so a merchant — and the seed — can set or clear it

4. Catalog: real sales

  • 4.1 Compute sold_count per product from order_items joined to orders in paid, fulfilling, shipped or completed, exposed on the list and detail payloads; verified a product with no paid orders reports zero
  • 4.2 Accept sort=sales with order, keeping the 400 for any other value; verified the order matches the reported counts and that a pending_payment order moves nothing
  • 4.3 Extend apps/api/tests/catalog.rs with brand_filter_and_real_sales, covering the brand filter, the unpaid exclusion, the paid count and the sales order; verified cargo test -p vmall-api is green at 29 tests and repeatable

5. Mall surfaces

  • 5.1 pages/search.vue: restored the brand facet from listBrands() and the sales sort; verified the facet renders only when brands exist and that brand plus category compose
  • 5.2 components/ui/ProductCard.vue: shows the product's real sold_count and no review figure; verified the card reads "N sold" only
  • 5.3 pages/goods/[id].vue: shows the real sold count, and the reviews tab, its summary and reply blocks are gone; verified the page offers only the detail and after-sale tabs, keeps the store card, and still shows the shop's after-sale copy
  • 5.4 Removed the now-unreferenced fabrication cluster from apps/mall/mock/data.tssalesOf, commentCountOf, commentsFor, commentStats, salesRankFor, productDetail, storeDetail and their types. Nothing imported them once the review UI went, and the fixed-data sales sort now orders by sold_count, which is zero there rather than an invented number

6. Verification

  • 6.1 All three frontends build and cargo test -p vmall-api is green at 29 tests. The browser check remains the real gate, since nuxt build does not typecheck
  • 6.2 Verified live in a browser: the search page shows the brand facet and a Sales sort, filtering by a brand narrows 24 products to 6, descending sales puts the sold products first with counts matching the API, product cards show only a real sold count, and the product page has no reviews while keeping its after-sale copy and store card
  • 6.3 Verified the rollback: with every domain on fixed data and the backend stopped, the facet renders the fixed-data brands and filtering by one narrows 24 products to 2