feat: backend MVP (auth/rbac, catalog, orders, fulfillment, invoices) + specs + scaffolds
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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?))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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?))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(¤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))
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user