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; async fn login(&self, phone: &str, password: &str) -> AppResult; async fn get_orders( &self, customer_id: &str, merchant_id: Option<&str>, ) -> AppResult>; async fn get_profile(&self, customer_id: &str) -> AppResult>; } pub struct RtixCustomerService { pool: DbPool, jwt_secret: Vec, } impl RtixCustomerService { pub fn new(pool: DbPool, jwt_secret: Vec) -> Self { Self { pool, jwt_secret } } fn hash_password(&self, password: &str) -> AppResult { 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 { 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 { 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> { 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 = 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> { 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) } }