chore(openspec): archive the mock-migration waves and green the spec set
Archive the three completed changes behind replace-mock-api-wave-1/2/3. Each merge applied cleanly to the main specs: - catalog gains the Public product browse requirement (subtree filtering and price sort) - frontend-mall picks up the per-domain adapter, the pinned home page, the discovery-page changes, the live auth panels and the live transaction flows - cart's Server-side cart requirement now documents the shop and stock carried by every line Also replace the TBD Purpose placeholder in all eleven specs with a one-line description of what each capability covers. Those placeholders predate this work and were the only reason `openspec validate --all --strict` reported 0 passed / 11 failed; it now reports 11 passed / 0 failed.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-17
|
||||
@@ -0,0 +1,67 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See `proposal.md` — Why. Three facts shape the approach:
|
||||
|
||||
- `apps/mall/plugins/api.ts` chooses one adapter for the entire app from a single boolean, so nothing can migrate domain-by-domain today.
|
||||
- The mall's browse surfaces never call the `ApiClient` catalog methods at all; 18 files import `~/mock/data` directly (`apps/mall/pages/{index,search}.vue`, `pages/goods/[id].vue`, `components/shell/CategoryMenu.vue`, and others).
|
||||
- The live catalog is far thinner than the mock: `apps/api/migrations/0003_catalog.sql` seeds 3 childless categories, and `scripts/seed-demo.mjs` seeds 4 products and 1 shop, against the mock's 6 categories with children/grandchildren, 24 products, 4 shops and 6 brands.
|
||||
|
||||
`packages/shared/src/api.ts` defines `ApiClient` as flat top-level methods plus `shop` and `admin` sub-objects, which is what makes per-domain composition cheap. `ProductListQuery` currently carries only `page`, `per_page`, `category_id`, `q`, `shop_id`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Make the adapter choice per domain, so `catalog` and `currency` run live while the rest stays on fixed data.
|
||||
- Move every browse surface onto the catalog contract without changing what the UI claims to show.
|
||||
- Make the live catalog good enough that the home page, search and product detail render convincingly.
|
||||
|
||||
**Non-Goals:**
|
||||
- No change to the `shop`/`admin` surfaces — both apps already run live.
|
||||
- No new content model. Banners, promos, quick links, floor advert art and the goods-page comment/coupon/sales rails stay local display-only content.
|
||||
- No category CRUD API. Categories remain read-only reference data.
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Compose the client from a typed per-domain pick map, not a string allowlist.**
|
||||
`plugins/api.ts` builds both clients and overlays the live one, domain by domain:
|
||||
|
||||
```ts
|
||||
const LIVE_PICKS = {
|
||||
auth: (a: ApiClient) => ({ register: a.register, login: a.login, me: a.me }),
|
||||
catalog: (a: ApiClient) => ({ listProducts: a.listProducts, getProduct: a.getProduct, listCategories: a.listCategories }),
|
||||
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
|
||||
// cart, orders, shipments, invoices
|
||||
} satisfies Record<string, (a: ApiClient) => Partial<ApiClient>>;
|
||||
```
|
||||
|
||||
`liveDomains` comes from `runtimeConfig.public.liveDomains` (default `["catalog", "currency"]`). The mock client stays whole and is the base object, so an unmigrated domain cannot regress and a live domain can be rolled back by removing one string.
|
||||
*Alternatives:* a bare `keyof ApiClient[]` string list needs an unsafe index into a union of methods (AGENTS.md forbids `any`); a per-domain boolean in each page pushes the choice into 18 files. The pick map keeps the type checker honest and the config in one place.
|
||||
|
||||
**2. Category subtree filtering becomes a recursive CTE inside the existing query.**
|
||||
`list_products` currently matches `p.category_id = $1` exactly (`apps/api/src/routes/catalog.rs:80`). Replace it with a `WITH RECURSIVE subtree AS (...)` CTE seeded from the requested category, referenced by both the count and the page query, so `items` and `total` cannot drift apart. This is the first recursive CTE in the codebase; at this tree size (3 levels, tens of rows) it is cheaper than a second round trip to resolve ids in Rust.
|
||||
*Alternative:* resolve subtree ids in Rust then bind `= ANY($ids)` — mirrors the mock's `categorySubtreeIds`, but issues two queries and risks the count/list disagreeing.
|
||||
|
||||
**3. Price sort, validated by hand so errors keep the project's shape.**
|
||||
`sort=price` orders by a correlated `(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active)`, with `order` of `asc`/`desc` and `NULLS LAST` so a product without a sellable SKU never sorts to the top. Parse `sort` and `order` as `Option<String>` and whitelist them, returning `ApiError` 400 on anything else — deserializing straight into a serde enum would make axum's own `Query` rejection answer with a body that is not `{"error":{"code","message"}}`, which AGENTS.md requires.
|
||||
|
||||
**4. Child categories ship as a migration, not as demo seed.**
|
||||
The proposal said to grow `seed-demo.mjs` with categories, but there is no category write endpoint (`GET /categories` is the only route), so the script cannot create them. `0003_catalog.sql` already seeds categories as reference data, so a new append-only migration adds the children/grandchildren on the same terms. This is taxonomy, not demo data: it also gives shop-admin a usable category picker for the first time. Products and shops stay in `seed-demo.mjs`, which is idempotent by slug. No test asserts category contents (`apps/api/tests/catalog.rs` does not mention them), so this cannot break the suite.
|
||||
|
||||
**5. Facets without a model are removed, not faked.**
|
||||
The brand facet and the `sales`/`comments` sorts are deleted from `/search`; only newest-first and price remain. Behaviour the spec does claim — the goods-page comment, coupon and sales-rail sections — is preserved and re-declared as local display-only content rather than dropped.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **A per-domain switch can produce incoherent intermediate states** (e.g. live cart with mock catalog posts mock SKU ids that the live DB rejects) → `liveDomains` defaults to `["catalog", "currency"]` only, and `docs/TBD-migrate-wave.md` records that auth and catalog must be live before cart.
|
||||
- **Rewiring 18 files can silently lose page behaviour that relied on mock-only fields** (sales counts, ratings, store cards) → keep those sections on local content, and verify each touched route in a real browser rather than trusting the build.
|
||||
- **The live catalog may still look sparse after the migration** (3 top-level categories until the new migration lands) → seed parity is a Wave 1 task with the same weight as the wiring, not a follow-up.
|
||||
- **Removing the brand facet is a visible regression** → deliberate; recorded as a Wave 4 capability in `docs/TBD-migrate-wave.md`.
|
||||
- **`sort=price` adds a correlated subquery per row** → acceptable at MVP scale; if it becomes hot, denormalise a `min_price_minor` onto `products`.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Ship the category-tree migration and the catalog route changes first; the backend stays additive and backwards compatible (unsorted, exact-category requests keep working).
|
||||
2. Re-run `scripts/seed-demo.mjs` to fill products and shops.
|
||||
3. Flip `liveDomains` to `["catalog", "currency"]`. Rollback is removing a string from that list — no data migration to reverse, because the fixed-data adapter remains complete.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
The mall is the only frontend still on the fixed-data mock adapter; `shop-admin` and `admin` run live. Every mall catalog surface also bypasses the `@vmall/shared` contract — 18 files import `~/mock/data` and no `$api` catalog call exists. The storefront cannot outgrow demo data, and no later domain can flip until real SKU ids come from the live catalog.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Replace the all-or-nothing `mockApi` boolean with a per-domain switch; Wave 1 flips `catalog` and `currency`, leaving auth, cart, orders, shipments and invoices on mock.
|
||||
- Rewire the browse surfaces (home floors, `/search`, `/goods/[id]`, category menu) from `~/mock/data` onto `listCategories`, `listProducts` and `getProduct`.
|
||||
- **BREAKING** (browse UX): remove the brand facet and the `sales`/`comments` sorts, which have no backing model; add `sort=price` with `order=asc|desc`.
|
||||
- Filter public product listing by category **subtree**, as the mock does today, instead of exact `category_id`.
|
||||
- Grow `scripts/seed-demo.mjs` to parity: categories with children and grandchildren, ~24 bilingual products, 4 shops (live: 3 childless categories, 4 products, 1 shop).
|
||||
- Banners, promos, quick links and floor art stay local.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `catalog`: add a public browse requirement — category subtree filtering and an optional price sort on `/api/products`.
|
||||
- `frontend-mall`: "Mock API adapter" becomes a per-domain switch with catalog and currency live; browse surfaces re-sourced from the API without the brand facet or non-price sorts.
|
||||
|
||||
## Impact
|
||||
|
||||
`apps/mall/plugins/api.ts`; 18 files under `apps/mall/{pages,components}`; `apps/mall/mock/data.ts` (marketing content only); `packages/shared/src/api.ts` (`ProductListQuery` gains `sort`/`order`); `apps/api/src/routes/catalog.rs`; `scripts/seed-demo.mjs`. The shared contract change means all three frontends must rebuild.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Auth, cart, orders, shipments and invoices stay mock. No backend capability for brands, storefront content, store directory, favorites, coupons or addresses; those stay mock. No change to money handling, i18n storage or the other apps.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Public product browse
|
||||
Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category **and every category beneath it**, so requesting a parent category returns products assigned to its child and grandchild categories. The listing SHALL accept an optional `sort` of `price` together with an `order` of `asc` or `desc`, ordering by each product's lowest active SKU price; any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`.
|
||||
|
||||
#### Scenario: parent category includes descendant products
|
||||
- **WHEN** a shopper requests products for a category that has child categories holding published products
|
||||
- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category
|
||||
|
||||
#### Scenario: sort by lowest active SKU price
|
||||
- **WHEN** a shopper requests the product list with `sort=price` and `order=asc`
|
||||
- **THEN** products come back ordered by their lowest active SKU price ascending
|
||||
|
||||
#### Scenario: unsupported sort is rejected
|
||||
- **WHEN** a client requests a `sort` value that is not `price`
|
||||
- **THEN** the API responds 400 with an `ApiError` body instead of ignoring the parameter
|
||||
|
||||
#### Scenario: unpublished products never appear
|
||||
- **WHEN** any public listing or filter is applied
|
||||
- **THEN** products that are not `published`, or whose shop is not active, are absent from both `items` and `total`
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Mock API adapter
|
||||
The mall SHALL select its API adapter per domain, so one domain can be served by the live backend while the others stay on fixed data. The mall SHALL still ship a fixed-data adapter implementing the whole `@vmall/shared` API client surface, and the live/fixed choice SHALL be configurable per domain without changing page call sites. The fixed-data adapter SHALL remain able to serve every domain when the live backend is unavailable.
|
||||
|
||||
#### Scenario: mall runs without backend
|
||||
- **WHEN** the mall starts with the API service unavailable and every domain configured to fixed data
|
||||
- **THEN** browsing, cart, checkout, payment, orders and invoice pages return deterministic fixed data and remain functional
|
||||
|
||||
#### Scenario: domains migrate independently
|
||||
- **WHEN** the live backend serves the catalog and currency domains while auth, cart, orders, shipments and invoices remain on fixed data
|
||||
- **THEN** browsing and prices come from the backend while those other flows keep working against fixed data
|
||||
|
||||
### Requirement: Mock PC home page
|
||||
The mall home page SHALL render a hero row composed of a pinned 240px category sidebar on the left and a hero carousel filling the remainder of the 1200px grid, both 450px tall and occupying layout space (not overlaid). The sidebar SHALL list the catalog's top-level categories with up to three child links each; hovering a top-level category SHALL expand the mega-menu panel to the right over the carousel. Banner images SHALL render at fixed 450px height, center-cropped horizontally to the narrower carousel width. Below the hero, the page SHALL render a six-item quick-link strip with promotion tiles and bilingual product floors, where each floor's products come from the catalog API while the banner, promotion, quick-link and floor advert assets remain local content.
|
||||
|
||||
#### Scenario: shopper lands on home
|
||||
- **WHEN** `/` loads
|
||||
- **THEN** the category sidebar is visible to the left of the carousel without any hover or click, the carousel renders center-cropped banners at 450px height, and the quick links, promotions and every non-empty product floor render, with floor products sourced from the catalog API
|
||||
|
||||
#### Scenario: sidebar stays while scrolling
|
||||
- **WHEN** a shopper scrolls the home page beyond 200px
|
||||
- **THEN** the category sidebar remains rendered in the hero row and does not auto-hide
|
||||
|
||||
#### Scenario: expand a category
|
||||
- **WHEN** a shopper hovers a top-level category in the pinned sidebar
|
||||
- **THEN** the mega-menu panel expands to the right, overlaying the carousel with that category's child and grandchild links from the catalog API
|
||||
|
||||
### Requirement: Product discovery pages
|
||||
The mall SHALL provide `/search` with breadcrumb, category and sort controls, a five-column desktop product grid, pagination and an empty state, listing products from the catalog API filtered by the selected category's subtree. The sort control SHALL offer newest-first and price ascending/descending only. It SHALL provide `/goods/[id]` rendering product and SKU data from the catalog API with image gallery/zoom, bilingual name/subtitle, integer-minor-unit prices, attribute and SKU selection, stock-aware quantity, store card, and detail/comments/after-sale tabs whose comment, coupon and sales content stays local display-only content.
|
||||
|
||||
#### Scenario: filter and inspect a product
|
||||
- **WHEN** a shopper filters the search page by a parent category and opens a product
|
||||
- **THEN** products from that category and its descendants are listed, and selecting an in-stock SKU updates the displayed price, stock and cart target from the catalog API
|
||||
@@ -0,0 +1,34 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Backend: catalog browse
|
||||
|
||||
- [x] 1.1 Add an append-only migration seeding child and grandchild categories under the existing `electronics`, `fashion` and `home-living` rows, matching the mock's 3-level shape; verify `GET /api/categories` returns more than 3 rows with populated `parent_id`
|
||||
- [x] 1.2 Replace the exact `p.category_id = $1` filter in `apps/api/src/routes/catalog.rs` with a `WITH RECURSIVE` subtree CTE shared by the count and page queries; verify a parent-category request returns its descendants' products
|
||||
- [x] 1.3 Add optional `sort=price` and `order=asc|desc` to `list_products`, ordering by each product's lowest active SKU price with `NULLS LAST`, and reject any other value with an `ApiError` 400 whose body is `{"error":{"code","message"}}`; verify the 400 shape with curl
|
||||
- [x] 1.4 Extend `apps/api/tests/catalog.rs` with cases for subtree listing, ascending/descending price order and the rejected-sort 400; verify `cargo test -p vmall-api` is green and repeatable
|
||||
|
||||
## 2. Shared contract
|
||||
|
||||
- [x] 2.1 Add optional `sort` and `order` to `ProductListQuery` in `packages/shared/src/api.ts` and pass them through in `createApi.listProducts`; verify `pnpm --filter @vmall/mall build` still type-checks
|
||||
|
||||
## 3. Demo data
|
||||
|
||||
- [x] 3.1 Grow `scripts/seed-demo.mjs` to roughly 24 bilingual products spread across the new category tree plus 4 shops, keeping the slug-lookup idempotency; verify a second run reports no duplicate creates and `GET /api/products?per_page=100` returns a full first page
|
||||
|
||||
## 4. Mall: per-domain adapter switch
|
||||
|
||||
- [x] 4.1 Replace the `mockApi` boolean in `apps/mall/plugins/api.ts` with a `liveDomains` list defaulting to `["catalog", "currency"]`, composing the live client over the fixed-data base through a typed per-domain pick map (no `any`); verify catalog requests hit `:8080` while `getCart` still resolves from fixed data with the backend stopped
|
||||
|
||||
## 5. Mall: rewire browse surfaces
|
||||
|
||||
- [x] 5.1 `apps/mall/pages/index.vue`: build floors from `listCategories` plus `listProducts` per category, leaving banners, promos, quick links and floor advert art local; verify every non-empty floor renders live products
|
||||
- [x] 5.2 `apps/mall/components/shell/CategoryMenu.vue`: source the tree from `listCategories`, keeping the header dropdown mode and the pinned home mode intact; verify the pinned sidebar shows children from the API
|
||||
- [x] 5.3 `apps/mall/pages/search.vue`: list through `listProducts` with the selected category's subtree and the new sort, and delete the brand facet plus the `sales`/`comments` sort options; verify filtering by a parent category lists descendant products
|
||||
- [x] 5.4 `apps/mall/pages/goods/[id].vue`: load the product and SKUs through `getProduct`, keeping the comment, coupon and sales-rail sections on local display-only content; verify an in-stock SKU selection updates price, stock and the cart target
|
||||
- [x] 5.5 Remove only the catalog helpers that lost their last consumer (`topCategories`, `childCategories`, `homeFloors`, `MOCK_BRANDS`, `brandOf`, `HomeFloor`, `MockBrand`) from `apps/mall/mock/data.ts`; amended from the original wording, which also removed `MOCK_PRODUCTS` and would have broken the rollback the `Mock API adapter` requirement promises, so `MOCK_PRODUCTS`/`searchMockProducts`/`productById` stay; verify `pnpm --filter @vmall/mall build` passes with no page importing a removed symbol
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- [x] 6.1 Run `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build` and `pnpm --filter @vmall/admin build`, since the shared contract changed; verify all three pass
|
||||
- [x] 6.2 With the live backend and seeds running, verify in a browser that home, search, product detail and the pinned category menu render live data, and that no console errors appear
|
||||
- [x] 6.3 Stop the backend and confirm the mall still starts and the unmigrated domains (auth, cart, orders, invoices) keep working from fixed data; verify the browse pages degrade without crashing
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-17
|
||||
@@ -0,0 +1,60 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See `proposal.md` — Why. After wave 1 the mall selects adapters per domain through `liveDomains` (`apps/mall/plugins/api.ts`); `auth`, `cart`, `orders`, `shipments` and `invoices` are what remain on fixed data.
|
||||
|
||||
Session truth today lives entirely in `localStorage`: `vmall.token` is read by the plugin's `getToken`, `vmall.user` is read by `middleware/auth.ts`, and both are written by `stores/session.ts`. The fixed-data `me()` returns a constant `MOCK_USER`, so nothing ever proves a stored token is still valid.
|
||||
|
||||
The live contract this wave targets (`apps/api/src/routes/auth.rs`):
|
||||
|
||||
| Endpoint | Behaviour |
|
||||
|---|---|
|
||||
| `POST /auth/register` | 400 on invalid email, password under 8 characters, or blank display name; **409** on duplicate email |
|
||||
| `POST /auth/login` | **401** on bad credentials; returns `{ token, user }` |
|
||||
| `GET /auth/me` | requires a bearer token; returns the `User` |
|
||||
|
||||
`ApiError` already carries `status` and `code` (`packages/shared/src/api.ts:26-34`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Credentials, roles and tokens become the real user's, with failures reported distinctly.
|
||||
- The flip stays confined to the `auth` domain, so every other domain keeps working.
|
||||
|
||||
**Non-Goals:**
|
||||
- No refresh-token or cookie/session-server move: the JWT stays in `localStorage`.
|
||||
- No password-reset backend; `/forgot-password` stays presentational, which is a known gap rather than something this wave fixes.
|
||||
- No change to what the buyer center displays — its data is still fixed.
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Validate a restored token once per load through `me()`, and only clear the session on 401/403.**
|
||||
`middleware/auth.ts` currently trusts `localStorage`, so a stale or forged token reaches protected pages and fails later, further from the cause. A single `me()` call on hydrate puts the check where the session is established.
|
||||
*Alternatives:* a 401 interceptor inside the shared `request()` helper (touches all three apps — a shared-package change outside this wave); validating in every guarded page (one call per navigation).
|
||||
A network failure is deliberately **not** treated as a rejected token, so an unreachable API does not silently log the shopper out.
|
||||
|
||||
**2. The 8-character rule lives in the panel and mirrors the API.**
|
||||
The API is the authority; the client check exists only to avoid a pointless round trip and a generic error. Relaxing the API to accept 6 would weaken the backend and needs a change outside this wave.
|
||||
|
||||
**3. Errors are mapped on `ApiError.status`/`code`, never on message text.**
|
||||
Matching the API's prose would break whenever its wording changes. New strings go in `apps/mall/locales-extra.ts`, because AGENTS.md reserves the shared locale bundle for contract changes.
|
||||
|
||||
**4. The register panel drops the verification-code field.**
|
||||
It is a mandatory no-op: the button only starts a countdown, the value is never sent, and no endpoint issues a code. Keeping it would demand input the API never checks.
|
||||
|
||||
**5. `liveDomains` gains `auth`; rollback stays a one-string edit.**
|
||||
The fixed-data adapter keeps serving auth, so removing the entry restores the previous behaviour with no data migration.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [A 401 from a still-mocked domain could be mistaken for a session failure] → only `me()` drives validation; cart, orders and invoices stay fixed-data and never answer 401.
|
||||
- [Validation adds a round trip before the session is usable] → it runs only when a stored token exists, and it replaces blind trust rather than adding a second source of truth.
|
||||
- [The demo loses "any password works"] → intended and marked **BREAKING**; the seeded `customer@vmall.local` account still demonstrates sign-in.
|
||||
- [The register panel becomes shorter than the backend's own validation] → the panel mirrors the API rule, and the API still enforces it independently.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add `auth` to `liveDomains` and land the panel, session and middleware changes in the same commit, so no user can submit the old 6-character rule against the new API.
|
||||
2. Verify against the running backend with the seeded customer, then a wrong password, then a tampered token.
|
||||
3. Rollback: drop `auth` from `liveDomains`. The fixed-data adapter still serves the domain, and no stored data needs reverting.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Auth is the last thing between the mall and real accounts. The fixed-data adapter accepts any credentials and always returns the same demo user, so login, register and "me" are theatre — and every domain needing a bearer token (cart, orders, invoices) is pinned behind them. `shop-admin` and `admin` already authenticate against the live backend.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Flip the `auth` domain to live via `liveDomains`; cart, orders, shipments and invoices stay on fixed data.
|
||||
- Align the client password rule with the API: the panels require 6 characters, but `/auth/register` requires 8 and answers 400 (`apps/api/src/routes/auth.rs:32`).
|
||||
- Map real failures to distinct messages — 401 for bad credentials, 409 when an email is taken — instead of one generic "request failed".
|
||||
- Drop the register SMS-code field: its button only runs a countdown, there is no endpoint, and the value is never sent, so it is a mandatory no-op once auth is real.
|
||||
- Validate a stored token through `/auth/me` on load rather than trusting `localStorage` alone.
|
||||
- **BREAKING** (auth UX): the demo's "log in as anyone, any password" behaviour ends; wrong credentials now fail.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `frontend-mall`: the "Auth and buyer center" requirement stops being backed by deterministic mock auth and uses the live auth API.
|
||||
|
||||
## Impact
|
||||
|
||||
`apps/mall/plugins/api.ts` and `apps/mall/nuxt.config.ts` (`liveDomains`), `pages/login.vue`, `pages/register.vue`, `stores/session.ts`, `middleware/auth.ts`, and `locales-extra.ts` for the new error strings. No backend or `shop-admin`/`admin` change.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Cart, orders, shipments and invoices stay on fixed data: the mock adapter holds `cart -> orders -> shipments` as one shared state, so flipping cart alone would fail checkout with `EMPTY_CART`. They move together in a later wave. No store, address, favourite or coupon work.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Auth and buyer center
|
||||
The mall SHALL provide B2B2C mall-style login, register and forgot-password panels backed by the live auth API, so credentials, roles and tokens belong to the real user rather than a fixed demo account. Registration SHALL require a password of at least 8 characters, matching the API's rule, and the register panel SHALL NOT ask for a verification code because no endpoint issues one. Failures SHALL be reported distinctly: invalid credentials on sign-in, and an already-registered email on registration. A token restored from storage SHALL be validated against the auth API on load, and a rejected token SHALL clear the session and return the shopper to sign-in. `/user` SHALL render a two-column buyer center with dashboard, order list/detail, addresses, favorites, coupons and invoices.
|
||||
|
||||
#### Scenario: sign in and inspect buyer data
|
||||
- **WHEN** a shopper signs in with valid credentials and opens `/user`
|
||||
- **THEN** the session carries the authenticated user, and the buyer-center shell and its fixed account/order/address/favorite/coupon/invoice data render
|
||||
|
||||
#### Scenario: wrong password rejected
|
||||
- **WHEN** a shopper submits a password that does not match the account
|
||||
- **THEN** sign-in fails with an invalid-credentials message and no session is established
|
||||
|
||||
#### Scenario: short password refused before the API call
|
||||
- **WHEN** a shopper submits a registration password shorter than 8 characters
|
||||
- **THEN** the panel asks for at least 8 characters without calling the API
|
||||
|
||||
#### Scenario: duplicate email reported
|
||||
- **WHEN** a shopper registers an email that already has an account
|
||||
- **THEN** the panel reports that the email is already registered rather than a generic failure
|
||||
|
||||
#### Scenario: rejected token clears the session
|
||||
- **WHEN** a token restored from storage is rejected by the auth API
|
||||
- **THEN** the stored session is cleared and the shopper is returned to sign-in
|
||||
@@ -0,0 +1,28 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Adapter and configuration
|
||||
|
||||
- [x] 1.1 Add `auth` to the default `liveDomains` in `apps/mall/nuxt.config.ts` and confirm `LIVE_PICKS.auth` in `apps/mall/plugins/api.ts` already covers `register`/`login`/`me`; verify a sign-in request reaches `:8080` while `getCart` still resolves from fixed data
|
||||
- [x] 1.2 Confirm the rollback path: with `NUXT_PUBLIC_LIVE_DOMAINS` set to catalog and currency only, sign-in returns the fixed demo user again; verify by signing in with any password
|
||||
|
||||
## 2. Session and route guard
|
||||
|
||||
- [x] 2.1 Add a session action that validates a restored token through `$api.me()`, clearing the stored session and returning to sign-in on 401/403 but leaving the session intact when the API is merely unreachable; verify with a tampered `vmall.token` and again with the backend stopped
|
||||
- [x] 2.2 Make `middleware/auth.ts` rely on the validated session rather than `localStorage` alone; verify a shopper with a stale token lands on `/login` while a valid one reaches `/user`
|
||||
- [x] 2.3 Render the session-dependent header and profile name client-only (`components/shell/TopBar.vue`, `pages/user.vue`); added during implementation because validating the session before hydration made those `localStorage`-backed branches report hydration mismatches that the pre-change code did not; verified by A/B that the sign-in flow now produces no mismatch warnings
|
||||
|
||||
## 3. Sign-in panel
|
||||
|
||||
- [x] 3.1 Map failures on `ApiError.status`/`code` so a 401 reports invalid credentials rather than the current generic message, adding the key to `apps/mall/locales/auth.ts` in en and zh (the per-domain module behind `locales-extra.ts`); verify with a deliberately wrong password
|
||||
|
||||
## 4. Registration panel
|
||||
|
||||
- [x] 4.1 Raise the client password rule to 8 characters with its own message; verify a 7-character password is refused without any network call. `apps/mall/pages/forgot-password.vue` was aligned to the same rule because it shares the `validationPassword` message
|
||||
- [x] 4.2 Remove the verification-code field and its countdown so the form no longer requires it; verify registration submits with display name, email and password only
|
||||
- [x] 4.3 Report a duplicate email (409) distinctly from other failures, adding the key to `apps/mall/locales/auth.ts` in en and zh; verify by registering an address that already exists
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 Run `pnpm --filter @vmall/mall build` and confirm it passes
|
||||
- [x] 5.2 With the backend running and seeded, verify in a browser: sign in as `customer@vmall.local`, see the utility-bar welcome/sign-out state, reach `/user`, and confirm the only console entries are the deliberately triggered 401/409 responses plus a pre-existing hydration warning on the unauthenticated `/user` redirect (A/B verified identical before this change)
|
||||
- [x] 5.3 With every domain set to fixed data and the backend stopped, verify sign-in and browsing still work, so the rollback path is intact
|
||||
@@ -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
|
||||
+25
@@ -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
|
||||
Reference in New Issue
Block a user