use std::time::Duration;
use serde::Serialize;
const TRIGGER_TIMEOUT: Duration = Duration::from_secs(10);
pub mod workflows {
pub const ACTION_CONFIRMATION: &str = "action-confirmation";
pub const SECURITY_TWO_FACTOR_CHANGED: &str = "security-two-factor-changed";
pub const REGISTER_EMAIL_VERIFICATION: &str = "register-email-verification";
pub const REGISTER_PHONE_VERIFICATION: &str = "register-phone-verification";
}
#[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, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
High,
Normal,
Low,
}
#[derive(Debug, Clone, Serialize)]
pub struct TriggerRequest {
#[serde(rename = "name")]
pub workflow: String,
pub to: Recipient,
pub payload: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<serde_json::Value>,
#[serde(rename = "transactionId", skip_serializing_if = "Option::is_none")]
pub transaction_id: Option<String>,
}
impl TriggerRequest {
pub fn new(workflow: impl Into<String>, to: Recipient, payload: serde_json::Value) -> Self {
Self {
workflow: workflow.into(),
to,
payload,
overrides: None,
transaction_id: None,
}
}
pub fn with_severity(mut self, severity: Severity) -> Self {
let severity = serde_json::to_value(severity).expect("severity serializes");
match self.overrides {
Some(serde_json::Value::Object(ref mut map)) => {
map.insert("severity".to_string(), severity);
}
_ => {
self.overrides = Some(serde_json::json!({ "severity": severity }));
}
}
self
}
pub fn with_transaction_id(mut self, id: impl Into<String>) -> Self {
self.transaction_id = Some(id.into());
self
}
}
#[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::new(
workflows::ACTION_CONFIRMATION,
Recipient::email("sub-1", "alice@example.com", "tr"),
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");
assert!(value.get("overrides").is_none());
assert!(value.get("transactionId").is_none());
}
#[test]
fn severity_and_transaction_id_ride_the_wire_when_set() {
let request = TriggerRequest::new(
workflows::REGISTER_EMAIL_VERIFICATION,
Recipient::email("sub-9", "b@x.com", "tr"),
serde_json::json!({}),
)
.with_severity(Severity::High)
.with_transaction_id("txn-1");
let value = serde_json::to_value(&request).unwrap();
assert_eq!(value["overrides"]["severity"], "high");
assert_eq!(value["transactionId"], "txn-1");
}
#[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());
}
}