use super::models::{ActiveModel, Column, Entity, Model, version_history};
use super::{builder, tts};
use crate::console::ConsoleState;
use crate::console::handlers::{bad_request, forms, normalize_optional_string, require_field};
use crate::console::middleware::AuthRequired;
use axum::{
Json, Router,
body::Body,
extract::{Multipart, Path as AxumPath, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
routing::{get, post},
};
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, EntityTrait, PaginatorTrait,
QueryFilter, QueryOrder,
};
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;
use tracing::warn;
pub fn urls() -> Router<Arc<ConsoleState>> {
Router::new()
.route(
"/ivr_editor",
get(page_index).post(query_projects).put(create_project),
)
.route(
"/ivr_editor/{id}",
get(page_editor)
.patch(update_project)
.delete(delete_project),
)
.route("/ivr_editor/{id}/publish", post(publish_project))
.route("/ivr_editor/{id}/versions", get(list_versions))
.route(
"/ivr_editor/{id}/versions/{version_id}/rollback",
post(rollback_version),
)
.route("/ivr_editor/tts", post(synthesize_tts))
.route("/ivr_editor/upload-sound", post(upload_sound))
.route("/ivr_editor/sounds/{*path}", get(serve_sound))
.route("/ivr_editor/api/projects", get(api_projects))
}
pub async fn page_index(
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
headers: HeaderMap,
) -> Response {
let _lic = crate::license::get_license_status("ivr_editor");
let license_valid = _lic
.as_ref()
.map(|l| l.valid && !l.expired)
.unwrap_or(false);
let license_expired = _lic.as_ref().map(|l| l.expired).unwrap_or(false);
state.render_with_headers(
"ivr_editor/index.html",
json!({
"nav_active": "ivr_editor",
"license_valid": license_valid,
"license_expired": license_expired,
"filters": {
"status_options": [
{"value": "all", "label": "Any status"},
{"value": "active", "label": "Active"},
{"value": "disabled", "label": "Disabled"},
],
},
}),
&headers,
)
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct ProjectListFilters {
#[serde(default)]
q: Option<String>,
#[serde(default)]
status: Option<String>,
}
pub async fn query_projects(
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
Json(payload): Json<forms::ListQuery<ProjectListFilters>>,
) -> Response {
let db = state.db();
let mut selector = Entity::find().order_by_desc(Column::UpdatedAt);
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(Column::Name.contains(trimmed));
condition = condition.add(Column::Description.contains(trimmed));
selector = selector.filter(condition);
}
}
if let Some(ref status) = filters.status {
match status.trim().to_ascii_lowercase().as_str() {
"active" => selector = selector.filter(Column::Status.eq("active")),
"disabled" => selector = selector.filter(Column::Status.eq("disabled")),
_ => {}
}
}
}
let summary_models = match selector.clone().all(db).await {
Ok(list) => list,
Err(err) => {
warn!("failed to load IVR projects: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to load projects"})),
)
.into_response();
}
};
let total = summary_models.len();
let active = summary_models
.iter()
.filter(|p| p.status == "active")
.count();
let disabled = total.saturating_sub(active);
let (_, per_page) = payload.normalize();
let paginator = selector.paginate(db, per_page);
let pagination = match forms::paginate(paginator, &payload).await {
Ok(result) => result,
Err(err) => {
warn!("failed to paginate IVR projects: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to load projects"})),
)
.into_response();
}
};
let items: Vec<Value> = pagination
.items
.into_iter()
.map(|m| project_item_payload(state.as_ref(), &m))
.collect();
Json(json!({
"page": pagination.current_page,
"per_page": pagination.per_page,
"total_pages": pagination.total_pages,
"total_items": pagination.total_items,
"items": items,
"summary": {
"total": total,
"active": active,
"disabled": disabled,
},
}))
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct CreateProjectPayload {
pub name: Option<String>,
pub description: Option<String>,
}
pub async fn create_project(
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
Json(payload): Json<CreateProjectPayload>,
) -> Response {
let db = state.db();
let name = match require_field(&payload.name, "name") {
Ok(v) => v,
Err(resp) => return resp,
};
match Entity::find()
.filter(Column::Name.eq(name.clone()))
.one(db)
.await
{
Ok(Some(_)) => return bad_request("Project name already exists"),
Ok(None) => {}
Err(err) => {
warn!("failed to check IVR project uniqueness: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to create project"})),
)
.into_response();
}
}
let default_data = json!({
"name": name,
"description": normalize_optional_string(&payload.description).unwrap_or_default(),
"root": {
"greeting": "sounds/ivr/welcome.wav",
"timeout_ms": 5000,
"max_retries": 3,
"entries": []
},
"menus": {}
});
let now = Utc::now();
let mut active: ActiveModel = Default::default();
active.name = Set(name);
active.description = Set(normalize_optional_string(&payload.description));
active.status = Set("active".to_string());
active.current_data = Set(default_data);
active.version = Set(1);
active.created_at = Set(now);
active.updated_at = Set(now);
match active.insert(db).await {
Ok(model) => Json(json!({"status": "ok", "id": model.id})).into_response(),
Err(err) => {
warn!("failed to insert IVR project: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to create project"})),
)
.into_response()
}
}
}
pub async fn page_editor(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
headers: HeaderMap,
) -> Response {
let db = state.db();
let model = match Entity::find_by_id(id).one(db).await {
Ok(Some(m)) => m,
Ok(None) => return (StatusCode::NOT_FOUND, "Project not found").into_response(),
Err(err) => {
warn!("failed to load IVR project {}: {}", id, err);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to load project").into_response();
}
};
let tts_voices: Vec<Value> = tts::VOICES
.iter()
.map(|(id, label)| json!({"id": id, "label": label}))
.collect();
state.render_with_headers(
"ivr_editor/editor.html",
json!({
"nav_active": "ivr_editor",
"project": {
"id": model.id,
"name": model.name,
"description": model.description,
"status": model.status,
"version": model.version,
"current_data": model.current_data,
"published_at": model.published_at.map(|t| t.to_rfc3339()),
"updated_at": model.updated_at.to_rfc3339(),
},
"tts_voices": tts_voices,
"list_url": state.url_for("/ivr_editor"),
"update_url": state.url_for(&format!("/ivr_editor/{}", model.id)),
"publish_url": state.url_for(&format!("/ivr_editor/{}/publish", model.id)),
"tts_url": state.url_for("/ivr_editor/tts"),
"upload_url": state.url_for("/ivr_editor/upload-sound"),
}),
&headers,
)
}
#[derive(Debug, Deserialize)]
pub struct UpdateProjectPayload {
pub name: Option<String>,
pub description: Option<String>,
pub status: Option<String>,
pub current_data: Option<Value>,
}
pub async fn update_project(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
Json(payload): Json<UpdateProjectPayload>,
) -> Response {
let db = state.db();
let model = match Entity::find_by_id(id).one(db).await {
Ok(Some(m)) => m,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"message": "Project not found"})),
)
.into_response();
}
Err(err) => {
warn!("failed to load IVR project {} for update: {}", id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to update project"})),
)
.into_response();
}
};
let requested_name = payload
.name
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
if let Some(ref name) = requested_name {
if name != &model.name {
match Entity::find()
.filter(Column::Name.eq(name.clone()))
.one(db)
.await
{
Ok(Some(other)) if other.id != id => {
return bad_request("Project name already exists");
}
Ok(_) => {}
Err(err) => {
warn!("failed to check uniqueness on update {}: {}", id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to update project"})),
)
.into_response();
}
}
}
}
let mut active: ActiveModel = model.into();
if let Some(name) = requested_name {
active.name = Set(name);
}
active.description = Set(normalize_optional_string(&payload.description));
if let Some(ref status) = payload.status {
let s = status.trim().to_lowercase();
if s == "active" || s == "disabled" {
active.status = Set(s);
}
}
if let Some(ref data) = payload.current_data {
active.current_data = Set(data.clone());
}
active.updated_at = Set(Utc::now());
match active.update(db).await {
Ok(updated) => Json(json!({"status": "ok", "id": updated.id})).into_response(),
Err(err) => {
warn!("failed to update IVR project {}: {}", id, err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to update project"})),
)
.into_response()
}
}
}
pub async fn delete_project(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
) -> Response {
let db = state.db();
match Entity::delete_by_id(id).exec(db).await {
Ok(result) => {
if result.rows_affected == 0 {
(
StatusCode::NOT_FOUND,
Json(json!({"message": "Project not found"})),
)
.into_response()
} else {
Json(json!({"status": "ok"})).into_response()
}
}
Err(err) => {
warn!("failed to delete IVR project {}: {}", id, err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to delete project"})),
)
.into_response()
}
}
}
pub async fn publish_project(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
) -> Response {
let db = state.db();
let model = match Entity::find_by_id(id).one(db).await {
Ok(Some(m)) => m,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"message": "Project not found"})),
)
.into_response();
}
Err(err) => {
warn!("failed to load IVR project {} for publish: {}", id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to publish project"})),
)
.into_response();
}
};
use crate::call::app::ivr_config::IvrDefinition;
let mut definition: IvrDefinition = match serde_json::from_value(model.current_data.clone()) {
Ok(d) => d,
Err(err) => {
warn!("failed to parse IVR definition for project {}: {}", id, err);
return bad_request(format!("Invalid IVR data: {}", err));
}
};
let tts_result = match builder::generate_tts_cache(&mut definition).await {
Ok(result) => result,
Err(err) => {
warn!(
"failed to generate TTS cache for IVR project {}: {}",
id, err
);
return bad_request(format!("TTS generation failed: {}", err));
}
};
let updated_data = match serde_json::to_value(&definition) {
Ok(v) => v,
Err(err) => {
warn!("failed to serialize updated IVR definition: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to serialize IVR data"})),
)
.into_response();
}
};
let path = match builder::build_and_write(&model.name, &updated_data) {
Ok(p) => p,
Err(err) => {
warn!("failed to build TOML for IVR project {}: {}", id, err);
return bad_request(format!("Failed to publish: {}", err));
}
};
let new_version = model.version + 1;
let snapshot_data = updated_data;
let snapshot_label = model.description.clone();
let history_entry = version_history::ActiveModel {
project_id: Set(model.id),
version: Set(new_version),
data: Set(snapshot_data),
label: Set(snapshot_label),
created_at: Set(Utc::now()),
..Default::default()
};
if let Err(err) = history_entry.insert(db).await {
warn!(
"failed to record version history for project {}: {}",
id, err
);
}
let now = Utc::now();
let mut active: ActiveModel = model.into();
active.published_data = Set(Some(active.current_data.clone().unwrap()));
active.published_at = Set(Some(now));
active.version = Set(new_version);
active.updated_at = Set(now);
match active.update(db).await {
Ok(_) => Json(json!({
"status": "ok",
"path": path.to_string_lossy(),
"version": new_version,
"tts": tts_result,
}))
.into_response(),
Err(err) => {
warn!("failed to update IVR project {} after publish: {}", id, err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Published file written but failed to update database"})),
)
.into_response()
}
}
}
#[derive(Debug, Deserialize)]
pub struct TtsPayload {
pub text: String,
#[serde(default)]
pub voice: String,
pub filename: String,
}
pub async fn synthesize_tts(
State(_state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
Json(payload): Json<TtsPayload>,
) -> Response {
match tts::synthesize(&payload.text, &payload.voice, &payload.filename).await {
Ok(path) => Json(json!({
"status": "ok",
"path": path.to_string_lossy(),
}))
.into_response(),
Err(err) => {
warn!("TTS synthesis failed: {}", err);
bad_request(format!("TTS failed: {}", err))
}
}
}
pub async fn upload_sound(
State(_state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
mut multipart: Multipart,
) -> Response {
let dir = std::path::Path::new("storage/sounds/ivr");
if let Err(err) = tokio::fs::create_dir_all(dir).await {
warn!("failed to create sounds directory: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to create upload directory"})),
)
.into_response();
}
while let Ok(Some(field)) = multipart.next_field().await {
let file_name = match field.file_name() {
Some(name) => name.to_string(),
None => continue,
};
let ext = std::path::Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext != "wav" && ext != "mp3" {
return bad_request("Only .wav and .mp3 files are accepted");
}
let data = match field.bytes().await {
Ok(d) => d,
Err(err) => {
warn!("failed to read upload: {}", err);
return bad_request("Failed to read uploaded file");
}
};
let dest = dir.join(&file_name);
if let Err(err) = tokio::fs::write(&dest, &data).await {
warn!("failed to write uploaded file: {}", err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to save file"})),
)
.into_response();
}
return Json(json!({
"status": "ok",
"path": dest.to_string_lossy(),
}))
.into_response();
}
bad_request("No file uploaded")
}
pub async fn serve_sound(
AxumPath(path): AxumPath<String>,
_auth: AuthRequired,
req_headers: HeaderMap,
) -> Response {
let clean: String = path
.split('/')
.filter(|s| !s.is_empty() && *s != ".." && *s != ".")
.collect::<Vec<_>>()
.join("/");
if clean.is_empty() {
return StatusCode::NOT_FOUND.into_response();
}
let file_path = format!("storage/sounds/ivr/{}", clean);
let meta = match tokio::fs::metadata(&file_path).await {
Ok(m) if m.is_file() => m,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let content_type = if file_path.ends_with(".mp3") {
"audio/mpeg"
} else if file_path.ends_with(".wav") {
"audio/wav"
} else {
"application/octet-stream"
};
let file_len = meta.len();
let range_header = req_headers.get(header::RANGE).and_then(|v| v.to_str().ok());
let (status, start, end) = match range_header.and_then(|v| parse_byte_range(v, file_len)) {
Some((s, e)) => (StatusCode::PARTIAL_CONTENT, s, e),
None => (StatusCode::OK, 0, file_len.saturating_sub(1)),
};
let chunk_len = end - start + 1;
let data = match read_file_range(&file_path, start, chunk_len).await {
Ok(d) => d,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
let mut builder = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, chunk_len)
.header(header::ACCEPT_RANGES, "bytes");
if status == StatusCode::PARTIAL_CONTENT {
builder = builder.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, file_len),
);
}
builder
.body(Body::from(data))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
fn parse_byte_range(value: &str, total: u64) -> Option<(u64, u64)> {
let s = value.strip_prefix("bytes=")?;
let mut parts = s.splitn(2, '-');
let start: u64 = parts.next()?.trim().parse().ok()?;
let end: u64 = parts
.next()
.and_then(|e| e.trim().parse().ok())
.unwrap_or(total.saturating_sub(1));
if start > end || end >= total {
None
} else {
Some((start, end))
}
}
async fn read_file_range(path: &str, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut f = tokio::fs::File::open(path).await?;
f.seek(std::io::SeekFrom::Start(offset)).await?;
let mut buf = vec![0u8; len as usize];
f.read_exact(&mut buf).await?;
Ok(buf)
}
pub async fn list_versions(
AxumPath(id): AxumPath<i64>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
) -> Response {
let db = state.db();
match Entity::find_by_id(id).one(db).await {
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"message": "Project not found"})),
)
.into_response();
}
Err(err) => {
warn!("failed to load IVR project {}: {}", id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to load project"})),
)
.into_response();
}
Ok(Some(_)) => {}
}
match version_history::Entity::find()
.filter(version_history::Column::ProjectId.eq(id))
.order_by_desc(version_history::Column::Version)
.all(db)
.await
{
Ok(rows) => {
let items: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"project_id": r.project_id,
"version": r.version,
"label": r.label,
"created_at": r.created_at.to_rfc3339(),
})
})
.collect();
Json(json!({"items": items, "total": items.len()})).into_response()
}
Err(err) => {
warn!("failed to list version history for project {}: {}", id, err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to load version history"})),
)
.into_response()
}
}
}
pub async fn rollback_version(
AxumPath((id, version_id)): AxumPath<(i64, i64)>,
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
) -> Response {
let db = state.db();
let snapshot = match version_history::Entity::find_by_id(version_id)
.one(db)
.await
{
Ok(Some(s)) if s.project_id == id => s,
Ok(Some(_)) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"message": "Version not found for this project"})),
)
.into_response();
}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"message": "Version snapshot not found"})),
)
.into_response();
}
Err(err) => {
warn!("failed to load version snapshot {}: {}", version_id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to load version snapshot"})),
)
.into_response();
}
};
let model = match Entity::find_by_id(id).one(db).await {
Ok(Some(m)) => m,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"message": "Project not found"})),
)
.into_response();
}
Err(err) => {
warn!("failed to load IVR project {} for rollback: {}", id, err);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to load project"})),
)
.into_response();
}
};
let mut active: ActiveModel = model.into();
active.current_data = Set(snapshot.data.clone());
active.updated_at = Set(Utc::now());
match active.update(db).await {
Ok(updated) => Json(json!({
"status": "ok",
"id": updated.id,
"rolled_back_to_version": snapshot.version,
}))
.into_response(),
Err(err) => {
warn!("failed to update project {} during rollback: {}", id, err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to apply rollback"})),
)
.into_response()
}
}
}
fn project_item_payload(state: &ConsoleState, model: &Model) -> Value {
json!({
"id": model.id,
"name": model.name,
"description": model.description,
"status": model.status,
"version": model.version,
"published_at": model.published_at.map(|t| t.to_rfc3339()),
"updated_at": model.updated_at.to_rfc3339(),
"detail_url": state.url_for(&format!("/ivr_editor/{}", model.id)),
"delete_url": state.url_for(&format!("/ivr_editor/{}", model.id)),
"publish_url": state.url_for(&format!("/ivr_editor/{}/publish", model.id)),
})
}
pub async fn api_projects(
State(state): State<Arc<ConsoleState>>,
AuthRequired(_): AuthRequired,
) -> Response {
let db = state.db();
match Entity::find()
.filter(Column::Status.eq("active"))
.order_by_asc(Column::Name)
.all(db)
.await
{
Ok(list) => {
let items: Vec<Value> = list
.iter()
.map(|p| {
let file = format!(
"config/ivr/{}.toml",
p.name
.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
})
.collect::<String>()
);
json!({
"id": p.id,
"name": p.name,
"description": p.description,
"status": p.status,
"published_at": p.published_at.map(|t| t.to_rfc3339()),
"file": file,
})
})
.collect();
Json(json!(items)).into_response()
}
Err(err) => {
warn!("failed to load IVR projects for API: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"message": "Failed to load IVR projects"})),
)
.into_response()
}
}
}