chore(api): apply rustfmt across modules and tests
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6c1357ec4d
commit
a4ef6ca49e
@@ -25,7 +25,11 @@ pub fn hash_password(password: &str) -> Result<String, ApiError> {
|
||||
|
||||
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||
PasswordHash::new(hash)
|
||||
.map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
|
||||
.map(|h| {
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &h)
|
||||
.is_ok()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ impl Config {
|
||||
}
|
||||
|
||||
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")?;
|
||||
let base = std::env::var("DATABASE_URL").context("DATABASE_URL must be set for tests")?;
|
||||
Ok(format!("{base}/{db}"))
|
||||
}
|
||||
|
||||
+10
-2
@@ -1,4 +1,8 @@
|
||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -40,7 +44,11 @@ impl IntoResponse for ApiError {
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": { "code": code, "message": message } }))).into_response()
|
||||
(
|
||||
status,
|
||||
Json(json!({ "error": { "code": code, "message": message } })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -3,8 +3,8 @@ pub mod config;
|
||||
pub mod error;
|
||||
pub mod http;
|
||||
pub mod models;
|
||||
pub mod money;
|
||||
pub mod modules;
|
||||
pub mod money;
|
||||
pub mod seed;
|
||||
pub mod state;
|
||||
|
||||
|
||||
@@ -294,7 +294,8 @@ pub struct CustomerAccountEntry {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub const CUSTOMER_ACCOUNT_ENTRY_COLUMNS: &str = "id, account_id, delta_minor, balance_minor, reason, reference_type, reference_id, created_at";
|
||||
pub const CUSTOMER_ACCOUNT_ENTRY_COLUMNS: &str =
|
||||
"id, account_id, delta_minor, balance_minor, reason, reference_type, reference_id, created_at";
|
||||
|
||||
/// Lifecycle of a customer-owned coupon snapshot.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -503,8 +504,7 @@ pub struct CollectiveGroupMember {
|
||||
pub joined_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str =
|
||||
"id, group_id, order_id, user_id, joined_at";
|
||||
pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str = "id, group_id, order_id, user_id, joined_at";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct AddressBookEntry {
|
||||
|
||||
@@ -3,8 +3,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{
|
||||
CustomerAccount, CustomerAccountEntry, CUSTOMER_ACCOUNT_COLUMNS,
|
||||
CUSTOMER_ACCOUNT_ENTRY_COLUMNS,
|
||||
CustomerAccount, CustomerAccountEntry, CUSTOMER_ACCOUNT_COLUMNS, CUSTOMER_ACCOUNT_ENTRY_COLUMNS,
|
||||
};
|
||||
|
||||
/// Monetary accounts are opened in the platform base currency. Multi-currency
|
||||
|
||||
@@ -43,7 +43,15 @@ pub async fn credit(
|
||||
let amount = positive(amount_minor)?;
|
||||
let account = account_for(tx, user_id, kind, currency).await?;
|
||||
let updated = repo::credit_atomic(tx, account.id, amount).await?;
|
||||
append(tx, account.id, amount, updated.balance_minor, reason, reference).await
|
||||
append(
|
||||
tx,
|
||||
account.id,
|
||||
amount,
|
||||
updated.balance_minor,
|
||||
reason,
|
||||
reference,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Subtract `amount_minor` only when the account can cover it; 409 otherwise.
|
||||
@@ -59,7 +67,15 @@ pub async fn debit(
|
||||
let amount = positive(amount_minor)?;
|
||||
let account = account_for(tx, user_id, kind, currency).await?;
|
||||
let updated = repo::debit_guarded(tx, account.id, amount).await?;
|
||||
append(tx, account.id, -amount, updated.balance_minor, reason, reference).await
|
||||
append(
|
||||
tx,
|
||||
account.id,
|
||||
-amount,
|
||||
updated.balance_minor,
|
||||
reason,
|
||||
reference,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Credit once per `reason`, in its own transaction. Demo seeding uses this so
|
||||
@@ -142,7 +158,15 @@ async fn transfer(
|
||||
let from_updated = repo::debit_guarded(tx, from, amount).await?;
|
||||
let to_updated = repo::credit_atomic(tx, to, amount).await?;
|
||||
|
||||
let out = append(tx, from, -amount, from_updated.balance_minor, reason, reference).await?;
|
||||
let out = append(
|
||||
tx,
|
||||
from,
|
||||
-amount,
|
||||
from_updated.balance_minor,
|
||||
reason,
|
||||
reference,
|
||||
)
|
||||
.await?;
|
||||
let into = append(tx, to, amount, to_updated.balance_minor, reason, reference).await?;
|
||||
Ok((out, into))
|
||||
}
|
||||
@@ -206,9 +230,7 @@ fn summarize(accounts: &[CustomerAccount]) -> ApiResult<AccountSummary> {
|
||||
let currency = available
|
||||
.currency
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
ApiError::Internal(anyhow::anyhow!("available account has no currency"))
|
||||
})?;
|
||||
.ok_or_else(|| ApiError::Internal(anyhow::anyhow!("available account has no currency")))?;
|
||||
Ok(AccountSummary {
|
||||
balance_minor: available.balance_minor,
|
||||
frozen_minor: frozen.balance_minor,
|
||||
@@ -226,9 +248,7 @@ fn find(accounts: &[CustomerAccount], kind: AccountKind) -> ApiResult<&CustomerA
|
||||
|
||||
fn positive(amount_minor: i64) -> ApiResult<i64> {
|
||||
if amount_minor <= 0 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"amount_minor must be positive".into(),
|
||||
));
|
||||
return Err(ApiError::BadRequest("amount_minor must be positive".into()));
|
||||
}
|
||||
Ok(amount_minor)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,7 @@ pub async fn list_for_user<'e, E: PgExecutor<'e>>(
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_own(
|
||||
db: &sqlx::PgPool,
|
||||
user_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> ApiResult<AddressBookEntry> {
|
||||
pub async fn get_own(db: &sqlx::PgPool, user_id: Uuid, id: Uuid) -> ApiResult<AddressBookEntry> {
|
||||
sqlx::query_as::<_, AddressBookEntry>(&format!(
|
||||
"SELECT {ADDR_COLS}
|
||||
FROM addresses WHERE id = $1 AND user_id = $2"
|
||||
|
||||
@@ -52,11 +52,7 @@ pub async fn update(
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn set_default(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> ApiResult<AddressBookEntry> {
|
||||
pub async fn set_default(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<AddressBookEntry> {
|
||||
repo::get_own(&state.db, user_id, id).await?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
repo::clear_defaults(&mut tx, user_id).await?;
|
||||
@@ -65,11 +61,7 @@ pub async fn set_default(
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> ApiResult<Vec<AddressBookEntry>> {
|
||||
pub async fn delete(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<Vec<AddressBookEntry>> {
|
||||
let existing = repo::get_own(&state.db, user_id, id).await?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
repo::delete(&mut tx, id).await?;
|
||||
|
||||
@@ -90,7 +90,11 @@ pub async fn request(
|
||||
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)
|
||||
&& 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(),
|
||||
|
||||
@@ -9,7 +9,12 @@ pub async fn get(state: &AppState, user_id: Uuid) -> ApiResult<CartView> {
|
||||
store::cart_view(state, user_id).await
|
||||
}
|
||||
|
||||
pub async fn add_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult<CartView> {
|
||||
pub async fn add_item(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
sku_id: Uuid,
|
||||
qty: i32,
|
||||
) -> ApiResult<CartView> {
|
||||
if qty <= 0 {
|
||||
return Err(ApiError::BadRequest("qty must be > 0".into()));
|
||||
}
|
||||
@@ -26,9 +31,16 @@ pub async fn add_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -
|
||||
store::cart_view(state, user_id).await
|
||||
}
|
||||
|
||||
pub async fn set_item(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) -> ApiResult<CartView> {
|
||||
pub async fn set_item(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
sku_id: Uuid,
|
||||
qty: i32,
|
||||
) -> ApiResult<CartView> {
|
||||
if qty <= 0 {
|
||||
return Err(ApiError::BadRequest("qty must be > 0; use DELETE to remove".into()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"qty must be > 0; use DELETE to remove".into(),
|
||||
));
|
||||
}
|
||||
if !store::is_purchasable(state, sku_id).await? {
|
||||
return Err(ApiError::BadRequest("sku is not purchasable".into()));
|
||||
|
||||
@@ -35,7 +35,9 @@ pub async fn set_qty(state: &AppState, user_id: Uuid, sku_id: Uuid, qty: i32) ->
|
||||
|
||||
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)?;
|
||||
conn.del::<_, ()>(key(user_id))
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -369,7 +369,9 @@ pub async fn upsert_sku(
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !currency_ok {
|
||||
return Err(ApiError::BadRequest(format!("unknown currency: {currency}")));
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"unknown currency: {currency}"
|
||||
)));
|
||||
}
|
||||
Ok(sqlx::query_as::<_, Sku>(
|
||||
"INSERT INTO skus (product_id, sku_code, attributes, price_minor, currency, stock, active)
|
||||
|
||||
@@ -50,7 +50,11 @@ pub struct ContentView {
|
||||
}
|
||||
|
||||
pub async fn load_content(state: &AppState, active_only: bool) -> ApiResult<ContentView> {
|
||||
let filter = if active_only { " WHERE active = TRUE" } else { "" };
|
||||
let filter = if active_only {
|
||||
" WHERE active = TRUE"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let banners = sqlx::query_as::<_, Banner>(&format!(
|
||||
"SELECT id, image, url, position, active FROM banners{filter} ORDER BY position, created_at"
|
||||
))
|
||||
@@ -130,11 +134,10 @@ fn default_active() -> bool {
|
||||
}
|
||||
|
||||
pub async fn replace_kind(state: &AppState, kind: &str, body: Value) -> ApiResult<ContentView> {
|
||||
if !matches!(
|
||||
kind,
|
||||
"banners" | "promos" | "quick-links" | "floor-adverts"
|
||||
) {
|
||||
return Err(ApiError::BadRequest(format!("unknown content kind: {kind}")));
|
||||
if !matches!(kind, "banners" | "promos" | "quick-links" | "floor-adverts") {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"unknown content kind: {kind}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
@@ -146,7 +149,11 @@ pub async fn replace_kind(state: &AppState, kind: &str, body: Value) -> ApiResul
|
||||
non_empty(&item.image, "image")?;
|
||||
non_empty(&item.url, "url")?;
|
||||
}
|
||||
let table = if kind == "banners" { "banners" } else { "promos" };
|
||||
let table = if kind == "banners" {
|
||||
"banners"
|
||||
} else {
|
||||
"promos"
|
||||
};
|
||||
sqlx::query(&format!("DELETE FROM {table}"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -34,10 +34,7 @@ async fn list_claimable(
|
||||
Ok(Json(service::list_claimable(&state, shop_id).await?))
|
||||
}
|
||||
|
||||
async fn list_mine(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> ApiResult<Json<Vec<Coupon>>> {
|
||||
async fn list_mine(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Json<Vec<Coupon>>> {
|
||||
auth.require_customer()?;
|
||||
Ok(Json(service::list_mine(&state, auth.id).await?))
|
||||
}
|
||||
|
||||
@@ -112,10 +112,7 @@ pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult
|
||||
}
|
||||
|
||||
/// Lock the template row for a claim so two customers cannot both take the last one.
|
||||
pub async fn lock_template(
|
||||
tx: &mut PgConnection,
|
||||
id: Uuid,
|
||||
) -> ApiResult<CouponTemplate> {
|
||||
pub async fn lock_template(tx: &mut PgConnection, id: Uuid) -> ApiResult<CouponTemplate> {
|
||||
sqlx::query_as::<_, CouponTemplate>(&format!(
|
||||
"SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates WHERE id = $1 FOR UPDATE"
|
||||
))
|
||||
@@ -212,11 +209,7 @@ pub async fn lock_owned_for_update(
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn redeem(
|
||||
tx: &mut PgConnection,
|
||||
coupon_id: Uuid,
|
||||
order_id: Uuid,
|
||||
) -> ApiResult<Coupon> {
|
||||
pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult<Coupon> {
|
||||
sqlx::query_as::<_, Coupon>(&format!(
|
||||
"UPDATE coupons SET status = 'redeemed', order_id = $2, redeemed_at = now()
|
||||
WHERE id = $1 AND status = 'claimed'
|
||||
|
||||
@@ -113,14 +113,14 @@ pub fn checkout_discount(
|
||||
}
|
||||
let now = Utc::now();
|
||||
if now < coupon.starts_at || now > coupon.ends_at {
|
||||
return Err(ApiError::Conflict("coupon is outside its active window".into()));
|
||||
return Err(ApiError::Conflict(
|
||||
"coupon is outside its active window".into(),
|
||||
));
|
||||
}
|
||||
let from = currencies
|
||||
.iter()
|
||||
.find(|c| c.code == coupon.currency)
|
||||
.ok_or_else(|| {
|
||||
ApiError::BadRequest(format!("currency {} is disabled", coupon.currency))
|
||||
})?;
|
||||
.ok_or_else(|| ApiError::BadRequest(format!("currency {} is disabled", coupon.currency)))?;
|
||||
let amount = convert_minor(coupon.amount_minor, from, target)?;
|
||||
let threshold = convert_minor(coupon.threshold_minor, from, target)?;
|
||||
if subtotal_minor < threshold {
|
||||
@@ -132,11 +132,7 @@ pub fn checkout_discount(
|
||||
Ok(amount.min(subtotal_minor))
|
||||
}
|
||||
|
||||
pub async fn redeem(
|
||||
tx: &mut PgConnection,
|
||||
coupon_id: Uuid,
|
||||
order_id: Uuid,
|
||||
) -> ApiResult<Coupon> {
|
||||
pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult<Coupon> {
|
||||
repo::redeem(tx, coupon_id, order_id).await
|
||||
}
|
||||
|
||||
|
||||
@@ -39,9 +39,10 @@ async fn convert(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ConvertQuery>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let (amount_minor, currency) =
|
||||
service::convert(&state, q.amount_minor, &q.from, &q.to).await?;
|
||||
Ok(Json(json!({ "amount_minor": amount_minor, "currency": currency })))
|
||||
let (amount_minor, currency) = service::convert(&state, q.amount_minor, &q.from, &q.to).await?;
|
||||
Ok(Json(
|
||||
json!({ "amount_minor": amount_minor, "currency": currency }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_all_currencies(
|
||||
|
||||
@@ -57,7 +57,9 @@ pub async fn upsert(
|
||||
use crate::error::ApiError;
|
||||
let code = code.to_uppercase();
|
||||
if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
|
||||
return Err(ApiError::BadRequest("code must be a 3-letter ISO code".into()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"code must be a 3-letter ISO code".into(),
|
||||
));
|
||||
}
|
||||
if rate_to_base <= rust_decimal::Decimal::ZERO {
|
||||
return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
|
||||
@@ -82,7 +84,11 @@ pub async fn upsert(
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn set_rate(state: &AppState, code: &str, rate: rust_decimal::Decimal) -> ApiResult<Currency> {
|
||||
pub async fn set_rate(
|
||||
state: &AppState,
|
||||
code: &str,
|
||||
rate: rust_decimal::Decimal,
|
||||
) -> ApiResult<Currency> {
|
||||
use crate::error::ApiError;
|
||||
if rate <= rust_decimal::Decimal::ZERO {
|
||||
return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
|
||||
|
||||
@@ -30,9 +30,7 @@ pub fn router() -> Router<AppState> {
|
||||
)
|
||||
}
|
||||
|
||||
async fn public_active(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<Vec<PublicSessionView>>> {
|
||||
async fn public_active(State(state): State<AppState>) -> ApiResult<Json<Vec<PublicSessionView>>> {
|
||||
Ok(Json(service::public_active(&state).await?))
|
||||
}
|
||||
|
||||
@@ -98,9 +96,7 @@ async fn shop_update_item(
|
||||
Json(body): Json<ItemInput>,
|
||||
) -> ApiResult<Json<FlashSaleItem>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(
|
||||
service::update_item(&state, shop_id, id, body).await?,
|
||||
))
|
||||
Ok(Json(service::update_item(&state, shop_id, id, body).await?))
|
||||
}
|
||||
|
||||
async fn shop_delete_item(
|
||||
|
||||
@@ -258,9 +258,11 @@ pub async fn overlapping_sale_exists<'e, E: PgExecutor<'e>>(
|
||||
/// The group-buying capability lands after this one, so its overlap check is
|
||||
/// dormant until its table exists.
|
||||
pub async fn group_buying_table_exists<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult<bool> {
|
||||
Ok(sqlx::query_scalar("SELECT to_regclass('public.group_buying_activities') IS NOT NULL")
|
||||
.fetch_one(exec)
|
||||
.await?)
|
||||
Ok(
|
||||
sqlx::query_scalar("SELECT to_regclass('public.group_buying_activities') IS NOT NULL")
|
||||
.fetch_one(exec)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn overlapping_group_buying_exists(
|
||||
@@ -356,11 +358,7 @@ pub async fn lock_active_items(
|
||||
}
|
||||
|
||||
/// Activity units this customer already holds on non-cancelled orders.
|
||||
pub async fn purchased_qty(
|
||||
tx: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
item_id: Uuid,
|
||||
) -> ApiResult<i64> {
|
||||
pub async fn purchased_qty(tx: &mut PgConnection, user_id: Uuid, item_id: Uuid) -> ApiResult<i64> {
|
||||
Ok(sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(oi.qty), 0)
|
||||
FROM order_items oi
|
||||
|
||||
@@ -33,9 +33,7 @@ async fn confirm_delivered(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<ShipmentView>> {
|
||||
Ok(Json(
|
||||
service::confirm_delivered(&state, auth.id, id).await?,
|
||||
))
|
||||
Ok(Json(service::confirm_delivered(&state, auth.id, id).await?))
|
||||
}
|
||||
|
||||
async fn create_shipment(
|
||||
|
||||
@@ -125,10 +125,14 @@ pub async fn create(
|
||||
body: ShipmentBody,
|
||||
) -> ApiResult<ShipmentView> {
|
||||
if body.carrier.trim().is_empty() || body.tracking_no.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("carrier and tracking_no are required".into()));
|
||||
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()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"items must be non-empty with qty > 0".into(),
|
||||
));
|
||||
}
|
||||
let mut tx = state.db.begin().await?;
|
||||
let order = order_repo::lock_for_shop(&mut tx, shop_id, order_id).await?;
|
||||
|
||||
@@ -2,9 +2,7 @@ use sqlx::{PgConnection, PgExecutor};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{
|
||||
CollectiveGroup, GroupBuyingActivity, COLLECTIVE_GROUP_COLUMNS,
|
||||
};
|
||||
use crate::models::{CollectiveGroup, GroupBuyingActivity, COLLECTIVE_GROUP_COLUMNS};
|
||||
|
||||
use super::dto::{ActivityInput, ActivityView, OpenGroupView};
|
||||
|
||||
@@ -89,10 +87,7 @@ pub async fn get_own<'e, E: PgExecutor<'e>>(
|
||||
.ok_or_else(|| ApiError::NotFound("group-buying activity".into()))
|
||||
}
|
||||
|
||||
pub async fn get_by_id<'e, E: PgExecutor<'e>>(
|
||||
exec: E,
|
||||
id: Uuid,
|
||||
) -> ApiResult<GroupBuyingActivity> {
|
||||
pub async fn get_by_id<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult<GroupBuyingActivity> {
|
||||
sqlx::query_as::<_, GroupBuyingActivity>(
|
||||
"SELECT id, shop_id, sku_id, name, description, image, group_price_minor, currency,
|
||||
required_members, starts_at, ends_at, group_lifetime_hours, enabled,
|
||||
@@ -170,12 +165,11 @@ pub async fn update(
|
||||
}
|
||||
|
||||
pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
|
||||
let result =
|
||||
sqlx::query("DELETE FROM group_buying_activities WHERE id = $1 AND shop_id = $2")
|
||||
.bind(id)
|
||||
.bind(shop_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("DELETE FROM group_buying_activities WHERE id = $1 AND shop_id = $2")
|
||||
.bind(id)
|
||||
.bind(shop_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound("group-buying activity".into()));
|
||||
}
|
||||
@@ -232,10 +226,7 @@ pub async fn open_groups_for_activities(
|
||||
}
|
||||
|
||||
/// Joining only reads the group: a seat is claimed at payment, not checkout.
|
||||
pub async fn get_group<'e, E: PgExecutor<'e>>(
|
||||
exec: E,
|
||||
id: Uuid,
|
||||
) -> ApiResult<CollectiveGroup> {
|
||||
pub async fn get_group<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult<CollectiveGroup> {
|
||||
sqlx::query_as::<_, CollectiveGroup>(&format!(
|
||||
"SELECT {COLLECTIVE_GROUP_COLUMNS} FROM collective_groups WHERE id = $1"
|
||||
))
|
||||
@@ -262,11 +253,13 @@ pub async fn insert_group(
|
||||
|
||||
/// Record which pending order opened the group.
|
||||
pub async fn set_leader(tx: &mut PgConnection, group_id: Uuid, order_id: Uuid) -> ApiResult<()> {
|
||||
sqlx::query("UPDATE collective_groups SET leader_order_id = $2, updated_at = now() WHERE id = $1")
|
||||
.bind(group_id)
|
||||
.bind(order_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE collective_groups SET leader_order_id = $2, updated_at = now() WHERE id = $1",
|
||||
)
|
||||
.bind(group_id)
|
||||
.bind(order_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -300,7 +293,11 @@ pub async fn insert_member(
|
||||
}
|
||||
|
||||
/// One paid seat: increment and become successful exactly at capacity.
|
||||
pub async fn claim_seat(tx: &mut PgConnection, group_id: Uuid, required: i32) -> ApiResult<CollectiveGroup> {
|
||||
pub async fn claim_seat(
|
||||
tx: &mut PgConnection,
|
||||
group_id: Uuid,
|
||||
required: i32,
|
||||
) -> ApiResult<CollectiveGroup> {
|
||||
Ok(sqlx::query_as::<_, CollectiveGroup>(&format!(
|
||||
"UPDATE collective_groups
|
||||
SET paid_member_count = paid_member_count + 1,
|
||||
|
||||
@@ -19,9 +19,6 @@ pub async fn ready(State(state): State<AppState>) -> ApiResult<Json<Value>> {
|
||||
.await
|
||||
.is_ok();
|
||||
let mut redis = state.redis.clone();
|
||||
let redis_ok = redis
|
||||
.set::<_, _, ()>("vmall:ready:ping", "1")
|
||||
.await
|
||||
.is_ok();
|
||||
let redis_ok = redis.set::<_, _, ()>("vmall:ready:ping", "1").await.is_ok();
|
||||
Ok(Json(json!({ "db": db_ok, "redis": redis_ok })))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -49,7 +54,9 @@ async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LoginBody>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
Ok(Json(service::login(&state, &body.email, &body.password).await?))
|
||||
Ok(Json(
|
||||
service::login(&state, &body.email, &body.password).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn me(State(state): State<AppState>, auth: AuthUser) -> ApiResult<Json<UserPublic>> {
|
||||
|
||||
@@ -38,15 +38,17 @@ pub async fn find_by_id<'e, E: PgExecutor<'e>>(
|
||||
exec: E,
|
||||
id: Uuid,
|
||||
) -> Result<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as::<_, User>(&format!(
|
||||
"SELECT {USER_COLUMNS} FROM users WHERE id = $1"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(exec)
|
||||
.await
|
||||
sqlx::query_as::<_, User>(&format!("SELECT {USER_COLUMNS} FROM users WHERE id = $1"))
|
||||
.bind(id)
|
||||
.fetch_optional(exec)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_users(db: &PgPool, page: Option<i64>, per_page: Option<i64>) -> ApiResult<Paged<User>> {
|
||||
pub async fn list_users(
|
||||
db: &PgPool,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
) -> ApiResult<Paged<User>> {
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM users")
|
||||
|
||||
@@ -68,33 +68,39 @@ pub async fn list_page(
|
||||
offset: i64,
|
||||
) -> ApiResult<Vec<Order>> {
|
||||
Ok(match scope {
|
||||
OrderScope::User(user_id) => sqlx::query_as::<_, Order>(&format!(
|
||||
"SELECT {ORDER_COLS} FROM orders WHERE user_id = $1
|
||||
OrderScope::User(user_id) => {
|
||||
sqlx::query_as::<_, Order>(&format!(
|
||||
"SELECT {ORDER_COLS} FROM orders WHERE user_id = $1
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(db)
|
||||
.await?,
|
||||
OrderScope::Shop(shop_id) => sqlx::query_as::<_, Order>(&format!(
|
||||
"SELECT {ORDER_COLS} FROM orders
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
}
|
||||
OrderScope::Shop(shop_id) => {
|
||||
sqlx::query_as::<_, Order>(&format!(
|
||||
"SELECT {ORDER_COLS} FROM orders
|
||||
WHERE shop_id = $1 AND ($2::order_status IS NULL OR status = $2)
|
||||
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.bind(status)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(db)
|
||||
.await?,
|
||||
OrderScope::Admin => sqlx::query_as::<_, Order>(&format!(
|
||||
"SELECT {ORDER_COLS} FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2"
|
||||
))
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(db)
|
||||
.await?,
|
||||
))
|
||||
.bind(shop_id)
|
||||
.bind(status)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
}
|
||||
OrderScope::Admin => {
|
||||
sqlx::query_as::<_, Order>(&format!(
|
||||
"SELECT {ORDER_COLS} FROM orders ORDER BY created_at DESC LIMIT $1 OFFSET $2"
|
||||
))
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,11 +126,7 @@ pub async fn get_for_shop(db: &PgPool, shop_id: Uuid, id: Uuid) -> ApiResult<Ord
|
||||
.ok_or_else(|| ApiError::NotFound("order".into()))
|
||||
}
|
||||
|
||||
pub async fn lock_for_shop(
|
||||
tx: &mut PgConnection,
|
||||
shop_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> ApiResult<Order> {
|
||||
pub async fn lock_for_shop(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<Order> {
|
||||
sqlx::query_as::<_, Order>(&format!(
|
||||
"SELECT {ORDER_COLS} FROM orders WHERE id = $1 AND shop_id = $2 FOR UPDATE"
|
||||
))
|
||||
@@ -198,13 +200,11 @@ pub async fn insert_item(
|
||||
}
|
||||
|
||||
pub async fn decrement_stock(tx: &mut PgConnection, sku_id: Uuid, qty: i32) -> ApiResult<()> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2",
|
||||
)
|
||||
.bind(sku_id)
|
||||
.bind(qty)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2")
|
||||
.bind(sku_id)
|
||||
.bind(qty)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::Conflict("insufficient stock".into()));
|
||||
}
|
||||
@@ -233,9 +233,7 @@ pub async fn mark_paid(tx: &mut PgConnection, id: Uuid) -> ApiResult<Order> {
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::Conflict("order not payable (missing or wrong status)".into())
|
||||
})
|
||||
.ok_or_else(|| ApiError::Conflict("order not payable (missing or wrong status)".into()))
|
||||
}
|
||||
|
||||
pub async fn cancel(tx: &mut PgConnection, user_id: Uuid, id: Uuid) -> ApiResult<Order> {
|
||||
|
||||
@@ -5,9 +5,9 @@ use uuid::Uuid;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::models::OrderStatus;
|
||||
use crate::money::convert_minor;
|
||||
use crate::modules::group_buying::GroupBuyIntent;
|
||||
use crate::modules::{cart, coupon, flash_sale, group_buying};
|
||||
use crate::money::convert_minor;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::{AddressBody, OrderScope, OrderView};
|
||||
@@ -45,14 +45,7 @@ pub async fn list(
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let total = repo::count(&state.db, scope, status).await?;
|
||||
let orders = repo::list_page(
|
||||
&state.db,
|
||||
scope,
|
||||
status,
|
||||
per_page,
|
||||
(page - 1) * per_page,
|
||||
)
|
||||
.await?;
|
||||
let orders = repo::list_page(&state.db, scope, status, per_page, (page - 1) * per_page).await?;
|
||||
let items = repo::attach_items(&state.db, orders).await?;
|
||||
Ok(Paged {
|
||||
items,
|
||||
@@ -186,9 +179,7 @@ pub async fn checkout(
|
||||
let index = lines
|
||||
.iter()
|
||||
.position(|line| line.sku_id == intent.sku_id)
|
||||
.ok_or_else(|| {
|
||||
ApiError::BadRequest("group-buying SKU is not in the cart".into())
|
||||
})?;
|
||||
.ok_or_else(|| ApiError::BadRequest("group-buying SKU is not in the cart".into()))?;
|
||||
let qty = lines[index].normal_qty + lines[index].activity_qty;
|
||||
if qty != 1 {
|
||||
return Err(ApiError::BadRequest(
|
||||
@@ -248,8 +239,10 @@ pub async fn checkout(
|
||||
let address = serde_json::to_value(&shipping_address).map_err(ApiError::internal)?;
|
||||
let mut created = Vec::new();
|
||||
for shop_id in shop_order {
|
||||
let shop_lines: Vec<&CheckoutLine> =
|
||||
lines.iter().filter(|line| line.shop_id == shop_id).collect();
|
||||
let shop_lines: Vec<&CheckoutLine> = lines
|
||||
.iter()
|
||||
.filter(|line| line.shop_id == shop_id)
|
||||
.collect();
|
||||
let subtotal = subtotal_by_shop.get(&shop_id).copied().unwrap_or(0);
|
||||
let selected_coupon = coupon_by_shop.get(&shop_id).copied();
|
||||
// Eligibility and the discount are resolved server-side; the client
|
||||
@@ -326,7 +319,8 @@ pub async fn checkout(
|
||||
}
|
||||
// The whole ordered quantity leaves SKU stock; only the activity
|
||||
// portion also leaves reserved activity stock.
|
||||
repo::decrement_stock(&mut tx, line.sku_id, line.normal_qty + line.activity_qty).await?;
|
||||
repo::decrement_stock(&mut tx, line.sku_id, line.normal_qty + line.activity_qty)
|
||||
.await?;
|
||||
}
|
||||
created.push(order);
|
||||
}
|
||||
@@ -340,7 +334,9 @@ pub async fn pay(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<OrderVi
|
||||
let mut tx = state.db.begin().await?;
|
||||
let order = repo::lock_for_user(&mut tx, user_id, id).await?;
|
||||
if order.status != OrderStatus::PendingPayment {
|
||||
return Err(ApiError::Conflict("order not payable (wrong status)".into()));
|
||||
return Err(ApiError::Conflict(
|
||||
"order not payable (wrong status)".into(),
|
||||
));
|
||||
}
|
||||
// A group order claims its paid seat in the same transaction as payment.
|
||||
if let Some(group_id) = order.group_id {
|
||||
|
||||
@@ -21,21 +21,19 @@ pub fn router() -> Router<AppState> {
|
||||
// Public catalog; redemption and history are customer-scoped.
|
||||
.route("/points/products", get(list_published))
|
||||
.route("/points/redemptions", get(list_mine).post(redeem))
|
||||
.route(
|
||||
"/admin/points/products",
|
||||
get(admin_list).post(admin_create),
|
||||
)
|
||||
.route("/admin/points/products", get(admin_list).post(admin_create))
|
||||
.route("/admin/points/products/{id}", put(admin_update))
|
||||
.route("/admin/points/products/{id}/publish", post(admin_publish))
|
||||
.route("/admin/points/products/{id}/unpublish", post(admin_unpublish))
|
||||
.route(
|
||||
"/admin/points/products/{id}/unpublish",
|
||||
post(admin_unpublish),
|
||||
)
|
||||
.route("/admin/points/orders", get(admin_orders))
|
||||
.route("/admin/points/orders/{id}/fulfill", post(admin_fulfill))
|
||||
.route("/admin/points/orders/{id}/cancel", post(admin_cancel))
|
||||
}
|
||||
|
||||
async fn list_published(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<Vec<IntegralProduct>>> {
|
||||
async fn list_published(State(state): State<AppState>) -> ApiResult<Json<Vec<IntegralProduct>>> {
|
||||
Ok(Json(service::list_published(&state).await?))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ use uuid::Uuid;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::models::{
|
||||
IntegralOrder, IntegralOrderItem, IntegralOrderStatus, IntegralProduct,
|
||||
INTEGRAL_ORDER_COLUMNS, INTEGRAL_ORDER_ITEM_COLUMNS, INTEGRAL_PRODUCT_COLUMNS,
|
||||
IntegralOrder, IntegralOrderItem, IntegralOrderStatus, IntegralProduct, INTEGRAL_ORDER_COLUMNS,
|
||||
INTEGRAL_ORDER_ITEM_COLUMNS, INTEGRAL_PRODUCT_COLUMNS,
|
||||
};
|
||||
|
||||
use super::dto::{IntegralProductInput, RedemptionView};
|
||||
@@ -35,10 +35,7 @@ pub async fn list_all<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult<Vec<IntegralP
|
||||
|
||||
/// Lock a published product for redemption. An unpublished or missing product
|
||||
/// is a 404 so customers cannot probe the draft catalog.
|
||||
pub async fn lock_published(
|
||||
tx: &mut PgConnection,
|
||||
id: Uuid,
|
||||
) -> ApiResult<IntegralProduct> {
|
||||
pub async fn lock_published(tx: &mut PgConnection, id: Uuid) -> ApiResult<IntegralProduct> {
|
||||
sqlx::query_as::<_, IntegralProduct>(&format!(
|
||||
"SELECT {INTEGRAL_PRODUCT_COLUMNS} FROM integral_products
|
||||
WHERE id = $1 AND published = TRUE
|
||||
@@ -242,7 +239,10 @@ pub async fn transition(
|
||||
.ok_or_else(|| ApiError::Conflict("redemption order is not in the expected state".into()))
|
||||
}
|
||||
|
||||
pub async fn attach_items(db: &PgPool, orders: Vec<IntegralOrder>) -> ApiResult<Vec<RedemptionView>> {
|
||||
pub async fn attach_items(
|
||||
db: &PgPool,
|
||||
orders: Vec<IntegralOrder>,
|
||||
) -> ApiResult<Vec<RedemptionView>> {
|
||||
let ids: Vec<Uuid> = orders.iter().map(|o| o.id).collect();
|
||||
let items = if ids.is_empty() {
|
||||
Vec::new()
|
||||
|
||||
@@ -163,9 +163,7 @@ async fn transition(
|
||||
fn validate_product(body: &IntegralProductInput) -> ApiResult<()> {
|
||||
bilingual(&body.name, "name")?;
|
||||
if body.points_price <= 0 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"points_price must be positive".into(),
|
||||
));
|
||||
return Err(ApiError::BadRequest("points_price must be positive".into()));
|
||||
}
|
||||
if body.stock < 0 {
|
||||
return Err(ApiError::BadRequest("stock must not be negative".into()));
|
||||
|
||||
@@ -34,13 +34,11 @@ const SELECT_PROFILE: &str = "SELECT s.id, s.slug, s.name,
|
||||
const SHOP_COLS: &str = "id, name, slug, status, created_at";
|
||||
|
||||
pub async fn get_by_id(state: &AppState, id: Uuid) -> ApiResult<Shop> {
|
||||
sqlx::query_as::<_, Shop>(&format!(
|
||||
"SELECT {SHOP_COLS} FROM shops WHERE id = $1"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("shop".into()))
|
||||
sqlx::query_as::<_, Shop>(&format!("SELECT {SHOP_COLS} FROM shops WHERE id = $1"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("shop".into()))
|
||||
}
|
||||
|
||||
pub async fn list_admin(state: &AppState) -> ApiResult<Vec<Shop>> {
|
||||
|
||||
@@ -8,11 +8,10 @@ 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?;
|
||||
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(());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user