Spaces:
Running
Running
| 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}; | |
| use std::sync::Arc; | |
| use uuid::Uuid; | |
| pub struct CustomerClaims { | |
| pub sub: String, // customer_id | |
| pub phone: String, | |
| pub role: String, | |
| pub exp: usize, | |
| } | |
| 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())) | |
| } | |
| } | |
| 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) | |
| } | |
| } | |