road-runner-common 0.16.1

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! The trigger wire contract — what actually goes on the wire to
//! `/admin/api/v1/events/trigger`.
//!
//! These types are a serialization contract, not a place for behaviour: every field
//! here exists because the notification center reads it. Keep changes additive and
//! `skip_serializing_if`-gated so an older caller keeps serializing byte-identically.

use serde::Serialize;

/// The recipient of a notification.
///
/// `subscriber_id` is the Novu subscriber key — always the auth subject, which is what
/// binds the message to the user's stored channel preferences.
///
/// `email` / `phone` are optional and normally omitted: cex-notification backfills the
/// delivery contact from cex-account itself. Set one only when the caller already holds
/// a verified address and the lookup would be redundant.
#[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 {
    /// A recipient identified by subscriber key alone — the default. cex-notification
    /// resolves the delivery contact for whichever channel the workflow uses.
    pub fn new(subscriber_id: impl Into<String>, locale: impl Into<String>) -> Self {
        Self {
            subscriber_id: subscriber_id.into(),
            email: None,
            phone: None,
            locale: locale.into(),
        }
    }

    /// A recipient carrying an already-known email address.
    pub fn email(
        subscriber_id: impl Into<String>,
        email: impl Into<String>,
        locale: impl Into<String>,
    ) -> Self {
        Self {
            email: Some(email.into()),
            ..Self::new(subscriber_id, locale)
        }
    }

    /// A recipient carrying an already-known phone number.
    pub fn phone(
        subscriber_id: impl Into<String>,
        phone: impl Into<String>,
        locale: impl Into<String>,
    ) -> Self {
        Self {
            phone: Some(phone.into()),
            ..Self::new(subscriber_id, locale)
        }
    }
}

/// A topic the notification center fans out to its own subscriber list — an operations
/// channel rather than a person.
///
/// The topic's membership is administered in the notification center, which is the point:
/// who is on call changes there, not in a service's configuration.
#[derive(Debug, Clone, Serialize)]
pub struct TopicRecipient {
    #[serde(rename = "type")]
    kind: &'static str,
    #[serde(rename = "topicKey")]
    pub topic_key: String,
}

impl TopicRecipient {
    pub fn new(topic_key: impl Into<String>) -> Self {
        Self {
            kind: "Topic",
            topic_key: topic_key.into(),
        }
    }
}

/// Who a trigger addresses: one person, or a topic.
///
/// `untagged` on purpose — the notification center distinguishes the two by shape, and a
/// subscriber target must keep serializing byte-identically to what it did before topics
/// existed.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum TriggerTarget {
    Subscriber(Recipient),
    Topic(TopicRecipient),
}

impl From<Recipient> for TriggerTarget {
    fn from(recipient: Recipient) -> Self {
        Self::Subscriber(recipient)
    }
}

impl From<TopicRecipient> for TriggerTarget {
    fn from(topic: TopicRecipient) -> Self {
        Self::Topic(topic)
    }
}

/// 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, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    High,
    #[default]
    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: TriggerTarget,
    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. Accepts either a
    /// [`Recipient`] or a [`TopicRecipient`].
    pub fn new(
        workflow: impl Into<String>,
        to: impl Into<TriggerTarget>,
        payload: serde_json::Value,
    ) -> Self {
        Self {
            workflow: workflow.into(),
            to: to.into(),
            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
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::notification::workflows;

    #[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());
    }

    #[test]
    fn a_contactless_recipient_sends_neither_channel() {
        let value = serde_json::to_value(Recipient::new("sub-3", "en")).unwrap();
        assert_eq!(value["subscriberId"], "sub-3");
        assert_eq!(value["locale"], "en");
        // cex-notification backfills both from cex-account.
        assert!(value.get("email").is_none());
        assert!(value.get("phone").is_none());
    }
}