road-runner-common 0.15.3

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Standard client for triggering notification events through the internal
//! cex-notification API (`/admin/api/v1/events/trigger`, Novu-shaped).
//!
//! Every service that needs to notify a user (OTP codes, alerts, …) builds a
//! [`TriggerRequest`] and calls [`NotificationClient::trigger`] instead of
//! hand-rolling the HTTP POST, so the wire contract lives in one place. The call
//! is an internal cluster request over `http://` (mesh mTLS handles transport).

use std::time::Duration;

use serde::Serialize;

/// Default timeout for a single trigger call.
const TRIGGER_TIMEOUT: Duration = Duration::from_secs(10);

/// Canonical Novu workflow trigger identifiers — the single Rust-side source of
/// truth (the value must match the workflow's trigger identifier in the
/// notification center; an unknown identifier yields HTTP 422 `workflow_not_found`).
///
/// The Java sender (`cex-keycloak`) and the notification service (`cex-notification`)
/// keep mirrored constants; all three must agree.
pub mod workflows {
    /// Sensitive-action confirmation OTP (withdraw / api-key / 2FA-method change).
    pub const ACTION_CONFIRMATION: &str = "action-confirmation";
    /// Security alert when the account's second-factor method changes.
    pub const SECURITY_TWO_FACTOR_CHANGED: &str = "security-two-factor-changed";
    /// Pre-registration email verification OTP.
    pub const REGISTER_EMAIL_VERIFICATION: &str = "register-email-verification";
    /// Pre-registration phone verification OTP.
    pub const REGISTER_PHONE_VERIFICATION: &str = "register-phone-verification";
}

/// A failed notification trigger.
#[derive(Debug, thiserror::Error)]
pub enum NotificationError {
    #[error("notification center unreachable: {0}")]
    Request(#[from] reqwest::Error),
    #[error("notification center returned {0}")]
    Status(reqwest::StatusCode),
}

/// The recipient of a notification. `subscriber_id` is the Novu subscriber key;
/// `email` / `phone` select the delivery channel (set exactly one).
#[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 {
    /// An email 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(),
        }
    }

    /// An SMS recipient.
    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(),
        }
    }
}

/// Delivery severity — a fork extension consumed by the notification center for
/// queue priority and (SMS) provider routing. Rides inside [`TriggerRequest::overrides`]
/// as `{"severity": …}`, mirroring the Java sender (`email = high`, `sms = low`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    High,
    Normal,
    Low,
}

/// A workflow trigger: the workflow name, the recipient, and the template payload.
///
/// `overrides` tunes provider/layout/severity (NOT channel on/off — channel
/// selection is the recipient's stored preference). `transaction_id` is an
/// optional idempotency/dedup key. Both omit from the wire when `None`, so
/// existing callers serialize byte-identically.
#[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 {
    /// A trigger with no overrides / transaction id — the common case.
    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,
        }
    }

    /// Set the delivery severity (merged into `overrides` as `{"severity": …}`).
    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
    }

    /// Set an idempotency/dedup transaction id.
    pub fn with_transaction_id(mut self, id: impl Into<String>) -> Self {
        self.transaction_id = Some(id.into());
        self
    }
}

/// Reusable client bound to the internal cex-notification trigger endpoint.
#[derive(Clone)]
pub struct NotificationClient {
    http: reqwest::Client,
    trigger_url: String,
}

impl NotificationClient {
    /// Build a client for the given trigger URL (e.g. the value of
    /// `NOTIFICATION_API_URL`).
    pub fn new(trigger_url: impl Into<String>) -> Self {
        Self {
            http: reqwest::Client::new(),
            trigger_url: trigger_url.into(),
        }
    }

    /// Build a client reusing an existing `reqwest::Client` (shared connection pool).
    pub fn with_client(http: reqwest::Client, trigger_url: impl Into<String>) -> Self {
        Self {
            http,
            trigger_url: trigger_url.into(),
        }
    }

    /// Fire the workflow. Returns `Err` on transport failure or a non-2xx status.
    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");
        // The unused channel is omitted, not sent as null.
        assert!(value["to"].get("phone").is_none());
        assert_eq!(value["to"]["locale"], "tr");
        assert_eq!(value["payload"]["otp"], "123456");
        // Optional fields omit from the wire when unset (byte-compatible with old callers).
        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());
    }
}