feat(mall): read the store directory from the API
Wave 5: the store directory, store home and product-page store card stop reading
MOCK_STORES, and the payment and order surfaces name their shop.
- a `shop_profiles` table beside `shops`, so the identity model both consoles
consume is untouched, with a public `GET /api/shops` and `GET /api/shops/{slug}`
and an admin `PUT /api/admin/shops/{id}/profile`
- a shop with no profile is still listed, with the fields absent rather than
invented; the pages guard every block, and a missing logo renders an
initial-letter placeholder
- `scripts/seed-demo.mjs` upserts a profile per demo shop, since profiles hang
off shops that script creates
- payment and order pages resolve shop ids to names from one cached shop read,
retiring the generic "Shop" label
- three things went rather than being faked, following the wave-1 precedent:
`distanceKm` and its sort (no geo model), the store home's sales/comments
sorts, and its "best sellers" rail (no sales model)
- `lowestSku` moved out of the fixed-data module into `apps/mall/utils/product.ts`
and re-exported, so live pages stop importing the mock module for a pure
helper
Verified: 28 backend tests green including five new shop tests; all three
frontends build; the directory, store home, store card and order cards all render
real data with no distance or sales claims; the fixed-data rollback still renders
the store surfaces with the backend stopped.
Note: `nuxt build` does not typecheck in this repo (no `typescript.typeCheck`,
no `vue-tsc`), which AGENTS.md implies it does. A re-export used here created no
local binding and broke internal callers at runtime while the build stayed green;
`docs/TBD-migrate-wave.md` records the gap.
OpenSpec change: openspec/changes/replace-mock-api-wave-5
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
-- Buyer-facing shop profile, kept beside `shops` rather than widening it, so the
|
||||
-- identity/status model both consoles already consume stays untouched.
|
||||
--
|
||||
-- Schema only: a profile hangs off a shop, and the demo shops are created by
|
||||
-- scripts/seed-demo.mjs, so the demo profile content lives there.
|
||||
|
||||
CREATE TABLE shop_profiles (
|
||||
shop_id UUID PRIMARY KEY REFERENCES shops (id) ON DELETE CASCADE,
|
||||
logo TEXT,
|
||||
banner TEXT,
|
||||
company TEXT,
|
||||
region TEXT,
|
||||
address JSONB,
|
||||
notice JSONB,
|
||||
after_sale JSONB,
|
||||
-- Platform-set profile scores. There is no review model behind them.
|
||||
score_rating DOUBLE PRECISION,
|
||||
score_agreement DOUBLE PRECISION,
|
||||
score_service DOUBLE PRECISION,
|
||||
score_speed DOUBLE PRECISION,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -10,6 +10,7 @@ pub mod orders;
|
||||
pub mod shop;
|
||||
pub mod shop_catalog;
|
||||
pub mod shop_orders;
|
||||
pub mod shops;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
@@ -27,6 +28,8 @@ pub fn api_router(state: AppState) -> Router<AppState> {
|
||||
.merge(cart::router(state.clone()))
|
||||
.merge(orders::router(state.clone()))
|
||||
.merge(shop::router(state.clone()))
|
||||
.merge(shops::router(state.clone()))
|
||||
.merge(shops::admin_router(state.clone()))
|
||||
.merge(shop_catalog::router(state.clone()))
|
||||
.merge(shop_orders::router(state.clone()))
|
||||
.merge(admin::router(state))
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::UserRole;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// A shop plus whatever profile it has. Every profile field is optional: a shop
|
||||
/// without a `shop_profiles` row still renders, with nothing invented.
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct ShopProfileView {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: Value,
|
||||
pub company: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub address: Option<Value>,
|
||||
pub logo: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
pub notice: Option<Value>,
|
||||
pub after_sale: Option<Value>,
|
||||
pub score_rating: Option<f64>,
|
||||
pub score_agreement: Option<f64>,
|
||||
pub score_service: Option<f64>,
|
||||
pub score_speed: Option<f64>,
|
||||
}
|
||||
|
||||
const SELECT_PROFILE: &str = "SELECT s.id, s.slug, s.name,
|
||||
p.company, p.region, p.address, p.logo, p.banner, p.notice, p.after_sale,
|
||||
p.score_rating, p.score_agreement, p.score_service, p.score_speed
|
||||
FROM shops s
|
||||
LEFT JOIN shop_profiles p ON p.shop_id = s.id";
|
||||
|
||||
pub fn router(_state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/shops", get(list_shops))
|
||||
.route("/shops/{slug}", get(get_shop))
|
||||
}
|
||||
|
||||
pub fn admin_router(_state: AppState) -> Router<AppState> {
|
||||
Router::new().route("/admin/shops/{id}/profile", put(set_shop_profile))
|
||||
}
|
||||
|
||||
async fn list_shops(State(state): State<AppState>) -> ApiResult<Json<Vec<ShopProfileView>>> {
|
||||
let shops = sqlx::query_as::<_, ShopProfileView>(&format!(
|
||||
"{SELECT_PROFILE} WHERE s.status = 'active' ORDER BY s.created_at, s.slug"
|
||||
))
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(shops))
|
||||
}
|
||||
|
||||
async fn get_shop(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> ApiResult<Json<ShopProfileView>> {
|
||||
let shop = sqlx::query_as::<_, ShopProfileView>(&format!(
|
||||
"{SELECT_PROFILE} WHERE s.slug = $1 AND s.status = 'active'"
|
||||
))
|
||||
.bind(&slug)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("shop".into()))?;
|
||||
Ok(Json(shop))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfileBody {
|
||||
logo: Option<String>,
|
||||
banner: Option<String>,
|
||||
company: Option<String>,
|
||||
region: Option<String>,
|
||||
address: Option<Value>,
|
||||
notice: Option<Value>,
|
||||
after_sale: Option<Value>,
|
||||
score_rating: Option<f64>,
|
||||
score_agreement: Option<f64>,
|
||||
score_service: Option<f64>,
|
||||
score_speed: Option<f64>,
|
||||
}
|
||||
|
||||
fn bilingual(label: &Value, field: &str) -> ApiResult<()> {
|
||||
let ok = ["en", "zh"].iter().all(|code| {
|
||||
label
|
||||
.get(code)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
});
|
||||
if !ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{field} needs non-empty en and zh"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_shop_profile(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<ProfileBody>,
|
||||
) -> ApiResult<Json<ShopProfileView>> {
|
||||
auth.require(&[UserRole::PlatformAdmin])?;
|
||||
|
||||
for (value, field) in [
|
||||
(&body.address, "address"),
|
||||
(&body.notice, "notice"),
|
||||
(&body.after_sale, "after_sale"),
|
||||
] {
|
||||
if let Some(label) = value {
|
||||
bilingual(label, field)?;
|
||||
}
|
||||
}
|
||||
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM shops WHERE id = $1)")
|
||||
.bind(id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(ApiError::NotFound("shop".into()));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO shop_profiles (shop_id, logo, banner, company, region, address, notice,
|
||||
after_sale, score_rating, score_agreement, score_service,
|
||||
score_speed, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())
|
||||
ON CONFLICT (shop_id) DO UPDATE SET
|
||||
logo = EXCLUDED.logo,
|
||||
banner = EXCLUDED.banner,
|
||||
company = EXCLUDED.company,
|
||||
region = EXCLUDED.region,
|
||||
address = EXCLUDED.address,
|
||||
notice = EXCLUDED.notice,
|
||||
after_sale = EXCLUDED.after_sale,
|
||||
score_rating = EXCLUDED.score_rating,
|
||||
score_agreement = EXCLUDED.score_agreement,
|
||||
score_service = EXCLUDED.score_service,
|
||||
score_speed = EXCLUDED.score_speed,
|
||||
updated_at = now()",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&body.logo)
|
||||
.bind(&body.banner)
|
||||
.bind(&body.company)
|
||||
.bind(&body.region)
|
||||
.bind(&body.address)
|
||||
.bind(&body.notice)
|
||||
.bind(&body.after_sale)
|
||||
.bind(body.score_rating)
|
||||
.bind(body.score_agreement)
|
||||
.bind(body.score_service)
|
||||
.bind(body.score_speed)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
let shop = sqlx::query_as::<_, ShopProfileView>(&format!("{SELECT_PROFILE} WHERE s.id = $1"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("shop".into()))?;
|
||||
Ok(Json(shop))
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
mod common;
|
||||
|
||||
use common::{client, create_shop, login_admin, make_shop_owner, register_customer, spawn_app};
|
||||
use serial_test::serial;
|
||||
|
||||
/// The suite shares one database and creating shops is additive, so each test
|
||||
/// works on shops it creates itself and never asserts on a global count.
|
||||
|
||||
async fn list_shops(app: &common::TestApp) -> serde_json::Value {
|
||||
let res = client().get(app.url("/api/shops")).send().await.unwrap();
|
||||
assert_eq!(res.status(), 200, "the directory must be unauthenticated");
|
||||
res.json().await.unwrap()
|
||||
}
|
||||
|
||||
fn find<'a>(shops: &'a serde_json::Value, id: &str) -> Option<&'a serde_json::Value> {
|
||||
shops
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["id"] == id)
|
||||
}
|
||||
|
||||
async fn set_profile(
|
||||
app: &common::TestApp,
|
||||
token: &str,
|
||||
shop_id: &str,
|
||||
body: serde_json::Value,
|
||||
) -> reqwest::Response {
|
||||
client()
|
||||
.put(app.url(&format!("/api/admin/shops/{shop_id}/profile")))
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn shop_without_a_profile_is_still_listed() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-noprofile").await;
|
||||
|
||||
let shops = list_shops(&app).await;
|
||||
let shop = find(&shops, &shop_id).expect("a shop with no profile must still be listed");
|
||||
assert!(shop["name"]["en"].is_string());
|
||||
assert!(shop["logo"].is_null(), "nothing may be invented for a missing profile");
|
||||
assert!(shop["score_rating"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn profile_upsert_round_trips_to_the_public_read() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-profile").await;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"logo": "/mock/store-1.svg",
|
||||
"company": "Profile Co.",
|
||||
"region": "California",
|
||||
"address": {"en": "1 Market Street", "zh": "市场街 1 号"},
|
||||
"notice": {"en": "Free shipping over $99.", "zh": "满 99 免运费。"},
|
||||
"after_sale": {"en": "7-day returns.", "zh": "7 天退货。"},
|
||||
"score_rating": 4.9,
|
||||
"score_agreement": 4.8,
|
||||
"score_service": 4.7,
|
||||
"score_speed": 4.6
|
||||
});
|
||||
let res = set_profile(&app, &admin, &shop_id, body).await;
|
||||
assert_eq!(res.status(), 200, "{:?}", res.text().await);
|
||||
|
||||
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone();
|
||||
assert_eq!(shop["company"], "Profile Co.");
|
||||
assert_eq!(shop["address"]["zh"], "市场街 1 号");
|
||||
assert_eq!(shop["score_rating"], 4.9);
|
||||
|
||||
// Public read by slug returns the same composed profile.
|
||||
let slug = shop["slug"].as_str().unwrap();
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/shops/{slug}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let by_slug: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(by_slug["id"], shop_id);
|
||||
assert_eq!(by_slug["notice"]["en"], "Free shipping over $99.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn suspended_or_unknown_shops_are_not_public() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-suspended").await;
|
||||
|
||||
let shops = list_shops(&app).await;
|
||||
let slug = find(&shops, &shop_id).unwrap()["slug"].as_str().unwrap().to_string();
|
||||
|
||||
client()
|
||||
.put(app.url(&format!("/api/admin/shops/{shop_id}/status")))
|
||||
.bearer_auth(&admin)
|
||||
.json(&serde_json::json!({ "status": "suspended" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(find(&list_shops(&app).await, &shop_id).is_none(), "suspended shops are hidden");
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/shops/{slug}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404);
|
||||
|
||||
let res = client()
|
||||
.get(app.url("/api/shops/no-such-shop-at-all"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404, "an unknown slug is a 404, not an empty profile");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn incomplete_bilingual_text_is_refused_without_writing() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-bilingual").await;
|
||||
|
||||
let good = serde_json::json!({
|
||||
"company": "Kept Co.",
|
||||
"notice": {"en": "Original notice", "zh": "原始公告"}
|
||||
});
|
||||
assert_eq!(set_profile(&app, &admin, &shop_id, good).await.status(), 200);
|
||||
|
||||
let bad = serde_json::json!({
|
||||
"company": "Changed Co.",
|
||||
"notice": {"en": "Only English"}
|
||||
});
|
||||
let res = set_profile(&app, &admin, &shop_id, bad).await;
|
||||
assert_eq!(res.status(), 400, "a label missing zh must be refused");
|
||||
|
||||
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone();
|
||||
assert_eq!(shop["company"], "Kept Co.", "the rejected write changed nothing");
|
||||
assert_eq!(shop["notice"]["zh"], "原始公告");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn profile_writes_require_a_platform_admin() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-profile-auth").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
let (customer, _) = register_customer(&app, "shop-profile-cust").await;
|
||||
|
||||
let body = serde_json::json!({ "company": "Nope" });
|
||||
for token in [&owner, &customer] {
|
||||
let res = set_profile(&app, token, &shop_id, body.clone()).await;
|
||||
assert_eq!(res.status(), 403, "only platform admins may write a profile");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user