use std::time::Duration;
use serde::Serialize;
const TRIGGER_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, thiserror::Error)]
pub enum NotificationError {
#[error("notification center unreachable: {0}")]
Request(#[from] reqwest::Error),
#[error("notification center returned {0}")]
Status(reqwest::StatusCode),
}
#[derive(Debug, Clone, Serialize)]
pub struct Recipient {
#[serde(rename = "subscriberId")]
pub subscriber_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
pub locale: String,
}
impl Recipient {
pub fn email(
subscriber_id: impl Into<String>,
email: impl Into<String>,
locale: impl Into<String>,
) -> Self {
Self {
subscriber_id: subscriber_id.into(),
email: Some(email.into()),
phone: None,
locale: locale.into(),
}
}
pub fn phone(
subscriber_id: impl Into<String>,
phone: impl Into<String>,
locale: impl Into<String>,
) -> Self {
Self {
subscriber_id: subscriber_id.into(),
email: None,
phone: Some(phone.into()),
locale: locale.into(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TriggerRequest {
#[serde(rename = "name")]
pub workflow: String,
pub to: Recipient,
pub payload: serde_json::Value,
}
#[derive(Clone)]
pub struct NotificationClient {
http: reqwest::Client,
trigger_url: String,
}
impl NotificationClient {
pub fn new(trigger_url: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
trigger_url: trigger_url.into(),
}
}
pub fn with_client(http: reqwest::Client, trigger_url: impl Into<String>) -> Self {
Self {
http,
trigger_url: trigger_url.into(),
}
}
pub async fn trigger(&self, request: &TriggerRequest) -> Result<(), NotificationError> {
let response = self
.http
.post(&self.trigger_url)
.json(request)
.timeout(TRIGGER_TIMEOUT)
.send()
.await?;
if !response.status().is_success() {
return Err(NotificationError::Status(response.status()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serializes_to_the_novu_trigger_shape() {
let request = TriggerRequest {
workflow: "action-confirmation".to_string(),
to: Recipient::email("sub-1", "alice@example.com", "tr"),
payload: serde_json::json!({ "otp": "123456" }),
};
let value = serde_json::to_value(&request).unwrap();
assert_eq!(value["name"], "action-confirmation");
assert_eq!(value["to"]["subscriberId"], "sub-1");
assert_eq!(value["to"]["email"], "alice@example.com");
assert!(value["to"].get("phone").is_none());
assert_eq!(value["to"]["locale"], "tr");
assert_eq!(value["payload"]["otp"], "123456");
}
#[test]
fn phone_recipient_omits_email() {
let value = serde_json::to_value(Recipient::phone("sub-2", "+905551234567", "tr")).unwrap();
assert_eq!(value["phone"], "+905551234567");
assert!(value.get("email").is_none());
}
}