feat: persist customer product and shop favorites through the live API

Replace mall fixture favorites with customer-scoped endpoints, and send signed-out shoppers back to the page they left after sign-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chengdong Zhang
2026-09-21 18:51:45 +08:00
co-authored by Cursor
parent 94a64ec712
commit 6c1357ec4d
34 changed files with 1783 additions and 119 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-21
+43
View File
@@ -0,0 +1,43 @@
## Context
The buyer center renders product and shop favorites from `MOCK_FAVORITES`; product and store detail pages toggle a local boolean that resets on navigation or reload. Catalog and store data are already live, authentication is customer-scoped, and the fixed-data adapter must continue implementing the full shared client surface.
## Goals / Non-Goals
**Goals:**
- Persist one customer favorite for a product or shop and enforce ownership and uniqueness in Postgres.
- Support efficient buyer-center lists, previews/counts, and detail-page favorite state without browser joins or N+1 API calls.
- Make add/remove safe to retry and keep live and fixed adapters behaviorally compatible.
**Non-Goals:**
- Folders, notes, sharing, alerts, recommendation signals, merchant analytics, or fixture-data migration.
## Decisions
### Model two explicit nullable targets in one table
`favorites` contains `id`, `user_id`, nullable `product_id`, nullable `shop_id`, and `created_at`. A check constraint requires exactly one target. Two partial unique indexes enforce `(user_id, product_id)` and `(user_id, shop_id)` uniqueness; foreign keys cascade on target or user deletion.
This is preferred to a polymorphic `target_type/target_id`, which cannot enforce both target foreign keys. Separate product/shop tables would duplicate ownership, timestamps, repositories, and list composition for no stronger invariant.
### Use idempotent target-resource mutations
Authenticated customer routes are:
- `GET /api/favorites?kind=product|shop&target_id=<optional>&page=&per_page=`
- `PUT /api/favorites/products/{product_id}` and matching `DELETE`
- `PUT /api/favorites/shops/{shop_id}` and matching `DELETE`
`PUT` returns the existing favorite when already present. `DELETE` succeeds with no body even when absent. Target-specific paths prevent malformed mixed-target request bodies and make detail-page retry behavior deterministic.
### Return paginated, hydrated discriminated unions
The shared `Favorite` type is a `product` or `shop` discriminated union containing favorite metadata and a current public target summary. Product summaries include IDs/slug/localized name/image and lowest active SKU price/currency; shop summaries include the public shop profile fields required by the current card. SQL aggregates the product price in the list query, avoiding one API call per favorite.
Only published products belonging to active shops and active shops themselves appear in lists or can be added. An unavailable target's row remains dormant and reappears if the target is republished; physical target deletion cascades the favorite. `total` counts only visible rows.
### Treat favorite state as authenticated UI state
Product and store detail load a filtered favorite query when signed in, preserve the return URL when sign-in is required, and disable the control while a mutation is in flight. Buyer-center pages use paginated favorite responses for tabs, previews, and counts. The `favorites` domain is added to live picks and the fixed adapter implements identical methods over its fixture-backed state.
## Risks / Trade-offs
- Dormant rows are invisible while a target is unavailable. This preserves user intent across temporary unpublishing but means stored-row count can exceed API `total`.
- Hydrated summaries couple this read model to catalog/shop presentation fields; the benefit is bounded query count and a stable buyer-center contract.
- Idempotent delete cannot tell the UI whether a row previously existed; the UI only needs the resulting unfavorited state.
@@ -0,0 +1,27 @@
## Why
Favorites are the last existing mall UI capability that still reads fixed fixtures directly. Persisting product and shop favorites completes the buyer-center journey and makes the product-detail heart survive reloads without touching checkout or payment.
## What Changes
- Add customer-owned product and shop favorites with database-enforced target shape and per-target uniqueness.
- Add authenticated APIs to list, add, and remove each favorite kind; repeated add/remove operations are idempotent.
- Return current target data with each favorite so the Mall does not issue one request per saved item.
- Replace fixture-derived favorites, buyer-center previews/counts, and the product-detail local heart state with the shared live API contract.
- Keep equivalent fixed-data adapter behavior as the rollback implementation.
## Capabilities
### New Capabilities
- `favorites`: Customer ownership, product/shop target integrity, idempotent mutation, and target-aware listing.
### Modified Capabilities
- `frontend-mall`: Product detail and buyer-center favorites use the selected API adapter instead of local state and `MOCK_FAVORITES`.
## Non-goals
Favorite folders, notes, sharing, notifications, ranking, merchant analytics, bulk mutation, and automatic migration of fixture favorites are excluded.
## Impact
Adds one Postgres migration, a Rust favorites module and customer routes, shared types/API methods, a `favorites` live-domain pick, fixed-adapter parity, and Mall updates for product detail and buyer-center pages.
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Customer-owned favorite targets
A favorite SHALL belong to one authenticated customer and reference exactly one product or one shop. The database SHALL enforce target foreign keys and no more than one favorite per customer and target. Customers SHALL never read or mutate another customer's favorites.
#### Scenario: target shape is enforced
- **WHEN** a favorite row would reference both a product and a shop or neither target
- **THEN** the database rejects the row
#### Scenario: ownership filters every operation
- **WHEN** one customer lists or removes favorites
- **THEN** only that customer's rows are read or changed
### Requirement: Idempotent favorite mutation
An authenticated customer SHALL add or remove a published product belonging to an active shop or an active shop as a favorite. Repeating the same add SHALL return the existing favorite without creating a duplicate, and repeating the same remove SHALL succeed with the target still unfavorited. A missing or unavailable target SHALL return 404 on add.
#### Scenario: repeated product add
- **WHEN** a customer adds the same published product twice
- **THEN** both requests succeed and exactly one favorite row exists
#### Scenario: repeated shop removal
- **WHEN** a customer removes the same shop favorite twice
- **THEN** both requests succeed and no favorite row remains
### Requirement: Paginated target-aware favorite listing
An authenticated customer SHALL list favorites filtered by product or shop kind, with optional target ID and pagination. Each result SHALL be a discriminated favorite containing current public target summary data; product summaries SHALL include the current lowest active SKU price and currency. Unpublished products, products of inactive shops, and inactive shops SHALL be absent, and `total` SHALL count only visible results.
#### Scenario: buyer center loads product favorites
- **WHEN** a customer lists product favorites
- **THEN** each visible row contains product card data without additional per-product requests
#### Scenario: unavailable target is hidden
- **WHEN** a favorited product becomes unpublished
- **THEN** it is absent from the favorite list and total until it becomes publicly available again
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Live customer favorites
The mall SHALL use the shared selected API adapter for product and shop favorite state. Product and store detail controls SHALL load persisted state, require customer authentication, prevent duplicate in-flight mutations, and survive reloads. The buyer-center favorites page and dashboard preview/counts SHALL render paginated live favorite results and remove targets through the API instead of reading `MOCK_FAVORITES` or mutating local-only state.
#### Scenario: product favorite survives reload
- **WHEN** a signed-in shopper favorites a product and reloads its detail page
- **THEN** the favorite control remains selected from backend state
#### Scenario: anonymous favorite requires sign-in
- **WHEN** a signed-out shopper uses a product or store favorite control
- **THEN** the mall sends the shopper to sign in with the current detail URL as the return destination
#### Scenario: remove from buyer center
- **WHEN** a shopper removes a product or shop from the favorites page
- **THEN** the API state, visible list, dashboard preview, and visible count reflect the removal without fixture mutation
#### Scenario: fixed adapter remains functional
- **WHEN** the favorites domain is configured to fixed data
- **THEN** detail controls and buyer-center favorite flows behave deterministically through the same shared client methods
+30
View File
@@ -0,0 +1,30 @@
## 1. Persistence and backend contract
- [x] 1.1 Add migration `0015_favorites.sql` with explicit product/shop foreign keys, exactly-one-target check, cascading deletes, customer indexes, and partial unique indexes for each target kind.
- [x] 1.2 Add shared discriminated favorite summary/query types and `listFavorites`, `addProductFavorite`, `removeProductFavorite`, `addShopFavorite`, and `removeShopFavorite` methods to `@vmall/shared`.
- [x] 1.3 Implement `apps/api/src/modules/favorite/` repository, service, DTO, handlers, and module registration with customer-only target-resource routes.
- [x] 1.4 Implement visible-target validation, idempotent upserts/deletes, ownership filtering, pagination totals, optional target filtering, and SQL-hydrated product/shop summaries.
## 2. Backend behavioral proof
- [x] 2.1 Add isolated API integration coverage for exactly-one-target and uniqueness constraints, customer ownership, missing/unavailable targets, repeated add/remove, product/shop listing, target filtering, pagination, and unavailable-target hiding.
- [x] 2.2 Run the focused favorites integration tests and then run `cargo test -p vmall-api` twice to prove list tests remain green against the non-truncated shared test database.
## 3. Adapter and live-domain wiring
- [x] 3.1 Implement the five favorite client methods in `apps/mall/mock/api.ts` with per-session mutable fixture state and the same idempotent/filter/pagination behavior.
- [x] 3.2 Add the `favorites` domain and exact shared-client method picks to Mall API selection and enable it in the default/live runtime configuration.
- [x] 3.3 Add or adjust bilingual favorite loading, mutation, and failure strings through the existing Mall locale source without introducing per-page hard-coded copy.
## 4. Mall favorite surfaces
- [x] 4.1 Replace product-detail local heart state with authenticated filtered lookup and idempotent live add/remove, preserving the detail URL through sign-in and disabling concurrent clicks.
- [x] 4.2 Replace store-detail local favorite state with the same persisted authenticated behavior for shop targets.
- [x] 4.3 Replace `apps/mall/pages/user/favorites.vue` fixture joins and local deletion with paginated product/shop API results and persisted removal.
- [x] 4.4 Replace buyer-dashboard `MOCK_FAVORITES` preview and count derivation with a bounded live product-favorites query and remove all page-level favorites fixture imports.
## 5. Verification and tracker cleanup
- [x] 5.1 Seed or create a deterministic customer product and shop favorite, run the API plus Mall, and browser-smoke add, reload persistence, buyer-center listing/removal, store favorite, and anonymous sign-in redirect.
- [x] 5.2 Build all three frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`.
- [x] 5.3 Mark Favorites implemented in `docs/TBD-marketing.md`, update the README mock boundary, check every OpenSpec task, and run `openspec change validate add-favorites --strict` plus `openspec validate --all --strict`.