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}