RTIX / src /application /services /customer.rs
github-actions
deploy: clean backend production release
18d6188
use crate::domain::error::{AppError, AppResult};
use crate::domain::models::{Customer, CustomerProfile, OrderRecord};
use crate::infrastructure::db::DbPool;
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use async_trait::async_trait;
use jsonwebtoken::{encode, EncodingKey, Header};
use serde::{Deserialize, Serialize};
#[allow(unused_imports)]
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
pub struct CustomerClaims {
pub sub: String, // customer_id
pub phone: String,
pub role: String,
pub exp: usize,
}
#[async_trait]
pub trait CustomerService: Send + Sync {
async fn signup(
&self,
phone: &str,
password: &str,
name: Option<&str>,
email: Option<&str>,
) -> AppResult<String>;
async fn login(&self, phone: &str, password: &str) -> AppResult<String>;
async fn get_orders(
&self,
customer_id: &str,
merchant_id: Option<&str>,
) -> AppResult<Vec<OrderRecord>>;
async fn get_profile(&self, customer_id: &str) -> AppResult<Option<CustomerProfile>>;
}
pub struct RtixCustomerService {
pool: DbPool,
jwt_secret: Vec<u8>,
}
impl RtixCustomerService {
pub fn new(pool: DbPool, jwt_secret: Vec<u8>) -> Self {
Self { pool, jwt_secret }
}
fn hash_password(&self, password: &str) -> AppResult<String> {
let salt = SaltString::generate(&mut rand::thread_rng());
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AppError::Internal(format!("Hashing failed: {}", e)))
}
fn verify_password(&self, password: &str, hash: &str) -> AppResult<()> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|_| AppError::Internal("Invalid hash stored".to_string()))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.map_err(|_| AppError::Auth("Invalid credentials".to_string()))
}
}
#[async_trait]
impl CustomerService for RtixCustomerService {
async fn signup(
&self,
phone: &str,
password: &str,
name: Option<&str>,
email: Option<&str>,
) -> AppResult<String> {
let password_hash = self.hash_password(password)?;
let customer_id = Uuid::new_v4().to_string();
let res = sqlx::query(
"INSERT INTO customers (customer_id, phone, name, email, password_hash) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (phone) DO NOTHING"
)
.bind(&customer_id)
.bind(phone)
.bind(name)
.bind(email)
.bind(password_hash)
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(AppError::Conflict(
"An account with this phone number already exists".into(),
));
}
Ok(customer_id)
}
async fn login(&self, phone: &str, password: &str) -> AppResult<String> {
let customer =
sqlx::query_as::<_, Customer>("SELECT * FROM customers WHERE phone = $1 OR email = $1")
.bind(phone)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| AppError::Auth("Invalid credentials".into()))?;
self.verify_password(password, &customer.password_hash)?;
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as usize;
let claims = CustomerClaims {
sub: customer.customer_id.clone(),
phone: customer.phone.clone(),
role: "customer".into(),
exp,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(&self.jwt_secret),
)
.map_err(|_| AppError::Internal("Token generation failed".into()))?;
Ok(token)
}
async fn get_orders(
&self,
customer_id: &str,
merchant_id: Option<&str>,
) -> AppResult<Vec<OrderRecord>> {
use crate::core::crypto::CryptoService;
use sqlx::Row;
// 1. Retrieve the customer's plaintext phone number from the auth table
let row = sqlx::query("SELECT phone FROM customers WHERE customer_id = $1")
.bind(customer_id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| AppError::NotFound("Customer not found".into()))?;
let target_phone: String = row.get("phone");
// 2. Compute a deterministic HMAC-SHA256 blind index of the phone number.
// This allows an O(1) indexed DB lookup without exposing PII in the query.
let phone_hash = CryptoService::deterministic_hash(&target_phone);
// 3. Run an indexed query — hit buyer_phone_hash directly, no full-table scan.
let raw_orders: Vec<OrderRecord> = if let Some(m_id) = merchant_id {
sqlx::query_as::<_, OrderRecord>(
"SELECT o.*, m.brand_name FROM orders o LEFT JOIN merchants m ON o.merchant_id = m.merchant_id WHERE o.buyer_phone_hash = $1 AND o.merchant_id = $2 ORDER BY o.created_at DESC",
)
.bind(&phone_hash)
.bind(m_id)
.fetch_all(&self.pool)
.await?
} else {
sqlx::query_as::<_, OrderRecord>(
"SELECT o.*, m.brand_name FROM orders o LEFT JOIN merchants m ON o.merchant_id = m.merchant_id WHERE o.buyer_phone_hash = $1 ORDER BY o.created_at DESC",
)
.bind(&phone_hash)
.fetch_all(&self.pool)
.await?
};
// 4. Decrypt only the matched rows (typically a very small set per customer)
let matched_orders = raw_orders
.into_iter()
.map(|mut order| {
order.decrypt_pii();
if order.vpa.as_deref() == Some("") {
order.vpa = None;
}
order
})
.collect();
Ok(matched_orders)
}
async fn get_profile(&self, customer_id: &str) -> AppResult<Option<CustomerProfile>> {
let profile = sqlx::query_as::<_, CustomerProfile>(
"SELECT customer_id, phone, email, name FROM customers WHERE customer_id = $1",
)
.bind(customer_id)
.fetch_optional(&self.pool)
.await?;
Ok(profile)
}
}