use crate::console::config_helpers::{find_or_404, internal_error};
use crate::console::handlers::{bad_request, forms, sanitize_optional_string};
use crate::console::{ConsoleState, ReloadTarget, middleware::AuthRequired};
use crate::models::{
routing::{
ActiveModel as RoutingActiveModel, Column as RoutingColumn, Entity as RoutingEntity,
Model as RoutingModel, RoutingDirection, RoutingSelectionStrategy,
},
sip_trunk::{Column as SipTrunkColumn, Entity as SipTrunkEntity, Model as SipTrunkModel},
};
use crate::proxy::routing::ConfigOrigin;
use axum::{
Router,
extract::{Json, Path as AxumPath, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{get, patch, post},
};
use chrono::{DateTime, Utc};
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection, DbErr,
EntityTrait, Iterable, PaginatorTrait, QueryFilter, QueryOrder, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value, json};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use tracing::warn;
const DEFAULT_PRIORITY: i32 = 100;
const MAX_PRIORITY: i32 = 10_000;
const DEFAULT_DIRECTION: RoutingDirection = RoutingDirection::Outbound;
const DEFAULT_SELECTION: RoutingSelectionStrategy = RoutingSelectionStrategy::RoundRobin;
pub fn urls() -> Router<Arc<ConsoleState>> {
Router::new()
.route(
"/routing",
get(page_routing).post(query_routing).put(create_routing),
)
.route("/routing/new", get(page_routing_create))
.route(
"/routing/{id}",
get(page_routing_edit)
.patch(update_routing)
.delete(delete_routing),
)
.route("/routing/{id}/clone", post(clone_routing))
.route("/routing/{id}/toggle", post(toggle_routing))
.route("/routing/{id}/data", get(route_detail_data))
}
pub fn api_urls() -> Router<Arc<ConsoleState>> {
Router::new()
.route("/routing", post(query_routing).put(create_routing))
.route(
"/routing/{id}",
patch(update_routing).delete(delete_routing),
)
.route("/routing/{id}/clone", post(clone_routing))
.route("/routing/{id}/toggle", post(toggle_routing))
.route("/routing/{id}/data", get(route_detail_data))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RouteDocument {
#[serde(default)]
id: Option<i64>,
#[serde(default)]
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
owner: Option<String>,
#[serde(default = "default_direction_value")]
direction: RoutingDirection,
#[serde(default = "default_priority_value")]
priority: i32,
#[serde(default)]
disabled: bool,
#[serde(rename = "match", default)]
matchers: JsonMap<String, Value>,
#[serde(default)]
rewrite: JsonMap<String, Value>,
#[serde(default)]
action: RouteActionDocument,
#[serde(rename = "source_trunk", default)]
source_trunk: Option<String>,
#[serde(default)]
notes: Vec<String>,
}
impl Default for RouteDocument {
fn default() -> Self {
Self {
id: None,
name: String::new(),
description: None,
owner: None,
direction: DEFAULT_DIRECTION,
priority: DEFAULT_PRIORITY,
disabled: false,
matchers: JsonMap::new(),
rewrite: JsonMap::new(),
action: RouteActionDocument::default(),
source_trunk: None,
notes: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub(crate) enum RouteTargetKind {
#[default]
SipTrunk,
Queue,
Voicemail,
Ivr,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RouteActionDocument {
select: RoutingSelectionStrategy,
#[serde(default)]
hash_key: Option<String>,
#[serde(default)]
trunks: Vec<RouteTrunkDocument>,
#[serde(default)]
target_type: RouteTargetKind,
#[serde(default)]
queue_file: Option<String>,
#[serde(default)]
voicemail_extension: Option<String>,
#[serde(default)]
ivr_file: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RouteTrunkDocument {
name: String,
weight: i32,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub(crate) struct QueryRoutingFilters {
#[serde(default)]
q: Option<String>,
#[serde(default)]
source_context: Option<String>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
selection: Option<String>,
#[serde(default)]
owner: Option<String>,
}
#[derive(Debug)]
struct RouteError {
message: String,
}
impl RouteError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
fn message(&self) -> &str {
&self.message
}
}
impl Default for RouteActionDocument {
fn default() -> Self {
Self {
select: DEFAULT_SELECTION,
hash_key: None,
trunks: Vec::new(),
target_type: RouteTargetKind::SipTrunk,
queue_file: None,
voicemail_extension: None,
ivr_file: None,
}
}
}
impl RouteDocument {
fn from_model(model: &RoutingModel) -> Self {
if let Some(meta) = model.metadata.clone()
&& let Ok(mut doc) = serde_json::from_value::<RouteDocument>(meta)
{
doc.id = Some(model.id);
doc.name = model.name.clone();
doc.description = model.description.clone();
doc.owner = model.owner.clone();
doc.direction = model.direction;
doc.priority = model.priority;
doc.disabled = !model.is_active;
doc.action.select = model.selection_strategy;
doc.action.hash_key = if doc.action.select == RoutingSelectionStrategy::Hash {
sanitize_optional_string(model.hash_key.clone()).or(doc.action.hash_key)
} else {
None
};
if let Some(filters) = model.header_filters.clone() {
doc.matchers = value_to_map(filters);
}
if let Some(rewrites) = model.rewrite_rules.clone() {
doc.rewrite = value_to_map(rewrites);
}
let assignments = parse_trunk_assignments(model.target_trunks.clone());
if !assignments.is_empty() {
doc.action.trunks = assignments;
}
if let Some(notes) = model.notes.clone() {
doc.notes = parse_notes_value(Some(notes));
}
doc.ensure_consistency();
return doc;
}
let mut doc = RouteDocument {
id: Some(model.id),
name: model.name.clone(),
description: model.description.clone(),
owner: model.owner.clone(),
direction: model.direction,
priority: model.priority,
disabled: !model.is_active,
matchers: model
.header_filters
.clone()
.map(value_to_map)
.unwrap_or_default(),
rewrite: model
.rewrite_rules
.clone()
.map(value_to_map)
.unwrap_or_default(),
action: RouteActionDocument {
select: model.selection_strategy,
hash_key: if model.selection_strategy == RoutingSelectionStrategy::Hash {
sanitize_optional_string(model.hash_key.clone())
} else {
None
},
trunks: parse_trunk_assignments(model.target_trunks.clone()),
..RouteActionDocument::default()
},
source_trunk: None,
notes: parse_notes_value(model.notes.clone()),
};
doc.ensure_consistency();
doc
}
fn validate(&self) -> Result<(), RouteError> {
if self.name.trim().is_empty() {
return Err(RouteError::new("Route name is required"));
}
match self.action.target_type {
RouteTargetKind::SipTrunk => {}
RouteTargetKind::Queue => {
let has_queue_file = self
.action
.queue_file
.as_ref()
.and_then(|value| sanitize_optional_string(Some(value.clone())));
if has_queue_file.is_none() {
return Err(RouteError::new(
"Queue destination requires a queue file reference",
));
}
}
RouteTargetKind::Voicemail => {
let has_ext = self
.action
.voicemail_extension
.as_ref()
.and_then(|value| sanitize_optional_string(Some(value.clone())));
if has_ext.is_none() {
return Err(RouteError::new(
"Voicemail destination requires an extension",
));
}
}
RouteTargetKind::Ivr => {
let has_file = self
.action
.ivr_file
.as_ref()
.and_then(|value| sanitize_optional_string(Some(value.clone())));
if has_file.is_none() {
return Err(RouteError::new(
"IVR destination requires an IVR project file",
));
}
}
}
Ok(())
}
fn ensure_consistency(&mut self) {
if self.action.select != RoutingSelectionStrategy::Hash {
self.action.hash_key = None;
}
self.action.queue_file = sanitize_optional_string(self.action.queue_file.take());
self.action.voicemail_extension =
sanitize_optional_string(self.action.voicemail_extension.take());
self.action.ivr_file = sanitize_optional_string(self.action.ivr_file.take());
match self.action.target_type {
RouteTargetKind::SipTrunk => {
self.action.queue_file = None;
self.action.voicemail_extension = None;
self.action.ivr_file = None;
}
RouteTargetKind::Queue => {
self.action.trunks.clear();
self.action.select = DEFAULT_SELECTION;
self.action.hash_key = None;
self.action.voicemail_extension = None;
self.action.ivr_file = None;
}
RouteTargetKind::Voicemail => {
self.action.trunks.clear();
self.action.select = DEFAULT_SELECTION;
self.action.hash_key = None;
self.action.queue_file = None;
self.action.ivr_file = None;
}
RouteTargetKind::Ivr => {
self.action.trunks.clear();
self.action.select = DEFAULT_SELECTION;
self.action.hash_key = None;
self.action.queue_file = None;
self.action.voicemail_extension = None;
}
}
let mut dedup = HashSet::new();
let mut unique_trunks = Vec::new();
for trunk in std::mem::take(&mut self.action.trunks) {
if dedup.insert(trunk.name.clone()) {
unique_trunks.push(trunk);
}
}
self.action.trunks = unique_trunks;
self.notes = self
.notes
.iter()
.filter_map(|note| sanitize_optional_string(Some(note.clone())))
.collect();
}
fn metadata_value(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
fn display_label(&self, fallback_id: Option<i64>) -> String {
if !self.name.trim().is_empty() {
return self.name.clone();
}
fallback_id
.map(|id| format!("Route #{id}"))
.unwrap_or_else(|| "New route".to_string())
}
fn apply_trunk_context(&mut self, model: &RoutingModel, trunks: &HashMap<i64, SipTrunkModel>) {
if let Some(source_id) = model.source_trunk_id
&& let Some(trunk) = trunks.get(&source_id)
{
self.source_trunk = Some(trunk.name.clone());
}
if !matches!(self.action.target_type, RouteTargetKind::SipTrunk) {
return;
}
if self.action.trunks.is_empty() {
if let Some(default_id) = model.default_trunk_id
&& let Some(trunk) = trunks.get(&default_id)
&& !self
.action
.trunks
.iter()
.any(|t| t.name.eq_ignore_ascii_case(&trunk.name))
{
self.action.trunks.push(RouteTrunkDocument {
name: trunk.name.clone(),
weight: DEFAULT_PRIORITY,
});
}
self.ensure_consistency();
}
}
}
fn default_direction_value() -> RoutingDirection {
DEFAULT_DIRECTION
}
fn default_priority_value() -> i32 {
DEFAULT_PRIORITY
}
fn normalize_selection(value: Option<String>) -> RoutingSelectionStrategy {
value
.and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
"rr" | "round_robin" | "round-robin" => Some(RoutingSelectionStrategy::RoundRobin),
"weight" | "weighted" => Some(RoutingSelectionStrategy::Weighted),
"hash" => Some(RoutingSelectionStrategy::Hash),
_ => None,
})
.unwrap_or(DEFAULT_SELECTION)
}
fn extract_string(value: Value) -> String {
match value {
Value::String(s) => s.trim().to_string(),
Value::Number(num) => num.to_string(),
Value::Bool(flag) => flag.to_string(),
Value::Null => String::new(),
other => serde_json::to_string(&other).unwrap_or_default(),
}
}
fn value_to_map(value: Value) -> JsonMap<String, Value> {
match value {
Value::Object(map) => map
.into_iter()
.filter_map(|(key, value)| {
let trimmed_key = key.trim();
if trimmed_key.is_empty() {
return None;
}
let str_value = extract_string(value);
if str_value.is_empty() {
return None;
}
Some((trimmed_key.to_string(), Value::String(str_value)))
})
.collect(),
_ => JsonMap::new(),
}
}
fn parse_trunk_assignments(value: Option<Value>) -> Vec<RouteTrunkDocument> {
match value {
Some(Value::Array(items)) => items
.into_iter()
.filter_map(|item| {
if let Value::Object(mut obj) = item {
let name = obj
.remove("name")
.and_then(|v| sanitize_optional_string(v.as_str().map(|s| s.to_string())));
let weight = obj
.remove("weight")
.and_then(|v| v.as_i64())
.map(|w| w.clamp(0, MAX_PRIORITY as i64) as i32)
.unwrap_or(0);
name.map(|n| RouteTrunkDocument { name: n, weight })
} else {
None
}
})
.collect(),
_ => Vec::new(),
}
}
fn parse_notes_value(value: Option<Value>) -> Vec<String> {
match value {
Some(Value::Array(items)) => items
.into_iter()
.filter_map(|item| sanitize_optional_string(item.as_str().map(|s| s.to_string())))
.collect(),
Some(Value::String(note)) => sanitize_optional_string(Some(note)).into_iter().collect(),
_ => Vec::new(),
}
}
fn value_from_map(map: &JsonMap<String, Value>) -> Option<Value> {
if map.is_empty() {
None
} else {
Some(Value::Object(map.clone()))
}
}
fn value_from_trunks(trunks: &[RouteTrunkDocument]) -> Option<Value> {
if trunks.is_empty() {
None
} else {
Some(json!(trunks))
}
}
fn resolve_trunk_id(lookup: &HashMap<String, i64>, name: Option<&str>) -> Option<i64> {
name.and_then(|raw| {
lookup
.get(raw)
.copied()
.or_else(|| lookup.get(&raw.to_ascii_lowercase()).copied())
})
}
fn build_trunk_name_lookup(trunks: &[SipTrunkModel]) -> HashMap<String, i64> {
let mut map = HashMap::new();
for trunk in trunks {
map.insert(trunk.name.clone(), trunk.id);
map.insert(trunk.name.to_ascii_lowercase(), trunk.id);
}
map
}
fn notes_value(notes: &[String]) -> Option<Value> {
if notes.is_empty() {
None
} else {
Some(json!(notes))
}
}
async fn generate_clone_name(db: &DatabaseConnection, original: &str) -> Result<String, DbErr> {
let trimmed = original.trim();
let base = if trimmed.is_empty() {
"Route copy".to_string()
} else {
format!("{trimmed} (copy)")
};
let mut candidate = base.clone();
let mut suffix = 2;
loop {
let existing = RoutingEntity::find()
.filter(RoutingColumn::Name.eq(candidate.clone()))
.one(db)
.await?;
if existing.is_none() {
return Ok(candidate);
}
candidate = format!("{base} {suffix}");
suffix += 1;
}
}
async fn load_trunks(db: &DatabaseConnection) -> Result<Vec<SipTrunkModel>, DbErr> {
SipTrunkEntity::find()
.order_by_asc(SipTrunkColumn::Name)
.all(db)
.await
}
fn load_catalogs(state: &ConsoleState) -> crate::console::catalog::ForwardingCatalog {
match crate::console::catalog::load_proxy_config(state.app_state().as_ref()) {
Some(proxy_config) => {
let catalog = crate::console::catalog::build_forwarding_catalog(&proxy_config);
tracing::info!(
queue_count = catalog.queues.len(),
ivr_count = catalog.ivr_projects.len(),
"load_catalogs: scanned file catalogs for routing form"
);
catalog
}
None => {
tracing::warn!("load_catalogs: app_state not available, returning empty catalogs");
crate::console::catalog::ForwardingCatalog::empty()
}
}
}
fn build_trunk_options(trunks: &[SipTrunkModel]) -> Vec<Value> {
trunks
.iter()
.map(|trunk| {
json!({
"id": trunk.id,
"name": trunk.name,
"display_name": trunk
.display_name
.clone()
.unwrap_or_else(|| trunk.name.clone()),
"carrier": trunk.carrier.clone().unwrap_or_default(),
"status": trunk.status,
"direction": trunk.direction,
})
})
.collect()
}
fn build_route_console_payload(
state: &ConsoleState,
doc: &RouteDocument,
model: &RoutingModel,
) -> Value {
let status = if doc.disabled { "paused" } else { "active" };
let source_context = if doc.source_trunk.is_some() || model.source_trunk_id.is_some() {
"from_trunk"
} else {
"any"
};
json!({
"id": model.id,
"name": doc.name,
"description": doc.description.clone().unwrap_or_default(),
"owner": doc.owner.clone().unwrap_or_default(),
"source_context": source_context,
"direction": doc.direction,
"priority": doc.priority,
"disabled": doc.disabled,
"match": doc.matchers.clone(),
"rewrite": doc.rewrite.clone(),
"action": {
"select": doc.action.select,
"hash_key": doc.action.hash_key.clone(),
"trunks": doc.action.trunks.clone(),
"target_type": doc.action.target_type,
"queue_file": doc.action.queue_file.clone(),
"voicemail_extension": doc.action.voicemail_extension.clone(),
"ivr_file": doc.action.ivr_file.clone(),
},
"source_trunk": doc.source_trunk.clone(),
"target_trunks": doc.action.trunks.iter().map(|t| t.name.clone()).collect::<Vec<_>>(),
"notes": doc.notes.clone(),
"created_at": model.created_at.to_rfc3339(),
"last_modified": model.updated_at.to_rfc3339(),
"last_deploy": model.last_deployed_at.map(|t|t.to_rfc3339()),
"status": status,
"detail_url": state.url_for(&format!("/routing/{}", model.id)),
"edit_url": state.url_for(&format!("/routing/{}", model.id)),
"delete_url": state.url_for(&format!("/routing/{}", model.id)),
"toggle_url": state.url_for(&format!("/routing/{}/toggle", model.id)),
"clone_url": state.url_for(&format!("/routing/{}/clone", model.id)),
})
}
fn build_routes_summary(routes: &[RoutingModel]) -> Value {
let total_routes = routes.len();
let active_routes = routes.iter().filter(|route| route.is_active).count();
let last_deploy = routes
.iter()
.filter_map(|route| route.last_deployed_at)
.max()
.map(|t| t.to_rfc3339());
json!({
"total_routes": total_routes,
"active_routes": active_routes,
"last_deploy": last_deploy,
})
}
fn selection_algorithms() -> Vec<Value> {
RoutingSelectionStrategy::iter()
.map(|strategy| {
let label = match strategy {
RoutingSelectionStrategy::RoundRobin => "Round robin",
RoutingSelectionStrategy::Weighted => "Weighted",
RoutingSelectionStrategy::Hash => "Deterministic hash",
};
json!({
"value": strategy.as_str(),
"label": label,
})
})
.collect()
}
fn selection_algorithms_value() -> Value {
Value::Array(selection_algorithms())
}
fn direction_options_value() -> Value {
json!(
RoutingDirection::iter()
.map(|direction| direction.as_str())
.collect::<Vec<_>>()
)
}
fn status_options_value() -> Value {
json!([
{"value": false, "label": "Active"},
{"value": true, "label": "Paused"},
])
}
#[allow(clippy::too_many_arguments)]
fn render_route_form(
state: &ConsoleState,
mode: &str,
doc: &RouteDocument,
trunks: &[SipTrunkModel],
forwarding_catalog: &crate::console::catalog::ForwardingCatalog,
error_message: Option<String>,
form_action: String,
headers: &HeaderMap,
current_user: &serde_json::Value,
) -> Response {
let route_value = serde_json::to_value(doc).unwrap_or(Value::Null);
let trunk_options = Value::Array(build_trunk_options(trunks));
let selection_algorithms = selection_algorithms_value();
let direction_options = direction_options_value();
let status_options = status_options_value();
let submit_label = if mode == "edit" {
"Save changes"
} else {
"Create routing rule"
};
let page_title = if mode == "edit" {
format!("Edit routing · {}", doc.display_label(doc.id))
} else {
"Create routing rule".to_string()
};
let script_path = if mode == "edit" {
if let Some(id) = doc.id {
format!("{}/routing/{}", state.base_path(), id)
} else {
format!("{}/routing/edit", state.base_path())
}
} else {
format!("{}/routing/new", state.base_path())
};
state.render_with_headers(
"console/routing_form.html",
json!({
"nav_active": "routing",
"mode": mode,
"page_title": page_title,
"submit_label": submit_label,
"route_data": route_value,
"trunk_options": trunk_options,
"forwarding_catalog": forwarding_catalog,
"selection_algorithms": selection_algorithms,
"direction_options": direction_options,
"status_options": status_options,
"form_action": form_action,
"back_url": state.url_for("/routing"),
"error_message": error_message,
"addon_scripts": state.get_injected_scripts(&script_path),
"current_user": current_user,
}),
headers,
)
}
fn apply_document_to_active(
active: &mut RoutingActiveModel,
doc: &RouteDocument,
trunk_lookup: &HashMap<String, i64>,
now: DateTime<Utc>,
) {
active.name = Set(doc.name.clone());
active.description = Set(doc.description.clone());
active.owner = Set(doc.owner.clone());
active.direction = Set(doc.direction);
active.priority = Set(doc.priority);
active.is_active = Set(!doc.disabled);
active.selection_strategy = Set(doc.action.select);
active.hash_key = Set(doc.action.hash_key.clone());
active.header_filters = Set(value_from_map(&doc.matchers));
active.rewrite_rules = Set(value_from_map(&doc.rewrite));
active.target_trunks = Set(value_from_trunks(&doc.action.trunks));
active.source_trunk_id = Set(resolve_trunk_id(trunk_lookup, doc.source_trunk.as_deref()));
let default_target = doc.action.trunks.first().map(|t| t.name.as_str());
active.default_trunk_id = Set(resolve_trunk_id(trunk_lookup, default_target));
active.notes = Set(notes_value(&doc.notes));
let metadata = doc.metadata_value();
active.metadata = Set(if metadata.is_null() {
None
} else {
Some(metadata)
});
active.updated_at = Set(now);
}
pub async fn page_routing(
State(state): State<Arc<ConsoleState>>,
headers: HeaderMap,
AuthRequired(user): AuthRequired,
) -> Response {
let db = state.db();
let trunk_options = match load_trunks(db).await {
Ok(trunks) => Value::Array(build_trunk_options(&trunks)),
Err(err) => {
warn!("failed to load trunks for routing filters: {}", err);
Value::Array(vec![])
}
};
let current_user = state.build_current_user_ctx(&user).await;
let has_file_routes = state
.app_state()
.map(|app| {
app.sip_server()
.inner
.data_context
.routes_snapshot()
.iter()
.any(|r| matches!(r.origin, ConfigOrigin::File(_)))
})
.unwrap_or(false);
let ami_endpoint = state
.config()
.proxy
.ami_path
.clone()
.unwrap_or_else(|| "/ami/v1".to_string());
let catalog = load_catalogs(state.as_ref());
state.render_with_headers(
"console/routing.html",
json!({
"nav_active": "routing",
"filters": {
"trunks": trunk_options,
"selection_algorithms": selection_algorithms_value(),
"direction_options": direction_options_value(),
"status_options": status_options_value(),
},
"forwarding_catalog": catalog,
"create_url": state.url_for("/routing/new"),
"current_user": current_user,
"has_file_routes": has_file_routes,
"ami_endpoint": ami_endpoint,
}),
&headers,
)
}
pub(crate) async fn query_routing(
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
Json(payload): Json<forms::ListQuery<QueryRoutingFilters>>,
) -> Response {
let db = state.db();
let mut selector = RoutingEntity::find()
.order_by_asc(RoutingColumn::Priority)
.order_by_asc(RoutingColumn::Name);
if let Some(filters) = &payload.filters {
if let Some(ref raw_q) = filters.q {
let trimmed = raw_q.trim();
if !trimmed.is_empty() {
let mut condition = Condition::any();
condition = condition.add(RoutingColumn::Name.contains(trimmed));
condition = condition.add(RoutingColumn::Description.contains(trimmed));
condition = condition.add(RoutingColumn::Owner.contains(trimmed));
selector = selector.filter(condition);
}
}
if let Some(ref raw_source) = filters.source_context {
match raw_source.trim().to_ascii_lowercase().as_str() {
"from_trunk" => {
selector = selector.filter(RoutingColumn::SourceTrunkId.is_not_null());
}
"internal" | "any" => {
selector = selector.filter(RoutingColumn::SourceTrunkId.is_null());
}
_ => {}
}
}
if let Some(ref raw_status) = filters.status {
match raw_status.trim().to_ascii_lowercase().as_str() {
"active" => selector = selector.filter(RoutingColumn::IsActive.eq(true)),
"paused" | "disabled" => {
selector = selector.filter(RoutingColumn::IsActive.eq(false))
}
_ => {}
}
}
if let Some(ref raw_selection) = filters.selection {
let strategy = normalize_selection(Some(raw_selection.clone()));
selector = selector.filter(RoutingColumn::SelectionStrategy.eq(strategy));
}
if let Some(ref owner) = filters.owner {
let trimmed = owner.trim();
if !trimmed.is_empty() {
selector = selector.filter(RoutingColumn::Owner.contains(trimmed));
}
}
}
let trunks = match load_trunks(db).await {
Ok(trunks) => trunks,
Err(err) => {
warn!("failed to load trunks for routing query: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to load routing data: {}", err)})),
)
.into_response();
}
};
let trunk_map: HashMap<i64, SipTrunkModel> =
trunks.iter().cloned().map(|t| (t.id, t)).collect();
let summary_routes = match selector.clone().all(db).await {
Ok(list) => list,
Err(err) => {
warn!("failed to load routes for summary: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to load routing data: {}", err)})),
)
.into_response();
}
};
let (_, per_page) = payload.normalize();
let paginator = selector.clone().paginate(db, per_page);
let pagination = match forms::paginate(paginator, &payload).await {
Ok(pagination) => pagination,
Err(err) => {
warn!("failed to paginate routing list: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to load routing data: {}", err)})),
)
.into_response();
}
};
let items: Vec<Value> = pagination
.items
.into_iter()
.map(|model| {
let mut doc = RouteDocument::from_model(&model);
doc.apply_trunk_context(&model, &trunk_map);
build_route_console_payload(state.as_ref(), &doc, &model)
})
.collect();
let file_routes: Vec<Value> = if let Some(app_state) = state.app_state() {
let snapshot = app_state.sip_server().inner.data_context.routes_snapshot();
let mut file_items: Vec<Value> = snapshot
.into_iter()
.filter_map(|route| {
if let ConfigOrigin::File(ref path) = route.origin {
let action_payload =
serde_json::to_value(&route.action).unwrap_or_else(|_| json!({}));
let source_context = if !route.source_trunks.is_empty() {
"from_trunk"
} else {
"any"
};
Some(json!({
"id": null,
"name": route.name,
"description": route.description,
"priority": route.priority,
"source_context": source_context,
"disabled": route.disabled.unwrap_or(false),
"source": "file",
"source_file": path,
"readonly": true,
"match": route.match_conditions,
"rewrite": route.rewrite,
"action": action_payload,
"source_trunk": route.source_trunks.first().cloned().unwrap_or_default(),
}))
} else {
None
}
})
.collect();
file_items.sort_by(|a, b| {
let a_name = a["name"].as_str().unwrap_or("");
let b_name = b["name"].as_str().unwrap_or("");
a_name.cmp(b_name)
});
file_items
} else {
vec![]
};
Json(json!({
"page": pagination.current_page,
"per_page": pagination.per_page,
"total_pages": pagination.total_pages,
"total_items": pagination.total_items,
"items": items,
"file_routes": file_routes,
"summary": build_routes_summary(&summary_routes),
"filters": {
"trunks": build_trunk_options(&trunks),
"selection_algorithms": selection_algorithms(),
"direction_options": direction_options_value(),
"status_options": status_options_value(),
},
}))
.into_response()
}
pub async fn page_routing_create(
State(state): State<Arc<ConsoleState>>,
headers: HeaderMap,
AuthRequired(user): AuthRequired,
) -> Response {
let db = state.db();
let trunks = match load_trunks(db).await {
Ok(list) => list,
Err(err) => {
warn!("failed to load trunks for route create page: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load routing form: {}", err),
)
.into_response();
}
};
let catalog = load_catalogs(state.as_ref());
let current_user = state.build_current_user_ctx(&user).await;
let doc = RouteDocument::default();
render_route_form(
state.as_ref(),
"create",
&doc,
&trunks,
&catalog,
None,
state.api_url_for("/routing"),
&headers,
¤t_user,
)
}
pub async fn page_routing_edit(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
headers: HeaderMap,
AuthRequired(user): AuthRequired,
) -> Response {
let db = state.db();
let model = crate::console::config_helpers::find_or_404!(RoutingEntity, id, db, "Route");
let trunks = match load_trunks(db).await {
Ok(list) => list,
Err(err) => {
warn!("failed to load trunks for route edit page: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load routing form: {}", err),
)
.into_response();
}
};
let trunk_map: HashMap<i64, SipTrunkModel> = trunks
.iter()
.cloned()
.map(|trunk| (trunk.id, trunk))
.collect();
let catalog = load_catalogs(state.as_ref());
let current_user = state.build_current_user_ctx(&user).await;
let mut doc = RouteDocument::from_model(&model);
doc.apply_trunk_context(&model, &trunk_map);
render_route_form(
state.as_ref(),
"edit",
&doc,
&trunks,
&catalog,
None,
state.api_url_for(&format!("/routing/{}", id)),
&headers,
¤t_user,
)
}
pub async fn route_detail_data(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
) -> Response {
let db = state.db();
let model = crate::console::config_helpers::find_or_404!(RoutingEntity, id, db, "Route");
let trunks = match load_trunks(db).await {
Ok(list) => list,
Err(err) => {
warn!("failed to load trunks for route detail {}: {}", id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to load routing data: {}", err)})),
)
.into_response();
}
};
let catalog = load_catalogs(state.as_ref());
let trunk_map: HashMap<i64, SipTrunkModel> =
trunks.iter().cloned().map(|t| (t.id, t)).collect();
let mut doc = RouteDocument::from_model(&model);
doc.apply_trunk_context(&model, &trunk_map);
Json(json!({
"route": doc,
"trunk_options": build_trunk_options(&trunks),
"forwarding_catalog": catalog,
"selection_algorithms": selection_algorithms(),
"direction_options": direction_options_value(),
"status_options": status_options_value(),
"meta": {
"update_url": state.url_for(&format!("/routing/{}", id)),
"delete_url": state.url_for(&format!("/routing/{}", id)),
"detail_url": state.url_for(&format!("/routing/{}", id)),
"toggle_url": state.url_for(&format!("/routing/{}/toggle", id)),
"clone_url": state.url_for(&format!("/routing/{}/clone", id)),
}
}))
.into_response()
}
pub async fn clone_routing(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
) -> Response {
let db = state.db();
let model = crate::console::config_helpers::find_or_404!(RoutingEntity, id, db, "Route");
let mut doc = RouteDocument::from_model(&model);
doc.id = None;
let name = match generate_clone_name(db, &doc.name).await {
Ok(name) => name,
Err(err) => {
warn!("failed to generate clone name for route {}: {}", id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to clone routing rule: {}", err)})),
)
.into_response();
}
};
doc.name = name;
doc.ensure_consistency();
if let Err(err) = doc.validate() {
return bad_request(err.message().to_string());
}
let trunks = match load_trunks(db).await {
Ok(list) => list,
Err(err) => {
warn!("failed to load trunks for route clone {}: {}", id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to clone routing rule: {}", err)})),
)
.into_response();
}
};
let trunk_lookup = build_trunk_name_lookup(&trunks);
let _catalog = load_catalogs(state.as_ref());
doc.source_trunk = sanitize_optional_string(doc.source_trunk.take());
if let Some(ref name) = doc.source_trunk
&& resolve_trunk_id(&trunk_lookup, Some(name.as_str())).is_none()
{
return bad_request(format!("Source trunk \"{}\" was not found", name));
}
let tx = match db.begin().await {
Ok(tx) => tx,
Err(err) => {
warn!(
"failed to start transaction for route clone {}: {}",
id, err
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to clone routing rule: {}", err)})),
)
.into_response();
}
};
let now = Utc::now();
let mut active = RoutingActiveModel {
created_at: Set(now),
..Default::default()
};
apply_document_to_active(&mut active, &doc, &trunk_lookup, now);
let new_model = match active.insert(&tx).await {
Ok(model) => model,
Err(err) => {
warn!("failed to insert cloned routing rule from {}: {}", id, err);
let _ = tx.rollback().await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to clone routing rule: {}", err)})),
)
.into_response();
}
};
if let Err(err) = tx.commit().await {
warn!("failed to commit cloned routing rule from {}: {}", id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to clone routing rule: {}", err)})),
)
.into_response();
}
Json(json!({"status": "ok", "id": new_model.id, "name": new_model.name})).into_response()
}
pub async fn toggle_routing(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
) -> Response {
let db = state.db();
let model = crate::console::config_helpers::find_or_404!(RoutingEntity, id, db, "Route");
let mut doc = RouteDocument::from_model(&model);
doc.disabled = !doc.disabled;
doc.ensure_consistency();
let new_is_active = !model.is_active;
let mut active: RoutingActiveModel = model.into();
active.is_active = Set(new_is_active);
active.updated_at = Set(Utc::now());
let metadata = doc.metadata_value();
active.metadata = Set(if metadata.is_null() {
None
} else {
Some(metadata)
});
match active.update(db).await {
Ok(updated) => {
let disabled = !updated.is_active;
Json(json!({
"status": "ok",
"id": id,
"disabled": disabled,
"is_active": updated.is_active,
}))
.into_response()
}
Err(err) => {
warn!("failed to toggle routing rule {}: {}", id, err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to toggle routing rule: {}", err)})),
)
.into_response()
}
}
}
pub(crate) async fn create_routing(
State(state): State<Arc<ConsoleState>>,
AuthRequired(user): AuthRequired,
Json(mut doc): Json<RouteDocument>,
) -> Response {
if let Err(resp) = state.require_permission(&user, "routes", "write").await {
return resp;
}
let db = state.db();
doc.id = None;
doc.ensure_consistency();
if let Err(err) = doc.validate() {
return bad_request(err.message().to_string());
}
let trunks = match load_trunks(db).await {
Ok(list) => list,
Err(err) => {
warn!("failed to load trunks for route create: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to create routing rule: {}", err)})),
)
.into_response();
}
};
let trunk_lookup = build_trunk_name_lookup(&trunks);
doc.source_trunk = sanitize_optional_string(doc.source_trunk.take());
if let Some(ref name) = doc.source_trunk
&& resolve_trunk_id(&trunk_lookup, Some(name.as_str())).is_none()
{
return bad_request(format!("Source trunk \"{}\" was not found", name));
}
let tx = match db.begin().await {
Ok(tx) => tx,
Err(err) => {
warn!("failed to start transaction for route create: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to create routing rule: {}", err)})),
)
.into_response();
}
};
match RoutingEntity::find()
.filter(RoutingColumn::Name.eq(doc.name.clone()))
.one(&tx)
.await
{
Ok(Some(_)) => {
let _ = tx.rollback().await;
return bad_request("Route name already exists");
}
Ok(None) => {}
Err(err) => {
warn!("failed to check route uniqueness: {}", err);
let _ = tx.rollback().await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to create routing rule: {}", err)})),
)
.into_response();
}
}
let now = Utc::now();
let mut active = RoutingActiveModel {
created_at: Set(now),
..Default::default()
};
apply_document_to_active(&mut active, &doc, &trunk_lookup, now);
let model = match active.insert(&tx).await {
Ok(model) => model,
Err(err) => {
warn!("failed to insert routing rule {}: {}", doc.name, err);
let _ = tx.rollback().await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to create routing rule: {}", err)})),
)
.into_response();
}
};
if let Err(err) = tx.commit().await {
warn!("failed to commit routing rule create {}: {}", doc.name, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to create routing rule: {}", err)})),
)
.into_response();
}
state.mark_pending_reload(ReloadTarget::Routes);
Json(json!({"status": "ok", "id": model.id})).into_response()
}
pub(crate) async fn update_routing(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(user): AuthRequired,
Json(mut doc): Json<RouteDocument>,
) -> Response {
if let Err(resp) = state.require_permission(&user, "routes", "write").await {
return resp;
}
let db = state.db();
let model = find_or_404!(RoutingEntity, id, db, "Route");
let trunks = match load_trunks(db).await {
Ok(list) => list,
Err(err) => {
warn!("failed to load trunks for route update: {}", err);
return internal_error(format!("Failed to update routing rule: {}", err));
}
};
let trunk_lookup = build_trunk_name_lookup(&trunks);
doc.id = Some(id);
doc.ensure_consistency();
if let Err(err) = doc.validate() {
return bad_request(err.message().to_string());
}
doc.source_trunk = sanitize_optional_string(doc.source_trunk.take());
if let Some(ref name) = doc.source_trunk
&& resolve_trunk_id(&trunk_lookup, Some(name.as_str())).is_none()
{
return bad_request(format!("Source trunk \"{}\" was not found", name));
}
let tx = match db.begin().await {
Ok(tx) => tx,
Err(err) => {
warn!(
"failed to start transaction for route update {}: {}",
id, err
);
return internal_error(format!("Failed to update routing rule: {}", err));
}
};
match RoutingEntity::find()
.filter(RoutingColumn::Name.eq(doc.name.clone()))
.one(&tx)
.await
{
Ok(Some(existing)) if existing.id != id => {
let _ = tx.rollback().await;
return bad_request("Route name already exists");
}
Ok(_) => {}
Err(err) => {
warn!(
"failed to check route uniqueness for update {}: {}",
id, err
);
let _ = tx.rollback().await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": format!("Failed to update routing rule: {}", err)})),
)
.into_response();
}
}
let mut active: RoutingActiveModel = model.clone().into();
let now = Utc::now();
apply_document_to_active(&mut active, &doc, &trunk_lookup, now);
if let Err(err) = active.update(&tx).await {
warn!("failed to update routing rule {}: {}", id, err);
let _ = tx.rollback().await;
return internal_error(format!("Failed to update routing rule: {}", err));
}
if let Err(err) = tx.commit().await {
warn!("failed to commit routing rule update {}: {}", id, err);
return internal_error(format!("Failed to update routing rule: {}", err));
}
state.mark_pending_reload(ReloadTarget::Routes);
Json(json!({"status": "ok", "id": id})).into_response()
}
pub async fn delete_routing(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(user): AuthRequired,
) -> Response {
if let Err(resp) = state.require_permission(&user, "routes", "write").await {
return resp;
}
let db = state.db();
match RoutingEntity::delete_by_id(id).exec(db).await {
Ok(result) => {
if result.rows_affected == 0 {
(
StatusCode::NOT_FOUND,
Json(json!({"message": "Route not found"})),
)
.into_response()
} else {
state.mark_pending_reload(ReloadTarget::Routes);
Json(json!({"status": "ok", "rows_affected": result.rows_affected})).into_response()
}
}
Err(err) => {
warn!("failed to delete routing rule {}: {}", id, err);
internal_error(format!("Failed to delete routing rule: {}", err))
}
}
}
#[cfg(test)]
mod tests {
use crate::console::handlers::test_helpers::{setup_state, superuser, unprivileged_user};
use super::*;
use crate::console::middleware::AuthRequired;
use axum::{Json, extract::State, http::StatusCode};
#[tokio::test]
async fn create_routing_denied_without_permission() {
let state = setup_state().await;
let user = unprivileged_user();
let doc = RouteDocument {
name: "test-route".into(),
..Default::default()
};
let resp = create_routing(State(state), AuthRequired(user), Json(doc)).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_routing_denied_without_permission() {
let state = setup_state().await;
let user = unprivileged_user();
let doc = RouteDocument {
name: "test-route".into(),
..Default::default()
};
let resp = update_routing(
AxumPath(999i64),
State(state),
AuthRequired(user),
Json(doc),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_routing_denied_without_permission() {
let state = setup_state().await;
let user = unprivileged_user();
let resp = delete_routing(AxumPath(999i64), State(state), AuthRequired(user)).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_routing_allowed_for_superuser() {
let state = setup_state().await;
let user = superuser();
let doc = RouteDocument {
name: "allowed-route".into(),
..Default::default()
};
let resp = create_routing(State(state), AuthRequired(user), Json(doc)).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn forwarding_catalog_queue_has_reference() {
use crate::console::catalog::{ForwardingCatalog, ForwardingQueue};
let catalog = ForwardingCatalog {
queues: vec![ForwardingQueue {
reference: "db-42".to_string(),
name: "Customer calls back".to_string(),
description: Some("Main support queue".to_string()),
}],
ivr_projects: vec![],
};
assert_eq!(catalog.queues.len(), 1);
assert_eq!(catalog.queues[0].reference, "db-42");
assert_eq!(catalog.queues[0].name, "Customer calls back");
}
#[test]
fn route_document_serialization_preserves_source_trunk_and_trunks() {
let doc = RouteDocument {
id: Some(1),
name: "test-route".into(),
description: Some("desc".into()),
owner: Some("admin".into()),
direction: RoutingDirection::Outbound,
priority: 10,
disabled: false,
matchers: JsonMap::new(),
rewrite: JsonMap::new(),
action: RouteActionDocument {
select: RoutingSelectionStrategy::RoundRobin,
hash_key: None,
trunks: vec![
RouteTrunkDocument {
name: "trunk-b".into(),
weight: 100,
},
RouteTrunkDocument {
name: "trunk-c".into(),
weight: 50,
},
],
target_type: RouteTargetKind::SipTrunk,
queue_file: None,
voicemail_extension: None,
ivr_file: None,
},
source_trunk: Some("trunk-a".into()),
notes: vec![],
};
let value = serde_json::to_value(&doc).unwrap();
let obj = value.as_object().expect("value should be object");
let source_trunk_val = obj
.get("source_trunk")
.expect("source_trunk key should exist");
assert_eq!(source_trunk_val, &Value::String("trunk-a".into()));
let action = obj
.get("action")
.expect("action key should exist")
.as_object()
.expect("action should be object");
let trunks = action
.get("trunks")
.expect("action.trunks key should exist")
.as_array()
.expect("trunks should be array");
assert_eq!(trunks.len(), 2);
assert_eq!(trunks[0].get("name").unwrap().as_str().unwrap(), "trunk-b");
assert_eq!(trunks[0].get("weight").unwrap().as_i64().unwrap(), 100);
assert_eq!(trunks[1].get("name").unwrap().as_str().unwrap(), "trunk-c");
assert_eq!(trunks[1].get("weight").unwrap().as_i64().unwrap(), 50);
assert_eq!(
action.get("target_type").unwrap().as_str().unwrap(),
"sip_trunk"
);
}
#[test]
fn route_document_roundtrip_through_metadata() {
let doc = RouteDocument {
id: Some(1),
name: "test-route".into(),
description: Some("desc".into()),
owner: Some("admin".into()),
direction: RoutingDirection::Outbound,
priority: 10,
disabled: false,
matchers: JsonMap::new(),
rewrite: JsonMap::new(),
action: RouteActionDocument {
select: RoutingSelectionStrategy::RoundRobin,
hash_key: None,
trunks: vec![RouteTrunkDocument {
name: "trunk-b".into(),
weight: 100,
}],
target_type: RouteTargetKind::SipTrunk,
queue_file: None,
voicemail_extension: None,
ivr_file: None,
},
source_trunk: Some("trunk-a".into()),
notes: vec![],
};
let metadata = doc.metadata_value();
let doc2: RouteDocument = serde_json::from_value(metadata).unwrap();
assert_eq!(doc2.source_trunk, Some("trunk-a".into()));
assert_eq!(doc2.action.trunks.len(), 1);
assert_eq!(doc2.action.trunks[0].name, "trunk-b");
assert_eq!(doc2.action.trunks[0].weight, 100);
assert_eq!(doc2.action.target_type, RouteTargetKind::SipTrunk);
}
}