1use axum::{
2 extract::{Path, Query, State},
3 http::StatusCode,
4 response::{IntoResponse, Json},
5};
6use serde_json::json;
7
8use super::models::*;
9use super::server::AppState;
10use crate::{
11 db::models::TaskSortBy, events::EventManager, search::SearchManager, tasks::TaskManager,
12 workspace::WorkspaceManager,
13};
14
15pub async fn list_tasks(
17 State(state): State<AppState>,
18 Query(query): Query<TaskListQuery>,
19) -> impl IntoResponse {
20 let db_pool = match state.get_active_db_pool().await {
21 Ok(pool) => pool,
22 Err(e) => {
23 return (
24 StatusCode::INTERNAL_SERVER_ERROR,
25 Json(ApiError {
26 code: "DATABASE_ERROR".to_string(),
27 message: e,
28 details: None,
29 }),
30 )
31 .into_response()
32 },
33 };
34 let task_mgr = TaskManager::new(&db_pool);
35
36 let parent_filter = query.parent.as_deref().map(|p| {
38 if p == "null" {
39 None
40 } else {
41 p.parse::<i64>().ok()
42 }
43 });
44
45 let sort_by = match query.sort_by.as_deref() {
47 Some("id") => Some(TaskSortBy::Id),
48 Some("priority") => Some(TaskSortBy::Priority),
49 Some("time") => Some(TaskSortBy::Time),
50 Some("focus") => Some(TaskSortBy::FocusAware),
51 _ => Some(TaskSortBy::FocusAware), };
53
54 match task_mgr
55 .find_tasks(
56 query.status.as_deref(),
57 parent_filter,
58 sort_by,
59 query.limit,
60 query.offset,
61 )
62 .await
63 {
64 Ok(result) => (StatusCode::OK, Json(ApiResponse { data: result })).into_response(),
65 Err(e) => {
66 tracing::error!(error = %e, "Failed to fetch tasks");
67 (
68 StatusCode::INTERNAL_SERVER_ERROR,
69 Json(ApiError {
70 code: "DATABASE_ERROR".to_string(),
71 message: format!("Failed to list tasks: {}", e),
72 details: None,
73 }),
74 )
75 .into_response()
76 },
77 }
78}
79
80pub async fn get_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
82 let db_pool = match state.get_active_db_pool().await {
83 Ok(pool) => pool,
84 Err(e) => {
85 return (
86 StatusCode::INTERNAL_SERVER_ERROR,
87 Json(ApiError {
88 code: "DATABASE_ERROR".to_string(),
89 message: e,
90 details: None,
91 }),
92 )
93 .into_response()
94 },
95 };
96 let task_mgr = TaskManager::new(&db_pool);
97
98 match task_mgr.get_task(id).await {
99 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
100 Err(e) if e.to_string().contains("not found") => (
101 StatusCode::NOT_FOUND,
102 Json(ApiError {
103 code: "TASK_NOT_FOUND".to_string(),
104 message: format!("Task {} not found", id),
105 details: None,
106 }),
107 )
108 .into_response(),
109 Err(e) => (
110 StatusCode::INTERNAL_SERVER_ERROR,
111 Json(ApiError {
112 code: "DATABASE_ERROR".to_string(),
113 message: format!("Failed to get task: {}", e),
114 details: None,
115 }),
116 )
117 .into_response(),
118 }
119}
120
121pub async fn create_task(
123 State(state): State<AppState>,
124 Json(req): Json<CreateTaskRequest>,
125) -> impl IntoResponse {
126 let db_pool = match state.get_active_db_pool().await {
127 Ok(pool) => pool,
128 Err(e) => {
129 return (
130 StatusCode::INTERNAL_SERVER_ERROR,
131 Json(ApiError {
132 code: "DATABASE_ERROR".to_string(),
133 message: e,
134 details: None,
135 }),
136 )
137 .into_response()
138 },
139 };
140 let project_path = state
141 .get_active_project()
142 .await
143 .map(|p| p.path.to_string_lossy().to_string())
144 .unwrap_or_default();
145
146 let task_mgr = TaskManager::with_websocket(
147 &db_pool,
148 std::sync::Arc::new(state.ws_state.clone()),
149 project_path,
150 );
151
152 let result = task_mgr
156 .add_task(&req.name, req.spec.as_deref(), req.parent_id, None)
157 .await;
158
159 match result {
160 Ok(mut task) => {
161 if let Some(priority) = req.priority {
163 if let Ok(updated_task) = task_mgr
164 .update_task(task.id, None, None, None, None, None, Some(priority))
165 .await
166 {
167 task = updated_task;
168 }
169 }
171 (StatusCode::CREATED, Json(ApiResponse { data: task })).into_response()
172 },
173 Err(e) => (
174 StatusCode::BAD_REQUEST,
175 Json(ApiError {
176 code: "INVALID_REQUEST".to_string(),
177 message: format!("Failed to create task: {}", e),
178 details: None,
179 }),
180 )
181 .into_response(),
182 }
183}
184
185pub async fn update_task(
187 State(state): State<AppState>,
188 Path(id): Path<i64>,
189 Json(req): Json<UpdateTaskRequest>,
190) -> impl IntoResponse {
191 let db_pool = match state.get_active_db_pool().await {
192 Ok(pool) => pool,
193 Err(e) => {
194 return (
195 StatusCode::INTERNAL_SERVER_ERROR,
196 Json(ApiError {
197 code: "DATABASE_ERROR".to_string(),
198 message: e,
199 details: None,
200 }),
201 )
202 .into_response()
203 },
204 };
205 let project_path = state
206 .get_active_project()
207 .await
208 .map(|p| p.path.to_string_lossy().to_string())
209 .unwrap_or_default();
210
211 let task_mgr = TaskManager::with_websocket(
212 &db_pool,
213 std::sync::Arc::new(state.ws_state.clone()),
214 project_path,
215 );
216
217 match task_mgr.get_task(id).await {
219 Err(e) if e.to_string().contains("not found") => {
220 return (
221 StatusCode::NOT_FOUND,
222 Json(ApiError {
223 code: "TASK_NOT_FOUND".to_string(),
224 message: format!("Task {} not found", id),
225 details: None,
226 }),
227 )
228 .into_response()
229 },
230 Err(e) => {
231 return (
232 StatusCode::INTERNAL_SERVER_ERROR,
233 Json(ApiError {
234 code: "DATABASE_ERROR".to_string(),
235 message: format!("Database error: {}", e),
236 details: None,
237 }),
238 )
239 .into_response()
240 },
241 Ok(_) => {},
242 }
243
244 match task_mgr
247 .update_task(
248 id,
249 req.name.as_deref(),
250 req.spec.as_deref(),
251 None, req.status.as_deref(),
253 None, req.priority,
255 )
256 .await
257 {
258 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
259 Err(e) => (
260 StatusCode::BAD_REQUEST,
261 Json(ApiError {
262 code: "INVALID_REQUEST".to_string(),
263 message: format!("Failed to update task: {}", e),
264 details: None,
265 }),
266 )
267 .into_response(),
268 }
269}
270
271pub async fn delete_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
273 let db_pool = match state.get_active_db_pool().await {
274 Ok(pool) => pool,
275 Err(e) => {
276 return (
277 StatusCode::INTERNAL_SERVER_ERROR,
278 Json(ApiError {
279 code: "DATABASE_ERROR".to_string(),
280 message: e,
281 details: None,
282 }),
283 )
284 .into_response()
285 },
286 };
287 let project_path = state
288 .get_active_project()
289 .await
290 .map(|p| p.path.to_string_lossy().to_string())
291 .unwrap_or_default();
292
293 let task_mgr = TaskManager::with_websocket(
294 &db_pool,
295 std::sync::Arc::new(state.ws_state.clone()),
296 project_path,
297 );
298
299 match task_mgr.delete_task(id).await {
300 Ok(_) => (StatusCode::NO_CONTENT).into_response(),
301 Err(e) if e.to_string().contains("not found") => (
302 StatusCode::NOT_FOUND,
303 Json(ApiError {
304 code: "TASK_NOT_FOUND".to_string(),
305 message: format!("Task {} not found", id),
306 details: None,
307 }),
308 )
309 .into_response(),
310 Err(e) => (
311 StatusCode::BAD_REQUEST,
312 Json(ApiError {
313 code: "INVALID_REQUEST".to_string(),
314 message: format!("Failed to delete task: {}", e),
315 details: None,
316 }),
317 )
318 .into_response(),
319 }
320}
321
322pub async fn start_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
324 let db_pool = match state.get_active_db_pool().await {
325 Ok(pool) => pool,
326 Err(e) => {
327 return (
328 StatusCode::INTERNAL_SERVER_ERROR,
329 Json(ApiError {
330 code: "DATABASE_ERROR".to_string(),
331 message: e,
332 details: None,
333 }),
334 )
335 .into_response()
336 },
337 };
338 let project_path = state
339 .get_active_project()
340 .await
341 .map(|p| p.path.to_string_lossy().to_string())
342 .unwrap_or_default();
343
344 let task_mgr = TaskManager::with_websocket(
345 &db_pool,
346 std::sync::Arc::new(state.ws_state.clone()),
347 project_path,
348 );
349
350 match task_mgr.start_task(id, false).await {
351 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
352 Err(e) if e.to_string().contains("not found") => (
353 StatusCode::NOT_FOUND,
354 Json(ApiError {
355 code: "TASK_NOT_FOUND".to_string(),
356 message: format!("Task {} not found", id),
357 details: None,
358 }),
359 )
360 .into_response(),
361 Err(e) => (
362 StatusCode::BAD_REQUEST,
363 Json(ApiError {
364 code: "INVALID_REQUEST".to_string(),
365 message: format!("Failed to start task: {}", e),
366 details: None,
367 }),
368 )
369 .into_response(),
370 }
371}
372
373pub async fn done_task(State(state): State<AppState>) -> impl IntoResponse {
375 let db_pool = match state.get_active_db_pool().await {
376 Ok(pool) => pool,
377 Err(e) => {
378 return (
379 StatusCode::INTERNAL_SERVER_ERROR,
380 Json(ApiError {
381 code: "DATABASE_ERROR".to_string(),
382 message: e,
383 details: None,
384 }),
385 )
386 .into_response()
387 },
388 };
389 let project_path = state
390 .get_active_project()
391 .await
392 .map(|p| p.path.to_string_lossy().to_string())
393 .unwrap_or_default();
394
395 let task_mgr = TaskManager::with_websocket(
396 &db_pool,
397 std::sync::Arc::new(state.ws_state.clone()),
398 project_path,
399 );
400
401 match task_mgr.done_task(false).await {
403 Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
404 Err(e) if e.to_string().contains("No current task") => (
405 StatusCode::BAD_REQUEST,
406 Json(ApiError {
407 code: "NO_CURRENT_TASK".to_string(),
408 message: "No current task to complete".to_string(),
409 details: None,
410 }),
411 )
412 .into_response(),
413 Err(e) => (
414 StatusCode::BAD_REQUEST,
415 Json(ApiError {
416 code: "INVALID_REQUEST".to_string(),
417 message: format!("Failed to complete task: {}", e),
418 details: None,
419 }),
420 )
421 .into_response(),
422 }
423}
424
425pub async fn spawn_subtask(
428 State(state): State<AppState>,
429 Path(_parent_id): Path<i64>, Json(req): Json<SpawnSubtaskRequest>,
431) -> impl IntoResponse {
432 let (db_pool, project_path) = match state.get_active_project_context().await {
433 Ok(ctx) => ctx,
434 Err(e) => {
435 return (
436 StatusCode::INTERNAL_SERVER_ERROR,
437 Json(ApiError {
438 code: "DATABASE_ERROR".to_string(),
439 message: e,
440 details: None,
441 }),
442 )
443 .into_response()
444 },
445 };
446
447 let task_mgr = TaskManager::with_websocket(
448 &db_pool,
449 std::sync::Arc::new(state.ws_state.clone()),
450 project_path,
451 );
452
453 match task_mgr.spawn_subtask(&req.name, req.spec.as_deref()).await {
455 Ok(response) => (StatusCode::CREATED, Json(ApiResponse { data: response })).into_response(),
456 Err(e) if e.to_string().contains("No current task") => (
457 StatusCode::BAD_REQUEST,
458 Json(ApiError {
459 code: "NO_CURRENT_TASK".to_string(),
460 message: "No current task to spawn subtask from".to_string(),
461 details: None,
462 }),
463 )
464 .into_response(),
465 Err(e) => (
466 StatusCode::BAD_REQUEST,
467 Json(ApiError {
468 code: "INVALID_REQUEST".to_string(),
469 message: format!("Failed to spawn subtask: {}", e),
470 details: None,
471 }),
472 )
473 .into_response(),
474 }
475}
476
477pub async fn get_current_task(State(state): State<AppState>) -> impl IntoResponse {
479 let db_pool = match state.get_active_db_pool().await {
480 Ok(pool) => pool,
481 Err(e) => {
482 return (
483 StatusCode::INTERNAL_SERVER_ERROR,
484 Json(ApiError {
485 code: "DATABASE_ERROR".to_string(),
486 message: e,
487 details: None,
488 }),
489 )
490 .into_response()
491 },
492 };
493 let workspace_mgr = WorkspaceManager::new(&db_pool);
494
495 match workspace_mgr.get_current_task(None).await {
496 Ok(response) => {
497 if response.task.is_some() {
498 (StatusCode::OK, Json(ApiResponse { data: response })).into_response()
499 } else {
500 (
501 StatusCode::OK,
502 Json(json!({
503 "data": null,
504 "message": "No current task"
505 })),
506 )
507 .into_response()
508 }
509 },
510 Err(e) => (
511 StatusCode::INTERNAL_SERVER_ERROR,
512 Json(ApiError {
513 code: "DATABASE_ERROR".to_string(),
514 message: format!("Failed to get current task: {}", e),
515 details: None,
516 }),
517 )
518 .into_response(),
519 }
520}
521
522pub async fn pick_next_task(State(state): State<AppState>) -> impl IntoResponse {
524 let db_pool = match state.get_active_db_pool().await {
525 Ok(pool) => pool,
526 Err(e) => {
527 return (
528 StatusCode::INTERNAL_SERVER_ERROR,
529 Json(ApiError {
530 code: "DATABASE_ERROR".to_string(),
531 message: e,
532 details: None,
533 }),
534 )
535 .into_response()
536 },
537 };
538 let task_mgr = TaskManager::new(&db_pool);
539
540 match task_mgr.pick_next().await {
541 Ok(response) => (StatusCode::OK, Json(ApiResponse { data: response })).into_response(),
542 Err(e) => (
543 StatusCode::INTERNAL_SERVER_ERROR,
544 Json(ApiError {
545 code: "DATABASE_ERROR".to_string(),
546 message: format!("Failed to pick next task: {}", e),
547 details: None,
548 }),
549 )
550 .into_response(),
551 }
552}
553
554pub async fn list_events(
556 State(state): State<AppState>,
557 Path(task_id): Path<i64>,
558 Query(query): Query<EventListQuery>,
559) -> impl IntoResponse {
560 let db_pool = match state.get_active_db_pool().await {
561 Ok(pool) => pool,
562 Err(e) => {
563 return (
564 StatusCode::INTERNAL_SERVER_ERROR,
565 Json(ApiError {
566 code: "DATABASE_ERROR".to_string(),
567 message: e,
568 details: None,
569 }),
570 )
571 .into_response()
572 },
573 };
574 let event_mgr = EventManager::new(&db_pool);
575
576 match event_mgr
578 .list_events(
579 Some(task_id),
580 query.limit.map(|l| l as i64),
581 query.event_type,
582 query.since,
583 )
584 .await
585 {
586 Ok(events) => (StatusCode::OK, Json(ApiResponse { data: events })).into_response(),
587 Err(e) => (
588 StatusCode::INTERNAL_SERVER_ERROR,
589 Json(ApiError {
590 code: "DATABASE_ERROR".to_string(),
591 message: format!("Failed to list events: {}", e),
592 details: None,
593 }),
594 )
595 .into_response(),
596 }
597}
598
599pub async fn create_event(
601 State(state): State<AppState>,
602 Path(task_id): Path<i64>,
603 Json(req): Json<CreateEventRequest>,
604) -> impl IntoResponse {
605 let (db_pool, project_path) = match state.get_active_project_context().await {
606 Ok(ctx) => ctx,
607 Err(e) => {
608 return (
609 StatusCode::INTERNAL_SERVER_ERROR,
610 Json(ApiError {
611 code: "DATABASE_ERROR".to_string(),
612 message: e,
613 details: None,
614 }),
615 )
616 .into_response()
617 },
618 };
619
620 let event_mgr = EventManager::with_websocket(
621 &db_pool,
622 std::sync::Arc::new(state.ws_state.clone()),
623 project_path,
624 );
625
626 if !["decision", "blocker", "milestone", "note"].contains(&req.event_type.as_str()) {
628 return (
629 StatusCode::BAD_REQUEST,
630 Json(ApiError {
631 code: "INVALID_REQUEST".to_string(),
632 message: format!("Invalid event type: {}", req.event_type),
633 details: None,
634 }),
635 )
636 .into_response();
637 }
638
639 match event_mgr
641 .add_event(task_id, &req.event_type, &req.data)
642 .await
643 {
644 Ok(event) => (StatusCode::CREATED, Json(ApiResponse { data: event })).into_response(),
645 Err(e) => (
646 StatusCode::BAD_REQUEST,
647 Json(ApiError {
648 code: "INVALID_REQUEST".to_string(),
649 message: format!("Failed to create event: {}", e),
650 details: None,
651 }),
652 )
653 .into_response(),
654 }
655}
656
657pub async fn update_event(
659 State(state): State<AppState>,
660 Path((task_id, event_id)): Path<(i64, i64)>,
661 Json(req): Json<UpdateEventRequest>,
662) -> impl IntoResponse {
663 let (db_pool, project_path) = match state.get_active_project_context().await {
664 Ok(ctx) => ctx,
665 Err(e) => {
666 return (
667 StatusCode::INTERNAL_SERVER_ERROR,
668 Json(ApiError {
669 code: "DATABASE_ERROR".to_string(),
670 message: e,
671 details: None,
672 }),
673 )
674 .into_response()
675 },
676 };
677
678 let event_mgr = EventManager::with_websocket(
679 &db_pool,
680 std::sync::Arc::new(state.ws_state.clone()),
681 project_path,
682 );
683
684 if let Some(ref event_type) = req.event_type {
686 if !["decision", "blocker", "milestone", "note"].contains(&event_type.as_str()) {
687 return (
688 StatusCode::BAD_REQUEST,
689 Json(ApiError {
690 code: "INVALID_REQUEST".to_string(),
691 message: format!("Invalid event type: {}", event_type),
692 details: None,
693 }),
694 )
695 .into_response();
696 }
697 }
698
699 match event_mgr
700 .update_event(event_id, req.event_type.as_deref(), req.data.as_deref())
701 .await
702 {
703 Ok(event) => {
704 if event.task_id != task_id {
706 return (
707 StatusCode::BAD_REQUEST,
708 Json(ApiError {
709 code: "INVALID_REQUEST".to_string(),
710 message: format!("Event {} does not belong to task {}", event_id, task_id),
711 details: None,
712 }),
713 )
714 .into_response();
715 }
716 (StatusCode::OK, Json(ApiResponse { data: event })).into_response()
717 },
718 Err(e) => (
719 StatusCode::BAD_REQUEST,
720 Json(ApiError {
721 code: "INVALID_REQUEST".to_string(),
722 message: format!("Failed to update event: {}", e),
723 details: None,
724 }),
725 )
726 .into_response(),
727 }
728}
729
730pub async fn delete_event(
732 State(state): State<AppState>,
733 Path((task_id, event_id)): Path<(i64, i64)>,
734) -> impl IntoResponse {
735 let (db_pool, project_path) = match state.get_active_project_context().await {
736 Ok(ctx) => ctx,
737 Err(e) => {
738 return (
739 StatusCode::INTERNAL_SERVER_ERROR,
740 Json(ApiError {
741 code: "DATABASE_ERROR".to_string(),
742 message: e,
743 details: None,
744 }),
745 )
746 .into_response()
747 },
748 };
749
750 let event_mgr = EventManager::with_websocket(
751 &db_pool,
752 std::sync::Arc::new(state.ws_state.clone()),
753 project_path,
754 );
755
756 match sqlx::query_as::<_, crate::db::models::Event>(crate::sql_constants::SELECT_EVENT_BY_ID)
758 .bind(event_id)
759 .fetch_optional(&db_pool)
760 .await
761 {
762 Ok(Some(event)) => {
763 if event.task_id != task_id {
764 return (
765 StatusCode::BAD_REQUEST,
766 Json(ApiError {
767 code: "INVALID_REQUEST".to_string(),
768 message: format!("Event {} does not belong to task {}", event_id, task_id),
769 details: None,
770 }),
771 )
772 .into_response();
773 }
774 },
775 Ok(None) => {
776 return (
777 StatusCode::NOT_FOUND,
778 Json(ApiError {
779 code: "EVENT_NOT_FOUND".to_string(),
780 message: format!("Event {} not found", event_id),
781 details: None,
782 }),
783 )
784 .into_response();
785 },
786 Err(e) => {
787 return (
788 StatusCode::INTERNAL_SERVER_ERROR,
789 Json(ApiError {
790 code: "DATABASE_ERROR".to_string(),
791 message: format!("Database error: {}", e),
792 details: None,
793 }),
794 )
795 .into_response();
796 },
797 }
798
799 match event_mgr.delete_event(event_id).await {
800 Ok(_) => (StatusCode::NO_CONTENT).into_response(),
801 Err(e) => (
802 StatusCode::BAD_REQUEST,
803 Json(ApiError {
804 code: "INVALID_REQUEST".to_string(),
805 message: format!("Failed to delete event: {}", e),
806 details: None,
807 }),
808 )
809 .into_response(),
810 }
811}
812
813pub async fn search(
815 State(state): State<AppState>,
816 Query(query): Query<SearchQuery>,
817) -> impl IntoResponse {
818 let db_pool = match state.get_active_db_pool().await {
819 Ok(pool) => pool,
820 Err(e) => {
821 return (
822 StatusCode::INTERNAL_SERVER_ERROR,
823 Json(ApiError {
824 code: "DATABASE_ERROR".to_string(),
825 message: e,
826 details: None,
827 }),
828 )
829 .into_response()
830 },
831 };
832 let search_mgr = SearchManager::new(&db_pool);
833
834 match search_mgr
835 .search(
836 &query.query,
837 query.include_tasks,
838 query.include_events,
839 query.limit,
840 query.offset,
841 false,
842 )
843 .await
844 {
845 Ok(results) => (StatusCode::OK, Json(ApiResponse { data: results })).into_response(),
846 Err(e) => (
847 StatusCode::INTERNAL_SERVER_ERROR,
848 Json(ApiError {
849 code: "DATABASE_ERROR".to_string(),
850 message: format!("Search failed: {}", e),
851 details: None,
852 }),
853 )
854 .into_response(),
855 }
856}
857
858pub async fn list_projects(State(state): State<AppState>) -> impl IntoResponse {
860 let host_path = state.host_project.path.clone();
861
862 let known_projects = state.known_projects.read().await;
864
865 let projects: Vec<serde_json::Value> = known_projects
866 .values()
867 .map(|proj| {
868 let is_host = proj.path.to_string_lossy() == host_path;
869 json!({
870 "name": proj.name,
871 "path": proj.path.to_string_lossy(),
872 "is_online": is_host, "mcp_connected": false, })
875 })
876 .collect();
877
878 (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
879}
880
881pub async fn switch_project(
883 State(state): State<AppState>,
884 Json(req): Json<SwitchProjectRequest>,
885) -> impl IntoResponse {
886 use std::path::PathBuf;
887
888 let project_path = PathBuf::from(&req.project_path);
890
891 if let Err(e) = state.add_project(project_path.clone()).await {
893 return (
894 StatusCode::NOT_FOUND,
895 Json(ApiError {
896 code: "PROJECT_NOT_FOUND".to_string(),
897 message: e,
898 details: None,
899 }),
900 )
901 .into_response();
902 }
903
904 if let Err(e) = state.switch_active_project(project_path.clone()).await {
906 return (
907 StatusCode::INTERNAL_SERVER_ERROR,
908 Json(ApiError {
909 code: "SWITCH_ERROR".to_string(),
910 message: e,
911 details: None,
912 }),
913 )
914 .into_response();
915 }
916
917 let project_name = project_path
919 .file_name()
920 .and_then(|n| n.to_str())
921 .unwrap_or("unknown")
922 .to_string();
923 let db_path = project_path.join(".intent-engine").join("project.db");
924
925 tracing::info!(
926 "Switched to project: {} at {}",
927 project_name,
928 project_path.display()
929 );
930
931 (
932 StatusCode::OK,
933 Json(ApiResponse {
934 data: json!({
935 "success": true,
936 "project_name": project_name,
937 "project_path": project_path.display().to_string(),
938 "database": db_path.display().to_string(),
939 }),
940 }),
941 )
942 .into_response()
943}
944
945pub async fn remove_project(
948 State(state): State<AppState>,
949 Json(req): Json<SwitchProjectRequest>,
950) -> impl IntoResponse {
951 use std::path::PathBuf;
952
953 let project_path = PathBuf::from(&req.project_path);
954
955 match state.remove_project(&project_path).await {
956 Ok(()) => {
957 tracing::info!("Removed project: {}", req.project_path);
958 (
959 StatusCode::OK,
960 Json(ApiResponse {
961 data: json!({
962 "success": true,
963 "removed_path": req.project_path,
964 }),
965 }),
966 )
967 .into_response()
968 },
969 Err(e) => (
970 StatusCode::BAD_REQUEST,
971 Json(ApiError {
972 code: "REMOVE_FAILED".to_string(),
973 message: e,
974 details: None,
975 }),
976 )
977 .into_response(),
978 }
979}
980
981pub async fn get_task_context(
983 State(state): State<AppState>,
984 Path(id): Path<i64>,
985) -> impl IntoResponse {
986 let db_pool = match state.get_active_db_pool().await {
987 Ok(pool) => pool,
988 Err(e) => {
989 return (
990 StatusCode::INTERNAL_SERVER_ERROR,
991 Json(ApiError {
992 code: "DATABASE_ERROR".to_string(),
993 message: e,
994 details: None,
995 }),
996 )
997 .into_response()
998 },
999 };
1000 let task_mgr = TaskManager::new(&db_pool);
1001
1002 match task_mgr.get_task_context(id).await {
1003 Ok(context) => (StatusCode::OK, Json(ApiResponse { data: context })).into_response(),
1004 Err(e) if e.to_string().contains("not found") => (
1005 StatusCode::NOT_FOUND,
1006 Json(ApiError {
1007 code: "TASK_NOT_FOUND".to_string(),
1008 message: format!("Task {} not found", id),
1009 details: None,
1010 }),
1011 )
1012 .into_response(),
1013 Err(e) => (
1014 StatusCode::INTERNAL_SERVER_ERROR,
1015 Json(ApiError {
1016 code: "DATABASE_ERROR".to_string(),
1017 message: format!("Failed to get task context: {}", e),
1018 details: None,
1019 }),
1020 )
1021 .into_response(),
1022 }
1023}
1024
1025pub async fn handle_cli_notification(
1027 State(state): State<AppState>,
1028 Json(message): Json<crate::dashboard::cli_notifier::NotificationMessage>,
1029) -> impl IntoResponse {
1030 use crate::dashboard::cli_notifier::NotificationMessage;
1031 use std::path::PathBuf;
1032
1033 tracing::debug!("Received CLI notification: {:?}", message);
1034
1035 let project_path = match &message {
1037 NotificationMessage::TaskChanged { project_path, .. } => project_path.clone(),
1038 NotificationMessage::EventAdded { project_path, .. } => project_path.clone(),
1039 NotificationMessage::WorkspaceChanged { project_path, .. } => project_path.clone(),
1040 };
1041
1042 if let Some(ref path_str) = project_path {
1044 let project_path = PathBuf::from(path_str);
1045
1046 if let Err(e) = state.add_project(project_path.clone()).await {
1048 tracing::warn!("Failed to add project from CLI notification: {}", e);
1049 } else {
1050 if let Err(e) = state.switch_active_project(project_path.clone()).await {
1052 tracing::warn!("Failed to switch to project from CLI notification: {}", e);
1053 } else {
1054 let project_name = project_path
1055 .file_name()
1056 .and_then(|n| n.to_str())
1057 .unwrap_or("unknown");
1058 tracing::info!(
1059 "Auto-switched to project: {} (from CLI notification)",
1060 project_name
1061 );
1062 }
1063 }
1064 }
1065
1066 let ui_message = match &message {
1068 NotificationMessage::TaskChanged {
1069 task_id,
1070 operation,
1071 project_path,
1072 } => {
1073 json!({
1075 "type": "db_operation",
1076 "payload": {
1077 "entity": "task",
1078 "operation": operation,
1079 "affected_ids": task_id.map(|id| vec![id]).unwrap_or_default(),
1080 "project_path": project_path
1081 }
1082 })
1083 },
1084 NotificationMessage::EventAdded {
1085 task_id,
1086 event_id,
1087 project_path,
1088 } => {
1089 json!({
1090 "type": "db_operation",
1091 "payload": {
1092 "entity": "event",
1093 "operation": "created",
1094 "affected_ids": vec![*event_id],
1095 "task_id": task_id,
1096 "project_path": project_path
1097 }
1098 })
1099 },
1100 NotificationMessage::WorkspaceChanged {
1101 current_task_id,
1102 project_path,
1103 } => {
1104 json!({
1105 "type": "db_operation",
1106 "payload": {
1107 "entity": "workspace",
1108 "operation": "updated",
1109 "current_task_id": current_task_id,
1110 "project_path": project_path
1111 }
1112 })
1113 },
1114 };
1115
1116 let notification_json = serde_json::to_string(&ui_message).unwrap_or_default();
1117 state.ws_state.broadcast_to_ui(¬ification_json).await;
1118
1119 (StatusCode::OK, Json(json!({"success": true}))).into_response()
1120}
1121
1122pub async fn shutdown_handler(State(state): State<AppState>) -> impl IntoResponse {
1125 tracing::info!("Shutdown requested via HTTP endpoint");
1126
1127 let mut shutdown = state.shutdown_tx.lock().await;
1129 if let Some(tx) = shutdown.take() {
1130 if tx.send(()).is_ok() {
1131 tracing::info!("Shutdown signal sent successfully");
1132 (
1133 StatusCode::OK,
1134 Json(json!({
1135 "status": "ok",
1136 "message": "Dashboard is shutting down gracefully"
1137 })),
1138 )
1139 .into_response()
1140 } else {
1141 tracing::error!("Failed to send shutdown signal");
1142 (
1143 StatusCode::INTERNAL_SERVER_ERROR,
1144 Json(json!({
1145 "status": "error",
1146 "message": "Failed to initiate shutdown"
1147 })),
1148 )
1149 .into_response()
1150 }
1151 } else {
1152 tracing::warn!("Shutdown already initiated");
1153 (
1154 StatusCode::CONFLICT,
1155 Json(json!({
1156 "status": "error",
1157 "message": "Shutdown already in progress"
1158 })),
1159 )
1160 .into_response()
1161 }
1162}