Files
vmall/docs/TBD-migrate-wave.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

8.0 KiB
Raw Blame History

TBD — migrate the mall off the mock API (waves 4+)

Waves 13 are captured in openspec/changes/replace-mock-api-wave-{1,2,3}/: catalog + currency, auth, and the transaction chain (cart, orders, shipments, invoices). Every domain the mall had an API for is now live; what remains in Wave 4 is the mock content that never had a backend behind it.

How to use: check a box only once the behaviour is implemented and verified against the live backend (cargo run -p vmall-api, node scripts/seed-demo.mjs).

Delete this file once every Wave 4 box is checked, or consciously dropped and recorded. The "deliberately out of scope" list at the bottom does not block deleting it.


Wave 2 — auth (done)

Captured in openspec/changes/replace-mock-api-wave-2/; auth is live, with the session validated through /auth/me rather than trusted from localStorage.

  • Flip auth to live and verify login / register / me against :8080 using the seeded customer@vmall.local / customer123.
  • Confirm bad credentials now produce a real 401 — the mock accepted any input (apps/mall/mock/api.ts:125), so this is a deliberate UX change.
  • Confirm the JWT round-trips through the vmall.token localStorage key, shared with shop-admin and admin, and that logout clears it.

Wave 3 — the transaction chain: cart + orders + shipments + invoices

These move together, not one at a time. The mock adapter keeps state.cart -> state.orders -> state.shipments in a single shared state, so a partial flip leaves the mock half reading state the live half never populates:

  • live cart + mock checkout() fails with EMPTY_CART (apps/mall/mock/api.ts:194),

  • mock requestInvoice() 404s on any live order id (mock/api.ts:285),

  • mock listMyShipments() returns shipments whose order_id matches no live order, so the shipment block is silently empty (mock/api.ts:282, pages/user/orders/[id].vue:25).

  • Flip cart, orders, shipments and invoices in the same change, then verify one purchase end to end: add to cart, check out into per-shop orders, pay, ship, confirm delivery, request an invoice. Done in replace-mock-api-wave-3; the merchant half was driven through the API because the mall has no merchant UI.

  • Verify cancel restores stock and payOrder only accepts pending_payment. Cancel and stock restore are asserted by cancel_rules_and_stock_restore; pay_order is a status-guarded UPDATE ... AND status = 'pending_payment' that answers 409 otherwise (apps/api/src/routes/orders.rs:279-287).

  • Verify a company invoice requires a tax number, and that one order can hold only one active invoice. Asserted by invoice_lifecycle (400 without a tax number, 409 on the second invoice).

  • Remove cart's mock display coupling. CartItemView now carries shop_id, shop_name and stock (apps/api/src/cart.rs), and pages/cart.vue groups by them.

  • Decide what caps cart quantity. CartItem.stock is exposed and the stepper caps at it, but the API deliberately does not check stock on add — checkout stays authoritative with its 409.

  • Gate add-to-cart for anonymous shoppers: pages/goods/[id].vue sends a 401 to /login?redirect=…, and pages/login.vue honours only same-origin paths.

  • Checkout keeps sourcing shipping_address from MOCK_ADDRESSES — intentional; see the out-of-scope note below.

  • Fix contract debt: Shipment.items is optional and Invoice.invoice_no is nullable, matching what the API returns. Invoice was declared twice in packages/shared/src/types.ts and TypeScript merges duplicate interfaces, so the duplicate had to go for the change to take effect.

  • Remove the remaining storeById mock usage on the order pages. They animate the generic store label instead; the real names need the public store read below.

  • Confirm the mall still renders when the live API is down, with every domain configured to fixed data.

Wave 4 — the mock content that never had an API

Each is a new backend capability rather than a domain flip.

  • 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.
  • 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 — 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.
  • 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.

Deliberately out of scope — not tracked here, and they do not block deleting this file

These have no API contract and no backend model. Leaving them on ~/mock/data is a decision, not a backlog item.

  • 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)

  • Category filtering is by subtree; the backend exact-match filter was the bug (fixed in Wave 1).
  • A migration wave must not change what the UI claims: facets without a backing model are removed rather than left matching nothing.
  • The mall is the last mock holdout; shop-admin and admin already run live against the same backend.
  • Auth flips independently, but the transaction domains do not: the mock's shared cart/order/shipment state makes any partial flip fail loudly.

Known gap: the frontend builds do not typecheck

pnpm --filter @vmall/<app> build runs nuxt build, which does not typecheck: nuxt.config.ts sets no typescript.typeCheck and vue-tsc is not installed. AGENTS.md describes the build as the type gate, so a passing build has been read as type-safe across waves 15; it is not. Wave 5 found this the hard way — export { lowestSku } from "~/utils/product" creates no local binding, so every internal caller broke at runtime while the build stayed green.

Cheap fix when someone wants it: add vue-tsc + typescript as devDependencies, set typescript: { typeCheck: true }, and expect a backlog of pre-existing errors on first run. Until then treat the browser check, not the build, as the thing that proves a page works.