feat: backend MVP (auth/rbac, catalog, orders, fulfillment, invoices) + specs + scaffolds

This commit is contained in:
Chengdong Zhang
2026-09-17 12:43:22 +08:00
commit dc9fd31c5e
96 changed files with 17550 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use axum::{
extract::FromRequestParts,
http::{header::AUTHORIZATION, request::Parts},
};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::ApiError;
use crate::models::UserRole;
use crate::state::AppState;
pub fn hash_password(password: &str) -> Result<String, ApiError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| ApiError::BadRequest(format!("password hashing failed: {e}")))
}
pub fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash)
.map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
.unwrap_or(false)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: Uuid,
pub role: UserRole,
pub shop_id: Option<Uuid>,
pub exp: i64,
}
pub fn issue_token(
secret: &str,
ttl_secs: i64,
user_id: Uuid,
role: UserRole,
shop_id: Option<Uuid>,
) -> Result<String, ApiError> {
let claims = Claims {
sub: user_id,
role,
shop_id,
exp: (Utc::now() + Duration::seconds(ttl_secs)).timestamp(),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(ApiError::internal)
}
/// Authenticated principal extracted from the Bearer token.
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: Uuid,
pub role: UserRole,
pub shop_id: Option<Uuid>,
}
impl AuthUser {
/// 403 unless the principal holds one of `roles`. Shop roles are further
/// scoped to their own shop via [`AuthUser::own_shop`].
pub fn require(&self, roles: &[UserRole]) -> Result<(), ApiError> {
if roles.contains(&self.role) {
Ok(())
} else {
Err(ApiError::Forbidden("insufficient role".into()))
}
}
/// The shop this principal may operate on; 403 for non-shop roles.
pub fn own_shop(&self) -> Result<Uuid, ApiError> {
if self.role.is_shop_role() {
self.shop_id
.ok_or_else(|| ApiError::Forbidden("account has no shop".into()))
} else {
Err(ApiError::Forbidden("shop role required".into()))
}
}
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let header = parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| ApiError::Unauthorized("missing bearer token".into()))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| ApiError::Unauthorized("malformed authorization header".into()))?;
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(state.config.jwt_secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| ApiError::Unauthorized("invalid or expired token".into()))?;
Ok(AuthUser {
id: data.claims.sub,
role: data.claims.role,
shop_id: data.claims.shop_id,
})
}
}
+110
View File
@@ -0,0 +1,110 @@
use redis::AsyncCommands;
use serde::Serialize;
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
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<Vec<(Uuid, i32)>> {
let mut conn = state.redis.clone();
let raw: Vec<(String, i32)> = conn.hgetall(key(user_id)).await.map_err(ApiError::from)?;
Ok(raw
.into_iter()
.filter_map(|(k, qty)| Uuid::parse_str(&k).ok().map(|id| (id, qty)))
.filter(|(_, qty)| *qty > 0)
.collect())
}
pub async fn set_qty(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult<()> {
let mut conn = state.redis.clone();
if qty <= 0 {
conn.hdel::<_, _, ()>(key(user_id), sku_id.to_string())
.await
.map_err(ApiError::from)?;
} else {
conn.hset::<_, _, _, ()>(key(user_id), sku_id.to_string(), qty)
.await
.map_err(ApiError::from)?;
}
Ok(())
}
pub async fn clear_cart(state: &AppState, user_id: Uuid) -> ApiResult<()> {
let mut conn = state.redis.clone();
conn.del::<_, ()>(key(user_id)).await.map_err(ApiError::from)?;
Ok(())
}
#[derive(Debug, Serialize)]
pub struct CartItemView {
pub sku_id: Uuid,
pub product_id: Uuid,
pub product_name: serde_json::Value,
pub sku_code: String,
pub image: Option<String>,
pub unit_price_minor: i64,
pub currency: String,
pub qty: i32,
}
#[derive(Debug, Serialize)]
pub struct CartView {
pub items: Vec<CartItemView>,
}
/// Cart read model: joins Redis entries with purchasable SKU snapshots.
/// Entries whose SKU vanished or became unpurchasable are dropped from the view.
pub async fn cart_view(state: &AppState, user_id: Uuid) -> ApiResult<CartView> {
let entries = read_cart(state, user_id).await?;
if entries.is_empty() {
return Ok(CartView { items: vec![] });
}
let sku_ids: Vec<Uuid> = entries.iter().map(|(id, _)| *id).collect();
let rows = sqlx::query_as::<_, CartRow>(
"SELECT s.id AS sku_id, p.id AS product_id, p.name AS product_name, s.sku_code,
(p.images ->> 0) AS image, s.price_minor, s.currency
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'",
)
.bind(&sku_ids)
.fetch_all(&state.db)
.await?;
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,
})
})
.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<String>,
price_minor: i64,
currency: String,
}
+45
View File
@@ -0,0 +1,45 @@
use anyhow::Context;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub redis_url: String,
pub jwt_secret: String,
pub port: u16,
/// token TTL in seconds
pub jwt_ttl_secs: i64,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:postgres@127.0.0.1:5432/vmall".into());
let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379/".into());
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "vmall-dev-secret-change-me".to_string());
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8080);
let jwt_ttl_secs: i64 = std::env::var("JWT_TTL_SECS")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(86_400);
let cfg = Self {
database_url,
redis_url,
jwt_secret,
port,
jwt_ttl_secs,
};
tracing::info!(port = cfg.port, "config loaded");
Ok(cfg)
}
}
pub fn database_url_for(db: &str) -> anyhow::Result<String> {
let base =
std::env::var("DATABASE_URL").context("DATABASE_URL must be set for tests")?;
Ok(format!("{base}/{db}"))
}
+59
View File
@@ -0,0 +1,59 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("forbidden: {0}")]
Forbidden(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("internal error")]
Internal(#[from] anyhow::Error),
}
impl ApiError {
pub fn internal<E: std::error::Error + Send + Sync + 'static>(e: E) -> Self {
ApiError::Internal(anyhow::Error::new(e))
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
ApiError::NotFound(m) => (StatusCode::NOT_FOUND, "NOT_FOUND", m.clone()),
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", m.clone()),
ApiError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", m.clone()),
ApiError::Forbidden(m) => (StatusCode::FORBIDDEN, "FORBIDDEN", m.clone()),
ApiError::Conflict(m) => (StatusCode::CONFLICT, "CONFLICT", m.clone()),
ApiError::Internal(e) => {
tracing::error!(error = %e, "internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL",
"internal server error".to_string(),
)
}
};
(status, Json(json!({ "error": { "code": code, "message": message } }))).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
ApiError::Internal(anyhow::Error::new(e))
}
}
impl From<redis::RedisError> for ApiError {
fn from(e: redis::RedisError) -> Self {
ApiError::Internal(anyhow::Error::new(e))
}
}
pub type ApiResult<T> = Result<T, ApiError>;
+24
View File
@@ -0,0 +1,24 @@
pub mod auth;
pub mod cart;
pub mod config;
pub mod error;
pub mod models;
pub mod money;
pub mod pagination;
pub mod routes;
pub mod seed;
pub mod state;
use axum::{routing::get, Router};
use tower_http::{cors::CorsLayer, trace::TraceLayer};
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()))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state)
}
+24
View File
@@ -0,0 +1,24 @@
use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state::build_state};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "vmall_api=info,tower_http=info".into()),
)
.init();
let config = Config::from_env()?;
sqlx::migrate!("./migrations")
.run(&sqlx::PgPool::connect(&config.database_url).await?)
.await?;
let state = build_state(&config).await?;
ensure_platform_admin(&state).await?;
let app = build_router(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.port)).await?;
tracing::info!(port = config.port, "vmall-api listening");
axum::serve(listener, app).await?;
Ok(())
}
+205
View File
@@ -0,0 +1,205 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[sqlx(type_name = "user_role", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum UserRole {
PlatformAdmin,
ShopOwner,
ShopStaff,
Customer,
}
impl UserRole {
pub fn is_shop_role(self) -> bool {
matches!(self, UserRole::ShopOwner | UserRole::ShopStaff)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[sqlx(type_name = "shop_status", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ShopStatus {
Active,
Suspended,
}
#[derive(Debug, Serialize, 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,
pub shop_id: Option<Uuid>,
pub locale: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Shop {
pub id: Uuid,
pub name: serde_json::Value,
pub slug: String,
pub status: ShopStatus,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[sqlx(type_name = "product_status", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ProductStatus {
Draft,
Published,
Unpublished,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Category {
pub id: Uuid,
pub parent_id: Option<Uuid>,
pub name: serde_json::Value,
pub slug: String,
pub position: i32,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Product {
pub id: Uuid,
pub shop_id: Uuid,
pub category_id: Option<Uuid>,
pub slug: String,
pub name: serde_json::Value,
pub description: serde_json::Value,
pub images: serde_json::Value,
pub status: ProductStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Sku {
pub id: Uuid,
pub product_id: Uuid,
pub sku_code: String,
pub attributes: serde_json::Value,
pub price_minor: i64,
pub currency: String,
pub stock: i32,
pub active: bool,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Currency {
pub code: String,
pub name: serde_json::Value,
pub symbol: String,
pub exponent: i16,
pub is_base: bool,
pub rate_to_base: rust_decimal::Decimal,
pub enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[sqlx(type_name = "order_status", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum OrderStatus {
PendingPayment,
Paid,
Fulfilling,
Shipped,
Completed,
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[sqlx(type_name = "shipment_status", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ShipmentStatus {
Pending,
Shipped,
Delivered,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[sqlx(type_name = "invoice_status", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum InvoiceStatus {
Requested,
Issued,
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[sqlx(type_name = "invoice_kind", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum InvoiceKind {
Personal,
Company,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Order {
pub id: Uuid,
pub order_no: String,
pub shop_id: Uuid,
pub user_id: Uuid,
pub status: OrderStatus,
pub currency: String,
pub total_minor: i64,
pub shipping_address: serde_json::Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct OrderItem {
pub id: Uuid,
pub order_id: Uuid,
pub sku_id: Uuid,
pub product_name: serde_json::Value,
pub sku_code: String,
pub image: Option<String>,
pub unit_price_minor: i64,
pub qty: i32,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Shipment {
pub id: Uuid,
pub shipment_no: String,
pub order_id: Uuid,
pub carrier: String,
pub tracking_no: String,
pub status: ShipmentStatus,
pub shipped_at: Option<DateTime<Utc>>,
pub delivered_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct ShipmentItem {
pub shipment_id: Uuid,
pub order_item_id: Uuid,
pub qty: i32,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Invoice {
pub id: Uuid,
pub invoice_no: Option<String>,
pub order_id: Uuid,
pub user_id: Uuid,
pub title: String,
pub tax_no: Option<String>,
pub kind: InvoiceKind,
pub amount_minor: i64,
pub currency: String,
pub status: InvoiceStatus,
pub issued_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
+23
View File
@@ -0,0 +1,23 @@
use rust_decimal::prelude::*;
use rust_decimal::Decimal;
use crate::error::ApiError;
use crate::models::Currency;
/// Convert integer minor units between currencies via base rates.
/// rate_to_base = units of currency per 1 unit of base. Rounds half-up
/// to whole minor units of the target currency.
pub fn convert_minor(amount_minor: i64, from: &Currency, to: &Currency) -> Result<i64, ApiError> {
if amount_minor < 0 {
return Err(ApiError::BadRequest("amount_minor must be >= 0".into()));
}
let amount = Decimal::from(amount_minor);
let from_scale = Decimal::from(10i64.pow(from.exponent as u32));
let to_scale = Decimal::from(10i64.pow(to.exponent as u32));
// minor(from) -> major(from) -> major(base) -> minor(to)
let base_major = amount / from_scale / from.rate_to_base;
let target = (base_major * to.rate_to_base * to_scale).round_dp(0);
target
.to_i64()
.ok_or_else(|| ApiError::BadRequest("amount out of range".into()))
}
+17
View File
@@ -0,0 +1,17 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct Paged<T: Serialize> {
pub items: Vec<T>,
pub total: i64,
pub page: i64,
pub per_page: i64,
}
pub fn clamp_page(page: Option<i64>) -> i64 {
page.unwrap_or(1).max(1)
}
pub fn clamp_per_page(per_page: Option<i64>) -> i64 {
per_page.unwrap_or(20).clamp(1, 100)
}
+290
View File
@@ -0,0 +1,290 @@
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<AppState> {
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<i64>,
per_page: Option<i64>,
}
async fn list_users(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<PageQuery>,
) -> ApiResult<Json<Paged<User>>> {
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<Uuid>,
}
async fn set_user_role(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<SetRoleBody>,
) -> ApiResult<Json<User>> {
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<AppState>, auth: AuthUser) -> ApiResult<Json<Vec<Shop>>> {
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<AppState>,
auth: AuthUser,
Json(body): Json<CreateShopBody>,
) -> ApiResult<(StatusCode, Json<Shop>)> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<SetStatusBody>,
) -> ApiResult<Json<Shop>> {
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<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<Currency>>> {
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<AppState>,
auth: AuthUser,
Json(body): Json<CurrencyBody>,
) -> ApiResult<Json<Currency>> {
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<AppState>,
auth: AuthUser,
Path(code): Path<String>,
Json(body): Json<SetRateBody>,
) -> ApiResult<Json<Currency>> {
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<AppState>,
auth: AuthUser,
Query(q): Query<PageQuery>,
) -> ApiResult<Json<Paged<OrderView>>> {
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,
}))
}
+97
View File
@@ -0,0 +1,97 @@
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<AppState> {
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<AppState>,
Json(body): Json<RegisterBody>,
) -> ApiResult<(StatusCode, Json<Value>)> {
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<AppState>,
Json(body): Json<LoginBody>,
) -> ApiResult<Json<Value>> {
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<AppState>, auth: AuthUser) -> ApiResult<Json<User>> {
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<Value> {
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 }))
}
+94
View File
@@ -0,0 +1,94 @@
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<AppState> {
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<AppState>, auth: AuthUser) -> ApiResult<Json<CartView>> {
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<AppState>,
auth: AuthUser,
Json(body): Json<AddBody>,
) -> ApiResult<Json<CartView>> {
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<AppState>,
auth: AuthUser,
Path(sku_id): Path<Uuid>,
Json(body): Json<SetBody>,
) -> ApiResult<Json<CartView>> {
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<AppState>,
auth: AuthUser,
Path(sku_id): Path<Uuid>,
) -> ApiResult<Json<CartView>> {
cart::set_qty(&state, auth.id, sku_id, 0).await?;
Ok(Json(cart::cart_view(&state, auth.id).await?))
}
+147
View File
@@ -0,0 +1,147 @@
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<Sku>,
}
pub async fn attach_skus(
db: &sqlx::PgPool,
products: Vec<Product>,
public_only: bool,
) -> ApiResult<Vec<ProductWithSkus>> {
let ids: Vec<Uuid> = products.iter().map(|p| p.id).collect();
let skus = if ids.is_empty() {
Vec::new()
} else if public_only {
sqlx::query_as::<_, Sku>(
"SELECT * 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?
};
Ok(products
.into_iter()
.map(|p| ProductWithSkus {
skus: skus.iter().filter(|s| s.product_id == p.id).cloned().collect(),
product: p,
})
.collect())
}
pub fn router(_state: AppState) -> Router<AppState> {
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<i64>,
per_page: Option<i64>,
category_id: Option<Uuid>,
shop_id: Option<Uuid>,
q: Option<String>,
}
/// Public catalog: only published products of active shops.
async fn list_products(
State(state): State<AppState>,
Query(q): Query<ListQuery>,
) -> ApiResult<Json<Paged<ProductWithSkus>>> {
let page = clamp_page(q.page);
let per_page = clamp_per_page(q.per_page);
let pattern = q.q.as_ref().map(|s| format!("%{s}%"));
let total: i64 = sqlx::query_scalar(
"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 = $1)
AND ($2::uuid IS NULL OR p.shop_id = $2)
AND ($3::text IS NULL OR p.name::text ILIKE $3)",
)
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.fetch_one(&state.db)
.await?;
let products = sqlx::query_as::<_, Product>(
"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 = $1)
AND ($2::uuid IS NULL OR p.shop_id = $2)
AND ($3::text IS NULL OR p.name::text ILIKE $3)
ORDER BY p.created_at DESC
LIMIT $4 OFFSET $5",
)
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.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<AppState>,
Path(id_or_slug): Path<String>,
) -> ApiResult<Json<ProductWithSkus>> {
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<AppState>) -> ApiResult<Json<Vec<Category>>> {
let cats = sqlx::query_as::<_, Category>(
"SELECT * FROM categories ORDER BY position, slug",
)
.fetch_all(&state.db)
.await?;
Ok(Json(cats))
}
+57
View File
@@ -0,0 +1,57 @@
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<AppState> {
Router::new()
.route("/currencies", get(list_currencies))
.route("/currencies/convert", get(convert))
}
pub async fn load_currencies(state: &AppState, enabled_only: bool) -> ApiResult<Vec<Currency>> {
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<AppState>) -> ApiResult<Json<Vec<Currency>>> {
Ok(Json(load_currencies(&state, true).await?))
}
#[derive(Deserialize)]
struct ConvertQuery {
amount_minor: i64,
from: String,
to: String,
}
async fn convert(
State(state): State<AppState>,
Query(q): Query<ConvertQuery>,
) -> ApiResult<Json<Value>> {
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 })))
}
+28
View File
@@ -0,0 +1,28 @@
use axum::{extract::State, routing::get, Json, Router};
use redis::AsyncCommands;
use serde_json::{json, Value};
use crate::error::ApiResult;
use crate::state::AppState;
pub fn router(_state: AppState) -> Router<AppState> {
Router::new().route("/ready", get(ready))
}
pub async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
}
/// Deep health: verifies Postgres and Redis connectivity.
pub async fn ready(State(state): State<AppState>) -> ApiResult<Json<Value>> {
let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.db)
.await
.is_ok();
let mut redis = state.redis.clone();
let redis_ok = redis
.set::<_, _, ()>("vmall:ready:ping", "1")
.await
.is_ok();
Ok(Json(json!({ "db": db_ok, "redis": redis_ok })))
}
+30
View File
@@ -0,0 +1,30 @@
pub mod admin;
pub mod auth;
pub mod cart;
pub mod catalog;
pub mod currency;
pub mod health;
pub mod order_common;
pub mod orders;
pub mod shop;
pub mod shop_catalog;
pub mod shop_orders;
use axum::Router;
use crate::state::AppState;
/// Routers for every domain module are merged here.
pub fn api_router(state: AppState) -> Router<AppState> {
Router::new()
.merge(health::router(state.clone()))
.merge(auth::router(state.clone()))
.merge(currency::router(state.clone()))
.merge(catalog::router(state.clone()))
.merge(cart::router(state.clone()))
.merge(orders::router(state.clone()))
.merge(shop::router(state.clone()))
.merge(shop_catalog::router(state.clone()))
.merge(shop_orders::router(state.clone()))
.merge(admin::router(state))
}
+175
View File
@@ -0,0 +1,175 @@
//! 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<OrderItem>,
}
pub async fn attach_items(db: &PgPool, orders: Vec<Order>) -> ApiResult<Vec<OrderView>> {
let ids: Vec<Uuid> = 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<ShipmentItem>,
}
pub async fn shipment_views(db: &PgPool, shipments: Vec<Shipment>) -> ApiResult<Vec<ShipmentView>> {
let ids: Vec<Uuid> = 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<Uuid> = 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<Invoice>) -> ApiResult<Vec<InvoiceView>> {
let order_ids: Vec<Uuid> = 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(())
}
+423
View File
@@ -0,0 +1,423 @@
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<AppState> {
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<i64>,
per_page: Option<i64>,
}
async fn list_my_orders(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<PageQuery>,
) -> ApiResult<Json<Paged<OrderView>>> {
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<Order> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<OrderView>> {
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<String>,
price_minor: i64,
currency: String,
stock: i32,
}
async fn checkout(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<CheckoutBody>,
) -> ApiResult<(StatusCode, Json<Vec<OrderView>>)> {
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<Uuid> = 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<Uuid> = 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<Order> = 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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<OrderView>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<OrderView>> {
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<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<ShipmentView>>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ShipmentView>> {
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<String>,
kind: InvoiceKind,
}
async fn request_invoice(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<InvoiceBody>,
) -> ApiResult<(StatusCode, Json<InvoiceView>)> {
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<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<InvoiceView>>> {
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?))
}
+21
View File
@@ -0,0 +1,21 @@
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<AppState> {
Router::new().route("/shop/profile", get(my_shop))
}
async fn my_shop(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Json<Shop>> {
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))
}
+289
View File
@@ -0,0 +1,289 @@
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<AppState> {
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<Uuid> {
auth.require(&[UserRole::ShopOwner, UserRole::ShopStaff])?;
auth.own_shop()
}
async fn load_own_product(
state: &AppState,
shop_id: Uuid,
id: Uuid,
) -> ApiResult<Product> {
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<i64>,
per_page: Option<i64>,
status: Option<ProductStatus>,
}
async fn list_products(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<ListQuery>,
) -> ApiResult<Json<Paged<ProductWithSkus>>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ProductWithSkus>> {
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<Uuid>,
slug: String,
name: serde_json::Value,
description: Option<serde_json::Value>,
images: Option<serde_json::Value>,
}
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<AppState>,
auth: AuthUser,
Json(body): Json<ProductBody>,
) -> ApiResult<(StatusCode, Json<ProductWithSkus>)> {
let shop_id = require_shop(&auth)?;
validate_product_body(&body)?;
let product = sqlx::query_as::<_, Product>(
"INSERT INTO products (shop_id, category_id, slug, name, description, images)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
)
.bind(shop_id)
.bind(body.category_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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ProductBody>,
) -> ApiResult<Json<ProductWithSkus>> {
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, slug = $3, name = $4, description = $5, images = $6, updated_at = now()
WHERE id = $1 RETURNING *",
)
.bind(id)
.bind(body.category_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<Json<ProductWithSkus>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ProductWithSkus>> {
transition(&state, &auth, id, ProductStatus::Published).await
}
async fn unpublish(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ProductWithSkus>> {
transition(&state, &auth, id, ProductStatus::Unpublished).await
}
#[derive(Deserialize)]
pub struct SkuBody {
sku_code: String,
attributes: Option<serde_json::Value>,
price_minor: i64,
currency: String,
stock: i32,
active: Option<bool>,
}
async fn upsert_sku(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<SkuBody>,
) -> ApiResult<Json<Sku>> {
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(&currency)
.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(&currency)
.bind(body.stock)
.bind(body.active.unwrap_or(true))
.fetch_one(&state.db)
.await?;
Ok(Json(sku))
}
+275
View File
@@ -0,0 +1,275 @@
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<AppState> {
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<Uuid> {
auth.require(&[UserRole::ShopOwner, UserRole::ShopStaff])?;
auth.own_shop()
}
async fn load_shop_order(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<Order> {
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<i64>,
per_page: Option<i64>,
status: Option<OrderStatus>,
}
async fn list_orders(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<OrderQuery>,
) -> ApiResult<Json<Paged<OrderView>>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<OrderView>> {
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<ShipmentItemBody>,
}
async fn create_shipment(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ShipmentBody>,
) -> ApiResult<(StatusCode, Json<ShipmentView>)> {
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<i64> = 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<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<ShipmentView>>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<ShipmentView>> {
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<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<InvoiceView>>> {
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<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<InvoiceView>> {
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)))
}
+26
View File
@@ -0,0 +1,26 @@
use crate::auth::hash_password;
use crate::state::AppState;
/// Idempotent dev seed: platform admin account.
pub async fn ensure_platform_admin(state: &AppState) -> anyhow::Result<()> {
let email = "admin@vmall.local";
let exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
.bind(email)
.fetch_one(&state.db)
.await?;
if exists {
return Ok(());
}
let hash = hash_password("admin1234")?;
sqlx::query(
"INSERT INTO users (email, password_hash, display_name, role)
VALUES ($1, $2, 'Platform Admin', 'platform_admin')",
)
.bind(email)
.bind(hash)
.execute(&state.db)
.await?;
tracing::info!("seeded platform admin admin@vmall.local");
Ok(())
}
+19
View File
@@ -0,0 +1,19 @@
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub redis: redis::aio::ConnectionManager,
pub config: crate::config::Config,
}
pub async fn build_state(config: &crate::config::Config) -> anyhow::Result<AppState> {
let db = PgPool::connect(&config.database_url).await?;
let client = redis::Client::open(config.redis_url.clone())?;
let redis = redis::aio::ConnectionManager::new(client).await?;
Ok(AppState {
db,
redis,
config: config.clone(),
})
}