Skip to main content

intent_engine/dashboard/
handlers.rs

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
18/// Get all tasks with optional filters
19pub 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    // Convert parent filter to Option<Option<i64>>
40    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    // Parse sort_by parameter
49    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), // Default to FocusAware
55    };
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
83/// Get a single task by ID
84pub 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
124/// Create a new task
125pub 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    // Dashboard creates human-owned tasks (owner=None defaults to 'human')
156    // This distinguishes from CLI-created tasks (owner='ai')
157    // Note: Priority is set separately via update_task if needed
158    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 priority was requested, update it
172            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                // Ignore priority update errors
186            }
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
201/// Update a task
202pub 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    // First check if task exists
234    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    // Update task fields
261    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
287/// Delete a task
288pub 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
338/// Start a task (set as current)
339pub 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
389/// Complete the current task
390pub 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    // Dashboard = human caller, no passphrase needed
418    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
441/// Spawn a subtask and switch to it
442/// Note: This creates a subtask of the CURRENT task, not an arbitrary parent
443pub async fn spawn_subtask(
444    State(state): State<AppState>,
445    Path(_parent_id): Path<i64>, // Ignored - uses current task
446    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    // spawn_subtask uses the current task as parent automatically
470    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
493/// Get current task
494pub 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
538/// Pick next task recommendation
539pub 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
570/// List events for a task
571pub 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    // Signature: list_events(task_id, limit, log_type, since)
593    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
615/// Add an event to a task
616pub 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    // Validate event type
643    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    // add_event signature: (task_id, log_type, discussion_data)
656    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
673/// Update an event
674pub 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    // Validate event type if provided
701    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            // Verify the event belongs to the specified task
721            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
746/// Delete an event
747pub 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    // First verify the event exists and belongs to the task
773    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
829/// Unified search across tasks and events
830pub 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
874/// List all registered projects (from known_projects state loaded from global registry)
875pub async fn list_projects(State(state): State<AppState>) -> impl IntoResponse {
876    let host_path = state.host_project.path.clone();
877
878    // Read from known_projects (loaded from global registry at startup)
879    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,  // Only host project is "online"
889                "mcp_connected": false, // MCP removed, always false
890            })
891        })
892        .collect();
893
894    (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
895}
896
897/// Switch to a different project database dynamically
898pub async fn switch_project(
899    State(state): State<AppState>,
900    Json(req): Json<SwitchProjectRequest>,
901) -> impl IntoResponse {
902    use std::path::PathBuf;
903
904    // Parse and validate project path
905    let project_path = PathBuf::from(&req.project_path);
906
907    // Add project to known projects (validates path and db existence)
908    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    // Switch to the new project
921    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    // Get project info for response
934    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
961/// Remove a project from the Dashboard and global registry
962/// DELETE /api/projects
963pub 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
997/// Get task context (ancestors, siblings, children)
998pub 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
1041/// Handle CLI notification (internal endpoint for CLI → Dashboard sync)
1042pub 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    // Extract project_path from notification
1052    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 project_path is provided, register it as a known project
1059    if let Some(ref path_str) = project_path {
1060        let project_path = PathBuf::from(path_str);
1061
1062        // Add project to known projects (this is idempotent - safe to call multiple times)
1063        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            // Switch to this project as the active one
1067            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    // Convert CLI notification to frontend-compatible format and broadcast
1083    let ui_message = match &message {
1084        NotificationMessage::TaskChanged {
1085            task_id,
1086            operation,
1087            project_path,
1088        } => {
1089            // Convert to db_operation format that frontend already handles
1090            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(&notification_json).await;
1134
1135    (StatusCode::OK, Json(json!({"success": true}))).into_response()
1136}
1137
1138/// Shutdown the Dashboard server gracefully
1139/// POST /api/internal/shutdown
1140pub async fn shutdown_handler(State(state): State<AppState>) -> impl IntoResponse {
1141    tracing::info!("Shutdown requested via HTTP endpoint");
1142
1143    // Trigger shutdown signal
1144    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}