Files
vmall/openspec/changes/replace-mock-api-wave-3/design.md
T
james e1a0a5dbdb feat(mall): run the transaction chain against the live API
Wave 3 of replacing the fixed-data mock adapter: cart, orders, shipments and
invoices flip together, so one purchase runs end to end against the backend.

- cart: CartItemView carries the line's shop and the SKU's stock, so the cart
  keeps grouping per shop and the quantity stepper caps at real stock instead
  of a hard-coded 999
- contract: Shipment.items is optional and Invoice.invoice_no nullable, both
  matching what the API actually returns. Invoice was declared twice in
  types.ts and TypeScript merges duplicate interfaces, so the duplicate had to
  go for the change to take effect at all
- an anonymous add-to-cart redirects to /login?redirect=..., and sign-in
  honours only same-origin paths
- the fixed-data adapter learns the new cart fields, and its persisted state
  key moves to v2 because a cart saved by an older build is no longer valid
- order surfaces drop their storeById lookups and keep the generic store label
  until the public store read arrives

Verified end to end: two-shop cart grouping with live shop names, stock caps
read from the API, checkout, payment, shipment, delivery confirmation and an
issued invoice. Rollback re-verified with every domain on fixed data and the
backend stopped.

Also checks off Wave 3 in docs/TBD-migrate-wave.md and re-points that file at
the mock content that remains.

OpenSpec change: openspec/changes/replace-mock-api-wave-3
2026-09-17 16:33:22 +00:00

62 lines
5.3 KiB
Markdown

# Design
## Context
See `proposal.md` — Why. After waves 1 and 2 the mall serves catalog, currency and auth live through the per-domain `liveDomains` switch (`apps/mall/plugins/api.ts`); cart, orders, shipments and invoices remain on fixed data. Facts that shape this design:
- The fixed-data adapter holds `state.cart -> state.orders -> state.shipments` in one localStorage blob (`apps/mall/mock/api.ts:31-43`): `checkout()` reads `state.cart` (`:194`), `requestInvoice()` looks up `state.orders` (`:285`), `listMyShipments()` returns `state.shipments` (`:282`).
- The live cart is Redis-backed per user; `cart_view` already joins products and shops (`apps/api/src/cart.rs:68-74`) but selects only `price_minor` and `currency`.
- The live cart checks purchasability, not stock (`apps/api/src/routes/cart.rs:31-47`); checkout is where stock is enforced, answering 409.
- `packages/shared/src/types.ts` requires `Shipment.items` and a non-null `Invoice.invoice_no`; the API returns neither (`apps/api/src/models.rs:172-182`, `:192-205`).
- Order and payment pages already fall back to a generic store label when `storeById` misses (`pages/user/orders/index.vue:94`, `pages/checkout/pay.vue:42`), so live orders degrade rather than break.
- `pages/goods/[id].vue` is public and is not behind `middleware/auth.ts`.
## Goals / Non-Goals
**Goals:**
- One purchase runs end to end against the backend: cart → per-shop orders → payment → shipment → invoice.
- The cart keeps grouping per shop and capping quantity at real stock.
- The shared contract stops describing fields the API never sends.
**Non-Goals:**
- No stock check on add-to-cart; checkout stays the authority.
- No store names on order or shipment surfaces — the Wave 4 public store read owns that.
- No addresses model, and no deletion of the mock cart/order code, which the rollback path needs.
## Decisions
**1. The four domains flip together, in dependency order.**
They share entities, so any subset leaves the mock half reading state the live half never writes. Cart must precede orders, orders precede shipments and invoices, and the flip lands in a single commit so no shopper can reach a live cart in front of a mock checkout.
*Alternative:* bridge a live cart into the mock's order state — rejected as throwaway code that would still be wrong for invoice lookups.
**2. Add `shop_id`, `shop_name` and `stock` to `CartItemView` rather than reading the mock catalog.**
The join already exists (`cart.rs:68-74`), so this is three columns. Without it every live cart line collapses into one `"unknown"` group (`pages/cart.vue:31-42`) and the quantity stepper falls back to a hard-coded 999 (`pages/cart.vue:60`) — both visible regressions of things the UI currently does correctly.
*Alternative:* derive the shop client-side — impossible, a live cart line carries no shop.
**3. Stock stays advisory in the cart.**
Exposing `stock` lets the stepper cap, matching the mock's behaviour, but the API deliberately does not re-check it when adding: two shoppers can race regardless, and checkout's 409 is the real gate. Recorded explicitly so nobody mistakes the cart for a stock reservation.
**4. An anonymous add-to-cart redirects to `/login?redirect=…`.**
The alternative — an inline "sign in to buy" panel — still leaves the shopper to find sign-in themselves, and a return path is needed either way. The `redirect` value is accepted only as a same-origin path, so it cannot become an open redirect.
**5. Fix the contract by relaxing the types, not by inventing API fields.**
`Shipment.items` becomes optional and `Invoice.invoice_no` becomes nullable. Nothing consumes shipment items, and the API genuinely does not send them, so adding fields nobody reads would be speculative. The invoices table renders a placeholder for a null number, mirroring how it already handles a missing `order_no` (`pages/user/invoices.vue:46`).
**6. Keep the fixed-data cart and order code.**
The `Mock API adapter` requirement promises the adapter can still serve every domain, so deleting it would break the documented rollback. This wave adds nothing to it, and touches no requirement that waves 1 or 2 modify — so archive order cannot clobber their text.
## Risks / Trade-offs
- [Shoppers lose their existing mock cart and orders] → intended and marked BREAKING; the localStorage blob is left untouched, so rolling `liveDomains` back restores it.
- [Exposed stock can go stale between read and checkout] → advisory by design (decision 3); checkout remains authoritative.
- [Order surfaces lose real store names] → pre-existing fallback to a generic label, unchanged here, owned by Wave 4.
- [A live cart needs a token while the product page is public] → decision 4 is the gate, verified explicitly for a signed-out shopper.
- [Four domains moving at once is a large diff] → the milestone order in `tasks.md` keeps the backend additive and each verification step independent.
## Migration Plan
1. Backend and contract first: extend `cart_view`'s selected columns and the shared types. Both are additive and still serve the fixed-data path.
2. Flip the four domains in `liveDomains` in the same commit as the page changes.
3. Verify one purchase end to end, then verify the all-fixed-data rollback with the backend stopped.
4. Rollback: remove the four names from `liveDomains`; no data migration to reverse.