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:
co-authored by
Cursor
parent
94a64ec712
commit
6c1357ec4d
@@ -0,0 +1,116 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::modules::shop::service::ShopProfileView;
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct FavoriteProductSummary {
|
||||
pub id: Uuid,
|
||||
pub shop_id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: Value,
|
||||
pub image: Option<String>,
|
||||
pub price_minor: Option<i64>,
|
||||
pub currency: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Favorite {
|
||||
#[serde(rename = "product")]
|
||||
Product {
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
created_at: DateTime<Utc>,
|
||||
product: FavoriteProductSummary,
|
||||
},
|
||||
#[serde(rename = "shop")]
|
||||
Shop {
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
created_at: DateTime<Utc>,
|
||||
shop: ShopProfileView,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct ProductFavoriteRow {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub product_id: Uuid,
|
||||
pub shop_id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: Value,
|
||||
pub image: Option<String>,
|
||||
pub price_minor: Option<i64>,
|
||||
pub currency: Option<String>,
|
||||
}
|
||||
|
||||
impl ProductFavoriteRow {
|
||||
pub fn into_favorite(self) -> Favorite {
|
||||
Favorite::Product {
|
||||
id: self.id,
|
||||
user_id: self.user_id,
|
||||
created_at: self.created_at,
|
||||
product: FavoriteProductSummary {
|
||||
id: self.product_id,
|
||||
shop_id: self.shop_id,
|
||||
slug: self.slug,
|
||||
name: self.name,
|
||||
image: self.image,
|
||||
price_minor: self.price_minor,
|
||||
currency: self.currency,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct ShopFavoriteRow {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub shop_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>,
|
||||
}
|
||||
|
||||
impl ShopFavoriteRow {
|
||||
pub fn into_favorite(self) -> Favorite {
|
||||
Favorite::Shop {
|
||||
id: self.id,
|
||||
user_id: self.user_id,
|
||||
created_at: self.created_at,
|
||||
shop: ShopProfileView {
|
||||
id: self.shop_id,
|
||||
slug: self.slug,
|
||||
name: self.name,
|
||||
company: self.company,
|
||||
region: self.region,
|
||||
address: self.address,
|
||||
logo: self.logo,
|
||||
banner: self.banner,
|
||||
notice: self.notice,
|
||||
after_sale: self.after_sale,
|
||||
score_rating: self.score_rating,
|
||||
score_agreement: self.score_agreement,
|
||||
score_service: self.score_service,
|
||||
score_speed: self.score_speed,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::http::Paged;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Favorite;
|
||||
use super::service;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FavoriteListQuery {
|
||||
kind: String,
|
||||
target_id: Option<Uuid>,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/favorites", get(list_favorites))
|
||||
.route(
|
||||
"/favorites/products/{product_id}",
|
||||
put(add_product).delete(remove_product),
|
||||
)
|
||||
.route(
|
||||
"/favorites/shops/{shop_id}",
|
||||
put(add_shop).delete(remove_shop),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_favorites(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<FavoriteListQuery>,
|
||||
) -> ApiResult<Json<Paged<Favorite>>> {
|
||||
auth.require_customer()?;
|
||||
Ok(Json(
|
||||
service::list(&state, auth.id, &q.kind, q.target_id, q.page, q.per_page).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn add_product(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(product_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<Favorite>> {
|
||||
auth.require_customer()?;
|
||||
Ok(Json(
|
||||
service::add_product(&state, auth.id, product_id).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn remove_product(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(product_id): Path<Uuid>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
auth.require_customer()?;
|
||||
service::remove_product(&state, auth.id, product_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn add_shop(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(shop_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<Favorite>> {
|
||||
auth.require_customer()?;
|
||||
Ok(Json(service::add_shop(&state, auth.id, shop_id).await?))
|
||||
}
|
||||
|
||||
async fn remove_shop(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(shop_id): Path<Uuid>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
auth.require_customer()?;
|
||||
service::remove_shop(&state, auth.id, shop_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mod dto;
|
||||
mod handlers;
|
||||
mod repo;
|
||||
pub mod service;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
handlers::router()
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
use sqlx::{PgConnection, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
|
||||
use super::dto::{Favorite, ProductFavoriteRow, ShopFavoriteRow};
|
||||
|
||||
const PRODUCT_VISIBLE: &str = "p.status = 'published' AND sh.status = 'active'";
|
||||
|
||||
const PRODUCT_FROM: &str = "FROM favorites f
|
||||
JOIN products p ON p.id = f.product_id
|
||||
JOIN shops sh ON sh.id = p.shop_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.price_minor, s.currency
|
||||
FROM skus s
|
||||
WHERE s.product_id = p.id AND s.active = TRUE
|
||||
ORDER BY s.price_minor ASC, s.sku_code ASC
|
||||
LIMIT 1
|
||||
) sku ON TRUE";
|
||||
|
||||
const PRODUCT_SELECT: &str = "SELECT f.id, f.user_id, f.created_at,
|
||||
p.id AS product_id, p.shop_id, p.slug, p.name,
|
||||
CASE WHEN jsonb_typeof(p.images) = 'array' AND jsonb_array_length(p.images) > 0
|
||||
THEN p.images->>0 ELSE NULL END AS image,
|
||||
sku.price_minor, sku.currency";
|
||||
|
||||
const SHOP_FROM: &str = "FROM favorites f
|
||||
JOIN shops s ON s.id = f.shop_id
|
||||
LEFT JOIN shop_profiles p ON p.shop_id = s.id";
|
||||
|
||||
const SHOP_SELECT: &str = "SELECT f.id, f.user_id, f.created_at,
|
||||
s.id AS shop_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";
|
||||
|
||||
pub async fn lock_visible_product(tx: &mut PgConnection, product_id: Uuid) -> ApiResult<bool> {
|
||||
Ok(sqlx::query_scalar(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM products p
|
||||
JOIN shops sh ON sh.id = p.shop_id
|
||||
WHERE p.id = $1 AND p.status = 'published' AND sh.status = 'active'
|
||||
FOR SHARE OF p, sh
|
||||
)",
|
||||
)
|
||||
.bind(product_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn lock_visible_shop(tx: &mut PgConnection, shop_id: Uuid) -> ApiResult<bool> {
|
||||
Ok(sqlx::query_scalar(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM shops
|
||||
WHERE id = $1 AND status = 'active'
|
||||
FOR SHARE
|
||||
)",
|
||||
)
|
||||
.bind(shop_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn upsert_product(
|
||||
tx: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
product_id: Uuid,
|
||||
) -> ApiResult<Uuid> {
|
||||
let inserted: Option<Uuid> = sqlx::query_scalar(
|
||||
"INSERT INTO favorites (user_id, product_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, product_id) WHERE product_id IS NOT NULL
|
||||
DO NOTHING
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(product_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some(id) = inserted {
|
||||
return Ok(id);
|
||||
}
|
||||
sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND product_id = $2")
|
||||
.bind(user_id)
|
||||
.bind(product_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("favorite".into()))
|
||||
}
|
||||
|
||||
pub async fn upsert_shop(tx: &mut PgConnection, user_id: Uuid, shop_id: Uuid) -> ApiResult<Uuid> {
|
||||
let inserted: Option<Uuid> = sqlx::query_scalar(
|
||||
"INSERT INTO favorites (user_id, shop_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, shop_id) WHERE shop_id IS NOT NULL
|
||||
DO NOTHING
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(shop_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some(id) = inserted {
|
||||
return Ok(id);
|
||||
}
|
||||
sqlx::query_scalar("SELECT id FROM favorites WHERE user_id = $1 AND shop_id = $2")
|
||||
.bind(user_id)
|
||||
.bind(shop_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("favorite".into()))
|
||||
}
|
||||
|
||||
pub async fn delete_product(db: &PgPool, user_id: Uuid, product_id: Uuid) -> ApiResult<()> {
|
||||
sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND product_id = $2")
|
||||
.bind(user_id)
|
||||
.bind(product_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_shop(db: &PgPool, user_id: Uuid, shop_id: Uuid) -> ApiResult<()> {
|
||||
sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND shop_id = $2")
|
||||
.bind(user_id)
|
||||
.bind(shop_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn count_products(db: &PgPool, user_id: Uuid, target_id: Option<Uuid>) -> ApiResult<i64> {
|
||||
Ok(sqlx::query_scalar(&format!(
|
||||
"SELECT count(*) {PRODUCT_FROM}
|
||||
WHERE f.user_id = $1 AND f.product_id IS NOT NULL
|
||||
AND {PRODUCT_VISIBLE}
|
||||
AND ($2::uuid IS NULL OR f.product_id = $2)"
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(target_id)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn count_shops(db: &PgPool, user_id: Uuid, target_id: Option<Uuid>) -> ApiResult<i64> {
|
||||
Ok(sqlx::query_scalar(&format!(
|
||||
"SELECT count(*) {SHOP_FROM}
|
||||
WHERE f.user_id = $1 AND f.shop_id IS NOT NULL
|
||||
AND s.status = 'active'
|
||||
AND ($2::uuid IS NULL OR f.shop_id = $2)"
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(target_id)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_products(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
target_id: Option<Uuid>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> ApiResult<Vec<Favorite>> {
|
||||
let rows = sqlx::query_as::<_, ProductFavoriteRow>(&format!(
|
||||
"{PRODUCT_SELECT} {PRODUCT_FROM}
|
||||
WHERE f.user_id = $1 AND f.product_id IS NOT NULL
|
||||
AND {PRODUCT_VISIBLE}
|
||||
AND ($2::uuid IS NULL OR f.product_id = $2)
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $3 OFFSET $4"
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(target_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(ProductFavoriteRow::into_favorite)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_shops(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
target_id: Option<Uuid>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> ApiResult<Vec<Favorite>> {
|
||||
let rows = sqlx::query_as::<_, ShopFavoriteRow>(&format!(
|
||||
"{SHOP_SELECT} {SHOP_FROM}
|
||||
WHERE f.user_id = $1 AND f.shop_id IS NOT NULL
|
||||
AND s.status = 'active'
|
||||
AND ($2::uuid IS NULL OR f.shop_id = $2)
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $3 OFFSET $4"
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(target_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(ShopFavoriteRow::into_favorite)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_product_favorite(
|
||||
tx: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
product_id: Uuid,
|
||||
) -> ApiResult<Favorite> {
|
||||
let row = sqlx::query_as::<_, ProductFavoriteRow>(&format!(
|
||||
"{PRODUCT_SELECT} {PRODUCT_FROM}
|
||||
WHERE f.user_id = $1 AND f.product_id = $2 AND {PRODUCT_VISIBLE}"
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(product_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("favorite".into()))?;
|
||||
Ok(row.into_favorite())
|
||||
}
|
||||
|
||||
pub async fn get_shop_favorite(
|
||||
tx: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
shop_id: Uuid,
|
||||
) -> ApiResult<Favorite> {
|
||||
let row = sqlx::query_as::<_, ShopFavoriteRow>(&format!(
|
||||
"{SHOP_SELECT} {SHOP_FROM}
|
||||
WHERE f.user_id = $1 AND f.shop_id = $2 AND s.status = 'active'"
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(shop_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("favorite".into()))?;
|
||||
Ok(row.into_favorite())
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Favorite;
|
||||
use super::repo;
|
||||
|
||||
pub async fn add_product(state: &AppState, user_id: Uuid, product_id: Uuid) -> ApiResult<Favorite> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
if !repo::lock_visible_product(&mut tx, product_id).await? {
|
||||
return Err(ApiError::NotFound("product".into()));
|
||||
}
|
||||
repo::upsert_product(&mut tx, user_id, product_id).await?;
|
||||
let favorite = repo::get_product_favorite(&mut tx, user_id, product_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(favorite)
|
||||
}
|
||||
|
||||
pub async fn add_shop(state: &AppState, user_id: Uuid, shop_id: Uuid) -> ApiResult<Favorite> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
if !repo::lock_visible_shop(&mut tx, shop_id).await? {
|
||||
return Err(ApiError::NotFound("shop".into()));
|
||||
}
|
||||
repo::upsert_shop(&mut tx, user_id, shop_id).await?;
|
||||
let favorite = repo::get_shop_favorite(&mut tx, user_id, shop_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(favorite)
|
||||
}
|
||||
|
||||
pub async fn remove_product(state: &AppState, user_id: Uuid, product_id: Uuid) -> ApiResult<()> {
|
||||
repo::delete_product(&state.db, user_id, product_id).await
|
||||
}
|
||||
|
||||
pub async fn remove_shop(state: &AppState, user_id: Uuid, shop_id: Uuid) -> ApiResult<()> {
|
||||
repo::delete_shop(&state.db, user_id, shop_id).await
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
kind: &str,
|
||||
target_id: Option<Uuid>,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
) -> ApiResult<Paged<Favorite>> {
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let offset = (page - 1) * per_page;
|
||||
let (total, items) = match kind {
|
||||
"product" => (
|
||||
repo::count_products(&state.db, user_id, target_id).await?,
|
||||
repo::list_products(&state.db, user_id, target_id, per_page, offset).await?,
|
||||
),
|
||||
"shop" => (
|
||||
repo::count_shops(&state.db, user_id, target_id).await?,
|
||||
repo::list_shops(&state.db, user_id, target_id, per_page, offset).await?,
|
||||
),
|
||||
other => {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"kind must be product or shop, got {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(Paged {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod catalog;
|
||||
pub mod content;
|
||||
pub mod coupon;
|
||||
pub mod currency;
|
||||
pub mod favorite;
|
||||
pub mod flash_sale;
|
||||
pub mod fulfillment;
|
||||
pub mod group_buying;
|
||||
@@ -30,6 +31,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.merge(content::router())
|
||||
.merge(cart::router())
|
||||
.merge(coupon::router())
|
||||
.merge(favorite::router())
|
||||
.merge(flash_sale::router())
|
||||
.merge(group_buying::router())
|
||||
.merge(order::router())
|
||||
|
||||
Reference in New Issue
Block a user