Skip to main content

oxinbox_backend/routes/
tasks.rs

1use axum::extract::State;
2use axum::{Extension, Json, extract::Path, http::StatusCode, middleware};
3use serde::{Deserialize, Serialize};
4use tracing::instrument;
5use uuid::Uuid;
6
7use crate::auth::{AuthState, AuthUser};
8use crate::middleware::require_auth;
9use crate::repository::{self, InMemoryTaskRepository, RepositoryError, TaskRepository};
10use crate::search::SearchResult;
11use oxinbox_core::Task;
12
13pub fn task_routes(state: AuthState) -> axum::Router<AuthState> {
14    axum::Router::new()
15        .route(
16            "/api/tasks",
17            axum::routing::get(list_tasks).post(create_task),
18        )
19        .route(
20            "/api/tasks/:id",
21            axum::routing::get(get_task)
22                .put(update_task)
23                .delete(delete_task),
24        )
25        .route("/api/tasks/search", axum::routing::post(search_tasks))
26        .layer(middleware::from_fn_with_state(state, require_auth))
27}
28
29#[derive(Debug, Deserialize)]
30pub struct CreateTaskRequest {
31    pub description: String,
32    pub priority: Option<char>,
33    pub projects: Vec<String>,
34    pub contexts: Vec<String>,
35    pub due_date: Option<chrono::NaiveDate>,
36    #[serde(default)]
37    pub status: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct SearchRequest {
42    pub query: String,
43    pub limit: Option<usize>,
44}
45
46#[derive(Debug, Serialize)]
47pub struct SearchResponse {
48    pub results: Vec<SearchResult>,
49}
50
51fn task_embedding_text(task: &Task) -> String {
52    let mut parts = vec![task.description.clone()];
53    for p in &task.projects {
54        parts.push(format!("+{p}"));
55    }
56    for c in &task.contexts {
57        parts.push(format!("@{c}"));
58    }
59    parts.join(" ")
60}
61
62async fn generate_and_store_embedding(
63    ai: &dyn crate::ai::AiProvider,
64    task: &Task,
65    db: Option<&crate::database::ParadeDbRepository>,
66) {
67    let text = task_embedding_text(task);
68    match ai.embed(&text).await {
69        Ok(embedding) => {
70            repository::store_embedding(task.id, embedding.clone()).await;
71            if let Some(database) = db {
72                let _ = database.update_embedding(task.id, &embedding).await;
73            }
74            tracing::debug!(task_id = %task.id, "embedding stored");
75        }
76        Err(e) => {
77            tracing::warn!(task_id = %task.id, error = %e, "embedding generation failed");
78        }
79    }
80}
81
82#[instrument(skip(state), fields(description = %req.description))]
83pub async fn create_task(
84    State(state): State<AuthState>,
85    Extension(user): Extension<AuthUser>,
86    Json(req): Json<CreateTaskRequest>,
87) -> Result<Json<Task>, (StatusCode, String)> {
88    let _ = user;
89    let now = chrono::Utc::now();
90    let task = Task {
91        id: Uuid::now_v7(),
92        completed: false,
93        priority: req.priority.filter(char::is_ascii_uppercase),
94        description: req.description,
95        projects: req.projects,
96        contexts: req.contexts,
97        status: oxinbox_core::TaskStatus::Inbox,
98        created_at: now,
99        updated_at: now,
100        completed_at: None,
101        due_date: req.due_date,
102    };
103
104    let created = if let Some(db) = &state.db {
105        db.create(&task, user.user_id).await.map_err(|e| {
106            tracing::error!(error = %e, "db create failed");
107            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
108        })?
109    } else {
110        let repo = InMemoryTaskRepository::shared();
111        repo.create(&task).await.map_err(|e| {
112            tracing::error!(error = %e, "create task failed");
113            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
114        })?
115    };
116
117    if let Some(ai) = &state.ai_provider {
118        generate_and_store_embedding(ai.as_ref(), &task, state.db.as_deref()).await;
119    }
120
121    tracing::info!(task_id = %created.id, "task created");
122    Ok(Json(created))
123}
124
125#[instrument(skip(state))]
126pub async fn list_tasks(
127    State(state): State<AuthState>,
128    Extension(user): Extension<AuthUser>,
129) -> Result<Json<Vec<Task>>, (StatusCode, String)> {
130    let tasks = if let Some(db) = &state.db {
131        db.list(user.user_id).await.map_err(|e| {
132            tracing::error!(error = %e, "db list failed");
133            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
134        })?
135    } else {
136        let repo = InMemoryTaskRepository::shared();
137        repo.list(0).await.map_err(|e| {
138            tracing::error!(error = %e, "list tasks failed");
139            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
140        })?
141    };
142    tracing::info!(count = tasks.len(), "tasks listed");
143    Ok(Json(tasks))
144}
145
146#[instrument(skip(state), fields(task_id = %id))]
147pub async fn get_task(
148    State(state): State<AuthState>,
149    Extension(user): Extension<AuthUser>,
150    Path(id): Path<Uuid>,
151) -> Result<Json<Task>, (StatusCode, String)> {
152    let task = if let Some(db) = &state.db {
153        db.get(id).await.map_err(|e| match e {
154            RepositoryError::NotFound(_) => {
155                tracing::warn!("task not found");
156                (StatusCode::NOT_FOUND, e.to_string())
157            }
158            RepositoryError::Database(_) => {
159                tracing::error!(error = %e, "get task database error");
160                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
161            }
162        })?
163    } else {
164        let repo = InMemoryTaskRepository::shared();
165        repo.get(id).await.map_err(|e| match e {
166            RepositoryError::NotFound(_) => {
167                tracing::warn!("task not found");
168                (StatusCode::NOT_FOUND, e.to_string())
169            }
170            RepositoryError::Database(_) => {
171                tracing::error!(error = %e, "get task database error");
172                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
173            }
174        })?
175    };
176    Ok(Json(task))
177}
178
179#[instrument(skip(state, _user), fields(task_id = %id))]
180pub async fn update_task(
181    State(state): State<AuthState>,
182    Extension(_user): Extension<AuthUser>,
183    Path(id): Path<Uuid>,
184    Json(updates): Json<CreateTaskRequest>,
185) -> Result<Json<Task>, (StatusCode, String)> {
186    let mut task = if let Some(db) = &state.db {
187        db.get(id).await.map_err(|e| match e {
188            RepositoryError::NotFound(_) => {
189                tracing::warn!("task not found for update");
190                (StatusCode::NOT_FOUND, e.to_string())
191            }
192            RepositoryError::Database(_) => {
193                tracing::error!(error = %e, "get task database error");
194                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
195            }
196        })?
197    } else {
198        let repo = InMemoryTaskRepository::shared();
199        repo.get(id).await.map_err(|e| match e {
200            RepositoryError::NotFound(_) => {
201                tracing::warn!("task not found for update");
202                (StatusCode::NOT_FOUND, e.to_string())
203            }
204            RepositoryError::Database(_) => {
205                tracing::error!(error = %e, "get task database error");
206                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
207            }
208        })?
209    };
210
211    task.description = updates.description;
212    task.priority = updates.priority.filter(char::is_ascii_uppercase);
213    task.projects = updates.projects;
214    task.contexts = updates.contexts;
215    task.due_date = updates.due_date;
216
217    if let Some(status_str) = &updates.status {
218        task.status = match status_str.to_lowercase().as_str() {
219            "inbox" => oxinbox_core::TaskStatus::Inbox,
220            "todo" => oxinbox_core::TaskStatus::Todo,
221            "doing" => oxinbox_core::TaskStatus::Doing,
222            "done" => oxinbox_core::TaskStatus::Done,
223            "someday" => oxinbox_core::TaskStatus::Someday,
224            _ => task.status,
225        };
226    }
227
228    let result = if let Some(db) = &state.db {
229        db.update(&task).await.map_err(|e| {
230            tracing::error!(error = %e, "db update failed");
231            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
232        })?
233    } else {
234        let repo = InMemoryTaskRepository::shared();
235        repo.update(&task).await.map_err(|e| {
236            tracing::error!(error = %e, "update task failed");
237            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
238        })?
239    };
240
241    if let Some(ai) = &state.ai_provider {
242        generate_and_store_embedding(ai.as_ref(), &task, state.db.as_deref()).await;
243    }
244
245    tracing::info!("task updated");
246    Ok(Json(result))
247}
248
249#[instrument(skip(state), fields(task_id = %id))]
250pub async fn delete_task(
251    State(state): State<AuthState>,
252    Extension(user): Extension<AuthUser>,
253    Path(id): Path<Uuid>,
254) -> Result<StatusCode, (StatusCode, String)> {
255    if let Some(db) = &state.db {
256        db.delete(id).await.map_err(|e| match e {
257            RepositoryError::NotFound(_) => {
258                tracing::warn!("task not found for deletion");
259                (StatusCode::NOT_FOUND, e.to_string())
260            }
261            RepositoryError::Database(_) => {
262                tracing::error!(error = %e, "delete task database error");
263                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
264            }
265        })?;
266    } else {
267        let repo = InMemoryTaskRepository::shared();
268        repo.delete(id).await.map_err(|e| match e {
269            RepositoryError::NotFound(_) => {
270                tracing::warn!("task not found for deletion");
271                (StatusCode::NOT_FOUND, e.to_string())
272            }
273            RepositoryError::Database(_) => {
274                tracing::error!(error = %e, "delete task database error");
275                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
276            }
277        })?;
278    }
279    tracing::info!("task deleted");
280    Ok(StatusCode::NO_CONTENT)
281}
282
283#[instrument(skip(state), fields(query = %req.query))]
284pub async fn search_tasks(
285    State(state): State<AuthState>,
286    Extension(user): Extension<AuthUser>,
287    Json(req): Json<SearchRequest>,
288) -> Result<Json<SearchResponse>, (StatusCode, String)> {
289    let limit = req.limit.unwrap_or(20).min(100);
290
291    let query_embedding = if let Some(ai) = &state.ai_provider {
292        match ai.embed(&req.query).await {
293            Ok(emb) => Some(emb),
294            Err(e) => {
295                tracing::warn!(error = %e, "query embedding failed, falling back to BM25-only");
296                None
297            }
298        }
299    } else {
300        None
301    };
302
303    let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
304    let (_tasks, results) = if let Some(db) = &state.db {
305        let bm25_tasks = db
306            .search_bm25(user.user_id, &req.query, limit_i64)
307            .await
308            .map_err(|e| {
309                tracing::error!(error = %e, "db search failed");
310                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
311            })?;
312
313        let vector_results = if let Some(emb) = query_embedding {
314            db.search_vector(user.user_id, &emb, limit_i64)
315                .await
316                .unwrap_or_default()
317        } else {
318            Vec::new()
319        };
320
321        let mut seen = std::collections::HashSet::new();
322        let mut results = Vec::new();
323
324        for task in bm25_tasks {
325            if seen.insert(task.id) {
326                results.push(crate::search::SearchResult { task, score: 1.0 });
327            }
328        }
329        for (task, score) in vector_results {
330            if seen.insert(task.id) {
331                results.push(crate::search::SearchResult { task, score });
332            }
333        }
334
335        results.truncate(limit);
336        results.sort_by(|a, b| {
337            b.score
338                .partial_cmp(&a.score)
339                .unwrap_or(std::cmp::Ordering::Equal)
340        });
341
342        (Vec::new(), results)
343    } else {
344        let repo = InMemoryTaskRepository::shared();
345        let tasks = repo.list(0).await.map_err(|e| {
346            tracing::error!(error = %e, "list tasks for search failed");
347            (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
348        })?;
349        let results =
350            repository::hybrid_search_tasks(&tasks, &req.query, query_embedding.as_deref(), limit)
351                .await;
352        (tasks, results)
353    };
354
355    tracing::info!(result_count = results.len(), "search completed");
356    Ok(Json(SearchResponse { results }))
357}