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