use axum::{ extract::{Path, State}, http::StatusCode, response::Json, routing::{delete, get, post, put}, Router, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::Mutex; use tracing::info; use crate::config_new::AppConfig; use crate::error::Result as FaxResult; use crate::queue::{FaxJob, FaxQueue, JobId, JobStatus}; use crate::worker::FaxWorker; #[derive(Clone)] pub struct AppState { pub config: AppConfig, pub queue: Arc>, pub worker: Arc>>, } #[derive(Serialize)] pub struct HealthResponse { pub status: String, pub uptime_seconds: u64, pub modems: Vec, pub queue: QueueHealthInfo, } #[derive(Serialize)] pub struct ModemHealthInfo { pub name: String, pub device: String, pub status: String, } #[derive(Serialize)] pub struct QueueHealthInfo { pub pending: usize, pub active: usize, pub failed: usize, } #[derive(Deserialize)] pub struct SendFaxRequest { pub recipient: String, pub document_path: String, #[serde(default)] pub cover_to: Option, #[serde(default)] pub cover_from: Option, #[serde(default)] pub cover_subject: Option, #[serde(default)] pub cover_notes: Option, } #[derive(Serialize)] pub struct SendFaxResponse { pub job_id: JobId, pub message: String, } #[derive(Deserialize)] pub struct UpdateCoverRequest { pub to: Option, pub from: Option, pub subject: Option, pub notes: Option, } #[derive(Serialize)] pub struct UpdateCoverResponse { pub job_id: JobId, pub cover_to: Option, pub cover_from: Option, pub cover_subject: Option, pub cover_notes: Option, } #[derive(Serialize)] pub struct JobResponse { pub id: JobId, pub recipient: String, pub status: String, pub pages: u32, pub retries: u8, pub created_at: String, pub updated_at: String, pub cover_to: Option, pub cover_from: Option, pub cover_subject: Option, pub cover_notes: Option, } #[derive(Serialize)] pub struct JobListResponse { pub jobs: Vec, pub total: usize, } #[derive(Serialize)] pub struct ErrorResponse { pub error: String, pub message: String, } async fn health(State(state): State) -> Json { let modems: Vec = state.config.modems.iter().map(|m| { ModemHealthInfo { name: m.name.clone(), device: m.device.clone(), status: "idle".to_string(), } }).collect(); let queue = state.queue.lock().await; let jobs = queue.list(); let pending = jobs.iter().filter(|j| matches!(j.status, JobStatus::Queued)).count(); let failed = jobs.iter().filter(|j| matches!(j.status, JobStatus::Failed(_))).count(); Json(HealthResponse { status: "healthy".to_string(), uptime_seconds: 0, modems, queue: QueueHealthInfo { pending, active: 0, failed, }, }) } async fn send_fax( State(state): State, Json(req): Json, ) -> Result, (StatusCode, Json)> { let mut job = FaxJob::new(req.recipient, req.document_path); job.cover_to = req.cover_to; job.cover_from = req.cover_from; job.cover_subject = req.cover_subject; job.cover_notes = req.cover_notes; let job_id = job.id; let mut queue = state.queue.lock().await; queue.enqueue(job).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "queue_error".to_string(), message: e.to_string(), }), ) })?; info!(job_id = %job_id, "Fax job queued via API"); Ok(Json(SendFaxResponse { job_id, message: "Job queued successfully".to_string(), })) } async fn list_jobs(State(state): State) -> Json { let queue = state.queue.lock().await; let jobs = queue.list(); let total = jobs.len(); let responses: Vec = jobs .into_iter() .map(|j| JobResponse { id: j.id, recipient: j.recipient, status: format!("{:?}", j.status), pages: j.pages, retries: j.retries, created_at: j.created_at.to_rfc3339(), updated_at: j.updated_at.to_rfc3339(), cover_to: j.cover_to, cover_from: j.cover_from, cover_subject: j.cover_subject, cover_notes: j.cover_notes, }) .collect(); Json(JobListResponse { jobs: responses, total }) } async fn get_job( State(state): State, Path(id): Path, ) -> Result, (StatusCode, Json)> { let queue = state.queue.lock().await; queue.get(&id).map(|j| Json(JobResponse { id: j.id, recipient: j.recipient, status: format!("{:?}", j.status), pages: j.pages, retries: j.retries, created_at: j.created_at.to_rfc3339(), updated_at: j.updated_at.to_rfc3339(), cover_to: j.cover_to, cover_from: j.cover_from, cover_subject: j.cover_subject, cover_notes: j.cover_notes, })).ok_or(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "not_found".to_string(), message: format!("Job {} not found", id), }), )) } async fn cancel_job( State(state): State, Path(id): Path, ) -> Result)> { let mut queue = state.queue.lock().await; if queue.get(&id).is_none() { return Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "not_found".to_string(), message: format!("Job {} not found", id), }), )); } queue.update_status(&id, JobStatus::Cancelled).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "update_failed".to_string(), message: e.to_string(), }), ) })?; info!(job_id = %id, "Job cancelled via API"); Ok(StatusCode::NO_CONTENT) } async fn retry_job( State(state): State, Path(id): Path, ) -> Result)> { let mut queue = state.queue.lock().await; if queue.get(&id).is_none() { return Err(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "not_found".to_string(), message: format!("Job {} not found", id), }), )); } queue.update_status(&id, JobStatus::Queued).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "update_failed".to_string(), message: e.to_string(), }), ) })?; info!(job_id = %id, "Job retry requested via API"); Ok(StatusCode::OK) } async fn update_job_cover( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { let mut queue = state.queue.lock().await; queue .update_cover(&id, req.to.clone(), req.from.clone(), req.subject.clone(), req.notes.clone()) .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "update_failed".to_string(), message: e.to_string(), }), ) })?; let job = queue.get(&id).ok_or(( StatusCode::NOT_FOUND, Json(ErrorResponse { error: "not_found".to_string(), message: format!("Job {} not found", id), }), ))?; Ok(Json(UpdateCoverResponse { job_id: job.id, cover_to: job.cover_to, cover_from: job.cover_from, cover_subject: job.cover_subject, cover_notes: job.cover_notes, })) } pub fn create_router(state: AppState) -> Router { Router::new() .route("/api/health", get(health)) .route("/api/fax/send", post(send_fax)) .route("/api/fax/jobs", get(list_jobs)) .route("/api/fax/jobs/{id}", get(get_job)) .route("/api/fax/jobs/{id}", delete(cancel_job)) .route("/api/fax/jobs/{id}/retry", post(retry_job)) .route("/api/fax/jobs/{id}/cover", put(update_job_cover)) .with_state(state) } pub async fn start_server(config: AppConfig, queue: Arc>) -> FaxResult<()> { let fax_config = if let Some(modem) = config.primary_modem() { crate::config::FaxConfig { device: modem.device.clone(), baud_rate: 115200, station_id: config.fax.station_id.clone(), header: config.fax.header.clone(), ..Default::default() } } else { crate::config::FaxConfig::default() }; let worker = crate::worker::FaxWorker::new(fax_config, queue.clone()); let worker_handle = Arc::new(Mutex::new(Some(worker))); // Spawn the worker in the background before moving into state // Take the worker out of the Mutex so we don't hold the lock during the loop { let mut w = worker_handle.lock().await; if let Some(worker) = w.take() { tokio::spawn(async move { if let Err(e) = worker.start().await { tracing::error!("Worker error: {}", e); } }); } } let state = AppState { config: config.clone(), queue, worker: worker_handle, }; let app = create_router(state); let addr = config.server.listen.clone(); info!("Starting API server on {}", addr); let listener = tokio::net::TcpListener::bind(&addr) .await .map_err(|e| crate::error::FaxError::Other(format!("Failed to bind: {}", e)))?; axum::serve(listener, app) .await .map_err(|e| crate::error::FaxError::Other(format!("Server error: {}", e)))?; Ok(()) }