oxinbox-backend 0.1.6

HTTP/WS API server for the oxinbox GTD productivity system
Documentation
use axum::extract::State;
use axum::{Extension, Json, extract::Path, http::StatusCode, middleware};
use serde::{Deserialize, Serialize};
use tracing::instrument;
use uuid::Uuid;

use crate::auth::{AuthState, AuthUser};
use crate::middleware::require_auth;
use crate::repository::{self, InMemoryTaskRepository, RepositoryError, TaskRepository};
use crate::search::SearchResult;
use oxinbox_core::Task;

pub fn task_routes(state: AuthState) -> axum::Router<AuthState> {
    axum::Router::new()
        .route(
            "/api/tasks",
            axum::routing::get(list_tasks).post(create_task),
        )
        .route(
            "/api/tasks/:id",
            axum::routing::get(get_task)
                .put(update_task)
                .delete(delete_task),
        )
        .route("/api/tasks/search", axum::routing::post(search_tasks))
        .layer(middleware::from_fn_with_state(state, require_auth))
}

#[derive(Debug, Deserialize)]
pub struct CreateTaskRequest {
    pub description: String,
    pub priority: Option<char>,
    pub projects: Vec<String>,
    pub contexts: Vec<String>,
    pub due_date: Option<chrono::NaiveDate>,
    #[serde(default)]
    pub status: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct SearchRequest {
    pub query: String,
    pub limit: Option<usize>,
}

#[derive(Debug, Serialize)]
pub struct SearchResponse {
    pub results: Vec<SearchResult>,
}

fn task_embedding_text(task: &Task) -> String {
    let mut parts = vec![task.description.clone()];
    for p in &task.projects {
        parts.push(format!("+{p}"));
    }
    for c in &task.contexts {
        parts.push(format!("@{c}"));
    }
    parts.join(" ")
}

async fn generate_and_store_embedding(
    ai: &dyn crate::ai::AiProvider,
    task: &Task,
    db: Option<&crate::database::ParadeDbRepository>,
) {
    let text = task_embedding_text(task);
    match ai.embed(&text).await {
        Ok(embedding) => {
            repository::store_embedding(task.id, embedding.clone()).await;
            if let Some(database) = db {
                let _ = database.update_embedding(task.id, &embedding).await;
            }
            tracing::debug!(task_id = %task.id, "embedding stored");
        }
        Err(e) => {
            tracing::warn!(task_id = %task.id, error = %e, "embedding generation failed");
        }
    }
}

#[instrument(skip(state), fields(description = %req.description))]
pub async fn create_task(
    State(state): State<AuthState>,
    Extension(user): Extension<AuthUser>,
    Json(req): Json<CreateTaskRequest>,
) -> Result<Json<Task>, (StatusCode, String)> {
    let _ = user;
    let now = chrono::Utc::now();
    let task = Task {
        id: Uuid::now_v7(),
        completed: false,
        priority: req.priority.filter(char::is_ascii_uppercase),
        description: req.description,
        projects: req.projects,
        contexts: req.contexts,
        status: oxinbox_core::TaskStatus::Inbox,
        created_at: now,
        updated_at: now,
        completed_at: None,
        due_date: req.due_date,
    };

    let created = if let Some(db) = &state.db {
        db.create(&task, user.user_id).await.map_err(|e| {
            tracing::error!(error = %e, "db create failed");
            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
        })?
    } else {
        let repo = InMemoryTaskRepository::shared();
        repo.create(&task).await.map_err(|e| {
            tracing::error!(error = %e, "create task failed");
            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
        })?
    };

    if let Some(ai) = &state.ai_provider {
        generate_and_store_embedding(ai.as_ref(), &task, state.db.as_deref()).await;
    }

    tracing::info!(task_id = %created.id, "task created");
    Ok(Json(created))
}

#[instrument(skip(state))]
pub async fn list_tasks(
    State(state): State<AuthState>,
    Extension(user): Extension<AuthUser>,
) -> Result<Json<Vec<Task>>, (StatusCode, String)> {
    let tasks = if let Some(db) = &state.db {
        db.list(user.user_id).await.map_err(|e| {
            tracing::error!(error = %e, "db list failed");
            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
        })?
    } else {
        let repo = InMemoryTaskRepository::shared();
        repo.list(0).await.map_err(|e| {
            tracing::error!(error = %e, "list tasks failed");
            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
        })?
    };
    tracing::info!(count = tasks.len(), "tasks listed");
    Ok(Json(tasks))
}

#[instrument(skip(state), fields(task_id = %id))]
pub async fn get_task(
    State(state): State<AuthState>,
    Extension(user): Extension<AuthUser>,
    Path(id): Path<Uuid>,
) -> Result<Json<Task>, (StatusCode, String)> {
    let task = if let Some(db) = &state.db {
        db.get(id).await.map_err(|e| match e {
            RepositoryError::NotFound(_) => {
                tracing::warn!("task not found");
                (StatusCode::NOT_FOUND, e.to_string())
            }
            RepositoryError::Database(_) => {
                tracing::error!(error = %e, "get task database error");
                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
            }
        })?
    } else {
        let repo = InMemoryTaskRepository::shared();
        repo.get(id).await.map_err(|e| match e {
            RepositoryError::NotFound(_) => {
                tracing::warn!("task not found");
                (StatusCode::NOT_FOUND, e.to_string())
            }
            RepositoryError::Database(_) => {
                tracing::error!(error = %e, "get task database error");
                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
            }
        })?
    };
    Ok(Json(task))
}

#[instrument(skip(state, _user), fields(task_id = %id))]
pub async fn update_task(
    State(state): State<AuthState>,
    Extension(_user): Extension<AuthUser>,
    Path(id): Path<Uuid>,
    Json(updates): Json<CreateTaskRequest>,
) -> Result<Json<Task>, (StatusCode, String)> {
    let mut task = if let Some(db) = &state.db {
        db.get(id).await.map_err(|e| match e {
            RepositoryError::NotFound(_) => {
                tracing::warn!("task not found for update");
                (StatusCode::NOT_FOUND, e.to_string())
            }
            RepositoryError::Database(_) => {
                tracing::error!(error = %e, "get task database error");
                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
            }
        })?
    } else {
        let repo = InMemoryTaskRepository::shared();
        repo.get(id).await.map_err(|e| match e {
            RepositoryError::NotFound(_) => {
                tracing::warn!("task not found for update");
                (StatusCode::NOT_FOUND, e.to_string())
            }
            RepositoryError::Database(_) => {
                tracing::error!(error = %e, "get task database error");
                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
            }
        })?
    };

    task.description = updates.description;
    task.priority = updates.priority.filter(char::is_ascii_uppercase);
    task.projects = updates.projects;
    task.contexts = updates.contexts;
    task.due_date = updates.due_date;

    if let Some(status_str) = &updates.status {
        task.status = match status_str.to_lowercase().as_str() {
            "inbox" => oxinbox_core::TaskStatus::Inbox,
            "todo" => oxinbox_core::TaskStatus::Todo,
            "doing" => oxinbox_core::TaskStatus::Doing,
            "done" => oxinbox_core::TaskStatus::Done,
            "someday" => oxinbox_core::TaskStatus::Someday,
            _ => task.status,
        };
    }

    let result = if let Some(db) = &state.db {
        db.update(&task).await.map_err(|e| {
            tracing::error!(error = %e, "db update failed");
            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
        })?
    } else {
        let repo = InMemoryTaskRepository::shared();
        repo.update(&task).await.map_err(|e| {
            tracing::error!(error = %e, "update task failed");
            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
        })?
    };

    if let Some(ai) = &state.ai_provider {
        generate_and_store_embedding(ai.as_ref(), &task, state.db.as_deref()).await;
    }

    tracing::info!("task updated");
    Ok(Json(result))
}

#[instrument(skip(state), fields(task_id = %id))]
pub async fn delete_task(
    State(state): State<AuthState>,
    Extension(user): Extension<AuthUser>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
    if let Some(db) = &state.db {
        db.delete(id).await.map_err(|e| match e {
            RepositoryError::NotFound(_) => {
                tracing::warn!("task not found for deletion");
                (StatusCode::NOT_FOUND, e.to_string())
            }
            RepositoryError::Database(_) => {
                tracing::error!(error = %e, "delete task database error");
                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
            }
        })?;
    } else {
        let repo = InMemoryTaskRepository::shared();
        repo.delete(id).await.map_err(|e| match e {
            RepositoryError::NotFound(_) => {
                tracing::warn!("task not found for deletion");
                (StatusCode::NOT_FOUND, e.to_string())
            }
            RepositoryError::Database(_) => {
                tracing::error!(error = %e, "delete task database error");
                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
            }
        })?;
    }
    tracing::info!("task deleted");
    Ok(StatusCode::NO_CONTENT)
}

#[instrument(skip(state), fields(query = %req.query))]
pub async fn search_tasks(
    State(state): State<AuthState>,
    Extension(user): Extension<AuthUser>,
    Json(req): Json<SearchRequest>,
) -> Result<Json<SearchResponse>, (StatusCode, String)> {
    let limit = req.limit.unwrap_or(20).min(100);

    let query_embedding = if let Some(ai) = &state.ai_provider {
        match ai.embed(&req.query).await {
            Ok(emb) => Some(emb),
            Err(e) => {
                tracing::warn!(error = %e, "query embedding failed, falling back to BM25-only");
                None
            }
        }
    } else {
        None
    };

    let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
    let (_tasks, results) = if let Some(db) = &state.db {
        let bm25_tasks = db
            .search_bm25(user.user_id, &req.query, limit_i64)
            .await
            .map_err(|e| {
                tracing::error!(error = %e, "db search failed");
                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
            })?;

        let vector_results = if let Some(emb) = query_embedding {
            db.search_vector(user.user_id, &emb, limit_i64)
                .await
                .unwrap_or_default()
        } else {
            Vec::new()
        };

        let mut seen = std::collections::HashSet::new();
        let mut results = Vec::new();

        for task in bm25_tasks {
            if seen.insert(task.id) {
                results.push(crate::search::SearchResult { task, score: 1.0 });
            }
        }
        for (task, score) in vector_results {
            if seen.insert(task.id) {
                results.push(crate::search::SearchResult { task, score });
            }
        }

        results.truncate(limit);
        results.sort_by(|a, b| {
            b.score
                .partial_cmp(&a.score)
                .unwrap_or(std::cmp::Ordering::Equal)
        });

        (Vec::new(), results)
    } else {
        let repo = InMemoryTaskRepository::shared();
        let tasks = repo.list(0).await.map_err(|e| {
            tracing::error!(error = %e, "list tasks for search failed");
            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
        })?;
        let results =
            repository::hybrid_search_tasks(&tasks, &req.query, query_embedding.as_deref(), limit)
                .await;
        (tasks, results)
    };

    tracing::info!(result_count = results.len(), "search completed");
    Ok(Json(SearchResponse { results }))
}