From 5e866d08f4722453c50f14911910cd794c2a9149 Mon Sep 17 00:00:00 2001 From: Chengdong Zhang Date: Fri, 18 Sep 2026 14:59:36 +0800 Subject: [PATCH] refactor(api): split Axum handlers into handler/service/repo modules Keep the REST contract; move domain logic out of route files so checkout, fulfillment, and identity can be reused across customer, shop, and admin surfaces. Co-authored-by: Cursor --- AGENTS.md | 2 +- apps/api/migrations/0009_addresses.sql | 22 + apps/api/src/auth.rs | 13 + apps/api/src/error.rs | 21 +- apps/api/src/http/mod.rs | 3 + apps/api/src/http/pagination.rs | 51 +++ apps/api/src/lib.rs | 9 +- apps/api/src/main.rs | 9 +- apps/api/src/models.rs | 47 +- apps/api/src/modules/address/dto.rs | 35 ++ apps/api/src/modules/address/handlers.rs | 73 +++ apps/api/src/modules/address/mod.rs | 12 + apps/api/src/modules/address/repo.rs | 140 ++++++ apps/api/src/modules/address/service.rs | 81 ++++ apps/api/src/modules/billing/handlers.rs | 57 +++ apps/api/src/modules/billing/mod.rs | 10 + apps/api/src/modules/billing/service.rs | 133 ++++++ apps/api/src/modules/cart/handlers.rs | 65 +++ apps/api/src/modules/cart/mod.rs | 11 + apps/api/src/modules/cart/service.rs | 51 +++ .../src/{cart.rs => modules/cart/store.rs} | 77 ++-- apps/api/src/modules/catalog/dto.rs | 71 +++ apps/api/src/modules/catalog/handlers.rs | 145 ++++++ apps/api/src/modules/catalog/mod.rs | 12 + apps/api/src/modules/catalog/repo.rs | 73 +++ apps/api/src/modules/catalog/service.rs | 390 ++++++++++++++++ apps/api/src/modules/content/handlers.rs | 41 ++ apps/api/src/modules/content/mod.rs | 10 + .../content.rs => modules/content/service.rs} | 61 +-- apps/api/src/modules/currency/handlers.rs | 106 +++++ apps/api/src/modules/currency/mod.rs | 10 + apps/api/src/modules/currency/service.rs | 99 ++++ apps/api/src/modules/fulfillment/handlers.rs | 69 +++ apps/api/src/modules/fulfillment/mod.rs | 10 + apps/api/src/modules/fulfillment/service.rs | 197 ++++++++ apps/api/src/{routes => modules}/health.rs | 3 +- apps/api/src/modules/identity/admin.rs | 48 ++ apps/api/src/modules/identity/handlers.rs | 57 +++ apps/api/src/modules/identity/mod.rs | 12 + apps/api/src/modules/identity/repo.rs | 93 ++++ apps/api/src/modules/identity/service.rs | 112 +++++ apps/api/src/modules/mod.rs | 30 ++ apps/api/src/modules/order/dto.rs | 48 ++ apps/api/src/modules/order/handlers.rs | 134 ++++++ apps/api/src/modules/order/mod.rs | 17 + apps/api/src/modules/order/repo.rs | 381 ++++++++++++++++ apps/api/src/modules/order/service.rs | 161 +++++++ apps/api/src/modules/shop/handlers.rs | 93 ++++ apps/api/src/modules/shop/mod.rs | 10 + .../shops.rs => modules/shop/service.rs} | 125 +++--- apps/api/src/money.rs | 46 ++ apps/api/src/pagination.rs | 17 - apps/api/src/routes/admin.rs | 290 ------------ apps/api/src/routes/auth.rs | 97 ---- apps/api/src/routes/brands.rs | 101 ----- apps/api/src/routes/cart.rs | 94 ---- apps/api/src/routes/catalog.rs | 254 ----------- apps/api/src/routes/currency.rs | 57 --- apps/api/src/routes/mod.rs | 39 -- apps/api/src/routes/order_common.rs | 175 -------- apps/api/src/routes/orders.rs | 423 ------------------ apps/api/src/routes/shop.rs | 21 - apps/api/src/routes/shop_catalog.rs | 293 ------------ apps/api/src/routes/shop_orders.rs | 275 ------------ apps/api/src/state.rs | 11 +- apps/api/tests/addresses.rs | 207 +++++++++ apps/api/tests/common/mod.rs | 22 +- apps/api/tests/order_service.rs | 138 ++++++ 68 files changed, 3796 insertions(+), 2304 deletions(-) create mode 100644 apps/api/migrations/0009_addresses.sql create mode 100644 apps/api/src/http/mod.rs create mode 100644 apps/api/src/http/pagination.rs create mode 100644 apps/api/src/modules/address/dto.rs create mode 100644 apps/api/src/modules/address/handlers.rs create mode 100644 apps/api/src/modules/address/mod.rs create mode 100644 apps/api/src/modules/address/repo.rs create mode 100644 apps/api/src/modules/address/service.rs create mode 100644 apps/api/src/modules/billing/handlers.rs create mode 100644 apps/api/src/modules/billing/mod.rs create mode 100644 apps/api/src/modules/billing/service.rs create mode 100644 apps/api/src/modules/cart/handlers.rs create mode 100644 apps/api/src/modules/cart/mod.rs create mode 100644 apps/api/src/modules/cart/service.rs rename apps/api/src/{cart.rs => modules/cart/store.rs} (74%) create mode 100644 apps/api/src/modules/catalog/dto.rs create mode 100644 apps/api/src/modules/catalog/handlers.rs create mode 100644 apps/api/src/modules/catalog/mod.rs create mode 100644 apps/api/src/modules/catalog/repo.rs create mode 100644 apps/api/src/modules/catalog/service.rs create mode 100644 apps/api/src/modules/content/handlers.rs create mode 100644 apps/api/src/modules/content/mod.rs rename apps/api/src/{routes/content.rs => modules/content/service.rs} (74%) create mode 100644 apps/api/src/modules/currency/handlers.rs create mode 100644 apps/api/src/modules/currency/mod.rs create mode 100644 apps/api/src/modules/currency/service.rs create mode 100644 apps/api/src/modules/fulfillment/handlers.rs create mode 100644 apps/api/src/modules/fulfillment/mod.rs create mode 100644 apps/api/src/modules/fulfillment/service.rs rename apps/api/src/{routes => modules}/health.rs (86%) create mode 100644 apps/api/src/modules/identity/admin.rs create mode 100644 apps/api/src/modules/identity/handlers.rs create mode 100644 apps/api/src/modules/identity/mod.rs create mode 100644 apps/api/src/modules/identity/repo.rs create mode 100644 apps/api/src/modules/identity/service.rs create mode 100644 apps/api/src/modules/mod.rs create mode 100644 apps/api/src/modules/order/dto.rs create mode 100644 apps/api/src/modules/order/handlers.rs create mode 100644 apps/api/src/modules/order/mod.rs create mode 100644 apps/api/src/modules/order/repo.rs create mode 100644 apps/api/src/modules/order/service.rs create mode 100644 apps/api/src/modules/shop/handlers.rs create mode 100644 apps/api/src/modules/shop/mod.rs rename apps/api/src/{routes/shops.rs => modules/shop/service.rs} (54%) delete mode 100644 apps/api/src/pagination.rs delete mode 100644 apps/api/src/routes/admin.rs delete mode 100644 apps/api/src/routes/auth.rs delete mode 100644 apps/api/src/routes/brands.rs delete mode 100644 apps/api/src/routes/cart.rs delete mode 100644 apps/api/src/routes/catalog.rs delete mode 100644 apps/api/src/routes/currency.rs delete mode 100644 apps/api/src/routes/mod.rs delete mode 100644 apps/api/src/routes/order_common.rs delete mode 100644 apps/api/src/routes/orders.rs delete mode 100644 apps/api/src/routes/shop.rs delete mode 100644 apps/api/src/routes/shop_catalog.rs delete mode 100644 apps/api/src/routes/shop_orders.rs create mode 100644 apps/api/tests/addresses.rs create mode 100644 apps/api/tests/order_service.rs diff --git a/AGENTS.md b/AGENTS.md index 2ccc3a8..43463d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## 布局与所有权 -- `apps/api` — Rust 后端(crate `vmall-api`)。迁移在 `apps/api/migrations/`(sqlx,启动时自动执行,只增不改)。 +- `apps/api` — Rust 后端(crate `vmall-api`)。迁移在 `apps/api/migrations/`(sqlx,启动时自动执行,只增不改)。代码按限界上下文放在 `src/modules//`(handler → service → repo);HTTP 抽取器在 `src/http/`。新功能写进对应模块,不要把 SQL 堆回 handler。简单 CRUD 允许 handler 直接调 repo。Service 返回 `ApiResult`,不依赖 `Json`/`StatusCode`。Repo 接受 `&mut PgConnection` / `&mut Transaction` 以便组合事务。不引入泛型 Repository trait。 - `apps/mall` / `apps/shop-admin` / `apps/admin` — 三个 Nuxt 3 应用。端口固定 3000/3001/3002。 - `packages/shared` — `@vmall/shared`:**唯一** API 契约(`src/types.ts` + `src/api.ts`)、en/zh 语言包、共享样式 `ui.css`。前端禁止自建 API 封装;契约变更只在这里改,且三个前端都要过构建。 - `openspec/` — 规范。`specs/` 是已归档能力规范(auth, rbac, catalog, currency, cart, order, shipment, invoice, frontend-*)。 diff --git a/apps/api/migrations/0009_addresses.sql b/apps/api/migrations/0009_addresses.sql new file mode 100644 index 0000000..5330e5a --- /dev/null +++ b/apps/api/migrations/0009_addresses.sql @@ -0,0 +1,22 @@ +-- Address book: per-customer saved shipping addresses with a single default. +-- The default invariant is enforced by the partial unique index; handlers keep +-- it via transactional unset-then-set. + +CREATE TABLE addresses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + recipient TEXT NOT NULL, + phone TEXT NOT NULL, + country TEXT NOT NULL, + region TEXT NOT NULL DEFAULT '', + city TEXT NOT NULL, + line1 TEXT NOT NULL, + postal_code TEXT NOT NULL DEFAULT '', + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX addresses_user_idx ON addresses (user_id); + +CREATE UNIQUE INDEX addresses_one_default_idx ON addresses (user_id) WHERE is_default; diff --git a/apps/api/src/auth.rs b/apps/api/src/auth.rs index aa0c6f4..8c50a99 100644 --- a/apps/api/src/auth.rs +++ b/apps/api/src/auth.rs @@ -86,6 +86,19 @@ impl AuthUser { Err(ApiError::Forbidden("shop role required".into())) } } + + pub fn require_customer(&self) -> Result<(), ApiError> { + self.require(&[UserRole::Customer]) + } + + pub fn require_admin(&self) -> Result<(), ApiError> { + self.require(&[UserRole::PlatformAdmin]) + } + + pub fn require_shop(&self) -> Result { + self.require(&[UserRole::ShopOwner, UserRole::ShopStaff])?; + self.own_shop() + } } impl FromRequestParts for AuthUser { diff --git a/apps/api/src/error.rs b/apps/api/src/error.rs index 53170f7..4565b3d 100644 --- a/apps/api/src/error.rs +++ b/apps/api/src/error.rs @@ -46,7 +46,24 @@ impl IntoResponse for ApiError { impl From for ApiError { fn from(e: sqlx::Error) -> Self { - ApiError::Internal(anyhow::Error::new(e)) + match &e { + sqlx::Error::RowNotFound => ApiError::NotFound("not found".into()), + sqlx::Error::Database(d) if d.is_unique_violation() => { + ApiError::Conflict("unique constraint violated".into()) + } + sqlx::Error::Database(d) if d.is_check_violation() => { + ApiError::Conflict("check constraint violated".into()) + } + _ => ApiError::Internal(anyhow::Error::new(e)), + } + } +} + +/// Prefer a domain-specific 409 over the generic unique-constraint mapping. +pub fn unique_conflict(err: sqlx::Error, message: impl Into) -> ApiError { + match &err { + sqlx::Error::Database(d) if d.is_unique_violation() => ApiError::Conflict(message.into()), + _ => ApiError::from(err), } } @@ -56,4 +73,6 @@ impl From for ApiError { } } +pub use unique_conflict as map_unique; + pub type ApiResult = Result; diff --git a/apps/api/src/http/mod.rs b/apps/api/src/http/mod.rs new file mode 100644 index 0000000..fac0c48 --- /dev/null +++ b/apps/api/src/http/mod.rs @@ -0,0 +1,3 @@ +pub mod pagination; + +pub use pagination::{clamp_page, clamp_per_page, PageQuery, Paged}; diff --git a/apps/api/src/http/pagination.rs b/apps/api/src/http/pagination.rs new file mode 100644 index 0000000..4814a4e --- /dev/null +++ b/apps/api/src/http/pagination.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct Paged { + pub items: Vec, + pub total: i64, + pub page: i64, + pub per_page: i64, +} + +#[derive(Debug, Default, Deserialize)] +pub struct PageQuery { + pub page: Option, + pub per_page: Option, +} + +impl PageQuery { + pub fn page(&self) -> i64 { + clamp_page(self.page) + } + + pub fn per_page(&self) -> i64 { + clamp_per_page(self.per_page) + } + + pub fn offset(&self) -> i64 { + (self.page() - 1) * self.per_page() + } +} + +pub fn clamp_page(page: Option) -> i64 { + page.unwrap_or(1).max(1) +} + +pub fn clamp_per_page(per_page: Option) -> i64 { + per_page.unwrap_or(20).clamp(1, 100) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clamps_page_and_per_page() { + assert_eq!(clamp_page(None), 1); + assert_eq!(clamp_page(Some(0)), 1); + assert_eq!(clamp_per_page(None), 20); + assert_eq!(clamp_per_page(Some(0)), 1); + assert_eq!(clamp_per_page(Some(500)), 100); + } +} diff --git a/apps/api/src/lib.rs b/apps/api/src/lib.rs index e674228..4f792ae 100644 --- a/apps/api/src/lib.rs +++ b/apps/api/src/lib.rs @@ -1,11 +1,10 @@ pub mod auth; -pub mod cart; pub mod config; pub mod error; +pub mod http; pub mod models; pub mod money; -pub mod pagination; -pub mod routes; +pub mod modules; pub mod seed; pub mod state; @@ -16,8 +15,8 @@ use crate::state::AppState; pub fn build_router(state: AppState) -> Router { Router::new() - .route("/api/health", get(routes::health::health)) - .nest("/api", routes::api_router(state.clone())) + .route("/api/health", get(modules::health::health)) + .nest("/api", modules::api_router()) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) .with_state(state) diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 294920a..dc74bab 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -1,4 +1,4 @@ -use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state::build_state}; +use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -10,10 +10,9 @@ async fn main() -> anyhow::Result<()> { .init(); let config = Config::from_env()?; - sqlx::migrate!("./migrations") - .run(&sqlx::PgPool::connect(&config.database_url).await?) - .await?; - let state = build_state(&config).await?; + let db = sqlx::PgPool::connect(&config.database_url).await?; + sqlx::migrate!("./migrations").run(&db).await?; + let state = state::assemble(config.clone(), db).await?; ensure_platform_admin(&state).await?; let app = build_router(state); diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index d9ca1fb..107b7b1 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -27,11 +27,10 @@ pub enum ShopStatus { Suspended, } -#[derive(Debug, Serialize, sqlx::FromRow)] +#[derive(Debug, Clone, sqlx::FromRow)] pub struct User { pub id: Uuid, pub email: String, - #[serde(skip_serializing)] pub password_hash: String, pub display_name: String, pub role: UserRole, @@ -40,6 +39,35 @@ pub struct User { pub created_at: DateTime, } +/// Public user JSON. Never includes `password_hash`. +#[derive(Debug, Serialize)] +pub struct UserPublic { + pub id: Uuid, + pub email: String, + pub display_name: String, + pub role: UserRole, + pub shop_id: Option, + pub locale: String, + pub created_at: DateTime, +} + +impl From for UserPublic { + fn from(u: User) -> Self { + Self { + id: u.id, + email: u.email, + display_name: u.display_name, + role: u.role, + shop_id: u.shop_id, + locale: u.locale, + created_at: u.created_at, + } + } +} + +pub const USER_COLUMNS: &str = + "id, email, password_hash, display_name, role, shop_id, locale, created_at"; + #[derive(Debug, Serialize, sqlx::FromRow)] pub struct Shop { pub id: Uuid, @@ -213,3 +241,18 @@ pub struct Invoice { pub issued_at: Option>, pub created_at: DateTime, } + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct AddressBookEntry { + pub id: Uuid, + pub user_id: Uuid, + pub recipient: String, + pub phone: String, + pub country: String, + pub region: String, + pub city: String, + pub line1: String, + pub postal_code: String, + pub is_default: bool, + pub created_at: DateTime, +} diff --git a/apps/api/src/modules/address/dto.rs b/apps/api/src/modules/address/dto.rs new file mode 100644 index 0000000..98caa44 --- /dev/null +++ b/apps/api/src/modules/address/dto.rs @@ -0,0 +1,35 @@ +use serde::Deserialize; + +use crate::error::{ApiError, ApiResult}; + +#[derive(Deserialize)] +pub struct AddressInput { + pub recipient: String, + pub phone: String, + pub country: String, + #[serde(default)] + pub region: String, + pub city: String, + pub line1: String, + #[serde(default)] + pub postal_code: String, + #[serde(default)] + pub is_default: bool, +} + +impl AddressInput { + pub fn validate(&self) -> ApiResult<()> { + for (field, value) in [ + ("recipient", &self.recipient), + ("phone", &self.phone), + ("country", &self.country), + ("city", &self.city), + ("line1", &self.line1), + ] { + if value.trim().is_empty() { + return Err(ApiError::BadRequest(format!("address.{field} is required"))); + } + } + Ok(()) + } +} diff --git a/apps/api/src/modules/address/handlers.rs b/apps/api/src/modules/address/handlers.rs new file mode 100644 index 0000000..30fe593 --- /dev/null +++ b/apps/api/src/modules/address/handlers.rs @@ -0,0 +1,73 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::models::AddressBookEntry; +use crate::state::AppState; + +use super::dto::AddressInput; +use super::service; + +pub fn router() -> Router { + Router::new() + .route("/addresses", get(list_addresses).post(create_address)) + .route( + "/addresses/{id}", + axum::routing::put(update_address).delete(delete_address), + ) + .route("/addresses/{id}/default", post(set_default)) +} + +async fn list_addresses( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require_customer()?; + Ok(Json(service::list(&state, auth.id).await?)) +} + +async fn create_address( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require_customer()?; + Ok(( + StatusCode::CREATED, + Json(service::create(&state, auth.id, body).await?), + )) +} + +async fn update_address( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_customer()?; + Ok(Json(service::update(&state, auth.id, id, body).await?)) +} + +async fn set_default( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_customer()?; + Ok(Json(service::set_default(&state, auth.id, id).await?)) +} + +async fn delete_address( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult>> { + auth.require_customer()?; + Ok(Json(service::delete(&state, auth.id, id).await?)) +} diff --git a/apps/api/src/modules/address/mod.rs b/apps/api/src/modules/address/mod.rs new file mode 100644 index 0000000..2cdf8fe --- /dev/null +++ b/apps/api/src/modules/address/mod.rs @@ -0,0 +1,12 @@ +mod dto; +mod handlers; +mod repo; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/address/repo.rs b/apps/api/src/modules/address/repo.rs new file mode 100644 index 0000000..52daf05 --- /dev/null +++ b/apps/api/src/modules/address/repo.rs @@ -0,0 +1,140 @@ +use sqlx::{PgConnection, PgExecutor}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::AddressBookEntry; + +const ADDR_COLS: &str = "id, user_id, recipient, phone, country, region, city, line1, postal_code, + is_default, created_at"; + +pub async fn list_for_user<'e, E: PgExecutor<'e>>( + exec: E, + user_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, AddressBookEntry>(&format!( + "SELECT {ADDR_COLS} + FROM addresses WHERE user_id = $1 + ORDER BY is_default DESC, created_at DESC" + )) + .bind(user_id) + .fetch_all(exec) + .await?) +} + +pub async fn get_own( + db: &sqlx::PgPool, + user_id: Uuid, + id: Uuid, +) -> ApiResult { + sqlx::query_as::<_, AddressBookEntry>(&format!( + "SELECT {ADDR_COLS} + FROM addresses WHERE id = $1 AND user_id = $2" + )) + .bind(id) + .bind(user_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::NotFound("address not found".into())) +} + +pub async fn count_for_user(tx: &mut PgConnection, user_id: Uuid) -> ApiResult { + Ok( + sqlx::query_scalar("SELECT COUNT(*) FROM addresses WHERE user_id = $1") + .bind(user_id) + .fetch_one(&mut *tx) + .await?, + ) +} + +pub async fn clear_defaults(tx: &mut PgConnection, user_id: Uuid) -> ApiResult<()> { + sqlx::query("UPDATE addresses SET is_default = FALSE WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn insert( + tx: &mut PgConnection, + user_id: Uuid, + body: &super::dto::AddressInput, + is_default: bool, +) -> ApiResult { + Ok(sqlx::query_as::<_, AddressBookEntry>(&format!( + "INSERT INTO addresses (user_id, recipient, phone, country, region, city, line1, postal_code, is_default) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING {ADDR_COLS}" + )) + .bind(user_id) + .bind(&body.recipient) + .bind(&body.phone) + .bind(&body.country) + .bind(&body.region) + .bind(&body.city) + .bind(&body.line1) + .bind(&body.postal_code) + .bind(is_default) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn update( + tx: &mut PgConnection, + user_id: Uuid, + id: Uuid, + body: &super::dto::AddressInput, + is_default: bool, +) -> ApiResult { + Ok(sqlx::query_as::<_, AddressBookEntry>(&format!( + "UPDATE addresses + SET recipient = $3, phone = $4, country = $5, region = $6, city = $7, + line1 = $8, postal_code = $9, is_default = $10, updated_at = now() + WHERE id = $1 AND user_id = $2 + RETURNING {ADDR_COLS}" + )) + .bind(id) + .bind(user_id) + .bind(&body.recipient) + .bind(&body.phone) + .bind(&body.country) + .bind(&body.region) + .bind(&body.city) + .bind(&body.line1) + .bind(&body.postal_code) + .bind(is_default) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn set_default_row(tx: &mut PgConnection, id: Uuid) -> ApiResult { + Ok(sqlx::query_as::<_, AddressBookEntry>(&format!( + "UPDATE addresses SET is_default = TRUE, updated_at = now() + WHERE id = $1 + RETURNING {ADDR_COLS}" + )) + .bind(id) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn delete(tx: &mut PgConnection, id: Uuid) -> ApiResult<()> { + sqlx::query("DELETE FROM addresses WHERE id = $1") + .bind(id) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn promote_latest_default(tx: &mut PgConnection, user_id: Uuid) -> ApiResult<()> { + sqlx::query( + "UPDATE addresses SET is_default = TRUE, updated_at = now() + WHERE id = ( + SELECT id FROM addresses WHERE user_id = $1 + ORDER BY created_at DESC LIMIT 1 + )", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + Ok(()) +} diff --git a/apps/api/src/modules/address/service.rs b/apps/api/src/modules/address/service.rs new file mode 100644 index 0000000..09a007e --- /dev/null +++ b/apps/api/src/modules/address/service.rs @@ -0,0 +1,81 @@ +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::models::AddressBookEntry; +use crate::state::AppState; + +use super::dto::AddressInput; +use super::repo; + +pub async fn list(state: &AppState, user_id: Uuid) -> ApiResult> { + repo::list_for_user(&state.db, user_id).await +} + +pub async fn create( + state: &AppState, + user_id: Uuid, + body: AddressInput, +) -> ApiResult { + body.validate()?; + let mut tx = state.db.begin().await?; + let count = repo::count_for_user(&mut tx, user_id).await?; + let make_default = body.is_default || count == 0; + if make_default { + repo::clear_defaults(&mut tx, user_id).await?; + } + let row = repo::insert(&mut tx, user_id, &body, make_default).await?; + tx.commit().await?; + Ok(row) +} + +pub async fn update( + state: &AppState, + user_id: Uuid, + id: Uuid, + body: AddressInput, +) -> ApiResult { + body.validate()?; + let existing = repo::get_own(&state.db, user_id, id).await?; + let mut tx = state.db.begin().await?; + if body.is_default && !existing.is_default { + repo::clear_defaults(&mut tx, user_id).await?; + } + let row = repo::update( + &mut tx, + user_id, + id, + &body, + body.is_default || existing.is_default, + ) + .await?; + tx.commit().await?; + Ok(row) +} + +pub async fn set_default( + state: &AppState, + user_id: Uuid, + id: Uuid, +) -> ApiResult { + repo::get_own(&state.db, user_id, id).await?; + let mut tx = state.db.begin().await?; + repo::clear_defaults(&mut tx, user_id).await?; + let row = repo::set_default_row(&mut tx, id).await?; + tx.commit().await?; + Ok(row) +} + +pub async fn delete( + state: &AppState, + user_id: Uuid, + id: Uuid, +) -> ApiResult> { + let existing = repo::get_own(&state.db, user_id, id).await?; + let mut tx = state.db.begin().await?; + repo::delete(&mut tx, id).await?; + if existing.is_default { + repo::promote_latest_default(&mut tx, user_id).await?; + } + tx.commit().await?; + repo::list_for_user(&state.db, user_id).await +} diff --git a/apps/api/src/modules/billing/handlers.rs b/apps/api/src/modules/billing/handlers.rs new file mode 100644 index 0000000..1ac37b8 --- /dev/null +++ b/apps/api/src/modules/billing/handlers.rs @@ -0,0 +1,57 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::state::AppState; + +use super::service::{self, InvoiceBody, InvoiceView}; + +pub fn router() -> Router { + Router::new() + .route("/orders/{id}/invoice", post(request_invoice)) + .route("/invoices", get(list_my_invoices)) + .route("/shop/invoices", get(list_shop_invoices)) + .route("/shop/invoices/{id}/issue", post(issue_invoice)) +} + +async fn request_invoice( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + Ok(( + StatusCode::CREATED, + Json(service::request(&state, auth.id, id, body).await?), + )) +} + +async fn list_my_invoices( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + Ok(Json(service::list_for_user(&state, auth.id).await?)) +} + +async fn list_shop_invoices( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + let shop_id = auth.require_shop()?; + Ok(Json(service::list_for_shop(&state, shop_id).await?)) +} + +async fn issue_invoice( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::issue(&state, shop_id, id).await?)) +} diff --git a/apps/api/src/modules/billing/mod.rs b/apps/api/src/modules/billing/mod.rs new file mode 100644 index 0000000..b8b87a5 --- /dev/null +++ b/apps/api/src/modules/billing/mod.rs @@ -0,0 +1,10 @@ +mod handlers; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/billing/service.rs b/apps/api/src/modules/billing/service.rs new file mode 100644 index 0000000..aaf992e --- /dev/null +++ b/apps/api/src/modules/billing/service.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::error::{unique_conflict, ApiError, ApiResult}; +use crate::models::{Invoice, InvoiceKind, OrderStatus}; +use crate::modules::order::repo as order_repo; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct InvoiceView { + #[serde(flatten)] + pub invoice: Invoice, + pub order_no: String, +} + +#[derive(Deserialize)] +pub struct InvoiceBody { + pub title: String, + pub tax_no: Option, + pub kind: InvoiceKind, +} + +const INVOICE_COLS: &str = "id, invoice_no, order_id, user_id, title, tax_no, kind, + amount_minor, currency, status, issued_at, created_at"; +const INVOICE_I: &str = "i.id, i.invoice_no, i.order_id, i.user_id, i.title, i.tax_no, i.kind, + i.amount_minor, i.currency, i.status, i.issued_at, i.created_at"; + +async fn views(db: &PgPool, invoices: Vec) -> ApiResult> { + let order_ids: Vec = invoices.iter().map(|i| i.order_id).collect(); + let order_nos: Vec<(Uuid, String)> = if order_ids.is_empty() { + Vec::new() + } else { + sqlx::query_as("SELECT id, order_no FROM orders WHERE id = ANY($1)") + .bind(&order_ids) + .fetch_all(db) + .await? + }; + let nos: HashMap = order_nos.into_iter().collect(); + Ok(invoices + .into_iter() + .map(|i| InvoiceView { + order_no: nos.get(&i.order_id).cloned().unwrap_or_default(), + invoice: i, + }) + .collect()) +} + +pub async fn list_for_user(state: &AppState, user_id: Uuid) -> ApiResult> { + let invoices = sqlx::query_as::<_, Invoice>(&format!( + "SELECT {INVOICE_COLS} FROM invoices WHERE user_id = $1 ORDER BY created_at DESC" + )) + .bind(user_id) + .fetch_all(&state.db) + .await?; + views(&state.db, invoices).await +} + +pub async fn list_for_shop(state: &AppState, shop_id: Uuid) -> ApiResult> { + let invoices = sqlx::query_as::<_, Invoice>(&format!( + "SELECT {INVOICE_I} FROM invoices i + JOIN orders o ON o.id = i.order_id + WHERE o.shop_id = $1 + ORDER BY i.created_at DESC" + )) + .bind(shop_id) + .fetch_all(&state.db) + .await?; + views(&state.db, invoices).await +} + +pub async fn request( + state: &AppState, + user_id: Uuid, + order_id: Uuid, + body: InvoiceBody, +) -> ApiResult { + let order = order_repo::get_for_user(&state.db, user_id, order_id).await?; + if matches!( + order.status, + OrderStatus::PendingPayment | OrderStatus::Cancelled + ) { + return Err(ApiError::BadRequest( + "invoices can only be requested for paid orders".into(), + )); + } + if body.title.trim().is_empty() { + return Err(ApiError::BadRequest("title is required".into())); + } + if body.kind == InvoiceKind::Company + && body.tax_no.as_ref().map(|t| t.trim().is_empty()).unwrap_or(true) + { + return Err(ApiError::BadRequest( + "tax_no is required for company invoices".into(), + )); + } + let invoice = sqlx::query_as::<_, Invoice>(&format!( + "INSERT INTO invoices (order_id, user_id, title, tax_no, kind, amount_minor, currency) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {INVOICE_COLS}" + )) + .bind(order.id) + .bind(user_id) + .bind(body.title.trim()) + .bind(body.tax_no.as_deref().map(str::trim)) + .bind(body.kind) + .bind(order.total_minor) + .bind(&order.currency) + .fetch_one(&state.db) + .await + .map_err(|e| unique_conflict(e, "order already has an open invoice"))?; + let mut out = views(&state.db, vec![invoice]).await?; + Ok(out.remove(0)) +} + +pub async fn issue(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + let invoice = sqlx::query_as::<_, Invoice>(&format!( + "UPDATE invoices i + SET status = 'issued', issued_at = now(), + invoice_no = 'INV' || to_char(now(), 'YYMMDD') || lpad(nextval('invoice_no_seq')::text, 6, '0') + FROM orders o + WHERE i.id = $1 AND o.id = i.order_id AND o.shop_id = $2 AND i.status = 'requested' + RETURNING {INVOICE_I}" + )) + .bind(id) + .bind(shop_id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::Conflict("invoice not found or not in requested status".into()))?; + let mut out = views(&state.db, vec![invoice]).await?; + Ok(out.remove(0)) +} diff --git a/apps/api/src/modules/cart/handlers.rs b/apps/api/src/modules/cart/handlers.rs new file mode 100644 index 0000000..7c55b61 --- /dev/null +++ b/apps/api/src/modules/cart/handlers.rs @@ -0,0 +1,65 @@ +use axum::{ + extract::{Path, State}, + routing::{get, post, put}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::state::AppState; + +use super::service; +use super::store::CartView; + +pub fn router() -> Router { + Router::new() + .route("/cart", get(get_cart)) + .route("/cart/items", post(add_item)) + .route("/cart/items/{sku_id}", put(set_item).delete(remove_item)) +} + +async fn get_cart(State(state): State, auth: AuthUser) -> ApiResult> { + Ok(Json(service::get(&state, auth.id).await?)) +} + +#[derive(Deserialize)] +struct AddBody { + sku_id: Uuid, + qty: i32, +} + +async fn add_item( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult> { + Ok(Json( + service::add_item(&state, auth.id, body.sku_id, body.qty).await?, + )) +} + +#[derive(Deserialize)] +struct SetBody { + qty: i32, +} + +async fn set_item( + State(state): State, + auth: AuthUser, + Path(sku_id): Path, + Json(body): Json, +) -> ApiResult> { + Ok(Json( + service::set_item(&state, auth.id, sku_id, body.qty).await?, + )) +} + +async fn remove_item( + State(state): State, + auth: AuthUser, + Path(sku_id): Path, +) -> ApiResult> { + Ok(Json(service::remove_item(&state, auth.id, sku_id).await?)) +} diff --git a/apps/api/src/modules/cart/mod.rs b/apps/api/src/modules/cart/mod.rs new file mode 100644 index 0000000..7750da3 --- /dev/null +++ b/apps/api/src/modules/cart/mod.rs @@ -0,0 +1,11 @@ +mod handlers; +pub mod service; +pub mod store; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/cart/service.rs b/apps/api/src/modules/cart/service.rs new file mode 100644 index 0000000..76646e8 --- /dev/null +++ b/apps/api/src/modules/cart/service.rs @@ -0,0 +1,51 @@ +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::state::AppState; + +use super::store::{self, CartView}; + +pub async fn get(state: &AppState, user_id: Uuid) -> ApiResult { + store::cart_view(state, user_id).await +} + +pub async fn add_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult { + if qty <= 0 { + return Err(ApiError::BadRequest("qty must be > 0".into())); + } + if !store::is_purchasable(state, sku_id).await? { + return Err(ApiError::BadRequest("sku is not purchasable".into())); + } + let current = store::read_cart(state, user_id).await?; + let existing = current + .iter() + .find(|(id, _)| *id == sku_id) + .map(|(_, q)| *q) + .unwrap_or(0); + store::set_qty(state, user_id, sku_id, existing + qty).await?; + store::cart_view(state, user_id).await +} + +pub async fn set_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult { + if qty <= 0 { + return Err(ApiError::BadRequest("qty must be > 0; use DELETE to remove".into())); + } + if !store::is_purchasable(state, sku_id).await? { + return Err(ApiError::BadRequest("sku is not purchasable".into())); + } + store::set_qty(state, user_id, sku_id, qty).await?; + store::cart_view(state, user_id).await +} + +pub async fn remove_item(state: &AppState, user_id: Uuid, sku_id: Uuid) -> ApiResult { + store::set_qty(state, user_id, sku_id, 0).await?; + store::cart_view(state, user_id).await +} + +pub async fn read_entries(state: &AppState, user_id: Uuid) -> ApiResult> { + store::read_cart(state, user_id).await +} + +pub async fn clear(state: &AppState, user_id: Uuid) -> ApiResult<()> { + store::clear_cart(state, user_id).await +} diff --git a/apps/api/src/cart.rs b/apps/api/src/modules/cart/store.rs similarity index 74% rename from apps/api/src/cart.rs rename to apps/api/src/modules/cart/store.rs index a52f37f..7ca91c1 100644 --- a/apps/api/src/cart.rs +++ b/apps/api/src/modules/cart/store.rs @@ -9,7 +9,6 @@ fn key(user_id: Uuid) -> String { format!("vmall:cart:{user_id}") } -/// Raw cart: sku_id -> qty (positive only). pub async fn read_cart(state: &AppState, user_id: Uuid) -> ApiResult> { let mut conn = state.redis.clone(); let raw: Vec<(String, i32)> = conn.hgetall(key(user_id)).await.map_err(ApiError::from)?; @@ -50,10 +49,8 @@ pub struct CartItemView { pub unit_price_minor: i64, pub currency: String, pub qty: i32, - /// Owning shop, so the storefront can group lines without the mock catalog. pub shop_id: Uuid, pub shop_name: serde_json::Value, - /// Current SKU stock; advisory for the quantity stepper. Checkout is authoritative. pub stock: i32, } @@ -62,8 +59,34 @@ pub struct CartView { pub items: Vec, } -/// Cart read model: joins Redis entries with purchasable SKU snapshots. -/// Entries whose SKU vanished or became unpurchasable are dropped from the view. +#[derive(sqlx::FromRow)] +struct CartRow { + sku_id: Uuid, + product_id: Uuid, + product_name: serde_json::Value, + sku_code: String, + image: Option, + price_minor: i64, + currency: String, + stock: i32, + shop_id: Uuid, + shop_name: serde_json::Value, +} + +pub async fn is_purchasable(state: &AppState, sku_id: Uuid) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM skus s + JOIN products p ON p.id = s.product_id + JOIN shops sh ON sh.id = p.shop_id + WHERE s.id = $1 AND s.active = TRUE + AND p.status = 'published' AND sh.status = 'active')", + ) + .bind(sku_id) + .fetch_one(&state.db) + .await?) +} + pub async fn cart_view(state: &AppState, user_id: Uuid) -> ApiResult { let entries = read_cart(state, user_id).await?; if entries.is_empty() { @@ -83,40 +106,24 @@ pub async fn cart_view(state: &AppState, user_id: Uuid) -> ApiResult { .bind(&sku_ids) .fetch_all(&state.db) .await?; + let qty_by_sku: std::collections::HashMap = entries.into_iter().collect(); let items = rows .into_iter() .filter_map(|r| { - entries - .iter() - .find(|(id, _)| *id == r.sku_id) - .map(|(_, qty)| CartItemView { - sku_id: r.sku_id, - product_id: r.product_id, - product_name: r.product_name, - sku_code: r.sku_code, - image: r.image, - unit_price_minor: r.price_minor, - currency: r.currency, - qty: *qty, - shop_id: r.shop_id, - shop_name: r.shop_name, - stock: r.stock, - }) + qty_by_sku.get(&r.sku_id).map(|qty| CartItemView { + sku_id: r.sku_id, + product_id: r.product_id, + product_name: r.product_name, + sku_code: r.sku_code, + image: r.image, + unit_price_minor: r.price_minor, + currency: r.currency, + qty: *qty, + shop_id: r.shop_id, + shop_name: r.shop_name, + stock: r.stock, + }) }) .collect(); Ok(CartView { items }) } - -#[derive(sqlx::FromRow)] -struct CartRow { - sku_id: Uuid, - product_id: Uuid, - product_name: serde_json::Value, - sku_code: String, - image: Option, - price_minor: i64, - currency: String, - stock: i32, - shop_id: Uuid, - shop_name: serde_json::Value, -} diff --git a/apps/api/src/modules/catalog/dto.rs b/apps/api/src/modules/catalog/dto.rs new file mode 100644 index 0000000..b42b397 --- /dev/null +++ b/apps/api/src/modules/catalog/dto.rs @@ -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, + pub sold_count: i64, +} + +#[derive(Deserialize)] +pub struct PublicListQuery { + pub page: Option, + pub per_page: Option, + pub category_id: Option, + pub brand_id: Option, + pub shop_id: Option, + pub q: Option, + pub sort: Option, + pub order: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortBy { + Newest, + Price, + Sales, +} + +impl PublicListQuery { + pub fn sort_by(&self) -> ApiResult { + 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 { + 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, + pub brand_id: Option, + pub slug: String, + pub name: serde_json::Value, + pub description: Option, + pub images: Option, +} + +#[derive(Deserialize)] +pub struct SkuBody { + pub sku_code: String, + pub attributes: Option, + pub price_minor: i64, + pub currency: String, + pub stock: i32, + pub active: Option, +} diff --git a/apps/api/src/modules/catalog/handlers.rs b/apps/api/src/modules/catalog/handlers.rs new file mode 100644 index 0000000..b6c4a0a --- /dev/null +++ b/apps/api/src/modules/catalog/handlers.rs @@ -0,0 +1,145 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{get, post, put}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::Paged; +use crate::models::{Brand, Category, ProductStatus, Sku}; +use crate::state::AppState; + +use super::dto::{ProductBody, ProductWithSkus, PublicListQuery, SkuBody}; +use super::service::{self, BrandInput}; + +pub fn router() -> Router { + Router::new() + .route("/products", get(list_products)) + .route("/products/{id_or_slug}", get(get_product)) + .route("/categories", get(list_categories)) + .route("/brands", get(list_brands)) + .route("/admin/brands", put(replace_brands)) + .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, + Query(q): Query, +) -> ApiResult>> { + Ok(Json(service::list_public(&state, q).await?)) +} + +async fn get_product( + State(state): State, + Path(id_or_slug): Path, +) -> ApiResult> { + Ok(Json(service::get_public(&state, &id_or_slug).await?)) +} + +async fn list_categories(State(state): State) -> ApiResult>> { + Ok(Json(service::list_categories(&state).await?)) +} + +async fn list_brands(State(state): State) -> ApiResult>> { + Ok(Json(service::list_brands(&state).await?)) +} + +async fn replace_brands( + State(state): State, + auth: AuthUser, + Json(body): Json>, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::replace_brands(&state, body).await?)) +} + +#[derive(Deserialize)] +struct ShopListQuery { + page: Option, + per_page: Option, + status: Option, +} + +async fn shop_list( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + 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, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::get_shop_product(&state, shop_id, id).await?)) +} + +async fn create_product( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + 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, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json( + service::update_product(&state, shop_id, id, body).await?, + )) +} + +async fn publish( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json( + service::transition(&state, shop_id, id, ProductStatus::Published).await?, + )) +} + +async fn unpublish( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json( + service::transition(&state, shop_id, id, ProductStatus::Unpublished).await?, + )) +} + +async fn upsert_sku( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::upsert_sku(&state, shop_id, id, body).await?)) +} diff --git a/apps/api/src/modules/catalog/mod.rs b/apps/api/src/modules/catalog/mod.rs new file mode 100644 index 0000000..2cdf8fe --- /dev/null +++ b/apps/api/src/modules/catalog/mod.rs @@ -0,0 +1,12 @@ +mod dto; +mod handlers; +mod repo; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/catalog/repo.rs b/apps/api/src/modules/catalog/repo.rs new file mode 100644 index 0000000..1cfb652 --- /dev/null +++ b/apps/api/src/modules/catalog/repo.rs @@ -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, + public_only: bool, +) -> ApiResult> { + let ids: Vec = 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 = 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> = HashMap::new(); + for sku in skus { + skus_by.entry(sku.product_id).or_default().push(sku); + } + let sold_by: HashMap = 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()) +} diff --git a/apps/api/src/modules/catalog/service.rs b/apps/api/src/modules/catalog/service.rs new file mode 100644 index 0000000..00e9a93 --- /dev/null +++ b/apps/api/src/modules/catalog/service.rs @@ -0,0 +1,390 @@ +use crate::error::{unique_conflict, ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::models::{Brand, Category, Product, ProductStatus, Sku}; +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 SUBTREE_CTE: &str = "WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = $1::uuid + UNION ALL + SELECT c.id FROM categories c JOIN subtree st ON c.parent_id = st.id + ) "; + +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> { + 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 { + 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)) +} + +pub async fn list_categories(state: &AppState) -> ApiResult> { + Ok(sqlx::query_as::<_, Category>( + "SELECT id, parent_id, name, slug, position FROM categories ORDER BY position, slug", + ) + .fetch_all(&state.db) + .await?) +} + +pub async fn list_brands(state: &AppState) -> ApiResult> { + Ok(sqlx::query_as::<_, Brand>( + "SELECT id, name, slug, position, active FROM brands WHERE active = TRUE ORDER BY position, created_at", + ) + .fetch_all(&state.db) + .await?) +} + +#[derive(serde::Deserialize)] +pub struct BrandInput { + pub slug: String, + pub name: serde_json::Value, + #[serde(default = "default_active")] + pub active: bool, +} + +fn default_active() -> bool { + true +} + +pub async fn replace_brands(state: &AppState, body: Vec) -> ApiResult> { + for brand in &body { + if brand.slug.trim().is_empty() + || !brand + .slug + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err(ApiError::BadRequest(format!( + "invalid brand slug: {}", + brand.slug + ))); + } + let ok = ["en", "zh"].iter().all(|code| { + brand + .name + .get(code) + .and_then(serde_json::Value::as_str) + .is_some_and(|s| !s.trim().is_empty()) + }); + if !ok { + return Err(ApiError::BadRequest(format!( + "brand {} needs non-empty en and zh names", + brand.slug + ))); + } + } + + let mut tx = state.db.begin().await?; + sqlx::query("DELETE FROM brands").execute(&mut *tx).await?; + for (i, brand) in body.iter().enumerate() { + sqlx::query("INSERT INTO brands (name, slug, position, active) VALUES ($1, $2, $3, $4)") + .bind(&brand.name) + .bind(brand.slug.trim()) + .bind(i as i32) + .bind(brand.active) + .execute(&mut *tx) + .await + .map_err(|e| unique_conflict(e, format!("duplicate brand slug: {}", brand.slug)))?; + } + tx.commit().await?; + list_brands(state).await +} + +async fn load_own_product(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + 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, + per_page: Option, + status: Option, +) -> ApiResult> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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?) +} diff --git a/apps/api/src/modules/content/handlers.rs b/apps/api/src/modules/content/handlers.rs new file mode 100644 index 0000000..5c46a2d --- /dev/null +++ b/apps/api/src/modules/content/handlers.rs @@ -0,0 +1,41 @@ +use axum::{ + extract::{Path, State}, + routing::{get, put}, + Json, Router, +}; +use serde_json::Value; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::state::AppState; + +use super::service::{self, ContentView}; + +pub fn router() -> Router { + Router::new() + .route("/content/home", get(home_content)) + .route("/admin/content", get(admin_content)) + .route("/admin/content/{kind}", put(replace_content)) +} + +async fn home_content(State(state): State) -> ApiResult> { + Ok(Json(service::load_content(&state, true).await?)) +} + +async fn admin_content( + State(state): State, + auth: AuthUser, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::load_content(&state, false).await?)) +} + +async fn replace_content( + State(state): State, + auth: AuthUser, + Path(kind): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::replace_kind(&state, &kind, body).await?)) +} diff --git a/apps/api/src/modules/content/mod.rs b/apps/api/src/modules/content/mod.rs new file mode 100644 index 0000000..b8b87a5 --- /dev/null +++ b/apps/api/src/modules/content/mod.rs @@ -0,0 +1,10 @@ +mod handlers; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/routes/content.rs b/apps/api/src/modules/content/service.rs similarity index 74% rename from apps/api/src/routes/content.rs rename to apps/api/src/modules/content/service.rs index 9585c86..6f50754 100644 --- a/apps/api/src/routes/content.rs +++ b/apps/api/src/modules/content/service.rs @@ -1,15 +1,8 @@ -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; #[derive(Debug, Serialize, sqlx::FromRow)] @@ -48,8 +41,6 @@ pub struct FloorAdvert { pub active: bool, } -/// Every content kind. The public read carries active rows only; the admin read -/// carries everything, so a disabled block stays editable. #[derive(Debug, Serialize)] pub struct ContentView { pub banners: Vec, @@ -58,36 +49,25 @@ pub struct ContentView { pub floor_adverts: Vec, } -pub fn router(_state: AppState) -> Router { - Router::new().route("/content/home", get(home_content)) -} - -pub fn admin_router(_state: AppState) -> Router { - Router::new() - .route("/admin/content", get(admin_content)) - .route("/admin/content/{kind}", put(replace_content)) -} - -/// Only the string literal below decides filtering; no input reaches it. -async fn load_content(state: &AppState, active_only: bool) -> ApiResult { +pub async fn load_content(state: &AppState, active_only: bool) -> ApiResult { let filter = if active_only { " WHERE active = TRUE" } else { "" }; let banners = sqlx::query_as::<_, Banner>(&format!( - "SELECT * FROM banners{filter} ORDER BY position, created_at" + "SELECT id, image, url, position, active FROM banners{filter} ORDER BY position, created_at" )) .fetch_all(&state.db) .await?; let promos = sqlx::query_as::<_, Promo>(&format!( - "SELECT * FROM promos{filter} ORDER BY position, created_at" + "SELECT id, image, url, position, active FROM promos{filter} ORDER BY position, created_at" )) .fetch_all(&state.db) .await?; let quick_links = sqlx::query_as::<_, QuickLink>(&format!( - "SELECT * FROM quick_links{filter} ORDER BY position, created_at" + "SELECT id, label, url, glyph, position, active FROM quick_links{filter} ORDER BY position, created_at" )) .fetch_all(&state.db) .await?; let floor_adverts = sqlx::query_as::<_, FloorAdvert>(&format!( - "SELECT * FROM floor_adverts{filter} ORDER BY position, created_at" + "SELECT id, image, position, active FROM floor_adverts{filter} ORDER BY position, created_at" )) .fetch_all(&state.db) .await?; @@ -99,18 +79,6 @@ async fn load_content(state: &AppState, active_only: bool) -> ApiResult) -> ApiResult> { - Ok(Json(load_content(&state, true).await?)) -} - -async fn admin_content( - State(state): State, - auth: AuthUser, -) -> ApiResult> { - auth.require(&[UserRole::PlatformAdmin])?; - Ok(Json(load_content(&state, false).await?)) -} - fn non_empty(value: &str, field: &str) -> ApiResult<()> { if value.trim().is_empty() { return Err(ApiError::BadRequest(format!("{field} must not be empty"))); @@ -161,26 +129,16 @@ fn default_active() -> bool { true } -/// Replaces one kind from an ordered list. Validation happens before the -/// transaction, and positions are reindexed from the submitted order, so a -/// rejected list leaves the stored content untouched. -async fn replace_content( - State(state): State, - auth: AuthUser, - Path(kind): Path, - Json(body): Json, -) -> ApiResult> { - auth.require(&[UserRole::PlatformAdmin])?; - +pub async fn replace_kind(state: &AppState, kind: &str, body: Value) -> ApiResult { if !matches!( - kind.as_str(), + kind, "banners" | "promos" | "quick-links" | "floor-adverts" ) { return Err(ApiError::BadRequest(format!("unknown content kind: {kind}"))); } let mut tx = state.db.begin().await?; - match kind.as_str() { + match kind { "banners" | "promos" => { let items: Vec = serde_json::from_value(body).map_err(|e| ApiError::BadRequest(e.to_string()))?; @@ -252,6 +210,5 @@ async fn replace_content( _ => unreachable!("kind validated above"), } tx.commit().await?; - - Ok(Json(load_content(&state, false).await?)) + load_content(state, false).await } diff --git a/apps/api/src/modules/currency/handlers.rs b/apps/api/src/modules/currency/handlers.rs new file mode 100644 index 0000000..87b3eaf --- /dev/null +++ b/apps/api/src/modules/currency/handlers.rs @@ -0,0 +1,106 @@ +use axum::{ + extract::{Path, Query, State}, + routing::{get, put}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::auth::AuthUser; +use crate::error::{ApiError, ApiResult}; +use crate::models::Currency; +use crate::state::AppState; + +use super::service; + +pub fn router() -> Router { + Router::new() + .route("/currencies", get(list_currencies)) + .route("/currencies/convert", get(convert)) + .route( + "/admin/currencies", + get(list_all_currencies).post(upsert_currency), + ) + .route("/admin/currencies/{code}/rate", put(set_rate)) +} + +async fn list_currencies(State(state): State) -> ApiResult>> { + Ok(Json(service::load_currencies(&state, true).await?)) +} + +#[derive(Deserialize)] +struct ConvertQuery { + amount_minor: i64, + from: String, + to: String, +} + +async fn convert( + State(state): State, + Query(q): Query, +) -> ApiResult> { + let (amount_minor, currency) = + service::convert(&state, q.amount_minor, &q.from, &q.to).await?; + Ok(Json(json!({ "amount_minor": amount_minor, "currency": currency }))) +} + +async fn list_all_currencies( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::load_currencies(&state, false).await?)) +} + +#[derive(Deserialize)] +struct CurrencyBody { + code: String, + name: Value, + symbol: String, + exponent: i16, + rate_to_base: String, + enabled: bool, +} + +async fn upsert_currency( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + let rate: rust_decimal::Decimal = body + .rate_to_base + .parse() + .map_err(|_| ApiError::BadRequest("rate_to_base must be numeric".into()))?; + Ok(Json( + service::upsert( + &state, + body.code, + body.name, + body.symbol, + body.exponent, + rate, + body.enabled, + ) + .await?, + )) +} + +#[derive(Deserialize)] +struct SetRateBody { + rate_to_base: String, +} + +async fn set_rate( + State(state): State, + auth: AuthUser, + Path(code): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + let rate: rust_decimal::Decimal = body + .rate_to_base + .parse() + .map_err(|_| ApiError::BadRequest("rate_to_base must be numeric".into()))?; + Ok(Json(service::set_rate(&state, &code, rate).await?)) +} diff --git a/apps/api/src/modules/currency/mod.rs b/apps/api/src/modules/currency/mod.rs new file mode 100644 index 0000000..b8b87a5 --- /dev/null +++ b/apps/api/src/modules/currency/mod.rs @@ -0,0 +1,10 @@ +mod handlers; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/currency/service.rs b/apps/api/src/modules/currency/service.rs new file mode 100644 index 0000000..484341e --- /dev/null +++ b/apps/api/src/modules/currency/service.rs @@ -0,0 +1,99 @@ +use crate::error::ApiResult; +use crate::models::Currency; +use crate::money::convert_minor; +use crate::state::AppState; + +pub async fn load_currencies(state: &AppState, enabled_only: bool) -> ApiResult> { + let rows = if enabled_only { + sqlx::query_as::<_, Currency>( + "SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled + FROM currencies WHERE enabled = TRUE ORDER BY code", + ) + .fetch_all(&state.db) + .await? + } else { + sqlx::query_as::<_, Currency>( + "SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled + FROM currencies ORDER BY code", + ) + .fetch_all(&state.db) + .await? + }; + Ok(rows) +} + +pub async fn convert( + state: &AppState, + amount_minor: i64, + from: &str, + to: &str, +) -> ApiResult<(i64, String)> { + let currencies = load_currencies(state, true).await?; + let from_c = currencies + .iter() + .find(|c| c.code == from.to_uppercase()) + .ok_or_else(|| { + crate::error::ApiError::BadRequest(format!("unknown or disabled currency: {from}")) + })?; + let to_c = currencies + .iter() + .find(|c| c.code == to.to_uppercase()) + .ok_or_else(|| { + crate::error::ApiError::BadRequest(format!("unknown or disabled currency: {to}")) + })?; + let converted = convert_minor(amount_minor, from_c, to_c)?; + Ok((converted, to_c.code.clone())) +} + +pub async fn upsert( + state: &AppState, + code: String, + name: serde_json::Value, + symbol: String, + exponent: i16, + rate_to_base: rust_decimal::Decimal, + enabled: bool, +) -> ApiResult { + use crate::error::ApiError; + let code = code.to_uppercase(); + if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) { + return Err(ApiError::BadRequest("code must be a 3-letter ISO code".into())); + } + if rate_to_base <= rust_decimal::Decimal::ZERO { + return Err(ApiError::BadRequest("rate_to_base must be > 0".into())); + } + if !(0..=6).contains(&exponent) { + return Err(ApiError::BadRequest("exponent must be 0..=6".into())); + } + Ok(sqlx::query_as::<_, Currency>( + "INSERT INTO currencies (code, name, symbol, exponent, rate_to_base, enabled) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (code) DO UPDATE + SET name = $2, symbol = $3, exponent = $4, rate_to_base = $5, enabled = $6 + RETURNING code, name, symbol, exponent, is_base, rate_to_base, enabled", + ) + .bind(&code) + .bind(&name) + .bind(&symbol) + .bind(exponent) + .bind(rate_to_base) + .bind(enabled) + .fetch_one(&state.db) + .await?) +} + +pub async fn set_rate(state: &AppState, code: &str, rate: rust_decimal::Decimal) -> ApiResult { + use crate::error::ApiError; + if rate <= rust_decimal::Decimal::ZERO { + return Err(ApiError::BadRequest("rate_to_base must be > 0".into())); + } + sqlx::query_as::<_, Currency>( + "UPDATE currencies SET rate_to_base = $2 WHERE code = $1 + RETURNING code, name, symbol, exponent, is_base, rate_to_base, enabled", + ) + .bind(code.to_uppercase()) + .bind(rate) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::NotFound("currency".into())) +} diff --git a/apps/api/src/modules/fulfillment/handlers.rs b/apps/api/src/modules/fulfillment/handlers.rs new file mode 100644 index 0000000..c54183c --- /dev/null +++ b/apps/api/src/modules/fulfillment/handlers.rs @@ -0,0 +1,69 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::state::AppState; + +use super::service::{self, ShipmentBody, ShipmentView}; + +pub fn router() -> Router { + Router::new() + .route("/shipments", get(list_my_shipments)) + .route("/shipments/{id}/confirm-delivered", post(confirm_delivered)) + .route("/shop/orders/{id}/shipments", post(create_shipment)) + .route("/shop/shipments", get(list_shop_shipments)) + .route("/shop/shipments/{id}/ship", post(mark_shipped)) +} + +async fn list_my_shipments( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + Ok(Json(service::list_for_user(&state, auth.id).await?)) +} + +async fn confirm_delivered( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + Ok(Json( + service::confirm_delivered(&state, auth.id, id).await?, + )) +} + +async fn create_shipment( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + let shop_id = auth.require_shop()?; + Ok(( + StatusCode::CREATED, + Json(service::create(&state, shop_id, id, body).await?), + )) +} + +async fn list_shop_shipments( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + let shop_id = auth.require_shop()?; + Ok(Json(service::list_for_shop(&state, shop_id).await?)) +} + +async fn mark_shipped( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::mark_shipped(&state, shop_id, id).await?)) +} diff --git a/apps/api/src/modules/fulfillment/mod.rs b/apps/api/src/modules/fulfillment/mod.rs new file mode 100644 index 0000000..b8b87a5 --- /dev/null +++ b/apps/api/src/modules/fulfillment/mod.rs @@ -0,0 +1,10 @@ +mod handlers; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/fulfillment/service.rs b/apps/api/src/modules/fulfillment/service.rs new file mode 100644 index 0000000..c00add6 --- /dev/null +++ b/apps/api/src/modules/fulfillment/service.rs @@ -0,0 +1,197 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{OrderStatus, Shipment, ShipmentItem}; +use crate::modules::order::repo as order_repo; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct ShipmentView { + #[serde(flatten)] + pub shipment: Shipment, + pub order_no: String, + pub items: Vec, +} + +#[derive(Deserialize)] +pub struct ShipmentItemBody { + pub order_item_id: Uuid, + pub qty: i32, +} + +#[derive(Deserialize)] +pub struct ShipmentBody { + pub carrier: String, + pub tracking_no: String, + pub items: Vec, +} + +const SHIPMENT_COLS: &str = "id, shipment_no, order_id, carrier, tracking_no, status, + shipped_at, delivered_at, created_at"; +const SHIPMENT_S: &str = "s.id, s.shipment_no, s.order_id, s.carrier, s.tracking_no, s.status, + s.shipped_at, s.delivered_at, s.created_at"; + +async fn views(db: &PgPool, shipments: Vec) -> ApiResult> { + let ids: Vec = shipments.iter().map(|s| s.id).collect(); + let items = if ids.is_empty() { + Vec::new() + } else { + sqlx::query_as::<_, ShipmentItem>( + "SELECT shipment_id, order_item_id, qty FROM shipment_items WHERE shipment_id = ANY($1)", + ) + .bind(&ids) + .fetch_all(db) + .await? + }; + let order_ids: Vec = shipments.iter().map(|s| s.order_id).collect(); + let order_nos: Vec<(Uuid, String)> = if order_ids.is_empty() { + Vec::new() + } else { + sqlx::query_as("SELECT id, order_no FROM orders WHERE id = ANY($1)") + .bind(&order_ids) + .fetch_all(db) + .await? + }; + let mut items_by: HashMap> = HashMap::new(); + for item in items { + items_by.entry(item.shipment_id).or_default().push(item); + } + let nos: HashMap = order_nos.into_iter().collect(); + Ok(shipments + .into_iter() + .map(|s| ShipmentView { + order_no: nos.get(&s.order_id).cloned().unwrap_or_default(), + items: items_by.remove(&s.id).unwrap_or_default(), + shipment: s, + }) + .collect()) +} + +pub async fn list_for_user(state: &AppState, user_id: Uuid) -> ApiResult> { + let shipments = sqlx::query_as::<_, Shipment>(&format!( + "SELECT {SHIPMENT_S} FROM shipments s + JOIN orders o ON o.id = s.order_id + WHERE o.user_id = $1 + ORDER BY s.created_at DESC" + )) + .bind(user_id) + .fetch_all(&state.db) + .await?; + views(&state.db, shipments).await +} + +pub async fn list_for_shop(state: &AppState, shop_id: Uuid) -> ApiResult> { + let shipments = sqlx::query_as::<_, Shipment>(&format!( + "SELECT {SHIPMENT_S} FROM shipments s + JOIN orders o ON o.id = s.order_id + WHERE o.shop_id = $1 + ORDER BY s.created_at DESC" + )) + .bind(shop_id) + .fetch_all(&state.db) + .await?; + views(&state.db, shipments).await +} + +pub async fn confirm_delivered( + state: &AppState, + user_id: Uuid, + id: Uuid, +) -> ApiResult { + let shipment = sqlx::query_as::<_, Shipment>(&format!( + "UPDATE shipments s SET status = 'delivered', delivered_at = now() + FROM orders o + WHERE s.id = $1 AND o.id = s.order_id AND o.user_id = $2 AND s.status = 'shipped' + RETURNING {SHIPMENT_S}" + )) + .bind(id) + .bind(user_id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::Conflict("shipment not confirmable".into()))?; + order_repo::maybe_mark_order_completed(&state.db, shipment.order_id).await?; + let mut out = views(&state.db, vec![shipment]).await?; + Ok(out.remove(0)) +} + +pub async fn create( + state: &AppState, + shop_id: Uuid, + order_id: Uuid, + body: ShipmentBody, +) -> ApiResult { + if body.carrier.trim().is_empty() || body.tracking_no.trim().is_empty() { + return Err(ApiError::BadRequest("carrier and tracking_no are required".into())); + } + if body.items.is_empty() || body.items.iter().any(|i| i.qty <= 0) { + return Err(ApiError::BadRequest("items must be non-empty with qty > 0".into())); + } + let mut tx = state.db.begin().await?; + let order = order_repo::lock_for_shop(&mut tx, shop_id, order_id).await?; + if !matches!(order.status, OrderStatus::Paid | OrderStatus::Fulfilling) { + return Err(ApiError::Conflict(format!( + "order status {} does not accept shipments", + serde_json::to_value(order.status) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default() + ))); + } + for item in &body.items { + let remainder = order_repo::remainder_for_item(&mut tx, item.order_item_id, order.id) + .await? + .ok_or_else(|| ApiError::BadRequest("order_item_id not in this order".into()))?; + if item.qty as i64 > remainder { + return Err(ApiError::BadRequest(format!( + "qty {} exceeds unshipped remainder {}", + item.qty, remainder + ))); + } + } + let shipment = sqlx::query_as::<_, Shipment>(&format!( + "INSERT INTO shipments (shipment_no, order_id, carrier, tracking_no) + VALUES ('SH' || to_char(now(), 'YYMMDD') || lpad(nextval('shipment_no_seq')::text, 6, '0'), + $1, $2, $3) + RETURNING {SHIPMENT_COLS}" + )) + .bind(order.id) + .bind(body.carrier.trim()) + .bind(body.tracking_no.trim()) + .fetch_one(&mut *tx) + .await?; + for item in &body.items { + sqlx::query( + "INSERT INTO shipment_items (shipment_id, order_item_id, qty) VALUES ($1, $2, $3)", + ) + .bind(shipment.id) + .bind(item.order_item_id) + .bind(item.qty) + .execute(&mut *tx) + .await?; + } + order_repo::mark_fulfilling(&mut tx, order.id).await?; + tx.commit().await?; + let mut out = views(&state.db, vec![shipment]).await?; + Ok(out.remove(0)) +} + +pub async fn mark_shipped(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + let shipment = sqlx::query_as::<_, Shipment>(&format!( + "UPDATE shipments s SET status = 'shipped', shipped_at = now() + FROM orders o + WHERE s.id = $1 AND o.id = s.order_id AND o.shop_id = $2 AND s.status = 'pending' + RETURNING {SHIPMENT_S}" + )) + .bind(id) + .bind(shop_id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::Conflict("shipment not found or not pending".into()))?; + order_repo::maybe_mark_order_shipped(&state.db, shipment.order_id).await?; + let mut out = views(&state.db, vec![shipment]).await?; + Ok(out.remove(0)) +} diff --git a/apps/api/src/routes/health.rs b/apps/api/src/modules/health.rs similarity index 86% rename from apps/api/src/routes/health.rs rename to apps/api/src/modules/health.rs index 615b239..031b31e 100644 --- a/apps/api/src/routes/health.rs +++ b/apps/api/src/modules/health.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; use crate::error::ApiResult; use crate::state::AppState; -pub fn router(_state: AppState) -> Router { +pub fn router() -> Router { Router::new().route("/ready", get(ready)) } @@ -13,7 +13,6 @@ pub async fn health() -> Json { Json(json!({ "status": "ok" })) } -/// Deep health: verifies Postgres and Redis connectivity. pub async fn ready(State(state): State) -> ApiResult> { let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1") .fetch_one(&state.db) diff --git a/apps/api/src/modules/identity/admin.rs b/apps/api/src/modules/identity/admin.rs new file mode 100644 index 0000000..2742e3f --- /dev/null +++ b/apps/api/src/modules/identity/admin.rs @@ -0,0 +1,48 @@ +use axum::{ + extract::{Path, Query, State}, + routing::{get, put}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::{PageQuery, Paged}; +use crate::models::{UserPublic, UserRole}; +use crate::state::AppState; + +use super::service; + +pub fn router() -> Router { + Router::new() + .route("/admin/users", get(list_users)) + .route("/admin/users/{id}/role", put(set_user_role)) +} + +async fn list_users( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::list_users(&state, q.page, q.per_page).await?)) +} + +#[derive(Deserialize)] +struct SetRoleBody { + role: UserRole, + shop_id: Option, +} + +async fn set_user_role( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json( + service::set_user_role(&state, id, body.role, body.shop_id).await?, + )) +} diff --git a/apps/api/src/modules/identity/handlers.rs b/apps/api/src/modules/identity/handlers.rs new file mode 100644 index 0000000..54f2529 --- /dev/null +++ b/apps/api/src/modules/identity/handlers.rs @@ -0,0 +1,57 @@ +use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; +use serde::Deserialize; +use serde_json::Value; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::models::UserPublic; +use crate::state::AppState; + +use super::service::{self, RegisterInput}; + +pub fn router() -> Router { + Router::new() + .route("/auth/register", post(register)) + .route("/auth/login", post(login)) + .route("/auth/me", get(me)) +} + +#[derive(Deserialize)] +pub struct RegisterBody { + email: String, + password: String, + display_name: String, +} + +async fn register( + State(state): State, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + let (_, payload) = service::register( + &state, + RegisterInput { + email: body.email, + password: body.password, + display_name: body.display_name, + }, + ) + .await?; + Ok((StatusCode::CREATED, Json(payload))) +} + +#[derive(Deserialize)] +pub struct LoginBody { + email: String, + password: String, +} + +async fn login( + State(state): State, + Json(body): Json, +) -> ApiResult> { + Ok(Json(service::login(&state, &body.email, &body.password).await?)) +} + +async fn me(State(state): State, auth: AuthUser) -> ApiResult> { + Ok(Json(service::me(&state, auth.id).await?)) +} diff --git a/apps/api/src/modules/identity/mod.rs b/apps/api/src/modules/identity/mod.rs new file mode 100644 index 0000000..1db1fcd --- /dev/null +++ b/apps/api/src/modules/identity/mod.rs @@ -0,0 +1,12 @@ +mod admin; +mod handlers; +mod repo; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router().merge(admin::router()) +} diff --git a/apps/api/src/modules/identity/repo.rs b/apps/api/src/modules/identity/repo.rs new file mode 100644 index 0000000..5e9929d --- /dev/null +++ b/apps/api/src/modules/identity/repo.rs @@ -0,0 +1,93 @@ +use sqlx::{PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::models::{User, UserRole, USER_COLUMNS}; + +pub async fn insert_customer<'e, E: PgExecutor<'e>>( + exec: E, + email: &str, + password_hash: &str, + display_name: &str, +) -> Result { + sqlx::query_as::<_, User>(&format!( + "INSERT INTO users (email, password_hash, display_name, role) + VALUES ($1, $2, $3, 'customer') RETURNING {USER_COLUMNS}" + )) + .bind(email) + .bind(password_hash) + .bind(display_name) + .fetch_one(exec) + .await +} + +pub async fn find_by_email<'e, E: PgExecutor<'e>>( + exec: E, + email: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>(&format!( + "SELECT {USER_COLUMNS} FROM users WHERE email = $1" + )) + .bind(email) + .fetch_optional(exec) + .await +} + +pub async fn find_by_id<'e, E: PgExecutor<'e>>( + exec: E, + id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>(&format!( + "SELECT {USER_COLUMNS} FROM users WHERE id = $1" + )) + .bind(id) + .fetch_optional(exec) + .await +} + +pub async fn list_users(db: &PgPool, page: Option, per_page: Option) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total: i64 = sqlx::query_scalar("SELECT count(*) FROM users") + .fetch_one(db) + .await?; + let items = sqlx::query_as::<_, User>(&format!( + "SELECT {USER_COLUMNS} FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2" + )) + .bind(per_page) + .bind((page - 1) * per_page) + .fetch_all(db) + .await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +pub async fn shop_exists(db: &PgPool, shop_id: Uuid) -> ApiResult { + Ok( + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM shops WHERE id = $1)") + .bind(shop_id) + .fetch_one(db) + .await?, + ) +} + +pub async fn set_role( + db: &PgPool, + id: Uuid, + role: UserRole, + shop_id: Option, +) -> ApiResult> { + Ok(sqlx::query_as::<_, User>(&format!( + "UPDATE users SET role = $2, shop_id = $3 WHERE id = $1 RETURNING {USER_COLUMNS}" + )) + .bind(id) + .bind(role) + .bind(shop_id) + .fetch_optional(db) + .await?) +} diff --git a/apps/api/src/modules/identity/service.rs b/apps/api/src/modules/identity/service.rs new file mode 100644 index 0000000..c69eeb7 --- /dev/null +++ b/apps/api/src/modules/identity/service.rs @@ -0,0 +1,112 @@ +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::auth::{hash_password, issue_token, verify_password}; +use crate::error::{unique_conflict, ApiError, ApiResult}; +use crate::http::Paged; +use crate::models::{User, UserPublic, UserRole}; +use crate::state::AppState; + +use super::repo; + +pub struct RegisterInput { + pub email: String, + pub password: String, + pub display_name: String, +} + +pub async fn register(state: &AppState, input: RegisterInput) -> ApiResult<(UserPublic, Value)> { + let email = input.email.trim().to_lowercase(); + if !email.contains('@') { + return Err(ApiError::BadRequest("invalid email".into())); + } + if input.password.len() < 8 { + return Err(ApiError::BadRequest( + "password must be at least 8 characters".into(), + )); + } + if input.display_name.trim().is_empty() { + return Err(ApiError::BadRequest("display_name is required".into())); + } + let hash = hash_password(&input.password)?; + let user = repo::insert_customer(&state.db, &email, &hash, input.display_name.trim()) + .await + .map_err(|e| unique_conflict(e, "email already registered"))?; + let payload = auth_payload(state, &user)?; + Ok((UserPublic::from(user), payload)) +} + +pub async fn login(state: &AppState, email: &str, password: &str) -> ApiResult { + let email = email.trim().to_lowercase(); + let user = repo::find_by_email(&state.db, &email) + .await? + .ok_or_else(|| ApiError::Unauthorized("invalid email or password".into()))?; + if !verify_password(password, &user.password_hash) { + return Err(ApiError::Unauthorized("invalid email or password".into())); + } + auth_payload(state, &user) +} + +pub async fn me(state: &AppState, user_id: Uuid) -> ApiResult { + let user = repo::find_by_id(&state.db, user_id) + .await? + .ok_or_else(|| ApiError::NotFound("user".into()))?; + Ok(UserPublic::from(user)) +} + +pub async fn list_users( + state: &AppState, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = repo::list_users(&state.db, page, per_page).await?; + Ok(Paged { + items: page.items.into_iter().map(UserPublic::from).collect(), + total: page.total, + page: page.page, + per_page: page.per_page, + }) +} + +pub async fn set_user_role( + state: &AppState, + id: Uuid, + role: UserRole, + shop_id: Option, +) -> ApiResult { + if role.is_shop_role() { + let shop_id = shop_id + .ok_or_else(|| ApiError::BadRequest("shop_id required for shop roles".into()))?; + if !repo::shop_exists(&state.db, shop_id).await? { + return Err(ApiError::BadRequest("shop not found".into())); + } + } else if shop_id.is_some() { + return Err(ApiError::BadRequest( + "shop_id only allowed for shop roles".into(), + )); + } + let user = repo::set_role(&state.db, id, role, shop_id) + .await? + .ok_or_else(|| ApiError::NotFound("user".into()))?; + Ok(UserPublic::from(user)) +} + +fn auth_payload(state: &AppState, user: &User) -> ApiResult { + let token = issue_token( + &state.config.jwt_secret, + state.config.jwt_ttl_secs, + user.id, + user.role, + user.shop_id, + )?; + let public = UserPublic { + id: user.id, + email: user.email.clone(), + display_name: user.display_name.clone(), + role: user.role, + shop_id: user.shop_id, + locale: user.locale.clone(), + created_at: user.created_at, + }; + Ok(json!({ "token": token, "user": public })) +} diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs new file mode 100644 index 0000000..16696c8 --- /dev/null +++ b/apps/api/src/modules/mod.rs @@ -0,0 +1,30 @@ +pub mod address; +pub mod billing; +pub mod cart; +pub mod catalog; +pub mod content; +pub mod currency; +pub mod fulfillment; +pub mod health; +pub mod identity; +pub mod order; +pub mod shop; + +use axum::Router; + +use crate::state::AppState; + +pub fn api_router() -> Router { + Router::new() + .merge(health::router()) + .merge(address::router()) + .merge(identity::router()) + .merge(currency::router()) + .merge(catalog::router()) + .merge(content::router()) + .merge(cart::router()) + .merge(order::router()) + .merge(shop::router()) + .merge(fulfillment::router()) + .merge(billing::router()) +} diff --git a/apps/api/src/modules/order/dto.rs b/apps/api/src/modules/order/dto.rs new file mode 100644 index 0000000..7b4683f --- /dev/null +++ b/apps/api/src/modules/order/dto.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{Order, OrderItem}; + +#[derive(Debug, Serialize)] +pub struct OrderView { + #[serde(flatten)] + pub order: Order, + pub items: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct AddressBody { + pub recipient: String, + pub phone: String, + pub country: String, + pub region: String, + pub city: String, + pub line1: String, + pub postal_code: String, +} + +impl AddressBody { + pub fn validate(&self) -> ApiResult<()> { + for (field, value) in [ + ("recipient", &self.recipient), + ("phone", &self.phone), + ("country", &self.country), + ("city", &self.city), + ("line1", &self.line1), + ] { + if value.trim().is_empty() { + return Err(ApiError::BadRequest(format!( + "shipping_address.{field} is required" + ))); + } + } + Ok(()) + } +} + +#[derive(Clone, Copy)] +pub enum OrderScope { + User(uuid::Uuid), + Shop(uuid::Uuid), + Admin, +} diff --git a/apps/api/src/modules/order/handlers.rs b/apps/api/src/modules/order/handlers.rs new file mode 100644 index 0000000..f24bfad --- /dev/null +++ b/apps/api/src/modules/order/handlers.rs @@ -0,0 +1,134 @@ +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::{PageQuery, Paged}; +use crate::models::OrderStatus; +use crate::state::AppState; + +use super::dto::{AddressBody, OrderScope, OrderView}; +use super::service; + +pub fn customer_router() -> Router { + Router::new() + .route("/orders", get(list_my_orders)) + .route("/orders/checkout", post(checkout)) + .route("/orders/{id}", get(get_order)) + .route("/orders/{id}/pay", post(pay_order)) + .route("/orders/{id}/cancel", post(cancel_order)) +} + +pub fn shop_router() -> Router { + Router::new() + .route("/shop/orders", get(shop_list)) + .route("/shop/orders/{id}", get(shop_get)) +} + +pub fn admin_router() -> Router { + Router::new().route("/admin/orders", get(admin_list)) +} + +async fn list_my_orders( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + Ok(Json( + service::list(&state, OrderScope::User(auth.id), None, q.page, q.per_page).await?, + )) +} + +async fn get_order( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + Ok(Json(service::get_for_user(&state, auth.id, id).await?)) +} + +#[derive(Deserialize)] +struct CheckoutBody { + shipping_address: AddressBody, + currency: String, +} + +async fn checkout( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json>)> { + Ok(( + StatusCode::CREATED, + Json( + service::checkout(&state, auth.id, body.shipping_address, body.currency).await?, + ), + )) +} + +async fn pay_order( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + Ok(Json(service::pay(&state, auth.id, id).await?)) +} + +async fn cancel_order( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + Ok(Json(service::cancel(&state, auth.id, id).await?)) +} + +#[derive(Deserialize)] +struct ShopOrderQuery { + page: Option, + per_page: Option, + status: Option, +} + +async fn shop_list( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + let shop_id = auth.require_shop()?; + Ok(Json( + service::list( + &state, + OrderScope::Shop(shop_id), + q.status, + q.page, + q.per_page, + ) + .await?, + )) +} + +async fn shop_get( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::get_for_shop(&state, shop_id, id).await?)) +} + +async fn admin_list( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json( + service::list(&state, OrderScope::Admin, None, q.page, q.per_page).await?, + )) +} diff --git a/apps/api/src/modules/order/mod.rs b/apps/api/src/modules/order/mod.rs new file mode 100644 index 0000000..8bf0221 --- /dev/null +++ b/apps/api/src/modules/order/mod.rs @@ -0,0 +1,17 @@ +mod dto; +mod handlers; +pub mod repo; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub use dto::AddressBody; +pub use service::{cancel, checkout, pay}; + +pub fn router() -> Router { + handlers::customer_router() + .merge(handlers::shop_router()) + .merge(handlers::admin_router()) +} diff --git a/apps/api/src/modules/order/repo.rs b/apps/api/src/modules/order/repo.rs new file mode 100644 index 0000000..abb2b81 --- /dev/null +++ b/apps/api/src/modules/order/repo.rs @@ -0,0 +1,381 @@ +use std::collections::HashMap; + +use sqlx::{PgConnection, PgPool}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{Order, OrderItem, OrderStatus}; + +use super::dto::{OrderScope, OrderView}; + +const ORDER_COLS: &str = "id, order_no, shop_id, user_id, status, currency, total_minor, + shipping_address, created_at, updated_at"; +const ORDER_ITEM_COLS: &str = + "id, order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty"; + +pub async fn attach_items(db: &PgPool, orders: Vec) -> ApiResult> { + let ids: Vec = orders.iter().map(|o| o.id).collect(); + let items = if ids.is_empty() { + Vec::new() + } else { + sqlx::query_as::<_, OrderItem>(&format!( + "SELECT {ORDER_ITEM_COLS} FROM order_items WHERE order_id = ANY($1) ORDER BY sku_code" + )) + .bind(&ids) + .fetch_all(db) + .await? + }; + let mut by_order: HashMap> = HashMap::new(); + for item in items { + by_order.entry(item.order_id).or_default().push(item); + } + Ok(orders + .into_iter() + .map(|o| OrderView { + items: by_order.remove(&o.id).unwrap_or_default(), + order: o, + }) + .collect()) +} + +pub async fn count(db: &PgPool, scope: OrderScope, status: Option) -> ApiResult { + Ok(match scope { + OrderScope::User(user_id) => { + sqlx::query_scalar("SELECT count(*) FROM orders WHERE user_id = $1") + .bind(user_id) + .fetch_one(db) + .await? + } + OrderScope::Shop(shop_id) => sqlx::query_scalar( + "SELECT count(*) FROM orders WHERE shop_id = $1 AND ($2::order_status IS NULL OR status = $2)", + ) + .bind(shop_id) + .bind(status) + .fetch_one(db) + .await?, + OrderScope::Admin => sqlx::query_scalar("SELECT count(*) FROM orders") + .fetch_one(db) + .await?, + }) +} + +pub async fn list_page( + db: &PgPool, + scope: OrderScope, + status: Option, + per_page: i64, + offset: i64, +) -> ApiResult> { + Ok(match scope { + OrderScope::User(user_id) => sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders WHERE user_id = $1 + ORDER BY created_at DESC LIMIT $2 OFFSET $3" + )) + .bind(user_id) + .bind(per_page) + .bind(offset) + .fetch_all(db) + .await?, + OrderScope::Shop(shop_id) => sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders + WHERE shop_id = $1 AND ($2::order_status IS NULL OR status = $2) + ORDER BY created_at DESC LIMIT $3 OFFSET $4" + )) + .bind(shop_id) + .bind(status) + .bind(per_page) + .bind(offset) + .fetch_all(db) + .await?, + OrderScope::Admin => sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2" + )) + .bind(per_page) + .bind(offset) + .fetch_all(db) + .await?, + }) +} + +pub async fn get_for_user(db: &PgPool, user_id: Uuid, id: Uuid) -> ApiResult { + sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders WHERE id = $1 AND user_id = $2" + )) + .bind(id) + .bind(user_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::NotFound("order".into())) +} + +pub async fn get_for_shop(db: &PgPool, shop_id: Uuid, id: Uuid) -> ApiResult { + sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders WHERE id = $1 AND shop_id = $2" + )) + .bind(id) + .bind(shop_id) + .fetch_optional(db) + .await? + .ok_or_else(|| ApiError::NotFound("order".into())) +} + +pub async fn lock_for_shop( + tx: &mut PgConnection, + shop_id: Uuid, + id: Uuid, +) -> ApiResult { + sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders WHERE id = $1 AND shop_id = $2 FOR UPDATE" + )) + .bind(id) + .bind(shop_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("order".into())) +} + +pub async fn insert_order( + tx: &mut PgConnection, + shop_id: Uuid, + user_id: Uuid, + currency: &str, + total: i64, + address: &serde_json::Value, +) -> ApiResult { + Ok(sqlx::query_as::<_, Order>(&format!( + "INSERT INTO orders (order_no, shop_id, user_id, currency, total_minor, shipping_address) + VALUES ('VM' || to_char(now(), 'YYMMDD') || lpad(nextval('order_no_seq')::text, 6, '0'), + $1, $2, $3, $4, $5) + RETURNING {ORDER_COLS}" + )) + .bind(shop_id) + .bind(user_id) + .bind(currency) + .bind(total) + .bind(address) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn insert_item( + tx: &mut PgConnection, + order_id: Uuid, + sku_id: Uuid, + product_name: &serde_json::Value, + sku_code: &str, + image: &Option, + unit: i64, + qty: i32, +) -> ApiResult<()> { + sqlx::query( + "INSERT INTO order_items (order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(order_id) + .bind(sku_id) + .bind(product_name) + .bind(sku_code) + .bind(image) + .bind(unit) + .bind(qty) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn decrement_stock(tx: &mut PgConnection, sku_id: Uuid, qty: i32) -> ApiResult<()> { + sqlx::query("UPDATE skus SET stock = stock - $2 WHERE id = $1") + .bind(sku_id) + .bind(qty) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn pay(db: &PgPool, user_id: Uuid, id: Uuid) -> ApiResult { + sqlx::query_as::<_, Order>(&format!( + "UPDATE orders SET status = 'paid', updated_at = now() + WHERE id = $1 AND user_id = $2 AND status = 'pending_payment' + RETURNING {ORDER_COLS}" + )) + .bind(id) + .bind(user_id) + .fetch_optional(db) + .await? + .ok_or_else(|| { + ApiError::Conflict("order not payable (missing, not yours, or wrong status)".into()) + }) +} + +pub async fn cancel(tx: &mut PgConnection, user_id: Uuid, id: Uuid) -> ApiResult { + sqlx::query_as::<_, Order>(&format!( + "UPDATE orders SET status = 'cancelled', updated_at = now() + WHERE id = $1 AND user_id = $2 AND status = 'pending_payment' + RETURNING {ORDER_COLS}" + )) + .bind(id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + ApiError::Conflict("order not cancellable (missing, not yours, or wrong status)".into()) + }) +} + +pub async fn restore_stock(tx: &mut PgConnection, order_id: Uuid) -> ApiResult<()> { + sqlx::query( + "UPDATE skus s SET stock = s.stock + oi.qty + FROM order_items oi WHERE oi.order_id = $1 AND oi.sku_id = s.id", + ) + .bind(order_id) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn mark_fulfilling(tx: &mut PgConnection, order_id: Uuid) -> ApiResult<()> { + sqlx::query( + "UPDATE orders SET status = 'fulfilling', updated_at = now() + WHERE id = $1 AND status = 'paid'", + ) + .bind(order_id) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn maybe_mark_order_shipped(db: &PgPool, order_id: Uuid) -> ApiResult<()> { + let fully_covered: bool = sqlx::query_scalar( + "SELECT NOT EXISTS ( + SELECT 1 FROM order_items oi + WHERE oi.order_id = $1 AND oi.qty > COALESCE(( + SELECT sum(si.qty) FROM shipment_items si + JOIN shipments s ON s.id = si.shipment_id + WHERE si.order_item_id = oi.id AND s.status IN ('shipped', 'delivered') + ), 0) + )", + ) + .bind(order_id) + .fetch_one(db) + .await?; + if fully_covered { + sqlx::query( + "UPDATE orders SET status = 'shipped', updated_at = now() + WHERE id = $1 AND status = 'fulfilling'", + ) + .bind(order_id) + .execute(db) + .await?; + } + Ok(()) +} + +pub async fn maybe_mark_order_completed(db: &PgPool, order_id: Uuid) -> ApiResult<()> { + let open_shipments: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM shipments WHERE order_id = $1 AND status <> 'delivered')", + ) + .bind(order_id) + .fetch_one(db) + .await?; + if open_shipments { + return Ok(()); + } + let fully_covered: bool = sqlx::query_scalar( + "SELECT NOT EXISTS ( + SELECT 1 FROM order_items oi + WHERE oi.order_id = $1 AND oi.qty > COALESCE(( + SELECT sum(si.qty) FROM shipment_items si + JOIN shipments s ON s.id = si.shipment_id + WHERE si.order_item_id = oi.id AND s.status = 'delivered' + ), 0) + )", + ) + .bind(order_id) + .fetch_one(db) + .await?; + if fully_covered { + sqlx::query( + "UPDATE orders SET status = 'completed', updated_at = now() + WHERE id = $1 AND status IN ('shipped', 'fulfilling')", + ) + .bind(order_id) + .execute(db) + .await?; + } + Ok(()) +} + +#[derive(sqlx::FromRow)] +pub struct CheckoutRow { + pub sku_id: Uuid, + pub shop_id: Uuid, + pub product_name: serde_json::Value, + pub sku_code: String, + pub image: Option, + pub price_minor: i64, + pub currency: String, + pub stock: i32, +} + +pub async fn lock_purchasable_skus( + tx: &mut PgConnection, + sku_ids: &[Uuid], +) -> ApiResult> { + Ok(sqlx::query_as::<_, CheckoutRow>( + "SELECT s.id AS sku_id, p.shop_id, p.name AS product_name, s.sku_code, + (p.images ->> 0) AS image, s.price_minor, s.currency, s.stock + FROM skus s + JOIN products p ON p.id = s.product_id + JOIN shops sh ON sh.id = p.shop_id + WHERE s.id = ANY($1) AND s.active = TRUE + AND p.status = 'published' AND sh.status = 'active' + FOR UPDATE OF s", + ) + .bind(sku_ids) + .fetch_all(&mut *tx) + .await?) +} + +pub async fn enabled_currency( + tx: &mut PgConnection, + code: &str, +) -> ApiResult { + sqlx::query_as::<_, crate::models::Currency>( + "SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled + FROM currencies WHERE code = $1 AND enabled = TRUE", + ) + .bind(code) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::BadRequest(format!("unknown currency: {code}"))) +} + +pub async fn all_enabled_currencies( + tx: &mut PgConnection, +) -> ApiResult> { + Ok(sqlx::query_as::<_, crate::models::Currency>( + "SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled + FROM currencies WHERE enabled = TRUE", + ) + .fetch_all(&mut *tx) + .await?) +} + +pub async fn remainder_for_item( + tx: &mut PgConnection, + order_item_id: Uuid, + order_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_scalar( + "SELECT oi.qty - COALESCE(( + SELECT sum(si.qty) FROM shipment_items si + JOIN shipments s ON s.id = si.shipment_id + WHERE si.order_item_id = oi.id), 0) + FROM order_items oi + WHERE oi.id = $1 AND oi.order_id = $2", + ) + .bind(order_item_id) + .bind(order_id) + .fetch_optional(&mut *tx) + .await?) +} diff --git a/apps/api/src/modules/order/service.rs b/apps/api/src/modules/order/service.rs new file mode 100644 index 0000000..cad30cb --- /dev/null +++ b/apps/api/src/modules/order/service.rs @@ -0,0 +1,161 @@ +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::models::OrderStatus; +use crate::money::convert_minor; +use crate::modules::cart; +use crate::state::AppState; + +use super::dto::{AddressBody, OrderScope, OrderView}; +use super::repo; + +pub async fn list( + state: &AppState, + scope: OrderScope, + status: Option, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total = repo::count(&state.db, scope, status).await?; + let orders = repo::list_page( + &state.db, + scope, + status, + per_page, + (page - 1) * per_page, + ) + .await?; + let items = repo::attach_items(&state.db, orders).await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +pub async fn get_for_user(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { + let order = repo::get_for_user(&state.db, user_id, id).await?; + let mut views = repo::attach_items(&state.db, vec![order]).await?; + Ok(views.remove(0)) +} + +pub async fn get_for_shop(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + let order = repo::get_for_shop(&state.db, shop_id, id).await?; + let mut views = repo::attach_items(&state.db, vec![order]).await?; + Ok(views.remove(0)) +} + +pub async fn checkout( + state: &AppState, + user_id: Uuid, + shipping_address: AddressBody, + currency: String, +) -> ApiResult> { + shipping_address.validate()?; + let target_currency = currency.to_uppercase(); + let entries = cart::service::read_entries(state, user_id).await?; + if entries.is_empty() { + return Err(ApiError::BadRequest("cart is empty".into())); + } + + let mut tx = state.db.begin().await?; + let target = repo::enabled_currency(&mut tx, &target_currency).await?; + let all_currencies = repo::all_enabled_currencies(&mut tx).await?; + + let sku_ids: Vec = entries.iter().map(|(id, _)| *id).collect(); + let rows = repo::lock_purchasable_skus(&mut tx, &sku_ids).await?; + if rows.len() != entries.len() { + return Err(ApiError::Conflict( + "some cart items are no longer purchasable".into(), + )); + } + let qty_by_sku: std::collections::HashMap = entries.iter().copied().collect(); + for row in &rows { + let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0); + if qty > row.stock { + return Err(ApiError::Conflict(format!( + "insufficient stock for SKU {}", + row.sku_code + ))); + } + } + + let mut shop_order: Vec = Vec::new(); + for row in &rows { + if !shop_order.contains(&row.shop_id) { + shop_order.push(row.shop_id); + } + } + + let address = serde_json::to_value(&shipping_address).map_err(ApiError::internal)?; + let mut created = Vec::new(); + for shop_id in shop_order { + let shop_rows: Vec<&repo::CheckoutRow> = + rows.iter().filter(|r| r.shop_id == shop_id).collect(); + let mut total: i64 = 0; + let mut line_prices: Vec<(Uuid, i64, i32)> = Vec::new(); + for row in &shop_rows { + let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0); + let from = all_currencies + .iter() + .find(|c| c.code == row.currency) + .ok_or_else(|| { + ApiError::BadRequest(format!("currency {} disabled", row.currency)) + })?; + let unit = convert_minor(row.price_minor, from, &target)?; + total += unit * qty as i64; + line_prices.push((row.sku_id, unit, qty)); + } + let order = repo::insert_order( + &mut tx, + shop_id, + user_id, + &target.code, + total, + &address, + ) + .await?; + for row in &shop_rows { + let (_, unit, qty) = line_prices + .iter() + .find(|(sku, _, _)| *sku == row.sku_id) + .unwrap(); + repo::insert_item( + &mut tx, + order.id, + row.sku_id, + &row.product_name, + &row.sku_code, + &row.image, + *unit, + *qty, + ) + .await?; + repo::decrement_stock(&mut tx, row.sku_id, *qty).await?; + } + created.push(order); + } + + tx.commit().await?; + cart::service::clear(state, user_id).await?; + repo::attach_items(&state.db, created).await +} + +pub async fn pay(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { + let order = repo::pay(&state.db, user_id, id).await?; + let mut views = repo::attach_items(&state.db, vec![order]).await?; + Ok(views.remove(0)) +} + +pub async fn cancel(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + let order = repo::cancel(&mut tx, user_id, id).await?; + repo::restore_stock(&mut tx, order.id).await?; + tx.commit().await?; + let mut views = repo::attach_items(&state.db, vec![order]).await?; + Ok(views.remove(0)) +} diff --git a/apps/api/src/modules/shop/handlers.rs b/apps/api/src/modules/shop/handlers.rs new file mode 100644 index 0000000..6712992 --- /dev/null +++ b/apps/api/src/modules/shop/handlers.rs @@ -0,0 +1,93 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{get, put}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::Value; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::models::{Shop, ShopStatus}; +use crate::state::AppState; + +use super::service::{self, ProfileBody, ShopProfileView}; + +pub fn router() -> Router { + Router::new() + .route("/shop/profile", get(my_shop)) + .route("/shops", get(list_shops)) + .route("/shops/{slug}", get(get_shop)) + .route("/admin/shops", get(admin_list_shops).post(create_shop)) + .route("/admin/shops/{id}/status", put(set_shop_status)) + .route("/admin/shops/{id}/profile", put(set_shop_profile)) +} + +async fn my_shop(State(state): State, auth: AuthUser) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::get_by_id(&state, shop_id).await?)) +} + +async fn list_shops(State(state): State) -> ApiResult>> { + Ok(Json(service::list_active_profiles(&state).await?)) +} + +async fn get_shop( + State(state): State, + Path(slug): Path, +) -> ApiResult> { + Ok(Json(service::get_active_by_slug(&state, &slug).await?)) +} + +async fn admin_list_shops( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::list_admin(&state).await?)) +} + +#[derive(Deserialize)] +struct CreateShopBody { + name: Value, + slug: String, +} + +async fn create_shop( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require_admin()?; + Ok(( + StatusCode::CREATED, + Json(service::create(&state, body.name, body.slug).await?), + )) +} + +#[derive(Deserialize)] +struct SetStatusBody { + status: ShopStatus, +} + +async fn set_shop_status( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::set_status(&state, id, body.status).await?)) +} + +async fn set_shop_profile( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::set_profile(&state, id, body).await?)) +} diff --git a/apps/api/src/modules/shop/mod.rs b/apps/api/src/modules/shop/mod.rs new file mode 100644 index 0000000..b8b87a5 --- /dev/null +++ b/apps/api/src/modules/shop/mod.rs @@ -0,0 +1,10 @@ +mod handlers; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/routes/shops.rs b/apps/api/src/modules/shop/service.rs similarity index 54% rename from apps/api/src/routes/shops.rs rename to apps/api/src/modules/shop/service.rs index d44e3b2..3200537 100644 --- a/apps/api/src/routes/shops.rs +++ b/apps/api/src/modules/shop/service.rs @@ -1,19 +1,12 @@ -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::error::{unique_conflict, ApiError, ApiResult}; +use crate::models::{Shop, ShopStatus}; 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. +/// A shop plus whatever profile it has. #[derive(Debug, Serialize, sqlx::FromRow)] pub struct ShopProfileView { pub id: Uuid, @@ -38,52 +31,86 @@ const SELECT_PROFILE: &str = "SELECT s.id, s.slug, s.name, FROM shops s LEFT JOIN shop_profiles p ON p.shop_id = s.id"; -pub fn router(_state: AppState) -> Router { - Router::new() - .route("/shops", get(list_shops)) - .route("/shops/{slug}", get(get_shop)) +const SHOP_COLS: &str = "id, name, slug, status, created_at"; + +pub async fn get_by_id(state: &AppState, id: Uuid) -> ApiResult { + sqlx::query_as::<_, Shop>(&format!( + "SELECT {SHOP_COLS} FROM shops WHERE id = $1" + )) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::NotFound("shop".into())) } -pub fn admin_router(_state: AppState) -> Router { - Router::new().route("/admin/shops/{id}/profile", put(set_shop_profile)) +pub async fn list_admin(state: &AppState) -> ApiResult> { + Ok(sqlx::query_as::<_, Shop>(&format!( + "SELECT {SHOP_COLS} FROM shops ORDER BY created_at" + )) + .fetch_all(&state.db) + .await?) } -async fn list_shops(State(state): State) -> ApiResult>> { - let shops = sqlx::query_as::<_, ShopProfileView>(&format!( +pub async fn list_active_profiles(state: &AppState) -> ApiResult> { + Ok(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)) + .await?) } -async fn get_shop( - State(state): State, - Path(slug): Path, -) -> ApiResult> { - let shop = sqlx::query_as::<_, ShopProfileView>(&format!( +pub async fn get_active_by_slug(state: &AppState, slug: &str) -> ApiResult { + sqlx::query_as::<_, ShopProfileView>(&format!( "{SELECT_PROFILE} WHERE s.slug = $1 AND s.status = 'active'" )) - .bind(&slug) + .bind(slug) .fetch_optional(&state.db) .await? - .ok_or_else(|| ApiError::NotFound("shop".into()))?; - Ok(Json(shop)) + .ok_or_else(|| ApiError::NotFound("shop".into())) +} + +pub async fn create(state: &AppState, name: Value, slug: String) -> ApiResult { + let name_en = 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())); + } + if slug.trim().is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err(ApiError::BadRequest("invalid slug".into())); + } + sqlx::query_as::<_, Shop>(&format!( + "INSERT INTO shops (name, slug) VALUES ($1, $2) RETURNING {SHOP_COLS}" + )) + .bind(&name) + .bind(slug.trim()) + .fetch_one(&state.db) + .await + .map_err(|e| unique_conflict(e, "slug already exists")) +} + +pub async fn set_status(state: &AppState, id: Uuid, status: ShopStatus) -> ApiResult { + sqlx::query_as::<_, Shop>(&format!( + "UPDATE shops SET status = $2 WHERE id = $1 RETURNING {SHOP_COLS}" + )) + .bind(id) + .bind(status) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::NotFound("shop".into())) } #[derive(Debug, Deserialize)] -struct ProfileBody { - logo: Option, - banner: Option, - company: Option, - region: Option, - address: Option, - notice: Option, - after_sale: Option, - score_rating: Option, - score_agreement: Option, - score_service: Option, - score_speed: Option, +pub struct ProfileBody { + pub logo: Option, + pub banner: Option, + pub company: Option, + pub region: Option, + pub address: Option, + pub notice: Option, + pub after_sale: Option, + pub score_rating: Option, + pub score_agreement: Option, + pub score_service: Option, + pub score_speed: Option, } fn bilingual(label: &Value, field: &str) -> ApiResult<()> { @@ -101,14 +128,11 @@ fn bilingual(label: &Value, field: &str) -> ApiResult<()> { Ok(()) } -async fn set_shop_profile( - State(state): State, - auth: AuthUser, - Path(id): Path, - Json(body): Json, -) -> ApiResult> { - auth.require(&[UserRole::PlatformAdmin])?; - +pub async fn set_profile( + state: &AppState, + id: Uuid, + body: ProfileBody, +) -> ApiResult { for (value, field) in [ (&body.address, "address"), (&body.notice, "notice"), @@ -161,10 +185,9 @@ async fn set_shop_profile( .execute(&state.db) .await?; - let shop = sqlx::query_as::<_, ShopProfileView>(&format!("{SELECT_PROFILE} WHERE s.id = $1")) + 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)) + .ok_or_else(|| ApiError::NotFound("shop".into())) } diff --git a/apps/api/src/money.rs b/apps/api/src/money.rs index b9392f3..5b930a4 100644 --- a/apps/api/src/money.rs +++ b/apps/api/src/money.rs @@ -22,3 +22,49 @@ pub fn convert_minor(amount_minor: i64, from: &Currency, to: &Currency) -> Resul .to_i64() .ok_or_else(|| ApiError::BadRequest("amount out of range".into())) } + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + fn usd() -> Currency { + Currency { + code: "USD".into(), + name: serde_json::json!({"en": "US Dollar"}), + symbol: "$".into(), + exponent: 2, + is_base: true, + rate_to_base: dec!(1), + enabled: true, + } + } + + fn jpy() -> Currency { + Currency { + code: "JPY".into(), + name: serde_json::json!({"en": "Yen"}), + symbol: "¥".into(), + exponent: 0, + is_base: false, + rate_to_base: dec!(150), + enabled: true, + } + } + + #[test] + fn same_currency_is_identity() { + assert_eq!(convert_minor(199, &usd(), &usd()).unwrap(), 199); + } + + #[test] + fn rejects_negative() { + assert!(convert_minor(-1, &usd(), &usd()).is_err()); + } + + #[test] + fn converts_via_base_half_up() { + // 1.00 USD -> 150 JPY at rate 150 + assert_eq!(convert_minor(100, &usd(), &jpy()).unwrap(), 150); + } +} diff --git a/apps/api/src/pagination.rs b/apps/api/src/pagination.rs deleted file mode 100644 index 0818741..0000000 --- a/apps/api/src/pagination.rs +++ /dev/null @@ -1,17 +0,0 @@ -use serde::Serialize; - -#[derive(Debug, Serialize)] -pub struct Paged { - pub items: Vec, - pub total: i64, - pub page: i64, - pub per_page: i64, -} - -pub fn clamp_page(page: Option) -> i64 { - page.unwrap_or(1).max(1) -} - -pub fn clamp_per_page(per_page: Option) -> i64 { - per_page.unwrap_or(20).clamp(1, 100) -} diff --git a/apps/api/src/routes/admin.rs b/apps/api/src/routes/admin.rs deleted file mode 100644 index a806cc4..0000000 --- a/apps/api/src/routes/admin.rs +++ /dev/null @@ -1,290 +0,0 @@ -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - routing::{get, put}, - Json, Router, -}; -use serde::Deserialize; -use serde_json::Value; -use uuid::Uuid; - -use crate::auth::AuthUser; -use crate::error::{ApiError, ApiResult}; -use crate::models::{Currency, Order, Shop, ShopStatus, User, UserRole}; -use crate::routes::order_common::{attach_items, OrderView}; -use crate::pagination::{clamp_page, clamp_per_page, Paged}; -use crate::routes::currency::load_currencies; -use crate::state::AppState; - -pub fn router(_state: AppState) -> Router { - Router::new() - .route("/admin/users", get(list_users)) - .route("/admin/users/{id}/role", put(set_user_role)) - .route("/admin/shops", get(list_shops).post(create_shop)) - .route("/admin/shops/{id}/status", put(set_shop_status)) - .route( - "/admin/currencies", - get(list_all_currencies).post(upsert_currency), - ) - .route("/admin/currencies/{code}/rate", put(set_rate)) - .route("/admin/orders", get(list_orders)) -} - -fn require_admin(auth: &AuthUser) -> ApiResult<()> { - auth.require(&[UserRole::PlatformAdmin]) -} - -#[derive(Deserialize)] -struct PageQuery { - page: Option, - per_page: Option, -} - -async fn list_users( - State(state): State, - auth: AuthUser, - Query(q): Query, -) -> ApiResult>> { - require_admin(&auth)?; - let page = clamp_page(q.page); - let per_page = clamp_per_page(q.per_page); - let total: i64 = sqlx::query_scalar("SELECT count(*) FROM users") - .fetch_one(&state.db) - .await?; - let items = sqlx::query_as::<_, User>( - "SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2", - ) - .bind(per_page) - .bind((page - 1) * per_page) - .fetch_all(&state.db) - .await?; - Ok(Json(Paged { - items, - total, - page, - per_page, - })) -} - -#[derive(Deserialize)] -struct SetRoleBody { - role: UserRole, - shop_id: Option, -} - -async fn set_user_role( - State(state): State, - auth: AuthUser, - Path(id): Path, - Json(body): Json, -) -> ApiResult> { - require_admin(&auth)?; - if body.role.is_shop_role() { - let shop_id = body - .shop_id - .ok_or_else(|| ApiError::BadRequest("shop_id required for shop roles".into()))?; - let shop_exists: bool = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM shops WHERE id = $1)") - .bind(shop_id) - .fetch_one(&state.db) - .await?; - if !shop_exists { - return Err(ApiError::BadRequest("shop not found".into())); - } - } else if body.shop_id.is_some() { - return Err(ApiError::BadRequest( - "shop_id only allowed for shop roles".into(), - )); - } - let user = sqlx::query_as::<_, User>( - "UPDATE users SET role = $2, shop_id = $3 WHERE id = $1 RETURNING *", - ) - .bind(id) - .bind(body.role) - .bind(body.shop_id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::NotFound("user".into()))?; - Ok(Json(user)) -} - -async fn list_shops(State(state): State, auth: AuthUser) -> ApiResult>> { - require_admin(&auth)?; - let shops = sqlx::query_as::<_, Shop>("SELECT * FROM shops ORDER BY created_at") - .fetch_all(&state.db) - .await?; - Ok(Json(shops)) -} - -#[derive(Deserialize)] -struct CreateShopBody { - name: Value, - slug: String, -} - -async fn create_shop( - State(state): State, - auth: AuthUser, - Json(body): Json, -) -> ApiResult<(StatusCode, Json)> { - require_admin(&auth)?; - 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())); - } - if body.slug.trim().is_empty() - || !body.slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') - { - return Err(ApiError::BadRequest("invalid slug".into())); - } - let shop = sqlx::query_as::<_, Shop>( - "INSERT INTO shops (name, slug) VALUES ($1, $2) RETURNING *", - ) - .bind(&body.name) - .bind(body.slug.trim()) - .fetch_one(&state.db) - .await - .map_err(|e| match e { - sqlx::Error::Database(d) if d.is_unique_violation() => { - ApiError::Conflict("slug already exists".into()) - } - other => ApiError::from(other), - })?; - Ok((StatusCode::CREATED, Json(shop))) -} - -#[derive(Deserialize)] -struct SetStatusBody { - status: ShopStatus, -} - -async fn set_shop_status( - State(state): State, - auth: AuthUser, - Path(id): Path, - Json(body): Json, -) -> ApiResult> { - require_admin(&auth)?; - let shop = sqlx::query_as::<_, Shop>( - "UPDATE shops SET status = $2 WHERE id = $1 RETURNING *", - ) - .bind(id) - .bind(body.status) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::NotFound("shop".into()))?; - Ok(Json(shop)) -} - -async fn list_all_currencies( - State(state): State, - auth: AuthUser, -) -> ApiResult>> { - require_admin(&auth)?; - Ok(Json(load_currencies(&state, false).await?)) -} - -#[derive(Deserialize)] -struct CurrencyBody { - code: String, - name: Value, - symbol: String, - exponent: i16, - rate_to_base: String, - enabled: bool, -} - -async fn upsert_currency( - State(state): State, - auth: AuthUser, - Json(body): Json, -) -> ApiResult> { - require_admin(&auth)?; - let code = body.code.to_uppercase(); - if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) { - return Err(ApiError::BadRequest("code must be a 3-letter ISO code".into())); - } - let rate: rust_decimal::Decimal = body - .rate_to_base - .parse() - .map_err(|_| ApiError::BadRequest("rate_to_base must be numeric".into()))?; - if rate <= rust_decimal::Decimal::ZERO { - return Err(ApiError::BadRequest("rate_to_base must be > 0".into())); - } - if !(0..=6).contains(&body.exponent) { - return Err(ApiError::BadRequest("exponent must be 0..=6".into())); - } - let currency = sqlx::query_as::<_, Currency>( - "INSERT INTO currencies (code, name, symbol, exponent, rate_to_base, enabled) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (code) DO UPDATE - SET name = $2, symbol = $3, exponent = $4, rate_to_base = $5, enabled = $6 - RETURNING *", - ) - .bind(&code) - .bind(&body.name) - .bind(&body.symbol) - .bind(body.exponent) - .bind(rate) - .bind(body.enabled) - .fetch_one(&state.db) - .await?; - Ok(Json(currency)) -} - -#[derive(Deserialize)] -struct SetRateBody { - rate_to_base: String, -} - -async fn set_rate( - State(state): State, - auth: AuthUser, - Path(code): Path, - Json(body): Json, -) -> ApiResult> { - require_admin(&auth)?; - let rate: rust_decimal::Decimal = body - .rate_to_base - .parse() - .map_err(|_| ApiError::BadRequest("rate_to_base must be numeric".into()))?; - if rate <= rust_decimal::Decimal::ZERO { - return Err(ApiError::BadRequest("rate_to_base must be > 0".into())); - } - let currency = sqlx::query_as::<_, Currency>( - "UPDATE currencies SET rate_to_base = $2 WHERE code = $1 RETURNING *", - ) - .bind(code.to_uppercase()) - .bind(rate) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::NotFound("currency".into()))?; - Ok(Json(currency)) -} - -async fn list_orders( - State(state): State, - auth: AuthUser, - Query(q): Query, -) -> ApiResult>> { - require_admin(&auth)?; - let page = clamp_page(q.page); - let per_page = clamp_per_page(q.per_page); - let total: i64 = sqlx::query_scalar("SELECT count(*) FROM orders") - .fetch_one(&state.db) - .await?; - let orders = sqlx::query_as::<_, Order>( - "SELECT * FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2", - ) - .bind(per_page) - .bind((page - 1) * per_page) - .fetch_all(&state.db) - .await?; - let items = attach_items(&state.db, orders).await?; - Ok(Json(Paged { - items, - total, - page, - per_page, - })) -} diff --git a/apps/api/src/routes/auth.rs b/apps/api/src/routes/auth.rs deleted file mode 100644 index 0d155b9..0000000 --- a/apps/api/src/routes/auth.rs +++ /dev/null @@ -1,97 +0,0 @@ -use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router}; -use serde::Deserialize; -use serde_json::{json, Value}; - -use crate::auth::{hash_password, issue_token, verify_password, AuthUser}; -use crate::error::{ApiError, ApiResult}; -use crate::models::User; -use crate::state::AppState; - -pub fn router(_state: AppState) -> Router { - Router::new() - .route("/auth/register", post(register)) - .route("/auth/login", post(login)) - .route("/auth/me", get(me)) -} - -#[derive(Deserialize)] -pub struct RegisterBody { - email: String, - password: String, - display_name: String, -} - -async fn register( - State(state): State, - Json(body): Json, -) -> ApiResult<(StatusCode, Json)> { - let email = body.email.trim().to_lowercase(); - if !email.contains('@') { - return Err(ApiError::BadRequest("invalid email".into())); - } - if body.password.len() < 8 { - return Err(ApiError::BadRequest("password must be at least 8 characters".into())); - } - if body.display_name.trim().is_empty() { - return Err(ApiError::BadRequest("display_name is required".into())); - } - let hash = hash_password(&body.password)?; - let user = sqlx::query_as::<_, User>( - "INSERT INTO users (email, password_hash, display_name, role) - VALUES ($1, $2, $3, 'customer') RETURNING *", - ) - .bind(&email) - .bind(&hash) - .bind(body.display_name.trim()) - .fetch_one(&state.db) - .await - .map_err(|e| match e { - sqlx::Error::Database(d) if d.is_unique_violation() => { - ApiError::Conflict("email already registered".into()) - } - other => ApiError::from(other), - })?; - Ok((StatusCode::CREATED, Json(auth_payload(&state, &user)?))) -} - -#[derive(Deserialize)] -pub struct LoginBody { - email: String, - password: String, -} - -async fn login( - State(state): State, - Json(body): Json, -) -> ApiResult> { - let email = body.email.trim().to_lowercase(); - let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") - .bind(&email) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::Unauthorized("invalid email or password".into()))?; - if !verify_password(&body.password, &user.password_hash) { - return Err(ApiError::Unauthorized("invalid email or password".into())); - } - Ok(Json(auth_payload(&state, &user)?)) -} - -async fn me(State(state): State, auth: AuthUser) -> ApiResult> { - let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") - .bind(auth.id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::NotFound("user".into()))?; - Ok(Json(user)) -} - -fn auth_payload(state: &AppState, user: &User) -> ApiResult { - let token = issue_token( - &state.config.jwt_secret, - state.config.jwt_ttl_secs, - user.id, - user.role, - user.shop_id, - )?; - Ok(json!({ "token": token, "user": user })) -} diff --git a/apps/api/src/routes/brands.rs b/apps/api/src/routes/brands.rs deleted file mode 100644 index f3270e1..0000000 --- a/apps/api/src/routes/brands.rs +++ /dev/null @@ -1,101 +0,0 @@ -use axum::{ - extract::State, - routing::{get, put}, - Json, Router, -}; -use serde::Deserialize; -use serde_json::Value; - -use crate::auth::AuthUser; -use crate::error::{ApiError, ApiResult}; -use crate::models::{Brand, UserRole}; -use crate::state::AppState; - -pub fn router(_state: AppState) -> Router { - Router::new().route("/brands", get(list_brands)) -} - -pub fn admin_router(_state: AppState) -> Router { - Router::new().route("/admin/brands", put(replace_brands)) -} - -async fn list_brands(State(state): State) -> ApiResult>> { - let brands = sqlx::query_as::<_, Brand>( - "SELECT * FROM brands WHERE active = TRUE ORDER BY position, created_at", - ) - .fetch_all(&state.db) - .await?; - Ok(Json(brands)) -} - -#[derive(Deserialize)] -struct BrandInput { - slug: String, - name: Value, - #[serde(default = "default_active")] - active: bool, -} - -fn default_active() -> bool { - true -} - -/// Replaces the whole ordered list, as storefront content does: small lists are -/// edited whole, and positions are reindexed from the submitted order. -async fn replace_brands( - State(state): State, - auth: AuthUser, - Json(body): Json>, -) -> ApiResult>> { - auth.require(&[UserRole::PlatformAdmin])?; - - for brand in &body { - if brand.slug.trim().is_empty() - || !brand - .slug - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-') - { - return Err(ApiError::BadRequest(format!( - "invalid brand slug: {}", - brand.slug - ))); - } - let ok = ["en", "zh"].iter().all(|code| { - brand - .name - .get(code) - .and_then(Value::as_str) - .is_some_and(|s| !s.trim().is_empty()) - }); - if !ok { - return Err(ApiError::BadRequest(format!( - "brand {} needs non-empty en and zh names", - brand.slug - ))); - } - } - - let mut tx = state.db.begin().await?; - sqlx::query("DELETE FROM brands").execute(&mut *tx).await?; - for (i, brand) in body.iter().enumerate() { - sqlx::query( - "INSERT INTO brands (name, slug, position, active) VALUES ($1, $2, $3, $4)", - ) - .bind(&brand.name) - .bind(brand.slug.trim()) - .bind(i as i32) - .bind(brand.active) - .execute(&mut *tx) - .await - .map_err(|e| match e { - sqlx::Error::Database(d) if d.is_unique_violation() => { - ApiError::BadRequest(format!("duplicate brand slug: {}", brand.slug)) - } - other => ApiError::from(other), - })?; - } - tx.commit().await?; - - list_brands(State(state)).await -} diff --git a/apps/api/src/routes/cart.rs b/apps/api/src/routes/cart.rs deleted file mode 100644 index d711a79..0000000 --- a/apps/api/src/routes/cart.rs +++ /dev/null @@ -1,94 +0,0 @@ -use axum::{ - extract::{Path, State}, - routing::{get, post, put}, - Json, Router, -}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::auth::AuthUser; -use crate::cart::{self, CartView}; -use crate::error::{ApiError, ApiResult}; -use crate::state::AppState; - -pub fn router(_state: AppState) -> Router { - Router::new() - .route("/cart", get(get_cart)) - .route("/cart/items", post(add_item)) - .route("/cart/items/{sku_id}", put(set_item).delete(remove_item)) -} - -async fn get_cart(State(state): State, auth: AuthUser) -> ApiResult> { - Ok(Json(cart::cart_view(&state, auth.id).await?)) -} - -#[derive(Deserialize)] -struct AddBody { - sku_id: Uuid, - qty: i32, -} - -async fn ensure_purchasable(state: &AppState, sku_id: Uuid) -> ApiResult<()> { - let ok: bool = sqlx::query_scalar( - "SELECT EXISTS( - SELECT 1 FROM skus s - JOIN products p ON p.id = s.product_id - JOIN shops sh ON sh.id = p.shop_id - WHERE s.id = $1 AND s.active = TRUE - AND p.status = 'published' AND sh.status = 'active')", - ) - .bind(sku_id) - .fetch_one(&state.db) - .await?; - if !ok { - return Err(ApiError::BadRequest("sku is not purchasable".into())); - } - Ok(()) -} - -async fn add_item( - State(state): State, - auth: AuthUser, - Json(body): Json, -) -> ApiResult> { - if body.qty <= 0 { - return Err(ApiError::BadRequest("qty must be > 0".into())); - } - ensure_purchasable(&state, body.sku_id).await?; - let current = cart::read_cart(&state, auth.id).await?; - let existing = current - .iter() - .find(|(id, _)| *id == body.sku_id) - .map(|(_, q)| *q) - .unwrap_or(0); - cart::set_qty(&state, auth.id, body.sku_id, existing + body.qty).await?; - Ok(Json(cart::cart_view(&state, auth.id).await?)) -} - -#[derive(Deserialize)] -struct SetBody { - qty: i32, -} - -async fn set_item( - State(state): State, - auth: AuthUser, - Path(sku_id): Path, - Json(body): Json, -) -> ApiResult> { - if body.qty <= 0 { - return Err(ApiError::BadRequest("qty must be > 0; use DELETE to remove".into())); - } - ensure_purchasable(&state, sku_id).await?; - cart::set_qty(&state, auth.id, sku_id, body.qty).await?; - Ok(Json(cart::cart_view(&state, auth.id).await?)) -} - -async fn remove_item( - State(state): State, - auth: AuthUser, - Path(sku_id): Path, -) -> ApiResult> { - cart::set_qty(&state, auth.id, sku_id, 0).await?; - Ok(Json(cart::cart_view(&state, auth.id).await?)) -} diff --git a/apps/api/src/routes/catalog.rs b/apps/api/src/routes/catalog.rs deleted file mode 100644 index 9e39136..0000000 --- a/apps/api/src/routes/catalog.rs +++ /dev/null @@ -1,254 +0,0 @@ -use axum::{ - extract::{Path, Query, State}, - routing::get, - Json, Router, -}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::error::{ApiError, ApiResult}; -use crate::models::{Category, Product, Sku}; -use crate::pagination::{clamp_page, clamp_per_page, Paged}; -use crate::state::AppState; - -#[derive(Debug, Serialize)] -pub struct ProductWithSkus { - #[serde(flatten)] - pub product: Product, - pub skus: Vec, - /// Units sold across orders that reached payment. Zero when nothing sold. - pub sold_count: i64, -} - -/// Orders that count as a sale: an abandoned or cancelled checkout does not. -const PAID_STATUSES: &str = - "('paid', 'fulfilling', 'shipped', 'completed')"; - -/// Units sold for one product, as a correlated subquery so it can also drive -/// the sales sort without a second round trip. -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'))"; - -#[derive(sqlx::FromRow)] -struct SoldRow { - product_id: Uuid, - sold: i64, -} - -pub async fn attach_skus( - db: &sqlx::PgPool, - products: Vec, - public_only: bool, -) -> ApiResult> { - let ids: Vec = products.iter().map(|p| p.id).collect(); - let skus = if ids.is_empty() { - Vec::new() - } else if public_only { - sqlx::query_as::<_, Sku>( - "SELECT * 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 * FROM skus WHERE product_id = ANY($1) ORDER BY sku_code", - ) - .bind(&ids) - .fetch_all(db) - .await? - }; - let sold: Vec = 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? - }; - Ok(products - .into_iter() - .map(|p| ProductWithSkus { - skus: skus.iter().filter(|s| s.product_id == p.id).cloned().collect(), - sold_count: sold - .iter() - .find(|row| row.product_id == p.id) - .map(|row| row.sold) - .unwrap_or(0), - product: p, - }) - .collect()) -} - -pub fn router(_state: AppState) -> Router { - Router::new() - .route("/products", get(list_products)) - .route("/products/{id_or_slug}", get(get_product)) - .route("/categories", get(list_categories)) -} - -#[derive(Deserialize)] -struct ListQuery { - page: Option, - per_page: Option, - category_id: Option, - brand_id: Option, - shop_id: Option, - q: Option, - sort: Option, - order: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SortBy { - Newest, - Price, - Sales, -} - -impl ListQuery { - /// Only `price` and `sales` are supported sorts; anything else is a client - /// error rather than being silently ignored. An absent `sort` keeps - /// newest-first. - fn sort_by(&self) -> ApiResult { - 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}"))), - } - } - - fn ascending(&self) -> ApiResult { - match self.order.as_deref() { - None | Some("asc") => Ok(true), - Some("desc") => Ok(false), - Some(other) => Err(ApiError::BadRequest(format!("unsupported order: {other}"))), - } - } -} - -/// Resolves the requested category to itself plus every descendant, so a parent -/// category lists its children's and grandchildren's products too. Shared by the -/// count and page queries so `total` cannot drift from `items`. -const SUBTREE_CTE: &str = "WITH RECURSIVE subtree AS ( - SELECT id FROM categories WHERE id = $1::uuid - UNION ALL - SELECT c.id FROM categories c JOIN subtree st ON c.parent_id = st.id - ) "; - -/// Lowest active SKU price, used only when sorting by price. Products without a -/// sellable SKU sort last in both directions. -const MIN_PRICE: &str = - "(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)"; - -/// Public catalog: only published products of active shops. -async fn list_products( - State(state): State, - Query(q): Query, -) -> ApiResult>> { - 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?; - - // Interpolates only from the validated SortBy/order pair, never from input. - 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 p.* 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 = attach_skus(&state.db, products, true).await?; - Ok(Json(Paged { - items, - total, - page, - per_page, - })) -} - -async fn get_product( - State(state): State, - Path(id_or_slug): Path, -) -> ApiResult> { - let product = if let Ok(id) = Uuid::parse_str(&id_or_slug) { - sqlx::query_as::<_, Product>( - "SELECT p.* 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>( - "SELECT p.* 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 = attach_skus(&state.db, vec![product], true).await?; - Ok(Json(items.remove(0))) -} - -async fn list_categories(State(state): State) -> ApiResult>> { - let cats = sqlx::query_as::<_, Category>( - "SELECT * FROM categories ORDER BY position, slug", - ) - .fetch_all(&state.db) - .await?; - Ok(Json(cats)) -} diff --git a/apps/api/src/routes/currency.rs b/apps/api/src/routes/currency.rs deleted file mode 100644 index be00714..0000000 --- a/apps/api/src/routes/currency.rs +++ /dev/null @@ -1,57 +0,0 @@ -use axum::{extract::{Query, State}, routing::get, Json, Router}; -use serde::Deserialize; -use serde_json::{json, Value}; - -use crate::error::{ApiError, ApiResult}; -use crate::models::Currency; -use crate::money::convert_minor; -use crate::state::AppState; - -pub fn router(_state: AppState) -> Router { - Router::new() - .route("/currencies", get(list_currencies)) - .route("/currencies/convert", get(convert)) -} - -pub async fn load_currencies(state: &AppState, enabled_only: bool) -> ApiResult> { - let rows = if enabled_only { - sqlx::query_as::<_, Currency>( - "SELECT * FROM currencies WHERE enabled = TRUE ORDER BY code", - ) - .fetch_all(&state.db) - .await? - } else { - sqlx::query_as::<_, Currency>("SELECT * FROM currencies ORDER BY code") - .fetch_all(&state.db) - .await? - }; - Ok(rows) -} - -async fn list_currencies(State(state): State) -> ApiResult>> { - Ok(Json(load_currencies(&state, true).await?)) -} - -#[derive(Deserialize)] -struct ConvertQuery { - amount_minor: i64, - from: String, - to: String, -} - -async fn convert( - State(state): State, - Query(q): Query, -) -> ApiResult> { - let currencies = load_currencies(&state, true).await?; - let from = currencies - .iter() - .find(|c| c.code == q.from.to_uppercase()) - .ok_or_else(|| ApiError::BadRequest(format!("unknown or disabled currency: {}", q.from)))?; - let to = currencies - .iter() - .find(|c| c.code == q.to.to_uppercase()) - .ok_or_else(|| ApiError::BadRequest(format!("unknown or disabled currency: {}", q.to)))?; - let converted = convert_minor(q.amount_minor, from, to)?; - Ok(Json(json!({ "amount_minor": converted, "currency": to.code }))) -} diff --git a/apps/api/src/routes/mod.rs b/apps/api/src/routes/mod.rs deleted file mode 100644 index 72c0167..0000000 --- a/apps/api/src/routes/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -pub mod admin; -pub mod auth; -pub mod brands; -pub mod cart; -pub mod catalog; -pub mod content; -pub mod currency; -pub mod health; -pub mod order_common; -pub mod orders; -pub mod shop; -pub mod shop_catalog; -pub mod shop_orders; -pub mod shops; - -use axum::Router; - -use crate::state::AppState; - -/// Routers for every domain module are merged here. -pub fn api_router(state: AppState) -> Router { - Router::new() - .merge(health::router(state.clone())) - .merge(auth::router(state.clone())) - .merge(currency::router(state.clone())) - .merge(catalog::router(state.clone())) - .merge(brands::router(state.clone())) - .merge(brands::admin_router(state.clone())) - .merge(content::router(state.clone())) - .merge(content::admin_router(state.clone())) - .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)) -} diff --git a/apps/api/src/routes/order_common.rs b/apps/api/src/routes/order_common.rs deleted file mode 100644 index befcaae..0000000 --- a/apps/api/src/routes/order_common.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! Shared read models and status-propagation helpers for order, shipment and -//! invoice routes (customer, shop and admin surfaces all use these). - -use serde::Serialize; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::error::ApiResult; -use crate::models::{Invoice, Order, OrderItem, Shipment, ShipmentItem}; - -#[derive(Debug, Serialize)] -pub struct OrderView { - #[serde(flatten)] - pub order: Order, - pub items: Vec, -} - -pub async fn attach_items(db: &PgPool, orders: Vec) -> ApiResult> { - let ids: Vec = orders.iter().map(|o| o.id).collect(); - let items = if ids.is_empty() { - Vec::new() - } else { - sqlx::query_as::<_, OrderItem>( - "SELECT * FROM order_items WHERE order_id = ANY($1) ORDER BY sku_code", - ) - .bind(&ids) - .fetch_all(db) - .await? - }; - Ok(orders - .into_iter() - .map(|o| OrderView { - items: items.iter().filter(|i| i.order_id == o.id).cloned().collect(), - order: o, - }) - .collect()) -} - -#[derive(Debug, Serialize)] -pub struct ShipmentView { - #[serde(flatten)] - pub shipment: Shipment, - pub order_no: String, - pub items: Vec, -} - -pub async fn shipment_views(db: &PgPool, shipments: Vec) -> ApiResult> { - let ids: Vec = shipments.iter().map(|s| s.id).collect(); - let items = if ids.is_empty() { - Vec::new() - } else { - sqlx::query_as::<_, ShipmentItem>( - "SELECT * FROM shipment_items WHERE shipment_id = ANY($1)", - ) - .bind(&ids) - .fetch_all(db) - .await? - }; - let order_ids: Vec = shipments.iter().map(|s| s.order_id).collect(); - let order_nos: Vec<(Uuid, String)> = if order_ids.is_empty() { - Vec::new() - } else { - sqlx::query_as("SELECT id, order_no FROM orders WHERE id = ANY($1)") - .bind(&order_ids) - .fetch_all(db) - .await? - }; - Ok(shipments - .into_iter() - .map(|s| ShipmentView { - order_no: order_nos - .iter() - .find(|(id, _)| *id == s.order_id) - .map(|(_, no)| no.clone()) - .unwrap_or_default(), - items: items.iter().filter(|i| i.shipment_id == s.id).cloned().collect(), - shipment: s, - }) - .collect()) -} - -#[derive(Debug, Serialize)] -pub struct InvoiceView { - #[serde(flatten)] - pub invoice: Invoice, - pub order_no: String, -} - -pub async fn invoice_views(db: &PgPool, invoices: Vec) -> ApiResult> { - let order_ids: Vec = invoices.iter().map(|i| i.order_id).collect(); - let order_nos: Vec<(Uuid, String)> = if order_ids.is_empty() { - Vec::new() - } else { - sqlx::query_as("SELECT id, order_no FROM orders WHERE id = ANY($1)") - .bind(&order_ids) - .fetch_all(db) - .await? - }; - Ok(invoices - .into_iter() - .map(|i| InvoiceView { - order_no: order_nos - .iter() - .find(|(id, _)| *id == i.order_id) - .map(|(_, no)| no.clone()) - .unwrap_or_default(), - invoice: i, - }) - .collect()) -} - -/// After a shipment is marked shipped: order becomes `shipped` once every -/// ordered unit is covered by shipped/delivered shipment lines. -pub async fn maybe_mark_order_shipped(db: &PgPool, order_id: Uuid) -> ApiResult<()> { - let fully_covered: bool = sqlx::query_scalar( - "SELECT NOT EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = $1 AND oi.qty > COALESCE(( - SELECT sum(si.qty) FROM shipment_items si - JOIN shipments s ON s.id = si.shipment_id - WHERE si.order_item_id = oi.id AND s.status IN ('shipped', 'delivered') - ), 0) - )", - ) - .bind(order_id) - .fetch_one(db) - .await?; - if fully_covered { - sqlx::query( - "UPDATE orders SET status = 'shipped', updated_at = now() - WHERE id = $1 AND status = 'fulfilling'", - ) - .bind(order_id) - .execute(db) - .await?; - } - Ok(()) -} - -/// After a delivery confirmation: order becomes `completed` when every item is -/// covered and every shipment of the order is delivered. -pub async fn maybe_mark_order_completed(db: &PgPool, order_id: Uuid) -> ApiResult<()> { - let open_shipments: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM shipments WHERE order_id = $1 AND status <> 'delivered')", - ) - .bind(order_id) - .fetch_one(db) - .await?; - if open_shipments { - return Ok(()); - } - let fully_covered: bool = sqlx::query_scalar( - "SELECT NOT EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = $1 AND oi.qty > COALESCE(( - SELECT sum(si.qty) FROM shipment_items si - JOIN shipments s ON s.id = si.shipment_id - WHERE si.order_item_id = oi.id AND s.status = 'delivered' - ), 0) - )", - ) - .bind(order_id) - .fetch_one(db) - .await?; - if fully_covered { - sqlx::query( - "UPDATE orders SET status = 'completed', updated_at = now() - WHERE id = $1 AND status IN ('shipped', 'fulfilling')", - ) - .bind(order_id) - .execute(db) - .await?; - } - Ok(()) -} diff --git a/apps/api/src/routes/orders.rs b/apps/api/src/routes/orders.rs deleted file mode 100644 index ad2d361..0000000 --- a/apps/api/src/routes/orders.rs +++ /dev/null @@ -1,423 +0,0 @@ -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::cart; -use crate::error::{ApiError, ApiResult}; -use crate::models::{Currency, Invoice, InvoiceKind, Order, OrderStatus, Shipment}; -use crate::money::convert_minor; -use crate::pagination::{clamp_page, clamp_per_page, Paged}; -use crate::routes::order_common::{ - attach_items, invoice_views, maybe_mark_order_completed, shipment_views, InvoiceView, - OrderView, ShipmentView, -}; -use crate::state::AppState; - -pub fn router(_state: AppState) -> Router { - Router::new() - .route("/orders", get(list_my_orders)) - .route("/orders/checkout", post(checkout)) - .route("/orders/{id}", get(get_order)) - .route("/orders/{id}/pay", post(pay_order)) - .route("/orders/{id}/cancel", post(cancel_order)) - .route("/orders/{id}/invoice", post(request_invoice)) - .route("/shipments", get(list_my_shipments)) - .route( - "/shipments/{id}/confirm-delivered", - post(confirm_delivered), - ) - .route("/invoices", get(list_my_invoices)) -} - -#[derive(Deserialize)] -struct PageQuery { - page: Option, - per_page: Option, -} - -async fn list_my_orders( - State(state): State, - auth: AuthUser, - Query(q): Query, -) -> ApiResult>> { - let page = clamp_page(q.page); - let per_page = clamp_per_page(q.per_page); - let total: i64 = sqlx::query_scalar("SELECT count(*) FROM orders WHERE user_id = $1") - .bind(auth.id) - .fetch_one(&state.db) - .await?; - let orders = sqlx::query_as::<_, Order>( - "SELECT * FROM orders WHERE user_id = $1 - ORDER BY created_at DESC LIMIT $2 OFFSET $3", - ) - .bind(auth.id) - .bind(per_page) - .bind((page - 1) * per_page) - .fetch_all(&state.db) - .await?; - let items = attach_items(&state.db, orders).await?; - Ok(Json(Paged { - items, - total, - page, - per_page, - })) -} - -async fn load_own_order(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { - sqlx::query_as::<_, Order>("SELECT * FROM orders WHERE id = $1 AND user_id = $2") - .bind(id) - .bind(user_id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::NotFound("order".into())) -} - -async fn get_order( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - let order = load_own_order(&state, auth.id, id).await?; - let mut views = attach_items(&state.db, vec![order]).await?; - Ok(Json(views.remove(0))) -} - -#[derive(Deserialize, serde::Serialize)] -pub struct AddressBody { - recipient: String, - phone: String, - country: String, - region: String, - city: String, - line1: String, - postal_code: String, -} - -impl AddressBody { - fn validate(&self) -> ApiResult<()> { - for (field, value) in [ - ("recipient", &self.recipient), - ("phone", &self.phone), - ("country", &self.country), - ("city", &self.city), - ("line1", &self.line1), - ] { - if value.trim().is_empty() { - return Err(ApiError::BadRequest(format!("shipping_address.{field} is required"))); - } - } - Ok(()) - } -} - -#[derive(Deserialize)] -struct CheckoutBody { - shipping_address: AddressBody, - currency: String, -} - -#[derive(sqlx::FromRow)] -struct CheckoutRow { - sku_id: Uuid, - shop_id: Uuid, - product_name: serde_json::Value, - sku_code: String, - image: Option, - price_minor: i64, - currency: String, - stock: i32, -} - -async fn checkout( - State(state): State, - auth: AuthUser, - Json(body): Json, -) -> ApiResult<(StatusCode, Json>)> { - body.shipping_address.validate()?; - let target_currency = body.currency.to_uppercase(); - let entries = cart::read_cart(&state, auth.id).await?; - if entries.is_empty() { - return Err(ApiError::BadRequest("cart is empty".into())); - } - - let mut tx = state.db.begin().await?; - - let target: Currency = sqlx::query_as::<_, Currency>( - "SELECT * FROM currencies WHERE code = $1 AND enabled = TRUE", - ) - .bind(&target_currency) - .fetch_optional(&mut *tx) - .await? - .ok_or_else(|| ApiError::BadRequest(format!("unknown currency: {target_currency}")))?; - let all_currencies = - sqlx::query_as::<_, Currency>("SELECT * FROM currencies WHERE enabled = TRUE") - .fetch_all(&mut *tx) - .await?; - - let sku_ids: Vec = entries.iter().map(|(id, _)| *id).collect(); - let rows = sqlx::query_as::<_, CheckoutRow>( - "SELECT s.id AS sku_id, p.shop_id, p.name AS product_name, s.sku_code, - (p.images ->> 0) AS image, s.price_minor, s.currency, s.stock - FROM skus s - JOIN products p ON p.id = s.product_id - JOIN shops sh ON sh.id = p.shop_id - WHERE s.id = ANY($1) AND s.active = TRUE - AND p.status = 'published' AND sh.status = 'active' - FOR UPDATE OF s", - ) - .bind(&sku_ids) - .fetch_all(&mut *tx) - .await?; - if rows.len() != entries.len() { - return Err(ApiError::Conflict( - "some cart items are no longer purchasable".into(), - )); - } - for row in &rows { - let qty = entries - .iter() - .find(|(id, _)| *id == row.sku_id) - .map(|(_, q)| *q) - .unwrap_or(0); - if qty > row.stock { - return Err(ApiError::Conflict(format!( - "insufficient stock for SKU {}", - row.sku_code - ))); - } - } - - // group by shop, preserving first-seen order - let mut shop_order: Vec = Vec::new(); - for row in &rows { - if !shop_order.contains(&row.shop_id) { - shop_order.push(row.shop_id); - } - } - - let address = serde_json::to_value(&body.shipping_address).map_err(ApiError::internal)?; - let mut created: Vec = Vec::new(); - for shop_id in shop_order { - let shop_rows: Vec<&CheckoutRow> = rows.iter().filter(|r| r.shop_id == shop_id).collect(); - let mut total: i64 = 0; - let mut line_prices: Vec<(Uuid, i64, i32)> = Vec::new(); - for row in &shop_rows { - let qty = entries - .iter() - .find(|(id, _)| *id == row.sku_id) - .map(|(_, q)| *q) - .unwrap_or(0); - let from = all_currencies - .iter() - .find(|c| c.code == row.currency) - .ok_or_else(|| { - ApiError::BadRequest(format!("currency {} disabled", row.currency)) - })?; - let unit = convert_minor(row.price_minor, from, &target)?; - total += unit * qty as i64; - line_prices.push((row.sku_id, unit, qty)); - } - let order = sqlx::query_as::<_, Order>( - "INSERT INTO orders (order_no, shop_id, user_id, currency, total_minor, shipping_address) - VALUES ('VM' || to_char(now(), 'YYMMDD') || lpad(nextval('order_no_seq')::text, 6, '0'), - $1, $2, $3, $4, $5) - RETURNING *", - ) - .bind(shop_id) - .bind(auth.id) - .bind(&target.code) - .bind(total) - .bind(&address) - .fetch_one(&mut *tx) - .await?; - for row in &shop_rows { - let (_, unit, qty) = line_prices - .iter() - .find(|(sku, _, _)| *sku == row.sku_id) - .unwrap(); - sqlx::query( - "INSERT INTO order_items (order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty) - VALUES ($1, $2, $3, $4, $5, $6, $7)", - ) - .bind(order.id) - .bind(row.sku_id) - .bind(&row.product_name) - .bind(&row.sku_code) - .bind(&row.image) - .bind(unit) - .bind(qty) - .execute(&mut *tx) - .await?; - sqlx::query("UPDATE skus SET stock = stock - $2 WHERE id = $1") - .bind(row.sku_id) - .bind(qty) - .execute(&mut *tx) - .await?; - } - created.push(order); - } - - tx.commit().await?; - cart::clear_cart(&state, auth.id).await?; - let views = attach_items(&state.db, created).await?; - Ok((StatusCode::CREATED, Json(views))) -} - -async fn pay_order( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - let order = sqlx::query_as::<_, Order>( - "UPDATE orders SET status = 'paid', updated_at = now() - WHERE id = $1 AND user_id = $2 AND status = 'pending_payment' - RETURNING *", - ) - .bind(id) - .bind(auth.id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::Conflict("order not payable (missing, not yours, or wrong status)".into()))?; - let mut views = attach_items(&state.db, vec![order]).await?; - Ok(Json(views.remove(0))) -} - -async fn cancel_order( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - let mut tx = state.db.begin().await?; - let order = sqlx::query_as::<_, Order>( - "UPDATE orders SET status = 'cancelled', updated_at = now() - WHERE id = $1 AND user_id = $2 AND status = 'pending_payment' - RETURNING *", - ) - .bind(id) - .bind(auth.id) - .fetch_optional(&mut *tx) - .await? - .ok_or_else(|| { - ApiError::Conflict("order not cancellable (missing, not yours, or wrong status)".into()) - })?; - // restore stock - sqlx::query( - "UPDATE skus s SET stock = s.stock + oi.qty - FROM order_items oi WHERE oi.order_id = $1 AND oi.sku_id = s.id", - ) - .bind(order.id) - .execute(&mut *tx) - .await?; - tx.commit().await?; - let mut views = attach_items(&state.db, vec![order]).await?; - Ok(Json(views.remove(0))) -} - -async fn list_my_shipments( - State(state): State, - auth: AuthUser, -) -> ApiResult>> { - let shipments = sqlx::query_as::<_, Shipment>( - "SELECT s.* FROM shipments s - JOIN orders o ON o.id = s.order_id - WHERE o.user_id = $1 - ORDER BY s.created_at DESC", - ) - .bind(auth.id) - .fetch_all(&state.db) - .await?; - Ok(Json(shipment_views(&state.db, shipments).await?)) -} - -async fn confirm_delivered( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - let shipment = sqlx::query_as::<_, Shipment>( - "UPDATE shipments s SET status = 'delivered', delivered_at = now() - FROM orders o - WHERE s.id = $1 AND o.id = s.order_id AND o.user_id = $2 AND s.status = 'shipped' - RETURNING s.*", - ) - .bind(id) - .bind(auth.id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::Conflict("shipment not confirmable".into()))?; - maybe_mark_order_completed(&state.db, shipment.order_id).await?; - let mut views = shipment_views(&state.db, vec![shipment]).await?; - Ok(Json(views.remove(0))) -} - -#[derive(Deserialize)] -struct InvoiceBody { - title: String, - tax_no: Option, - kind: InvoiceKind, -} - -async fn request_invoice( - State(state): State, - auth: AuthUser, - Path(id): Path, - Json(body): Json, -) -> ApiResult<(StatusCode, Json)> { - let order = load_own_order(&state, auth.id, id).await?; - if matches!(order.status, OrderStatus::PendingPayment | OrderStatus::Cancelled) { - return Err(ApiError::BadRequest( - "invoices can only be requested for paid orders".into(), - )); - } - if body.title.trim().is_empty() { - return Err(ApiError::BadRequest("title is required".into())); - } - if body.kind == InvoiceKind::Company - && body.tax_no.as_ref().map(|t| t.trim().is_empty()).unwrap_or(true) - { - return Err(ApiError::BadRequest( - "tax_no is required for company invoices".into(), - )); - } - let invoice = sqlx::query_as::<_, Invoice>( - "INSERT INTO invoices (order_id, user_id, title, tax_no, kind, amount_minor, currency) - VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", - ) - .bind(order.id) - .bind(auth.id) - .bind(body.title.trim()) - .bind(body.tax_no.as_deref().map(str::trim)) - .bind(body.kind) - .bind(order.total_minor) - .bind(&order.currency) - .fetch_one(&state.db) - .await - .map_err(|e| match e { - sqlx::Error::Database(d) if d.is_unique_violation() => { - ApiError::Conflict("order already has an open invoice".into()) - } - other => ApiError::from(other), - })?; - let mut views = invoice_views(&state.db, vec![invoice]).await?; - Ok((StatusCode::CREATED, Json(views.remove(0)))) -} - -async fn list_my_invoices( - State(state): State, - auth: AuthUser, -) -> ApiResult>> { - let invoices = sqlx::query_as::<_, Invoice>( - "SELECT * FROM invoices WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(auth.id) - .fetch_all(&state.db) - .await?; - Ok(Json(invoice_views(&state.db, invoices).await?)) -} diff --git a/apps/api/src/routes/shop.rs b/apps/api/src/routes/shop.rs deleted file mode 100644 index 8270995..0000000 --- a/apps/api/src/routes/shop.rs +++ /dev/null @@ -1,21 +0,0 @@ -use axum::{extract::State, routing::get, Json, Router}; - -use crate::auth::AuthUser; -use crate::error::{ApiError, ApiResult}; -use crate::models::{Shop, UserRole}; -use crate::state::AppState; - -pub fn router(_state: AppState) -> Router { - Router::new().route("/shop/profile", get(my_shop)) -} - -async fn my_shop(State(state): State, auth: AuthUser) -> ApiResult> { - auth.require(&[UserRole::ShopOwner, UserRole::ShopStaff])?; - let shop_id = auth.own_shop()?; - let shop = sqlx::query_as::<_, Shop>("SELECT * FROM shops WHERE id = $1") - .bind(shop_id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::NotFound("shop".into()))?; - Ok(Json(shop)) -} diff --git a/apps/api/src/routes/shop_catalog.rs b/apps/api/src/routes/shop_catalog.rs deleted file mode 100644 index 30e9c1f..0000000 --- a/apps/api/src/routes/shop_catalog.rs +++ /dev/null @@ -1,293 +0,0 @@ -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::{ApiError, ApiResult}; -use crate::models::{Product, ProductStatus, Sku, UserRole}; -use crate::pagination::{clamp_page, clamp_per_page, Paged}; -use crate::routes::catalog::{attach_skus, ProductWithSkus}; -use crate::state::AppState; - -pub fn router(_state: AppState) -> Router { - Router::new() - .route("/shop/products", get(list_products).post(create_product)) - .route( - "/shop/products/{id}", - get(get_product).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)) -} - -fn require_shop(auth: &AuthUser) -> ApiResult { - auth.require(&[UserRole::ShopOwner, UserRole::ShopStaff])?; - auth.own_shop() -} - -async fn load_own_product( - state: &AppState, - shop_id: Uuid, - id: Uuid, -) -> ApiResult { - sqlx::query_as::<_, Product>("SELECT * 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())) -} - -#[derive(Deserialize)] -struct ListQuery { - page: Option, - per_page: Option, - status: Option, -} - -async fn list_products( - State(state): State, - auth: AuthUser, - Query(q): Query, -) -> ApiResult>> { - let shop_id = require_shop(&auth)?; - let page = clamp_page(q.page); - let per_page = clamp_per_page(q.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(q.status) - .fetch_one(&state.db) - .await?; - let products = sqlx::query_as::<_, Product>( - "SELECT * 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(q.status) - .bind(per_page) - .bind((page - 1) * per_page) - .fetch_all(&state.db) - .await?; - let items = attach_skus(&state.db, products, false).await?; - Ok(Json(Paged { - items, - total, - page, - per_page, - })) -} - -async fn get_product( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - let shop_id = require_shop(&auth)?; - let product = load_own_product(&state, shop_id, id).await?; - let mut items = attach_skus(&state.db, vec![product], false).await?; - Ok(Json(items.remove(0))) -} - -#[derive(Deserialize)] -pub struct ProductBody { - category_id: Option, - brand_id: Option, - slug: String, - name: serde_json::Value, - description: Option, - images: Option, -} - -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(()) -} - -async fn create_product( - State(state): State, - auth: AuthUser, - Json(body): Json, -) -> ApiResult<(StatusCode, Json)> { - let shop_id = require_shop(&auth)?; - 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 *", - ) - .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| match e { - sqlx::Error::Database(d) if d.is_unique_violation() => { - ApiError::Conflict("slug already exists in this shop".into()) - } - other => ApiError::from(other), - })?; - let mut items = attach_skus(&state.db, vec![product], false).await?; - Ok((StatusCode::CREATED, Json(items.remove(0)))) -} - -async fn update_product( - State(state): State, - auth: AuthUser, - Path(id): Path, - Json(body): Json, -) -> ApiResult> { - let shop_id = require_shop(&auth)?; - 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 *", - ) - .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| match e { - sqlx::Error::Database(d) if d.is_unique_violation() => { - ApiError::Conflict("slug already exists in this shop".into()) - } - other => ApiError::from(other), - })?; - let mut items = attach_skus(&state.db, vec![product], false).await?; - Ok(Json(items.remove(0))) -} - -async fn transition( - state: &AppState, - auth: &AuthUser, - id: Uuid, - target: ProductStatus, -) -> ApiResult> { - let shop_id = require_shop(auth)?; - 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 *", - ) - .bind(product.id) - .bind(target) - .fetch_one(&state.db) - .await?; - let mut items = attach_skus(&state.db, vec![updated], false).await?; - Ok(Json(items.remove(0))) -} - -async fn publish( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - transition(&state, &auth, id, ProductStatus::Published).await -} - -async fn unpublish( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - transition(&state, &auth, id, ProductStatus::Unpublished).await -} - -#[derive(Deserialize)] -pub struct SkuBody { - sku_code: String, - attributes: Option, - price_minor: i64, - currency: String, - stock: i32, - active: Option, -} - -async fn upsert_sku( - State(state): State, - auth: AuthUser, - Path(id): Path, - Json(body): Json, -) -> ApiResult> { - let shop_id = require_shop(&auth)?; - load_own_product(&state, shop_id, 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}"))); - } - let sku = 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 *", - ) - .bind(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?; - Ok(Json(sku)) -} diff --git a/apps/api/src/routes/shop_orders.rs b/apps/api/src/routes/shop_orders.rs deleted file mode 100644 index 21cef92..0000000 --- a/apps/api/src/routes/shop_orders.rs +++ /dev/null @@ -1,275 +0,0 @@ -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::{ApiError, ApiResult}; -use crate::models::{Invoice, Order, OrderStatus, Shipment, UserRole}; -use crate::pagination::{clamp_page, clamp_per_page, Paged}; -use crate::routes::order_common::{ - attach_items, invoice_views, maybe_mark_order_shipped, shipment_views, InvoiceView, OrderView, - ShipmentView, -}; -use crate::state::AppState; - -pub fn router(_state: AppState) -> Router { - Router::new() - .route("/shop/orders", get(list_orders)) - .route("/shop/orders/{id}", get(get_order)) - .route("/shop/orders/{id}/shipments", post(create_shipment)) - .route("/shop/shipments", get(list_shipments)) - .route("/shop/shipments/{id}/ship", post(mark_shipped)) - .route("/shop/invoices", get(list_invoices)) - .route("/shop/invoices/{id}/issue", post(issue_invoice)) -} - -fn require_shop(auth: &AuthUser) -> ApiResult { - auth.require(&[UserRole::ShopOwner, UserRole::ShopStaff])?; - auth.own_shop() -} - -async fn load_shop_order(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { - sqlx::query_as::<_, Order>("SELECT * FROM orders WHERE id = $1 AND shop_id = $2") - .bind(id) - .bind(shop_id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::NotFound("order".into())) -} - -#[derive(Deserialize)] -struct OrderQuery { - page: Option, - per_page: Option, - status: Option, -} - -async fn list_orders( - State(state): State, - auth: AuthUser, - Query(q): Query, -) -> ApiResult>> { - let shop_id = require_shop(&auth)?; - let page = clamp_page(q.page); - let per_page = clamp_per_page(q.per_page); - let total: i64 = sqlx::query_scalar( - "SELECT count(*) FROM orders WHERE shop_id = $1 AND ($2::order_status IS NULL OR status = $2)", - ) - .bind(shop_id) - .bind(q.status) - .fetch_one(&state.db) - .await?; - let orders = sqlx::query_as::<_, Order>( - "SELECT * FROM orders - WHERE shop_id = $1 AND ($2::order_status IS NULL OR status = $2) - ORDER BY created_at DESC LIMIT $3 OFFSET $4", - ) - .bind(shop_id) - .bind(q.status) - .bind(per_page) - .bind((page - 1) * per_page) - .fetch_all(&state.db) - .await?; - let items = attach_items(&state.db, orders).await?; - Ok(Json(Paged { - items, - total, - page, - per_page, - })) -} - -async fn get_order( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - let shop_id = require_shop(&auth)?; - let order = load_shop_order(&state, shop_id, id).await?; - let mut views = attach_items(&state.db, vec![order]).await?; - Ok(Json(views.remove(0))) -} - -#[derive(Deserialize)] -struct ShipmentItemBody { - order_item_id: Uuid, - qty: i32, -} - -#[derive(Deserialize)] -struct ShipmentBody { - carrier: String, - tracking_no: String, - items: Vec, -} - -async fn create_shipment( - State(state): State, - auth: AuthUser, - Path(id): Path, - Json(body): Json, -) -> ApiResult<(StatusCode, Json)> { - let shop_id = require_shop(&auth)?; - if body.carrier.trim().is_empty() || body.tracking_no.trim().is_empty() { - return Err(ApiError::BadRequest("carrier and tracking_no are required".into())); - } - if body.items.is_empty() || body.items.iter().any(|i| i.qty <= 0) { - return Err(ApiError::BadRequest("items must be non-empty with qty > 0".into())); - } - let mut tx = state.db.begin().await?; - let order = sqlx::query_as::<_, Order>( - "SELECT * FROM orders WHERE id = $1 AND shop_id = $2 FOR UPDATE", - ) - .bind(id) - .bind(shop_id) - .fetch_optional(&mut *tx) - .await? - .ok_or_else(|| ApiError::NotFound("order".into()))?; - if !matches!(order.status, OrderStatus::Paid | OrderStatus::Fulfilling) { - return Err(ApiError::Conflict(format!( - "order status {} does not accept shipments", - serde_json::to_value(order.status) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default() - ))); - } - // validate quantities against unshipped remainder - for item in &body.items { - let remainder: Option = sqlx::query_scalar( - "SELECT oi.qty - COALESCE(( - SELECT sum(si.qty) FROM shipment_items si - JOIN shipments s ON s.id = si.shipment_id - WHERE si.order_item_id = oi.id), 0) - FROM order_items oi - WHERE oi.id = $1 AND oi.order_id = $2", - ) - .bind(item.order_item_id) - .bind(order.id) - .fetch_optional(&mut *tx) - .await?; - let remainder = remainder - .ok_or_else(|| ApiError::BadRequest("order_item_id not in this order".into()))?; - if item.qty as i64 > remainder { - return Err(ApiError::BadRequest(format!( - "qty {} exceeds unshipped remainder {}", - item.qty, remainder - ))); - } - } - let shipment = sqlx::query_as::<_, Shipment>( - "INSERT INTO shipments (shipment_no, order_id, carrier, tracking_no) - VALUES ('SH' || to_char(now(), 'YYMMDD') || lpad(nextval('shipment_no_seq')::text, 6, '0'), - $1, $2, $3) - RETURNING *", - ) - .bind(order.id) - .bind(body.carrier.trim()) - .bind(body.tracking_no.trim()) - .fetch_one(&mut *tx) - .await?; - for item in &body.items { - sqlx::query( - "INSERT INTO shipment_items (shipment_id, order_item_id, qty) VALUES ($1, $2, $3)", - ) - .bind(shipment.id) - .bind(item.order_item_id) - .bind(item.qty) - .execute(&mut *tx) - .await?; - } - sqlx::query( - "UPDATE orders SET status = 'fulfilling', updated_at = now() - WHERE id = $1 AND status = 'paid'", - ) - .bind(order.id) - .execute(&mut *tx) - .await?; - tx.commit().await?; - let mut views = shipment_views(&state.db, vec![shipment]).await?; - Ok((StatusCode::CREATED, Json(views.remove(0)))) -} - -async fn list_shipments( - State(state): State, - auth: AuthUser, -) -> ApiResult>> { - let shop_id = require_shop(&auth)?; - let shipments = sqlx::query_as::<_, Shipment>( - "SELECT s.* FROM shipments s - JOIN orders o ON o.id = s.order_id - WHERE o.shop_id = $1 - ORDER BY s.created_at DESC", - ) - .bind(shop_id) - .fetch_all(&state.db) - .await?; - Ok(Json(shipment_views(&state.db, shipments).await?)) -} - -async fn mark_shipped( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - let shop_id = require_shop(&auth)?; - let shipment = sqlx::query_as::<_, Shipment>( - "UPDATE shipments s SET status = 'shipped', shipped_at = now() - FROM orders o - WHERE s.id = $1 AND o.id = s.order_id AND o.shop_id = $2 AND s.status = 'pending' - RETURNING s.*", - ) - .bind(id) - .bind(shop_id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::Conflict("shipment not found or not pending".into()))?; - maybe_mark_order_shipped(&state.db, shipment.order_id).await?; - let mut views = shipment_views(&state.db, vec![shipment]).await?; - Ok(Json(views.remove(0))) -} - -async fn list_invoices( - State(state): State, - auth: AuthUser, -) -> ApiResult>> { - let shop_id = require_shop(&auth)?; - let invoices = sqlx::query_as::<_, Invoice>( - "SELECT i.* FROM invoices i - JOIN orders o ON o.id = i.order_id - WHERE o.shop_id = $1 - ORDER BY i.created_at DESC", - ) - .bind(shop_id) - .fetch_all(&state.db) - .await?; - Ok(Json(invoice_views(&state.db, invoices).await?)) -} - -async fn issue_invoice( - State(state): State, - auth: AuthUser, - Path(id): Path, -) -> ApiResult> { - let shop_id = require_shop(&auth)?; - let invoice = sqlx::query_as::<_, Invoice>( - "UPDATE invoices i - SET status = 'issued', issued_at = now(), - invoice_no = 'INV' || to_char(now(), 'YYMMDD') || lpad(nextval('invoice_no_seq')::text, 6, '0') - FROM orders o - WHERE i.id = $1 AND o.id = i.order_id AND o.shop_id = $2 AND i.status = 'requested' - RETURNING i.*", - ) - .bind(id) - .bind(shop_id) - .fetch_optional(&state.db) - .await? - .ok_or_else(|| ApiError::Conflict("invoice not found or not in requested status".into()))?; - let mut views = invoice_views(&state.db, vec![invoice]).await?; - Ok(Json(views.remove(0))) -} diff --git a/apps/api/src/state.rs b/apps/api/src/state.rs index 755b9ae..d30a587 100644 --- a/apps/api/src/state.rs +++ b/apps/api/src/state.rs @@ -9,11 +9,12 @@ pub struct AppState { pub async fn build_state(config: &crate::config::Config) -> anyhow::Result { let db = PgPool::connect(&config.database_url).await?; + assemble(config.clone(), db).await +} + +/// Build state from an already-open pool (migrate + serve share one pool). +pub async fn assemble(config: crate::config::Config, db: PgPool) -> anyhow::Result { let client = redis::Client::open(config.redis_url.clone())?; let redis = redis::aio::ConnectionManager::new(client).await?; - Ok(AppState { - db, - redis, - config: config.clone(), - }) + Ok(AppState { db, redis, config }) } diff --git a/apps/api/tests/addresses.rs b/apps/api/tests/addresses.rs new file mode 100644 index 0000000..81c8d42 --- /dev/null +++ b/apps/api/tests/addresses.rs @@ -0,0 +1,207 @@ +mod common; + +use common::{client, register_customer, spawn_app}; +use serial_test::serial; + +fn addr_body(recipient: &str, city: &str, is_default: bool) -> serde_json::Value { + serde_json::json!({ + "recipient": recipient, + "phone": "+1 555 0100", + "country": "US", + "region": "California", + "city": city, + "line1": "1 Infinite Loop", + "postal_code": "95014", + "is_default": is_default, + }) +} + +async fn create(app: &common::TestApp, token: &str, body: serde_json::Value) -> serde_json::Value { + let res = client() + .post(app.url("/api/addresses")) + .bearer_auth(token) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "create failed: {:?}", res.text().await); + res.json().await.unwrap() +} + +async fn list(app: &common::TestApp, token: &str) -> Vec { + let res = client() + .get(app.url("/api/addresses")) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + res.json().await.unwrap() +} + +#[tokio::test] +#[serial] +async fn address_crud_roundtrip() { + let app = spawn_app().await; + let (token, _user_id) = register_customer(&app, "addr-crud").await; + + let created = create(&app, &token, addr_body("Alice", "Cupertino", false)).await; + let id = created["id"].as_str().unwrap().to_string(); + assert_eq!(created["city"], "Cupertino"); + // The first address of an account is always the default. + assert_eq!(created["is_default"], true); + + let rows = list(&app, &token).await; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["id"], id); + + let res = client() + .put(app.url(&format!("/api/addresses/{id}"))) + .bearer_auth(&token) + .json(&addr_body("Alice B", "Sunnyvale", false)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let updated: serde_json::Value = res.json().await.unwrap(); + assert_eq!(updated["city"], "Sunnyvale"); + assert_eq!(updated["recipient"], "Alice B"); + + let res = client() + .delete(app.url(&format!("/api/addresses/{id}"))) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let remaining: Vec = res.json().await.unwrap(); + assert!(remaining.is_empty()); +} + +#[tokio::test] +#[serial] +async fn single_default_invariant() { + let app = spawn_app().await; + let (token, _user_id) = register_customer(&app, "addr-default").await; + + let first = create(&app, &token, addr_body("A", "Cupertino", true)).await; + let first_id = first["id"].as_str().unwrap().to_string(); + let second = create(&app, &token, addr_body("B", "Sunnyvale", true)).await; + let second_id = second["id"].as_str().unwrap().to_string(); + + // Creating a new default must clear the previous one. + let rows = list(&app, &token).await; + let defaults: Vec<_> = rows.iter().filter(|r| r["is_default"] == true).collect(); + assert_eq!(defaults.len(), 1); + assert_eq!(defaults[0]["id"], second_id); + + // Setting the first one back as default flips the flag atomically. + let res = client() + .post(app.url(&format!("/api/addresses/{first_id}/default"))) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let rows = list(&app, &token).await; + let defaults: Vec<_> = rows.iter().filter(|r| r["is_default"] == true).collect(); + assert_eq!(defaults.len(), 1); + assert_eq!(defaults[0]["id"], first_id); + + // Deleting the current default promotes the most recent remaining row. + let res = client() + .delete(app.url(&format!("/api/addresses/{first_id}"))) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let remaining: Vec = res.json().await.unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0]["id"], second_id); + assert_eq!(remaining[0]["is_default"], true); +} + +#[tokio::test] +#[serial] +async fn cross_user_access_is_404() { + let app = spawn_app().await; + let (token_a, _) = register_customer(&app, "addr-a").await; + let (token_b, _) = register_customer(&app, "addr-b").await; + + let created = create(&app, &token_a, addr_body("A", "Cupertino", true)).await; + let id = created["id"].as_str().unwrap().to_string(); + + let res = client() + .put(app.url(&format!("/api/addresses/{id}"))) + .bearer_auth(&token_b) + .json(&addr_body("Hijack", "Nowhere", true)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + + let res = client() + .delete(app.url(&format!("/api/addresses/{id}"))) + .bearer_auth(&token_b) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + + let res = client() + .post(app.url(&format!("/api/addresses/{id}/default"))) + .bearer_auth(&token_b) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_requests_are_rejected() { + let app = spawn_app().await; + for (method, path) in [ + ("GET", "/api/addresses".to_string()), + ("POST", "/api/addresses".to_string()), + ( + "PUT", + format!("/api/addresses/{}", uuid::Uuid::new_v4()), + ), + ( + "DELETE", + format!("/api/addresses/{}", uuid::Uuid::new_v4()), + ), + ] { + let res = client() + .request(method.parse().unwrap(), app.url(&path)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 401, "{method} {path} must require auth"); + } +} + +#[tokio::test] +#[serial] +async fn missing_fields_are_400() { + let app = spawn_app().await; + let (token, _) = register_customer(&app, "addr-invalid").await; + let res = client() + .post(app.url("/api/addresses")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "recipient": "", + "phone": "+1 555 0100", + "country": "US", + "region": "California", + "city": "Cupertino", + "line1": "1 Infinite Loop", + "postal_code": "95014", + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); +} diff --git a/apps/api/tests/common/mod.rs b/apps/api/tests/common/mod.rs index dcbc459..0f6dd74 100644 --- a/apps/api/tests/common/mod.rs +++ b/apps/api/tests/common/mod.rs @@ -1,4 +1,4 @@ -use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state::build_state}; +use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state}; pub const TEST_DB_URL: &str = "postgres://postgres:postgres@127.0.0.1:5432/vmall_test"; pub const TEST_REDIS_URL: &str = "redis://127.0.0.1:6379/"; @@ -14,9 +14,7 @@ impl TestApp { } } -/// Spin up the full app against the test database on an ephemeral port. -/// Migrations run once per process; domain tables are truncated per app. -pub async fn spawn_app() -> TestApp { +pub async fn spawn_state() -> vmall_api::state::AppState { let config = Config { database_url: TEST_DB_URL.into(), redis_url: TEST_REDIS_URL.into(), @@ -24,11 +22,17 @@ pub async fn spawn_app() -> TestApp { port: 0, jwt_ttl_secs: 3600, }; - sqlx::migrate!("./migrations") - .run(&sqlx::PgPool::connect(TEST_DB_URL).await.unwrap()) - .await - .unwrap(); - let state = build_state(&config).await.unwrap(); + let db = sqlx::PgPool::connect(TEST_DB_URL).await.unwrap(); + sqlx::migrate!("./migrations").run(&db).await.unwrap(); + let state = state::assemble(config, db).await.unwrap(); + ensure_platform_admin(&state).await.unwrap(); + state +} + +/// Spin up the full app against the test database on an ephemeral port. +/// Migrations run once per process; domain tables are truncated per app. +pub async fn spawn_app() -> TestApp { + let state = spawn_state().await; ensure_platform_admin(&state).await.unwrap(); let app = build_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/apps/api/tests/order_service.rs b/apps/api/tests/order_service.rs new file mode 100644 index 0000000..dda85c4 --- /dev/null +++ b/apps/api/tests/order_service.rs @@ -0,0 +1,138 @@ +mod common; + +use serial_test::serial; +use uuid::Uuid; +use vmall_api::error::ApiError; +use vmall_api::modules::cart; +use vmall_api::modules::identity::service::{self as identity, RegisterInput}; +use vmall_api::modules::order::{self, AddressBody}; +use vmall_api::state::AppState; + +fn address() -> AddressBody { + AddressBody { + recipient: "Test".into(), + phone: "123".into(), + country: "US".into(), + region: "CA".into(), + city: "SJ".into(), + line1: "1 Way".into(), + postal_code: "95131".into(), + } +} + +async fn register_user(state: &AppState, label: &str) -> Uuid { + let email = format!("{label}-{}@test.local", Uuid::new_v4()); + let (user, _) = identity::register( + state, + RegisterInput { + email, + password: "password123".into(), + display_name: label.into(), + }, + ) + .await + .unwrap(); + user.id +} + +async fn sellable_sku(state: &AppState, slug: &str, price_minor: i64, stock: i32) -> Uuid { + let slug = format!("{slug}-{}", &Uuid::new_v4().simple().to_string()[..8]); + let shop_id: Uuid = sqlx::query_scalar( + "INSERT INTO shops (name, slug) VALUES ($1, $2) RETURNING id", + ) + .bind(serde_json::json!({"en": slug, "zh": slug})) + .bind(&slug) + .fetch_one(&state.db) + .await + .unwrap(); + let product_id: Uuid = sqlx::query_scalar( + "INSERT INTO products (shop_id, slug, name, status) + VALUES ($1, $2, $3, 'published') RETURNING id", + ) + .bind(shop_id) + .bind(&slug) + .bind(serde_json::json!({"en": slug, "zh": slug})) + .fetch_one(&state.db) + .await + .unwrap(); + sqlx::query_scalar( + "INSERT INTO skus (product_id, sku_code, price_minor, currency, stock, active) + VALUES ($1, $2, $3, 'USD', $4, TRUE) RETURNING id", + ) + .bind(product_id) + .bind(format!("{slug}-sku")) + .bind(price_minor) + .bind(stock) + .fetch_one(&state.db) + .await + .unwrap() +} + +#[tokio::test] +#[serial] +async fn checkout_rejects_empty_cart() { + let state = common::spawn_state().await; + let user_id = register_user(&state, "empty-cart").await; + let err = order::checkout(&state, user_id, address(), "USD".into()) + .await + .unwrap_err(); + assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty"))); +} + +#[tokio::test] +#[serial] +async fn checkout_rejects_insufficient_stock() { + let state = common::spawn_state().await; + let user_id = register_user(&state, "low-stock").await; + let sku_id = sellable_sku(&state, "low", 1000, 1).await; + cart::service::add_item(&state, user_id, sku_id, 2) + .await + .unwrap(); + let err = order::checkout(&state, user_id, address(), "USD".into()) + .await + .unwrap_err(); + assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock"))); +} + +#[tokio::test] +#[serial] +async fn checkout_splits_per_shop() { + let state = common::spawn_state().await; + let user_id = register_user(&state, "split").await; + let sku_a = sellable_sku(&state, "sa", 1000, 5).await; + let sku_b = sellable_sku(&state, "sb", 2000, 5).await; + cart::service::add_item(&state, user_id, sku_a, 2) + .await + .unwrap(); + cart::service::add_item(&state, user_id, sku_b, 1) + .await + .unwrap(); + let orders = order::checkout(&state, user_id, address(), "USD".into()) + .await + .unwrap(); + assert_eq!(orders.len(), 2); + let totals: Vec = orders.iter().map(|o| o.order.total_minor).collect(); + assert!(totals.contains(&2000) && totals.contains(&2000)); +} + +#[tokio::test] +#[serial] +async fn pay_and_cancel_require_pending_payment() { + let state = common::spawn_state().await; + let user_id = register_user(&state, "status").await; + let sku_id = sellable_sku(&state, "st", 500, 3).await; + cart::service::add_item(&state, user_id, sku_id, 1) + .await + .unwrap(); + let orders = order::checkout(&state, user_id, address(), "USD".into()) + .await + .unwrap(); + let id = orders[0].order.id; + order::service::pay(&state, user_id, id).await.unwrap(); + let err = order::service::pay(&state, user_id, id).await.unwrap_err(); + assert!(matches!(err, ApiError::Conflict(_))); + let err = order::service::cancel(&state, user_id, id) + .await + .unwrap_err(); + assert!(matches!(err, ApiError::Conflict(_))); +}