RTIX / src /application /services /notification.rs
github-actions
deploy: clean backend production release
b198f8a
Raw
History Blame Contribute Delete
11.4 kB
use crate::infrastructure::db::DbPool;
use crate::interfaces::http::api::RealtimeEvent;
use reqwest::Client;
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::{error, info, warn};
use uuid::Uuid;
use sqlx::Row;
pub struct SmsNotificationService {
pool: DbPool,
client: Client,
frontend_url: String,
}
impl SmsNotificationService {
pub fn new(pool: DbPool) -> Self {
let frontend_url = std::env::var("FRONTEND_URL")
.unwrap_or_else(|_| "https://rtix.app".to_string());
Self {
pool,
client: Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap(),
frontend_url,
}
}
pub async fn run(&self, mut rx: broadcast::Receiver<RealtimeEvent>) {
info!("Sms/WhatsApp Notification Service started.");
loop {
match rx.recv().await {
Ok(event) => {
let service = Arc::new(Self {
pool: self.pool.clone(),
client: self.client.clone(),
frontend_url: self.frontend_url.clone(),
});
tokio::spawn(async move {
if let Err(e) = service.process_event(event).await {
error!("Error processing notification: {:?}", e);
}
});
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("Notification Service lagged by {} events", n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
async fn process_event(
&self,
event: RealtimeEvent,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match event {
RealtimeEvent::NewOrder {
transaction_id,
merchant_id,
amount,
buyer_phone,
} => {
let msg = format!(
"Your Rtix order for ₹{:.2} has been placed. Pay or track here: {}/track/{}",
amount, self.frontend_url, transaction_id
);
self.send_sms(&buyer_phone, Some(&merchant_id), "SMS_ORDER_CREATED", &msg)
.await?;
}
RealtimeEvent::OrderStatusChanged {
transaction_id,
merchant_id,
new_status,
} => {
// Fetch buyer phone number from the database using dynamic query
let record = sqlx::query("SELECT buyer_phone FROM orders WHERE transaction_id = $1")
.bind(&transaction_id)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = record {
let buyer_phone: String = row.get("buyer_phone");
let msg = format!(
"Status Update: Your Rtix order {} is now {}. Track here: {}/track/{}",
transaction_id, new_status, self.frontend_url, transaction_id
);
self.send_sms(&buyer_phone, Some(&merchant_id), "SMS_STATUS_CHANGED", &msg)
.await?;
}
}
RealtimeEvent::NewChatMessage {
transaction_id,
merchant_id,
message,
} => {
// message is a JSON object matching ChatMessage struct
let sender_type = message.get("sender_type").and_then(|v| v.as_str()).unwrap_or("");
let content = message.get("content").and_then(|v| v.as_str()).unwrap_or("");
if sender_type == "MERCHANT" {
// Notify buyer
let record = sqlx::query("SELECT buyer_phone FROM orders WHERE transaction_id = $1")
.bind(&transaction_id)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = record {
let buyer_phone: String = row.get("buyer_phone");
let msg = format!(
"New message from Merchant: '{}'. Reply here: {}/track/{}",
content, self.frontend_url, transaction_id
);
self.send_sms(&buyer_phone, Some(&merchant_id), "SMS_CHAT_MESSAGE", &msg)
.await?;
}
} else if sender_type == "BUYER" {
// Notify merchant via email/log
let record = sqlx::query("SELECT email FROM merchants WHERE merchant_id = $1")
.bind(&merchant_id)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = record {
let email: String = row.get("email");
let msg = format!(
"New message from Buyer for order {}: '{}'. Reply in dashboard: {}/dashboard",
transaction_id, content, self.frontend_url
);
// Log this to the notifications table as an email notification
let log_id = Uuid::new_v4().to_string();
sqlx::query(
r#"INSERT INTO notification_log
(id, merchant_id, recipient_email, event_type, subject, status, provider_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
)
.bind(&log_id)
.bind(&merchant_id)
.bind(&email)
.bind("EMAIL_CHAT_MESSAGE")
.bind(&msg)
.bind("SENT")
.bind("mock-email-provider")
.execute(&self.pool)
.await?;
info!(
recipient = %email,
"Email notification logged: {}", msg
);
}
}
}
_ => {}
}
Ok(())
}
async fn send_sms(
&self,
phone_number: &str,
merchant_id: Option<&str>,
event_type: &str,
message: &str,
) -> Result<(), sqlx::Error> {
let mut plan = "FREE".to_string();
if let Some(m_id) = merchant_id {
if let Ok(Some(row)) = sqlx::query("SELECT plan FROM merchants WHERE merchant_id = $1")
.bind(m_id)
.fetch_optional(&self.pool)
.await
{
plan = row.get("plan");
}
}
if plan == "FREE" {
let log_id = Uuid::new_v4().to_string();
let formatted_phone = format!("sms:{}", phone_number);
sqlx::query(
r#"INSERT INTO notification_log
(id, merchant_id, recipient_email, event_type, subject, status, provider_id, error_message)
VALUES ($1, $2, $3, $4, $5, 'FAILED', $6, $7)"#,
)
.bind(&log_id)
.bind(merchant_id)
.bind(&formatted_phone)
.bind(event_type)
.bind(message)
.bind("mock-sms-provider")
.bind("SMS notifications are disabled on FREE plan. Upgrade to STARTER or PRO plan to enable SMS.")
.execute(&self.pool)
.await?;
info!(
recipient = phone_number,
"SMS skipped (FREE plan): {}", message
);
return Ok(());
}
let account_sid = std::env::var("TWILIO_ACCOUNT_SID").ok();
let auth_token = std::env::var("TWILIO_AUTH_TOKEN").ok();
let from_number = std::env::var("TWILIO_FROM_NUMBER").ok();
let mut provider_id = None;
let mut status = "SENT";
let mut error_message = None;
if let (Some(sid), Some(token), Some(from)) = (account_sid, auth_token, from_number) {
let url = format!(
"https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json",
sid
);
let params = [
("To", phone_number),
("From", &from),
("Body", message),
];
match self
.client
.post(&url)
.basic_auth(&sid, Some(&token))
.form(&params)
.send()
.await
{
Ok(resp) => {
let status_code = resp.status();
if status_code.is_success() {
#[derive(serde::Deserialize)]
struct TwilioResponse {
sid: String,
}
if let Ok(data) = resp.json::<TwilioResponse>().await {
provider_id = Some(data.sid);
} else {
provider_id = Some("twilio-unknown-sid".to_string());
}
info!(
recipient = phone_number,
event = event_type,
"SMS sent successfully via Twilio"
);
} else {
status = "FAILED";
let err_text = resp.text().await.unwrap_or_else(|_| "Unknown error".to_string());
error!(
recipient = phone_number,
status = %status_code,
"Twilio error response: {}", err_text
);
error_message = Some(err_text);
}
}
Err(e) => {
status = "FAILED";
let err_msg = e.to_string();
error!("HTTP request to Twilio failed: {}", err_msg);
error_message = Some(err_msg);
}
}
} else {
// Mock sending via external provider (Dry Run)
info!(
recipient = phone_number,
event = event_type,
"[DRY RUN] SMS notification: {}", message
);
provider_id = Some("mock-sms-provider".to_string());
}
let log_id = Uuid::new_v4().to_string();
let formatted_phone = format!("sms:{}", phone_number);
sqlx::query(
r#"INSERT INTO notification_log
(id, merchant_id, recipient_email, event_type, subject, status, provider_id, error_message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"#,
)
.bind(&log_id)
.bind(merchant_id)
.bind(&formatted_phone)
.bind(event_type)
.bind(message)
.bind(status)
.bind(provider_id)
.bind(error_message)
.execute(&self.pool)
.await?;
Ok(())
}
}