#![allow(missing_docs)]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(transparent)]
pub struct LeadId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(transparent)]
pub struct PersonId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(transparent)]
pub struct CompanyId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(transparent)]
pub struct SellerId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(transparent)]
pub struct TenantIdRef(pub String);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum LeadState {
Cold,
Engaged,
MeetingScheduled,
Qualified,
Lost,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum DomainKind {
Personal,
Corporate,
Disposable,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum EnrichmentStatus {
None,
SignatureParsed,
LlmExtracted,
CrossLinked,
ApiEnriched,
Manual,
PersonalOnlyGiveup,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum SentimentBand {
VeryNegative,
Negative,
Neutral,
Positive,
VeryPositive,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum IntentClass {
Browsing,
Comparing,
ReadyToBuy,
Objecting,
SupportRequest,
OutOfScope,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum MailboxMode {
Idle,
Adaptive,
Poll,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum DraftStatus {
Pending,
Approved,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RulePredicate {
SenderDomainKind {
value: DomainKind,
},
SenderEmailMatches {
pattern: String,
},
CompanyIndustry {
value: String,
},
PersonHasTag {
tag: String,
},
ScoreGte {
score: u8,
},
BodyContains {
needle: String,
},
SubjectContains {
needle: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AssignTarget {
Seller {
id: SellerId,
},
RoundRobin {
pool: Vec<SellerId>,
},
Drop,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RoutingRule {
pub id: String,
pub name: String,
pub conditions: Vec<RulePredicate>,
pub assigns_to: AssignTarget,
pub followup_profile: String,
pub active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuleSet {
pub tenant_id: TenantIdRef,
pub version: u32,
pub rules: Vec<RoutingRule>,
pub default_target: AssignTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FollowupProfile {
pub id: String,
pub cadence: Vec<String>,
pub max_attempts: u8,
pub stop_on_reply: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Person {
pub id: PersonId,
pub tenant_id: TenantIdRef,
pub primary_name: String,
pub primary_email: String,
pub alt_emails: Vec<String>,
pub company_id: Option<CompanyId>,
pub enrichment_status: EnrichmentStatus,
pub enrichment_confidence: f32,
pub tags: Vec<String>,
pub created_at_ms: i64,
pub last_seen_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Company {
pub id: CompanyId,
pub tenant_id: TenantIdRef,
pub domain: String,
pub name: String,
pub industry: Option<String>,
pub size_band: Option<String>,
pub enriched_at_ms: Option<i64>,
pub is_personal_domain: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkingHoursWindow {
pub timezone: String,
pub mon_fri: Option<DayWindow>,
pub saturday: Option<DayWindow>,
pub sunday: Option<DayWindow>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DayWindow {
pub start: String,
pub end: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Seller {
pub id: SellerId,
pub tenant_id: TenantIdRef,
pub name: String,
pub primary_email: String,
pub alt_emails: Vec<String>,
pub signature_text: String,
pub working_hours: Option<WorkingHoursWindow>,
pub on_vacation: bool,
pub vacation_until: Option<DateTime<Utc>>,
pub preferred_language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_override: Option<crate::admin::agents::ModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notification_settings: Option<SellerNotificationSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smtp_credential: Option<SmtpCredential>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft_template: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SmtpCredential {
pub instance: String,
pub host: String,
pub port: u16,
pub username: String,
pub password_env: String,
#[serde(default = "default_smtp_starttls")]
pub starttls: bool,
}
fn default_smtp_starttls() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NotificationChannel {
Disabled,
Whatsapp { instance: String },
Email { from_instance: String, to: String },
}
impl Default for NotificationChannel {
fn default() -> Self {
Self::Whatsapp {
instance: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SellerNotificationSettings {
#[serde(default = "default_true")]
pub on_lead_created: bool,
#[serde(default = "default_true")]
pub on_lead_replied: bool,
#[serde(default)]
pub on_lead_transitioned: bool,
#[serde(default = "default_true")]
pub on_draft_pending: bool,
#[serde(default = "default_true")]
pub on_meeting_intent: bool,
#[serde(default)]
pub channel: NotificationChannel,
}
fn default_true() -> bool {
true
}
impl Default for SellerNotificationSettings {
fn default() -> Self {
Self {
on_lead_created: true,
on_lead_replied: true,
on_lead_transitioned: false,
on_draft_pending: true,
on_meeting_intent: true,
channel: NotificationChannel::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct NotificationTemplates {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lead_created: Option<TemplateLocaleSet>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lead_replied: Option<TemplateLocaleSet>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lead_transitioned: Option<TemplateLocaleSet>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meeting_intent: Option<TemplateLocaleSet>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft_pending: Option<TemplateLocaleSet>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TemplateLocaleSet {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub es: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub en: Option<String>,
}
impl TemplateLocaleSet {
pub fn for_lang(&self, lang: &str) -> Option<&str> {
match lang {
"en" => self.en.as_deref().or(self.es.as_deref()),
_ => self.es.as_deref().or(self.en.as_deref()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EmailNotificationKind {
LeadCreated,
LeadReplied,
LeadTransitioned,
DraftPending,
MeetingIntent,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EmailNotification {
pub kind: EmailNotificationKind,
pub tenant_id: TenantIdRef,
pub agent_id: String,
pub lead_id: LeadId,
pub seller_id: SellerId,
pub seller_email: String,
pub from_email: String,
pub subject: String,
pub at_ms: i64,
pub summary: String,
pub channel: NotificationChannel,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ActiveHoursWindow {
pub timezone: String,
pub mon_fri: Option<DayWindow>,
pub saturday: Option<DayWindow>,
pub sunday: Option<DayWindow>,
pub off_hours_poll_seconds: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MailboxConfig {
pub id: String,
pub tenant_id: TenantIdRef,
pub address: String,
pub provider: String,
pub mode: MailboxMode,
pub poll_interval_seconds: u32,
pub active: bool,
pub draft_mode: bool,
pub active_hours: Option<ActiveHoursWindow>,
pub email_plugin_instance: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Lead {
pub id: LeadId,
pub tenant_id: TenantIdRef,
pub thread_id: String,
pub subject: String,
pub person_id: PersonId,
pub seller_id: SellerId,
pub state: LeadState,
pub score: u8,
pub sentiment: SentimentBand,
pub intent: IntentClass,
pub topic_tags: Vec<String>,
pub last_activity_ms: i64,
pub next_check_at_ms: Option<i64>,
pub followup_attempts: u8,
pub why_routed: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator_notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OutboundDraft {
pub thread_id: String,
pub lead_id: LeadId,
pub seller_id: SellerId,
pub body: String,
pub status: DraftStatus,
pub created_at_ms: i64,
pub idempotency_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LeadProfileArgs {
pub tenant_id: TenantIdRef,
pub from_email: String,
pub subject: String,
pub body_excerpt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LeadProfileResponse {
pub person_id: PersonId,
pub company_id: Option<CompanyId>,
pub enrichment_status: EnrichmentStatus,
pub enrichment_confidence: f32,
pub merged_into_existing: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LeadRouteArgs {
pub tenant_id: TenantIdRef,
pub lead_id: LeadId,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LeadRouteResponse {
pub seller_id: Option<SellerId>,
pub matched_rule_id: Option<String>,
pub why_routed: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MeetingIntent {
pub accepted: bool,
pub proposed_time_iso: Option<String>,
pub confidence: f32,
pub evidence: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EnrichmentResult {
pub source: String,
pub confidence: f32,
pub person_inferred: Option<PersonInferred>,
pub company_inferred: Option<CompanyInferred>,
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PersonInferred {
pub name: Option<String>,
pub role: Option<String>,
pub seniority: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompanyInferred {
pub name: Option<String>,
pub domain: Option<String>,
pub industry: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LeadTransitionEvent {
pub tenant_id: TenantIdRef,
pub lead_id: LeadId,
pub from: LeadState,
pub to: LeadState,
pub at_ms: i64,
pub reason: String,
pub tool_call_ids: Vec<String>,
pub meta: BTreeMap<String, String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{from_str, to_string};
fn roundtrip<T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug>(
value: &T,
) {
let s = to_string(value).expect("serialize");
let back: T = from_str(&s).expect("deserialize");
assert_eq!(value, &back);
}
#[test]
fn lead_state_roundtrip() {
for s in [
LeadState::Cold,
LeadState::Engaged,
LeadState::MeetingScheduled,
LeadState::Qualified,
LeadState::Lost,
] {
roundtrip(&s);
}
}
#[test]
fn lead_state_serialises_snake_case() {
let s = to_string(&LeadState::MeetingScheduled).unwrap();
assert_eq!(s, "\"meeting_scheduled\"");
}
#[test]
fn domain_kind_roundtrip() {
for k in [
DomainKind::Personal,
DomainKind::Corporate,
DomainKind::Disposable,
] {
roundtrip(&k);
}
}
#[test]
fn rule_predicate_tagged_union() {
let p = RulePredicate::ScoreGte { score: 70 };
let s = to_string(&p).unwrap();
assert!(s.contains("\"kind\":\"score_gte\""));
roundtrip(&p);
}
#[test]
fn assign_target_round_robin_roundtrip() {
let t = AssignTarget::RoundRobin {
pool: vec![SellerId("pedro".into()), SellerId("ana".into())],
};
roundtrip(&t);
}
#[test]
fn routing_rule_full_roundtrip() {
let r = RoutingRule {
id: "vip-personal".into(),
name: "VIP personal".into(),
conditions: vec![RulePredicate::PersonHasTag { tag: "vip".into() }],
assigns_to: AssignTarget::Seller {
id: SellerId("ana".into()),
},
followup_profile: "vip".into(),
active: true,
};
roundtrip(&r);
}
#[test]
fn followup_profile_roundtrip() {
let p = FollowupProfile {
id: "default".into(),
cadence: vec!["24h".into(), "72h".into(), "168h".into()],
max_attempts: 3,
stop_on_reply: true,
};
roundtrip(&p);
}
#[test]
fn person_full_roundtrip() {
let p = Person {
id: PersonId("juan".into()),
tenant_id: TenantIdRef("acme".into()),
primary_name: "Juan García".into(),
primary_email: "juan@acme.com".into(),
alt_emails: vec!["juan.alt@gmail.com".into()],
company_id: Some(CompanyId("acme".into())),
enrichment_status: EnrichmentStatus::ApiEnriched,
enrichment_confidence: 0.95,
tags: vec!["recurring".into()],
created_at_ms: 1_700_000_000_000,
last_seen_at_ms: 1_700_900_000_000,
};
roundtrip(&p);
}
#[test]
fn lead_full_roundtrip() {
let l = Lead {
id: LeadId("lead-001".into()),
tenant_id: TenantIdRef("acme".into()),
thread_id: "th-001".into(),
subject: "Re: cotización".into(),
person_id: PersonId("juan".into()),
seller_id: SellerId("pedro".into()),
state: LeadState::Engaged,
score: 73,
sentiment: SentimentBand::Positive,
intent: IntentClass::ReadyToBuy,
topic_tags: vec!["pricing".into()],
last_activity_ms: 1_700_000_000_000,
next_check_at_ms: Some(1_700_259_200_000),
followup_attempts: 0,
why_routed: vec!["score 73 >= 70".into()],
operator_notes: Some("called PA, voicemail".into()),
};
roundtrip(&l);
}
#[test]
fn meeting_intent_roundtrip() {
let m = MeetingIntent {
accepted: true,
proposed_time_iso: Some("2026-05-12T15:00:00-05:00".into()),
confidence: 0.85,
evidence: "yes Tuesday at 3pm".into(),
};
roundtrip(&m);
}
#[test]
fn mailbox_config_roundtrip() {
let m = MailboxConfig {
id: "ventas-acme".into(),
tenant_id: TenantIdRef("acme".into()),
address: "ventas@acme.com".into(),
provider: "gmail".into(),
mode: MailboxMode::Adaptive,
poll_interval_seconds: 60,
active: true,
draft_mode: true,
active_hours: Some(ActiveHoursWindow {
timezone: "America/Bogota".into(),
mon_fri: Some(DayWindow {
start: "07:00".into(),
end: "20:00".into(),
}),
saturday: None,
sunday: None,
off_hours_poll_seconds: 300,
}),
email_plugin_instance: "acme-ventas".into(),
};
roundtrip(&m);
}
#[test]
fn lead_transition_event_roundtrip() {
let mut meta = BTreeMap::new();
meta.insert("rule_id".into(), "corporate-warm".into());
let e = LeadTransitionEvent {
tenant_id: TenantIdRef("acme".into()),
lead_id: LeadId("lead-001".into()),
from: LeadState::Cold,
to: LeadState::Engaged,
at_ms: 1_700_000_000_000,
reason: "rule corporate-warm matched".into(),
tool_call_ids: vec!["call-1".into()],
meta,
};
roundtrip(&e);
}
#[test]
fn enrichment_result_partial_optionals() {
let r = EnrichmentResult {
source: "signature".into(),
confidence: 0.78,
person_inferred: Some(PersonInferred {
name: Some("Juan".into()),
role: Some("VP Sales".into()),
seniority: Some("VP".into()),
}),
company_inferred: None,
note: None,
};
roundtrip(&r);
}
#[test]
fn seller_without_agent_binding_roundtrip() {
let v = Seller {
id: SellerId("pedro".into()),
tenant_id: TenantIdRef("acme".into()),
name: "Pedro García".into(),
primary_email: "pedro@acme.com".into(),
alt_emails: Vec::new(),
signature_text: "—\nPedro".into(),
working_hours: None,
on_vacation: false,
vacation_until: None,
preferred_language: None,
agent_id: None,
model_override: None,
notification_settings: None,
smtp_credential: None,
system_prompt: None,
model_provider: None,
model_id: None,
draft_template: None,
};
roundtrip(&v);
let s = serde_json::to_string(&v).unwrap();
assert!(!s.contains("agent_id"), "agent_id leaked: {s}");
assert!(!s.contains("model_override"), "model_override leaked: {s}");
assert!(!s.contains("draft_template"), "draft_template leaked: {s}");
}
#[test]
fn seller_with_agent_binding_and_override_roundtrip() {
use crate::admin::agents::ModelRef;
let v = Seller {
id: SellerId("pedro".into()),
tenant_id: TenantIdRef("acme".into()),
name: "Pedro García".into(),
primary_email: "pedro@acme.com".into(),
alt_emails: Vec::new(),
signature_text: "—\nPedro".into(),
working_hours: None,
on_vacation: false,
vacation_until: None,
preferred_language: Some("es".into()),
agent_id: Some("ana".into()),
model_override: Some(ModelRef {
provider: "anthropic".into(),
model: "claude-opus-4-7".into(),
}),
notification_settings: None,
smtp_credential: None,
system_prompt: None,
model_provider: None,
model_id: None,
draft_template: None,
};
roundtrip(&v);
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains("\"agent_id\":\"ana\""), "{s}");
assert!(s.contains("\"provider\":\"anthropic\""), "{s}");
}
#[test]
fn seller_with_draft_template_roundtrip() {
let v = Seller {
id: SellerId("pedro".into()),
tenant_id: TenantIdRef("acme".into()),
name: "Pedro García".into(),
primary_email: "pedro@acme.com".into(),
alt_emails: Vec::new(),
signature_text: "—\nPedro".into(),
working_hours: None,
on_vacation: false,
vacation_until: None,
preferred_language: None,
agent_id: None,
model_override: None,
notification_settings: None,
smtp_credential: None,
system_prompt: None,
model_provider: None,
model_id: None,
draft_template: Some("Hola {{person.name}}, soy {{seller.name}}.".into()),
};
roundtrip(&v);
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains("draft_template"), "{s}");
}
#[test]
fn notification_channel_whatsapp_carries_instance() {
let ch = NotificationChannel::Whatsapp {
instance: "personal".into(),
};
roundtrip(&ch);
let s = serde_json::to_string(&ch).unwrap();
assert!(s.contains(r#""kind":"whatsapp""#), "{s}");
assert!(s.contains(r#""instance":"personal""#), "{s}");
}
#[test]
fn notification_channel_email_carries_from_instance_and_to() {
let ch = NotificationChannel::Email {
from_instance: "ventas-acme".into(),
to: "ops@acme.com".into(),
};
roundtrip(&ch);
let s = serde_json::to_string(&ch).unwrap();
assert!(s.contains(r#""kind":"email""#), "{s}");
assert!(s.contains(r#""from_instance":"ventas-acme""#), "{s}");
assert!(s.contains(r#""to":"ops@acme.com""#), "{s}");
}
#[test]
fn notification_channel_default_is_empty_whatsapp_instance() {
match NotificationChannel::default() {
NotificationChannel::Whatsapp { instance } => {
assert!(instance.is_empty());
}
other => panic!("expected default Whatsapp, got {other:?}"),
}
}
#[test]
fn seller_notification_settings_default_matches_spec() {
let s = SellerNotificationSettings::default();
assert!(s.on_lead_created);
assert!(s.on_lead_replied);
assert!(!s.on_lead_transitioned);
assert!(s.on_draft_pending);
assert!(s.on_meeting_intent);
assert!(matches!(
s.channel,
NotificationChannel::Whatsapp { ref instance } if instance.is_empty()
));
}
#[test]
fn seller_notification_settings_partial_payload_uses_serde_defaults() {
let json = r#"{
"channel": {
"kind": "email",
"from_instance": "ventas-acme",
"to": "ops@acme.com"
}
}"#;
let parsed: SellerNotificationSettings = serde_json::from_str(json).unwrap();
assert!(parsed.on_lead_created);
assert!(parsed.on_lead_replied);
assert!(!parsed.on_lead_transitioned);
assert!(parsed.on_draft_pending);
assert!(parsed.on_meeting_intent);
assert_eq!(
parsed.channel,
NotificationChannel::Email {
from_instance: "ventas-acme".into(),
to: "ops@acme.com".into(),
}
);
}
#[test]
fn notification_templates_default_is_all_none() {
let t = NotificationTemplates::default();
assert!(t.lead_created.is_none());
assert!(t.lead_replied.is_none());
assert!(t.lead_transitioned.is_none());
assert!(t.meeting_intent.is_none());
assert!(t.draft_pending.is_none());
}
#[test]
fn template_locale_set_picks_requested_lang_first() {
let set = TemplateLocaleSet {
es: Some("ES".into()),
en: Some("EN".into()),
};
assert_eq!(set.for_lang("es"), Some("ES"));
assert_eq!(set.for_lang("en"), Some("EN"));
}
#[test]
fn template_locale_set_falls_through_to_other_lang() {
let set = TemplateLocaleSet {
es: Some("ES only".into()),
en: None,
};
assert_eq!(set.for_lang("en"), Some("ES only"));
assert_eq!(set.for_lang("es"), Some("ES only"));
}
#[test]
fn template_locale_set_empty_both_returns_none() {
let set = TemplateLocaleSet::default();
assert_eq!(set.for_lang("es"), None);
assert_eq!(set.for_lang("en"), None);
}
#[test]
fn notification_templates_partial_payload_uses_serde_defaults() {
let json = r#"{
"lead_created": {
"es": "🚀 [Acme] Lead caliente: {{from}}\nAsunto: {{subject}}"
}
}"#;
let parsed: NotificationTemplates = serde_json::from_str(json).unwrap();
assert!(parsed.lead_created.is_some());
assert_eq!(
parsed.lead_created.as_ref().unwrap().es.as_deref(),
Some("🚀 [Acme] Lead caliente: {{from}}\nAsunto: {{subject}}")
);
assert!(parsed.lead_replied.is_none());
}
#[test]
fn email_notification_full_roundtrip() {
let n = EmailNotification {
kind: EmailNotificationKind::LeadCreated,
tenant_id: TenantIdRef("acme".into()),
agent_id: "pedro-agent".into(),
lead_id: LeadId("l-42".into()),
seller_id: SellerId("pedro".into()),
seller_email: "pedro@acme.com".into(),
from_email: "cliente@empresa.com".into(),
subject: "Cotización".into(),
at_ms: 1_700_000_000_000,
summary: "📧 Nuevo lead de cliente@empresa.com (Cotización)".into(),
channel: NotificationChannel::Whatsapp {
instance: "personal".into(),
},
};
roundtrip(&n);
}
}