road-runner-common 0.21.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Who to notify, and in what language.
//!
//! Trigger sites hold whichever identity their flow happens to carry: a request-scoped
//! handler has the auth subject, a back-office or settlement flow has only the numeric
//! trading account id the ledger keys on. [`RecipientRef`] lets a caller state what it
//! has; [`AudienceResolver`] turns that into the [`Audience`] a trigger needs.
//!
//! Keeping this behind a trait is what makes the module testable without a network — a
//! unit test supplies a fixed [`Audience`] instead of standing up cex-account. The
//! shipped gRPC implementation lives in [`grpc`](super::grpc) behind the
//! `notification-grpc` feature.

use async_trait::async_trait;

use super::admin_topics::AdminChannel;

/// Fallback language, mirroring cex-account's member default (`members.locale`).
pub const DEFAULT_LOCALE: &str = "tr";

/// The identity a trigger site holds for the user it wants to notify.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecipientRef {
    /// Auth subject (Keycloak sub) — already the subscriber key, so only the language
    /// has to be resolved.
    Subject(String),
    /// Numeric trading account id, as it appears on the balance ledger. Both the
    /// subject and the language have to be resolved.
    AccountId(i64),
    /// A named internal audience rather than a person — routed to the topic this
    /// deployment maps the channel to. Resolves from configuration, never from
    /// cex-account, because there is no member behind it.
    Admin(AdminChannel),
}

impl RecipientRef {
    /// A short, non-identifying label for logs. The subject is a UUID and the account id
    /// is a customer key, so neither is spelled out in full.
    pub fn log_label(&self) -> String {
        match self {
            Self::Subject(sub) => {
                let head: String = sub.chars().take(8).collect();
                format!("sub={head}")
            }
            Self::AccountId(id) => format!("accountId={id}"),
            Self::Admin(channel) => channel.to_string(),
        }
    }
}

/// A resolved recipient: the notification center's subscriber key plus the language to
/// render in. Deliberately carries no email or phone — cex-notification backfills the
/// delivery contact itself, so member PII never reaches a triggering service.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Audience {
    pub subscriber_id: String,
    pub locale: String,
}

impl Audience {
    pub fn new(subscriber_id: impl Into<String>, locale: impl Into<String>) -> Self {
        Self {
            subscriber_id: subscriber_id.into(),
            locale: locale.into(),
        }
    }
}

/// Why an audience could not be resolved.
#[derive(Debug, thiserror::Error)]
pub enum AudienceError {
    /// No member behind the given subject/account id. Permanent — retrying cannot help.
    #[error("no notification target for {0}")]
    NotFound(String),
    /// The directory could not be reached or errored. Transient — the true answer is
    /// unknown.
    #[error("notification target lookup failed for {reference}: {message}")]
    Unavailable { reference: String, message: String },
    /// The resolver was constructed without an endpoint, so it can never answer.
    #[error("notification audience resolver is not configured")]
    NotConfigured,
}

/// Resolves a [`RecipientRef`] to the [`Audience`] a trigger needs.
#[async_trait]
pub trait AudienceResolver: Send + Sync {
    async fn resolve(&self, reference: &RecipientRef) -> Result<Audience, AudienceError>;
}

/// Normalize an account/UI locale string to the workflow translation key shape:
/// `EN` → `en`, `en_US` → `en`, `tr-TR` → `tr`. `None` for a blank input.
pub fn normalize_locale(raw: &str) -> Option<String> {
    let language = raw
        .trim()
        .split(['-', '_'])
        .next()
        .unwrap_or_default()
        .to_ascii_lowercase();

    if language.is_empty() {
        None
    } else {
        Some(language)
    }
}

/// A resolver that answers from a fixed audience. For tests and for services whose
/// recipient is known statically (an ops alert to a fixed subscriber).
pub struct StaticAudienceResolver(Audience);

impl StaticAudienceResolver {
    pub fn new(audience: Audience) -> Self {
        Self(audience)
    }
}

#[async_trait]
impl AudienceResolver for StaticAudienceResolver {
    async fn resolve(&self, _reference: &RecipientRef) -> Result<Audience, AudienceError> {
        Ok(self.0.clone())
    }
}

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

    #[test]
    fn locale_normalization_matches_notification_translation_keys() {
        assert_eq!(normalize_locale("EN"), Some("en".to_string()));
        assert_eq!(normalize_locale("en_US"), Some("en".to_string()));
        assert_eq!(normalize_locale("tr-TR"), Some("tr".to_string()));
        assert_eq!(normalize_locale("   "), None);
    }

    #[test]
    fn log_labels_do_not_spell_out_the_subject() {
        let label = RecipientRef::Subject("0192f3ab-cdef-7000-8000-000000000001".into()).log_label();
        assert_eq!(label, "sub=0192f3ab…");
        assert_eq!(RecipientRef::AccountId(42).log_label(), "accountId=42");
    }
}