feat(admin,shop-admin): content/brand management + merchant shop profile (add-content-admin-ui)
This commit is contained in:
@@ -13,11 +13,11 @@ use crate::error::ApiResult;
|
||||
use crate::models::{Shop, ShopStatus};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::service::{self, ProfileBody, ShopProfileView};
|
||||
use super::service::{self, MerchantProfileBody, ProfileBody, ShopProfileView};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/shop/profile", get(my_shop))
|
||||
.route("/shop/profile", get(my_shop).put(update_my_profile))
|
||||
.route("/shops", get(list_shops))
|
||||
.route("/shops/{slug}", get(get_shop))
|
||||
.route("/admin/shops", get(admin_list_shops).post(create_shop))
|
||||
@@ -30,6 +30,15 @@ async fn my_shop(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Jso
|
||||
Ok(Json(service::get_by_id(&state, shop_id).await?))
|
||||
}
|
||||
|
||||
async fn update_my_profile(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<MerchantProfileBody>,
|
||||
) -> ApiResult<Json<ShopProfileView>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::set_my_profile(&state, shop_id, body).await?))
|
||||
}
|
||||
|
||||
async fn list_shops(State(state): State<AppState>) -> ApiResult<Json<Vec<ShopProfileView>>> {
|
||||
Ok(Json(service::list_active_profiles(&state).await?))
|
||||
}
|
||||
|
||||
@@ -111,6 +111,19 @@ pub struct ProfileBody {
|
||||
pub score_speed: Option<f64>,
|
||||
}
|
||||
|
||||
/// Merchant self-service write: same fields as the admin body minus the
|
||||
/// platform-owned scores; serde ignores any score values merchants send.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MerchantProfileBody {
|
||||
pub logo: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub address: Option<Value>,
|
||||
pub notice: Option<Value>,
|
||||
pub after_sale: Option<Value>,
|
||||
}
|
||||
|
||||
fn bilingual(label: &Value, field: &str) -> ApiResult<()> {
|
||||
let ok = ["en", "zh"].iter().all(|code| {
|
||||
label
|
||||
@@ -189,3 +202,60 @@ pub async fn set_profile(
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("shop".into()))
|
||||
}
|
||||
|
||||
/// Merchant upsert of their own shop profile. Scores are platform-owned, so
|
||||
/// this statement never touches the score columns on insert or update.
|
||||
pub async fn set_my_profile(
|
||||
state: &AppState,
|
||||
id: Uuid,
|
||||
body: MerchantProfileBody,
|
||||
) -> ApiResult<ShopProfileView> {
|
||||
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, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 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,
|
||||
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)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -182,3 +182,116 @@ async fn profile_writes_require_a_platform_admin() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_my_profile(
|
||||
app: &common::TestApp,
|
||||
token: &str,
|
||||
body: serde_json::Value,
|
||||
) -> reqwest::Response {
|
||||
client()
|
||||
.put(app.url("/api/shop/profile"))
|
||||
.bearer_auth(token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn merchant_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-self-profile").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"logo": "/mock/store-self.svg",
|
||||
"company": "Self Co.",
|
||||
"region": "California",
|
||||
"address": {"en": "2 Mission Street", "zh": "米申街 2 号"},
|
||||
"notice": {"en": "Self-service notice", "zh": "自助公告"},
|
||||
"after_sale": {"en": "Self returns.", "zh": "自助退货。"}
|
||||
});
|
||||
let res = set_my_profile(&app, &owner, 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"], "Self Co.");
|
||||
assert_eq!(shop["address"]["zh"], "米申街 2 号");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn merchant_profile_refuses_incomplete_bilingual_without_writing() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-self-bilingual").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
|
||||
let good = serde_json::json!({
|
||||
"company": "Merchant Kept Co.",
|
||||
"notice": {"en": "Merchant notice", "zh": "商家公告"}
|
||||
});
|
||||
assert_eq!(set_my_profile(&app, &owner, good).await.status(), 200);
|
||||
|
||||
let bad = serde_json::json!({
|
||||
"company": "Merchant Changed Co.",
|
||||
"notice": {"en": "Only English"}
|
||||
});
|
||||
let res = set_my_profile(&app, &owner, 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"], "Merchant Kept Co.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn merchant_profile_never_stores_scores() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-self-scores").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
|
||||
let admin_body = serde_json::json!({
|
||||
"company": "Scored Co.",
|
||||
"score_rating": 4.9,
|
||||
"score_service": 4.7
|
||||
});
|
||||
assert_eq!(set_profile(&app, &admin, &shop_id, admin_body).await.status(), 200);
|
||||
|
||||
let merchant_body = serde_json::json!({
|
||||
"company": "Scored Co. Renamed",
|
||||
"score_rating": 1.0,
|
||||
"score_service": 1.0
|
||||
});
|
||||
assert_eq!(set_my_profile(&app, &owner, merchant_body).await.status(), 200);
|
||||
|
||||
let shop = find(&list_shops(&app).await, &shop_id).unwrap().clone();
|
||||
assert_eq!(shop["company"], "Scored Co. Renamed");
|
||||
assert_eq!(shop["score_rating"], 4.9, "merchant writes must not touch scores");
|
||||
assert_eq!(shop["score_service"], 4.7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn merchant_profile_requires_a_shop_and_scopes_to_it() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_a = create_shop(&app, &admin, "shop-self-scope-a").await;
|
||||
let shop_b = create_shop(&app, &admin, "shop-self-scope-b").await;
|
||||
let owner_a = make_shop_owner(&app, &admin, &shop_a).await;
|
||||
let (customer, _) = register_customer(&app, "shop-self-cust").await;
|
||||
|
||||
let body = serde_json::json!({ "company": "Scoped Co." });
|
||||
let res = set_my_profile(&app, &customer, body.clone()).await;
|
||||
assert_eq!(res.status(), 403, "a user without a shop must be refused");
|
||||
|
||||
assert_eq!(set_my_profile(&app, &owner_a, body).await.status(), 200);
|
||||
let shop_b_view = find(&list_shops(&app).await, &shop_b).unwrap().clone();
|
||||
assert!(
|
||||
shop_b_view["company"].is_null(),
|
||||
"another shop's profile must stay untouched"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user