use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use super::processing::ProcessingScope;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EscalationReason {
OutOfScope,
MissingData,
NeedsHumanJudgment,
Complaint,
Error,
Ambiguity,
PolicyViolation,
UnknownQuery,
Other,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EscalationUrgency {
Low,
#[default]
Normal,
High,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResolvedBy {
OperatorTakeover,
OperatorDismissed {
reason: String,
},
AgentResolved,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum EscalationState {
None,
Pending {
scope: ProcessingScope,
summary: String,
reason: EscalationReason,
urgency: EscalationUrgency,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
context: BTreeMap<String, Value>,
requested_at_ms: u64,
},
Resolved {
scope: ProcessingScope,
resolved_at_ms: u64,
by: ResolvedBy,
},
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EscalationsListFilter {
#[default]
Pending,
Resolved,
All,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct EscalationsListParams {
#[serde(default)]
pub filter: EscalationsListFilter,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope_kind: Option<String>,
#[serde(default)]
pub limit: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EscalationEntry {
pub agent_id: String,
pub scope: ProcessingScope,
pub state: EscalationState,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct EscalationsListResponse {
pub entries: Vec<EscalationEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EscalationsResolveParams {
pub scope: ProcessingScope,
pub by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dismiss_reason: Option<String>,
pub operator_token_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EscalationsResolveResponse {
pub changed: bool,
pub correlation_id: Uuid,
}
pub const ESCALATION_REQUESTED_NOTIFY_KIND: &str = "escalation_requested";
pub const ESCALATION_RESOLVED_NOTIFY_KIND: &str = "escalation_resolved";
pub const ESCALATIONS_LIST_METHOD: &str = "nexo/admin/escalations/list";
pub const ESCALATIONS_RESOLVE_METHOD: &str = "nexo/admin/escalations/resolve";
#[cfg(test)]
mod tests {
use super::*;
fn convo() -> ProcessingScope {
ProcessingScope::Conversation {
agent_id: "ana".into(),
channel: "whatsapp".into(),
account_id: "acc".into(),
contact_id: "wa.55".into(),
mcp_channel_source: None,
}
}
#[test]
fn pending_state_round_trip() {
let mut ctx: BTreeMap<String, Value> = BTreeMap::new();
ctx.insert("question".into(), Value::String("can I refund?".into()));
let s = EscalationState::Pending {
scope: convo(),
summary: "customer wants a refund I cannot authorise".into(),
reason: EscalationReason::OutOfScope,
urgency: EscalationUrgency::High,
context: ctx.clone(),
requested_at_ms: 1_700_000_000_000,
};
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["state"], "pending");
assert_eq!(v["reason"], "out_of_scope");
assert_eq!(v["urgency"], "high");
let back: EscalationState = serde_json::from_value(v).unwrap();
assert_eq!(back, s);
}
#[test]
fn resolved_state_carries_kind_discriminator() {
let s = EscalationState::Resolved {
scope: convo(),
resolved_at_ms: 1_700_000_001_000,
by: ResolvedBy::OperatorDismissed {
reason: "duplicate".into(),
},
};
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["state"], "resolved");
assert_eq!(v["by"]["kind"], "operator_dismissed");
assert_eq!(v["by"]["reason"], "duplicate");
}
#[test]
fn unknown_query_reason_round_trips_as_snake_case() {
let v = serde_json::to_value(EscalationReason::UnknownQuery).unwrap();
assert_eq!(v, serde_json::json!("unknown_query"));
let back: EscalationReason = serde_json::from_value(v).unwrap();
assert_eq!(back, EscalationReason::UnknownQuery);
}
#[test]
fn list_filter_defaults_to_pending() {
let f = EscalationsListFilter::default();
let v = serde_json::to_value(&f).unwrap();
assert_eq!(v, serde_json::json!("pending"));
}
#[test]
fn list_params_round_trip_omits_unset() {
let p = EscalationsListParams {
filter: EscalationsListFilter::All,
..Default::default()
};
let s = serde_json::to_string(&p).unwrap();
assert!(!s.contains("agent_id"));
assert!(!s.contains("scope_kind"));
let back: EscalationsListParams = serde_json::from_str(&s).unwrap();
assert_eq!(back, p);
assert_eq!(ESCALATION_REQUESTED_NOTIFY_KIND, "escalation_requested");
assert_eq!(ESCALATION_RESOLVED_NOTIFY_KIND, "escalation_resolved");
}
}