feat(mall): restore real brand and sales facets

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

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

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

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

OpenSpec change: openspec/changes/replace-mock-api-wave-6
This commit is contained in:
2026-09-17 17:35:28 +00:00
parent 65cea42da6
commit ce3e8db5b1
20 changed files with 487 additions and 193 deletions
+3 -2
View File
@@ -50,8 +50,8 @@ Each is a new backend capability rather than a domain flip.
- [x] **Storefront content** — banners, promos, quick links and floor advert art. Done in `replace-mock-api-wave-4`: four tables seeded from the existing assets, a public `GET /api/content/home`, and an admin read/replace pair.
- [x] **Public store read** — a buyer-facing shop endpoint so `stores/index`, `stores/[id]` and the cart's shop grouping leave mock. Done in `replace-mock-api-wave-5`: a `shop_profiles` table, `GET /api/shops` + `GET /api/shops/{slug}`, an admin upsert, and the order surfaces now name their shop. Two things went rather than being faked: `distanceKm` (no geo model) and the store home's "best sellers" rail plus its sales/comments sorts (no sales model).
- [ ] **Brand model + sales/comments sorts**restores the brand facet and the sorts removed in Wave 1. Needs a `brands` table (`products.brand_id` + i18n) plus sales and comments data, neither of which exists today.
- [ ] Extend the `ORDER BY` whitelist if more sorts are wanted beyond the `sort=price` added in Wave 1.
- [x] **Brand model + sales/comments sorts**Done in `replace-mock-api-wave-6` for the two that have a model: a `brands` table with a product column, a public read and an ordered admin replace, so the search facet is back; and `sort=sales` computed from `order_items` over orders that reached payment, with a real `sold_count` on every product payload. **Comments are not done and cannot be**: there is no reviews model, so the review UI went rather than staying as invented reviewers and ratings. Reviews are future work — see the out-of-scope note below.
- [x] Extend the `ORDER BY` whitelist. Nothing more is wanted: `price` and `sales` are the two orderings with a model behind them, and any other value is a 400 by design.
- [ ] *(adjacent, not part of the migration)* Move the session token to a cookie so SSR knows whether anyone is signed in. Today a full page load of a guarded route renders the page and then redirects on the client, which logs a hydration mismatch; it is pre-existing (verified identical before Wave 2) and harmless, but it is the real fix for the `ClientOnly` workarounds in `components/shell/TopBar.vue` and `pages/user.vue`.
---
@@ -62,6 +62,7 @@ These have no API contract and no backend model. Leaving them on `~/mock/data` i
- **Addresses** — never a blocker: `Address` is embedded in the order and live checkout takes it in the request body, so no addresses table is needed. `MOCK_ADDRESSES` can stay behind checkout indefinitely.
- **Favorites, coupons, account stats** — pure presentation, no transactional impact.
- **Reviews** — the mall no longer presents any: the card's review count and the product detail page's reviews tab, summary and replies were removed in Wave 6 rather than kept as invented reviewers and ratings. Writing, moderating and displaying reviews is a feature with its own lifecycle, not a migration.
- **seckill / collective / integral marketing pages** — display-only mock content.
## Decisions already made (do not relitigate)