refactor(api): split catalog module into product, category, brand
Rearranges the Rust backend by domain: `modules/catalog` bundled four distinct concepts (Product, SKU, Category, Brand) behind one router and one 392-line service file, unlike every other module in the codebase which owns exactly one bounded aggregate. Splits into `modules/product` (Product/SKU, publish lifecycle, shop-scoped CRUD), `modules/category` (category tree, subtree query), and `modules/brand` (brand list, admin replace-all). `Category`/`Brand` move out of the shared `models.rs` into their owning modules; `Product`/`Sku` stay since `favorite`/`flash_sale`/`group_buying` reference them across modules. Purely internal restructuring — no route, schema, or behavior changes. Implements openspec change split-catalog-into-product-category-brand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0b594c0147
commit
8446650baf
@@ -0,0 +1,71 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{Product, Sku};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProductWithSkus {
|
||||
#[serde(flatten)]
|
||||
pub product: Product,
|
||||
pub skus: Vec<Sku>,
|
||||
pub sold_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PublicListQuery {
|
||||
pub page: Option<i64>,
|
||||
pub per_page: Option<i64>,
|
||||
pub category_id: Option<Uuid>,
|
||||
pub brand_id: Option<Uuid>,
|
||||
pub shop_id: Option<Uuid>,
|
||||
pub q: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
pub order: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SortBy {
|
||||
Newest,
|
||||
Price,
|
||||
Sales,
|
||||
}
|
||||
|
||||
impl PublicListQuery {
|
||||
pub fn sort_by(&self) -> ApiResult<SortBy> {
|
||||
match self.sort.as_deref() {
|
||||
None => Ok(SortBy::Newest),
|
||||
Some("price") => Ok(SortBy::Price),
|
||||
Some("sales") => Ok(SortBy::Sales),
|
||||
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ascending(&self) -> ApiResult<bool> {
|
||||
match self.order.as_deref() {
|
||||
None | Some("asc") => Ok(true),
|
||||
Some("desc") => Ok(false),
|
||||
Some(other) => Err(ApiError::BadRequest(format!("unsupported order: {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProductBody {
|
||||
pub category_id: Option<Uuid>,
|
||||
pub brand_id: Option<Uuid>,
|
||||
pub slug: String,
|
||||
pub name: serde_json::Value,
|
||||
pub description: Option<serde_json::Value>,
|
||||
pub images: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SkuBody {
|
||||
pub sku_code: String,
|
||||
pub attributes: Option<serde_json::Value>,
|
||||
pub price_minor: i64,
|
||||
pub currency: String,
|
||||
pub stock: i32,
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::http::Paged;
|
||||
use crate::models::{ProductStatus, Sku};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::{ProductBody, ProductWithSkus, PublicListQuery, SkuBody};
|
||||
use super::service;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/products", get(list_products))
|
||||
.route("/products/{id_or_slug}", get(get_product))
|
||||
.route("/shop/products", get(shop_list).post(create_product))
|
||||
.route("/shop/products/{id}", get(shop_get).put(update_product))
|
||||
.route("/shop/products/{id}/publish", post(publish))
|
||||
.route("/shop/products/{id}/unpublish", post(unpublish))
|
||||
.route("/shop/products/{id}/skus", post(upsert_sku))
|
||||
}
|
||||
|
||||
async fn list_products(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<PublicListQuery>,
|
||||
) -> ApiResult<Json<Paged<ProductWithSkus>>> {
|
||||
Ok(Json(service::list_public(&state, q).await?))
|
||||
}
|
||||
|
||||
async fn get_product(
|
||||
State(state): State<AppState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> ApiResult<Json<ProductWithSkus>> {
|
||||
Ok(Json(service::get_public(&state, &id_or_slug).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShopListQuery {
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
status: Option<ProductStatus>,
|
||||
}
|
||||
|
||||
async fn shop_list(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<ShopListQuery>,
|
||||
) -> ApiResult<Json<Paged<ProductWithSkus>>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(
|
||||
service::list_shop_products(&state, shop_id, q.page, q.per_page, q.status).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn shop_get(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<ProductWithSkus>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::get_shop_product(&state, shop_id, id).await?))
|
||||
}
|
||||
|
||||
async fn create_product(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<ProductBody>,
|
||||
) -> ApiResult<(StatusCode, Json<ProductWithSkus>)> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service::create_product(&state, shop_id, body).await?),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_product(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<ProductBody>,
|
||||
) -> ApiResult<Json<ProductWithSkus>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(
|
||||
service::update_product(&state, shop_id, id, body).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn publish(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<ProductWithSkus>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(
|
||||
service::transition(&state, shop_id, id, ProductStatus::Published).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn unpublish(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<ProductWithSkus>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(
|
||||
service::transition(&state, shop_id, id, ProductStatus::Unpublished).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn upsert_sku(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<SkuBody>,
|
||||
) -> ApiResult<Json<Sku>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::upsert_sku(&state, shop_id, id, body).await?))
|
||||
}
|
||||
@@ -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,73 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiResult;
|
||||
use crate::models::{Product, Sku};
|
||||
|
||||
use super::dto::ProductWithSkus;
|
||||
|
||||
const PAID_STATUSES: &str = "('paid', 'fulfilling', 'shipped', 'completed')";
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SoldRow {
|
||||
product_id: Uuid,
|
||||
sold: i64,
|
||||
}
|
||||
|
||||
pub async fn attach_skus(
|
||||
db: &PgPool,
|
||||
products: Vec<Product>,
|
||||
public_only: bool,
|
||||
) -> ApiResult<Vec<ProductWithSkus>> {
|
||||
let ids: Vec<Uuid> = products.iter().map(|p| p.id).collect();
|
||||
let skus = if ids.is_empty() {
|
||||
Vec::new()
|
||||
} else if public_only {
|
||||
sqlx::query_as::<_, Sku>(
|
||||
"SELECT id, product_id, sku_code, attributes, price_minor, currency, stock, active
|
||||
FROM skus WHERE product_id = ANY($1) AND active = TRUE ORDER BY sku_code",
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, Sku>(
|
||||
"SELECT id, product_id, sku_code, attributes, price_minor, currency, stock, active
|
||||
FROM skus WHERE product_id = ANY($1) ORDER BY sku_code",
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
};
|
||||
let sold: Vec<SoldRow> = if ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query_as::<_, SoldRow>(&format!(
|
||||
"SELECT sk.product_id, COALESCE(SUM(oi.qty), 0)::bigint AS sold
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN skus sk ON sk.id = oi.sku_id
|
||||
WHERE sk.product_id = ANY($1)
|
||||
AND o.status IN {PAID_STATUSES}
|
||||
GROUP BY sk.product_id"
|
||||
))
|
||||
.bind(&ids)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
};
|
||||
let mut skus_by: HashMap<Uuid, Vec<Sku>> = HashMap::new();
|
||||
for sku in skus {
|
||||
skus_by.entry(sku.product_id).or_default().push(sku);
|
||||
}
|
||||
let sold_by: HashMap<Uuid, i64> = sold.into_iter().map(|r| (r.product_id, r.sold)).collect();
|
||||
Ok(products
|
||||
.into_iter()
|
||||
.map(|p| ProductWithSkus {
|
||||
skus: skus_by.remove(&p.id).unwrap_or_default(),
|
||||
sold_count: sold_by.get(&p.id).copied().unwrap_or(0),
|
||||
product: p,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use crate::error::{unique_conflict, ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::models::{Product, ProductStatus, Sku};
|
||||
use crate::modules::category::service::SUBTREE_CTE;
|
||||
use crate::state::AppState;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::dto::{ProductBody, ProductWithSkus, PublicListQuery, SkuBody, SortBy};
|
||||
use super::repo;
|
||||
|
||||
const SOLD_UNITS: &str = "(SELECT COALESCE(SUM(oi.qty), 0)
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN skus sk ON sk.id = oi.sku_id
|
||||
WHERE sk.product_id = p.id
|
||||
AND o.status IN ('paid', 'fulfilling', 'shipped', 'completed'))";
|
||||
|
||||
const MIN_PRICE: &str =
|
||||
"(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)";
|
||||
|
||||
const PRODUCT_COLS: &str = "p.id, p.shop_id, p.category_id, p.brand_id, p.slug, p.name,
|
||||
p.description, p.images, p.status, p.created_at, p.updated_at";
|
||||
|
||||
pub async fn list_public(
|
||||
state: &AppState,
|
||||
q: PublicListQuery,
|
||||
) -> ApiResult<Paged<ProductWithSkus>> {
|
||||
let page = clamp_page(q.page);
|
||||
let per_page = clamp_per_page(q.per_page);
|
||||
let pattern = q.q.as_ref().map(|s| format!("%{s}%"));
|
||||
let sort_by = q.sort_by()?;
|
||||
let ascending = q.ascending()?;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(&format!(
|
||||
"{SUBTREE_CTE}
|
||||
SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id
|
||||
WHERE p.status = 'published' AND s.status = 'active'
|
||||
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
|
||||
AND ($2::uuid IS NULL OR p.shop_id = $2)
|
||||
AND ($3::text IS NULL OR p.name::text ILIKE $3)
|
||||
AND ($4::uuid IS NULL OR p.brand_id = $4)"
|
||||
))
|
||||
.bind(q.category_id)
|
||||
.bind(q.shop_id)
|
||||
.bind(&pattern)
|
||||
.bind(q.brand_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let order_clause = match (sort_by, ascending) {
|
||||
(SortBy::Newest, _) => "p.created_at DESC".to_string(),
|
||||
(SortBy::Price, true) => format!("{MIN_PRICE} ASC NULLS LAST, p.created_at DESC"),
|
||||
(SortBy::Price, false) => format!("{MIN_PRICE} DESC NULLS LAST, p.created_at DESC"),
|
||||
(SortBy::Sales, true) => format!("{SOLD_UNITS} ASC, p.created_at DESC"),
|
||||
(SortBy::Sales, false) => format!("{SOLD_UNITS} DESC, p.created_at DESC"),
|
||||
};
|
||||
let products = sqlx::query_as::<_, Product>(&format!(
|
||||
"{SUBTREE_CTE}
|
||||
SELECT {PRODUCT_COLS} FROM products p JOIN shops s ON s.id = p.shop_id
|
||||
WHERE p.status = 'published' AND s.status = 'active'
|
||||
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
|
||||
AND ($2::uuid IS NULL OR p.shop_id = $2)
|
||||
AND ($3::text IS NULL OR p.name::text ILIKE $3)
|
||||
AND ($4::uuid IS NULL OR p.brand_id = $4)
|
||||
ORDER BY {order_clause}
|
||||
LIMIT $5 OFFSET $6"
|
||||
))
|
||||
.bind(q.category_id)
|
||||
.bind(q.shop_id)
|
||||
.bind(&pattern)
|
||||
.bind(q.brand_id)
|
||||
.bind(per_page)
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let items = repo::attach_skus(&state.db, products, true).await?;
|
||||
Ok(Paged {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_public(state: &AppState, id_or_slug: &str) -> ApiResult<ProductWithSkus> {
|
||||
let product = if let Ok(id) = Uuid::parse_str(id_or_slug) {
|
||||
sqlx::query_as::<_, Product>(&format!(
|
||||
"SELECT {PRODUCT_COLS} FROM products p JOIN shops s ON s.id = p.shop_id
|
||||
WHERE p.id = $1 AND p.status = 'published' AND s.status = 'active'"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, Product>(&format!(
|
||||
"SELECT {PRODUCT_COLS} FROM products p JOIN shops s ON s.id = p.shop_id
|
||||
WHERE p.slug = $1 AND p.status = 'published' AND s.status = 'active'"
|
||||
))
|
||||
.bind(id_or_slug)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
}
|
||||
.ok_or_else(|| ApiError::NotFound("product".into()))?;
|
||||
let mut items = repo::attach_skus(&state.db, vec![product], true).await?;
|
||||
Ok(items.remove(0))
|
||||
}
|
||||
|
||||
async fn load_own_product(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<Product> {
|
||||
sqlx::query_as::<_, Product>(
|
||||
"SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at
|
||||
FROM products WHERE id = $1 AND shop_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(shop_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("product".into()))
|
||||
}
|
||||
|
||||
pub async fn list_shop_products(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
status: Option<ProductStatus>,
|
||||
) -> ApiResult<Paged<ProductWithSkus>> {
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM products WHERE shop_id = $1 AND ($2::product_status IS NULL OR status = $2)",
|
||||
)
|
||||
.bind(shop_id)
|
||||
.bind(status)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let products = sqlx::query_as::<_, Product>(
|
||||
"SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at
|
||||
FROM products
|
||||
WHERE shop_id = $1 AND ($2::product_status IS NULL OR status = $2)
|
||||
ORDER BY created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(shop_id)
|
||||
.bind(status)
|
||||
.bind(per_page)
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let items = repo::attach_skus(&state.db, products, false).await?;
|
||||
Ok(Paged {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_shop_product(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> ApiResult<ProductWithSkus> {
|
||||
let product = load_own_product(state, shop_id, id).await?;
|
||||
let mut items = repo::attach_skus(&state.db, vec![product], false).await?;
|
||||
Ok(items.remove(0))
|
||||
}
|
||||
|
||||
fn validate_product_body(body: &ProductBody) -> ApiResult<()> {
|
||||
if body.slug.trim().is_empty()
|
||||
|| !body
|
||||
.slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
{
|
||||
return Err(ApiError::BadRequest(
|
||||
"slug must be non-empty alphanumeric with dashes".into(),
|
||||
));
|
||||
}
|
||||
let name_en = body.name.get("en").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if name_en.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("name.en is required".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_product(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
body: ProductBody,
|
||||
) -> ApiResult<ProductWithSkus> {
|
||||
validate_product_body(&body)?;
|
||||
let product = sqlx::query_as::<_, Product>(
|
||||
"INSERT INTO products (shop_id, category_id, brand_id, slug, name, description, images)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at",
|
||||
)
|
||||
.bind(shop_id)
|
||||
.bind(body.category_id)
|
||||
.bind(body.brand_id)
|
||||
.bind(body.slug.trim())
|
||||
.bind(&body.name)
|
||||
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
|
||||
.bind(body.images.unwrap_or_else(|| serde_json::json!([])))
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, "slug already exists in this shop"))?;
|
||||
let mut items = repo::attach_skus(&state.db, vec![product], false).await?;
|
||||
Ok(items.remove(0))
|
||||
}
|
||||
|
||||
pub async fn update_product(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
id: Uuid,
|
||||
body: ProductBody,
|
||||
) -> ApiResult<ProductWithSkus> {
|
||||
load_own_product(state, shop_id, id).await?;
|
||||
validate_product_body(&body)?;
|
||||
let product = sqlx::query_as::<_, Product>(
|
||||
"UPDATE products
|
||||
SET category_id = $2, brand_id = $3, slug = $4, name = $5, description = $6,
|
||||
images = $7, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(body.category_id)
|
||||
.bind(body.brand_id)
|
||||
.bind(body.slug.trim())
|
||||
.bind(&body.name)
|
||||
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
|
||||
.bind(body.images.unwrap_or_else(|| serde_json::json!([])))
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, "slug already exists in this shop"))?;
|
||||
let mut items = repo::attach_skus(&state.db, vec![product], false).await?;
|
||||
Ok(items.remove(0))
|
||||
}
|
||||
|
||||
pub async fn transition(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
id: Uuid,
|
||||
target: ProductStatus,
|
||||
) -> ApiResult<ProductWithSkus> {
|
||||
let product = load_own_product(state, shop_id, id).await?;
|
||||
if target == ProductStatus::Published {
|
||||
let sellable: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM skus WHERE product_id = $1 AND active = TRUE AND price_minor > 0)",
|
||||
)
|
||||
.bind(product.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !sellable {
|
||||
return Err(ApiError::BadRequest(
|
||||
"product needs at least one active SKU with price > 0 to publish".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let updated = sqlx::query_as::<_, Product>(
|
||||
"UPDATE products SET status = $2, updated_at = now() WHERE id = $1
|
||||
RETURNING id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at",
|
||||
)
|
||||
.bind(product.id)
|
||||
.bind(target)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let mut items = repo::attach_skus(&state.db, vec![updated], false).await?;
|
||||
Ok(items.remove(0))
|
||||
}
|
||||
|
||||
pub async fn upsert_sku(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
product_id: Uuid,
|
||||
body: SkuBody,
|
||||
) -> ApiResult<Sku> {
|
||||
load_own_product(state, shop_id, product_id).await?;
|
||||
if body.sku_code.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("sku_code is required".into()));
|
||||
}
|
||||
if body.price_minor < 0 {
|
||||
return Err(ApiError::BadRequest("price_minor must be >= 0".into()));
|
||||
}
|
||||
if body.stock < 0 {
|
||||
return Err(ApiError::BadRequest("stock must be >= 0".into()));
|
||||
}
|
||||
let currency = body.currency.to_uppercase();
|
||||
let currency_ok: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM currencies WHERE code = $1 AND enabled)")
|
||||
.bind(¤cy)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !currency_ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"unknown currency: {currency}"
|
||||
)));
|
||||
}
|
||||
Ok(sqlx::query_as::<_, Sku>(
|
||||
"INSERT INTO skus (product_id, sku_code, attributes, price_minor, currency, stock, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (product_id, sku_code)
|
||||
DO UPDATE SET attributes = $3, price_minor = $4, currency = $5, stock = $6, active = $7
|
||||
RETURNING id, product_id, sku_code, attributes, price_minor, currency, stock, active",
|
||||
)
|
||||
.bind(product_id)
|
||||
.bind(body.sku_code.trim())
|
||||
.bind(body.attributes.unwrap_or_else(|| serde_json::json!({})))
|
||||
.bind(body.price_minor)
|
||||
.bind(¤cy)
|
||||
.bind(body.stock)
|
||||
.bind(body.active.unwrap_or(true))
|
||||
.fetch_one(&state.db)
|
||||
.await?)
|
||||
}
|
||||
Reference in New Issue
Block a user