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
This commit is contained in:
2026-09-17 16:33:22 +00:00
parent 0ceb4a2b25
commit e1a0a5dbdb
19 changed files with 314 additions and 92 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-17
@@ -0,0 +1,61 @@
# 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.
@@ -0,0 +1,33 @@
# Proposal
## Why
Cart, orders, shipments and invoices are the last mocked domains, and they cannot move one at a time: the fixed-data adapter keeps `cart -> orders -> shipments` in one shared state, so a live cart with a mock checkout dies on `EMPTY_CART` and mock invoices 404 on live order ids.
## What Changes
- Flip `cart`, `orders`, `shipments` and `invoices` to live **together**, so one purchase runs end to end.
- **BREAKING** (data): cart and order state moves from `localStorage` to Redis/Postgres; existing mock carts and orders do not carry over.
- Extend the cart line with its shop and stock so the cart keeps grouping per shop and the stepper caps at real stock; `CartItemView` already joins both (`apps/api/src/cart.rs:68-74`).
- Ask an anonymous shopper to sign in rather than fail: a live `addCartItem` answers 401 on a public page, which redirects to `/login?redirect=…`.
- Stop the shared contract lying: `Shipment.items` is required in TS but never returned, and `Invoice.invoice_no` is nullable live but non-null in TS.
- Keep `MOCK_ADDRESSES` behind checkout: live checkout takes the address in the request body.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `cart`: the cart view carries each line's shop and current stock.
- `frontend-mall`: the shopping and transaction flows run against the live API, and an anonymous add-to-cart prompts sign-in.
## Impact
`apps/api/src/cart.rs`; `packages/shared/src/{api,types}.ts`; the mall's `plugins/api.ts`, `nuxt.config.ts`, cart/checkout/order/invoice pages and `middleware/auth.ts`. The contract change means all three frontends rebuild.
## Non-goals
No addresses, favourites, coupons or storefront content. No stock check on add-to-cart; checkout stays the authority. Store names on order surfaces keep their generic fallback until Wave 4.
@@ -0,0 +1,18 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Server-side cart
Authenticated shoppers SHALL have a Redis-backed cart keyed by user id, containing sku_id + qty entries. Reading the cart SHALL return, for every line, the SKU's current price, currency and stock together with the product's name, image and owning shop, so the storefront can group lines by shop and cap quantity without reading the fixed-data catalog. Lines whose SKU has become inactive or unpurchasable SHALL be omitted from the view.
#### Scenario: add and update
- **WHEN** a shopper POSTs sku + qty, then PUTs a new qty
- **THEN** GET /api/cart reflects the latest qty with current price/name snapshot
#### Scenario: unpurchasable SKU rejected
- **WHEN** adding a SKU that is inactive or whose product is not published
- **THEN** the API returns 400
#### Scenario: cart view carries shop and stock
- **WHEN** a shopper reads a cart holding SKUs from more than one shop
- **THEN** each line reports its shop and the SKU's current stock, so the storefront can group the lines per shop and cap quantity from the response alone
@@ -0,0 +1,25 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Shopping flow
A shopper SHALL be able to browse, view detail, add to cart, checkout with a shipping address, pay, track orders/shipments, confirm delivery, and request an invoice against the live API. Cart, order, shipment and invoice state SHALL be the backend's rather than the browser's, and the fixed-data adapter SHALL remain available as a configured fallback rather than the default. Adding to the cart SHALL require an authenticated shopper: an anonymous add SHALL send the shopper to sign in and return them to where they left off.
#### Scenario: end-to-end purchase
- **WHEN** a shopper completes checkout on a non-empty cart
- **THEN** the resulting order appears in the buyer center and the cart is empty
#### Scenario: anonymous add prompts sign-in
- **WHEN** a signed-out shopper adds an in-stock SKU from a product page
- **THEN** they are sent to sign in and, once signed in, returned to that product page
### Requirement: Mock transaction flow
The mall SHALL provide a store-grouped cart, address-selecting checkout preview, payment selection and payment-success result, all reading and writing the live cart and order APIs. Quantity changes, removals, selection totals, checkout and payment SHALL be persisted by the backend for the signed-in shopper, so they survive a page reload.
#### Scenario: complete mock purchase
- **WHEN** a shopper adds an in-stock SKU, checks out with a mock address and confirms a payment
- **THEN** the cart is cleared, the success page is shown and the new order appears in the user order list
#### Scenario: cart survives a reload
- **WHEN** a signed-in shopper adds an item and then reloads the page
- **THEN** the cart still holds that item, priced and stocked from the catalog
@@ -0,0 +1,34 @@
# Tasks
## 1. Contract and backend cart view
- [x] 1.1 Add `shop_id`, `shop_name` and `stock` to `CartItemView` and to the `cart_view` SELECT in `apps/api/src/cart.rs` (the query already joins `products`, `shops` and `skus`); verified `GET /api/cart` returns a distinct shop and the SKU's stock per line for a two-shop cart
- [x] 1.2 Add `shop_id`, `shop_name` and `stock` to `CartItem` in `packages/shared/src/types.ts`. Also had to teach the fixed-data adapter to construct the new fields (`apps/mall/mock/api.ts`) and bump its persisted-state key to `v2`, because a cart saved by an older build is no longer a valid `CartItem[]`; verified the mall, shop-admin and admin builds all pass
- [x] 1.3 Make `Shipment.items` optional and `Invoice.invoice_no` nullable in `packages/shared/src/types.ts`. `Invoice` was declared twice in that file and TypeScript merges duplicate interfaces, so the duplicate was removed for the change to take effect at all; verified against a live shipment (no `items` key) and a requested invoice (`invoice_no: null`)
- [x] 1.4 Extend the two-shop cart test in `apps/api/tests/orders.rs` to assert each line reports its own shop, a bilingual shop name and the SKU stock; verified `cargo test -p vmall-api --test orders` is green
## 2. Anonymous add-to-cart gate
- [x] 2.1 Send a signed-out shopper to `/login?redirect=<current path>` when `addCartItem` answers 401, accepting only same-origin paths; verified the parameter is dropped for `//host` and absolute URLs
- [x] 2.2 Have `pages/login.vue` return the shopper to a valid `redirect` target after a successful sign-in; verified the round trip from a product page
## 3. Cart surface
- [x] 3.1 Group `pages/cart.vue` by the line's own `shop_id` and label each group with `shop_name`, dropping the `productById`/`storeById` lookups; verified a two-shop cart renders two groups labelled with the live names (`Aurora Digital`, `Demo Store`)
- [x] 3.2 Cap the quantity stepper with the line's `stock` instead of the 999 fallback, and use the line's `image` for the thumbnail; verified the caps read `12` and `25` from the live cart rather than 999
## 4. Transaction surfaces
- [x] 4.1 `pages/checkout/index.vue`: group by the cart line's shop and image rather than the mock catalog, keeping `MOCK_ADDRESSES` as the address source; verified the checkout preview groups correctly and the order submits
- [x] 4.2 `pages/checkout/pay.vue` and `pages/user/orders/index.vue`: drop the `storeById` lookups and keep the existing generic store label until Wave 4; verified order cards render with the placeholder and without errors
- [x] 4.3 `pages/user/invoices.vue`: render a placeholder when `invoice_no` is null, mirroring the existing `order_no` handling; verified the invoices table shows `—` for a requested-but-unissued invoice alongside real `INV…` numbers
## 5. Flip the domains
- [x] 5.1 Add `cart`, `orders`, `shipments` and `invoices` to the default `liveDomains` in `apps/mall/nuxt.config.ts` in one commit with the page changes above; verified every one of those pages now reads from `:8080`
## 6. Verification
- [x] 6.1 Run `pnpm --filter @vmall/mall build` plus the shop-admin and admin builds, since the shared contract changed; all three pass
- [x] 6.2 With the backend seeded, ran one purchase in a browser: signed in, added from a product page, saw a two-shop cart group correctly, checked out, paid, then confirmed the order. The merchant half of the chain (ship → issue invoice) has no mall UI and was driven through the API, after which the order page showed the live shipment, confirming delivery moved the order to `completed`, and the invoices page listed the issued numbers. The only console entries were the header's signed-out `GET /api/cart` 401 (caught, count 0) and no hydration warnings
- [x] 6.3 Verified the rollback: with every domain set to fixed data and the backend stopped, sign-in, add-to-cart, cart grouping, the stock cap and checkout all still work from localStorage