use crate::error::SpecError;
use crate::ids::{BundleId, MessagingEndpointId, PackId};
use crate::refs::SecretRef;
use crate::version::SchemaVersion;
use chrono::{DateTime, Utc};
use greentic_types::EnvId;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WelcomeFlowRef {
pub bundle_id: BundleId,
pub pack_id: PackId,
pub flow_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessagingEndpoint {
pub schema: SchemaVersion,
pub env_id: EnvId,
pub endpoint_id: MessagingEndpointId,
pub provider_id: String,
pub provider_type: String,
pub display_name: String,
#[serde(default)]
pub secret_refs: Vec<SecretRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhook_secret_ref: Option<SecretRef>,
#[serde(default)]
pub linked_bundles: Vec<BundleId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub welcome_flow: Option<WelcomeFlowRef>,
#[serde(default)]
pub generation: u64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub updated_by: String,
}
impl MessagingEndpoint {
pub fn schema_str() -> &'static str {
SchemaVersion::MESSAGING_ENDPOINT_V1
}
pub fn validate(&self) -> Result<(), SpecError> {
if self.schema.as_str() != SchemaVersion::MESSAGING_ENDPOINT_V1 {
return Err(SpecError::SchemaMismatch {
expected: SchemaVersion::MESSAGING_ENDPOINT_V1,
actual: self.schema.as_str().to_string(),
});
}
if self.provider_id.trim().is_empty() {
return Err(SpecError::EmptyMessagingProviderId);
}
if self.provider_type.trim().is_empty() {
return Err(SpecError::EmptyMessagingProviderType);
}
for secret in &self.secret_refs {
let actual = secret.env_segment();
if actual != self.env_id.as_str() {
return Err(SpecError::CrossEnvRef {
context: "messaging_endpoint.secret_refs",
uri: secret.as_str().to_string(),
expected_env: self.env_id.clone(),
actual_env: actual.to_string(),
});
}
}
if let Some(welcome) = &self.welcome_flow
&& welcome.flow_id.trim().is_empty()
{
return Err(SpecError::EmptyWelcomeFlowId);
}
if let Some(ref_) = &self.webhook_secret_ref {
let actual = ref_.env_segment();
if actual != self.env_id.as_str() {
return Err(SpecError::CrossEnvRef {
context: "messaging_endpoint.webhook_secret_ref",
uri: ref_.as_str().to_string(),
expected_env: self.env_id.clone(),
actual_env: actual.to_string(),
});
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn fixture(webhook_secret_ref: Option<SecretRef>) -> MessagingEndpoint {
MessagingEndpoint {
schema: SchemaVersion::new(SchemaVersion::MESSAGING_ENDPOINT_V1),
env_id: EnvId::from_str("prod").unwrap(),
endpoint_id: MessagingEndpointId::new(),
provider_id: "tg-legal".into(),
provider_type: "telegram".into(),
display_name: "Legal Bot".into(),
secret_refs: vec![],
webhook_secret_ref,
linked_bundles: vec![],
welcome_flow: None,
generation: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
updated_by: "operator://test".into(),
}
}
#[test]
fn validate_accepts_none_webhook_secret_ref() {
assert!(fixture(None).validate().is_ok());
}
#[test]
fn validate_accepts_webhook_secret_ref_with_matching_env() {
let secret_ref =
SecretRef::try_new("secret://prod/messaging/01JABC/webhook-secret").unwrap();
assert!(fixture(Some(secret_ref)).validate().is_ok());
}
#[test]
fn validate_rejects_webhook_secret_ref_with_mismatched_env() {
let secret_ref =
SecretRef::try_new("secret://staging/messaging/01JABC/webhook-secret").unwrap();
let err = fixture(Some(secret_ref)).validate().unwrap_err();
assert!(
matches!(
&err,
SpecError::CrossEnvRef { context, expected_env, actual_env, .. }
if *context == "messaging_endpoint.webhook_secret_ref"
&& expected_env.as_str() == "prod"
&& actual_env == "staging"
),
"got: {err:?}",
);
}
#[test]
fn serde_skips_webhook_secret_ref_when_none() {
let json = serde_json::to_string(&fixture(None)).unwrap();
assert!(
!json.contains("webhook_secret_ref"),
"None webhook_secret_ref should be omitted from serialized JSON: {json}",
);
}
#[test]
fn serde_round_trip_with_some_webhook_secret_ref() {
let secret_ref =
SecretRef::try_new("secret://prod/messaging/01JABC/webhook-secret").unwrap();
let original = fixture(Some(secret_ref.clone()));
let json = serde_json::to_string(&original).unwrap();
let parsed: MessagingEndpoint = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.webhook_secret_ref, Some(secret_ref));
}
#[test]
fn serde_default_when_field_absent() {
let json = serde_json::to_string(&fixture(None)).unwrap();
let parsed: MessagingEndpoint = serde_json::from_str(&json).unwrap();
assert!(parsed.webhook_secret_ref.is_none());
}
}