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, events::EventManager, search::SearchManager, tasks::TaskManager,
12    workspace::WorkspaceManager,
13};
14
15/// Get all tasks with optional filters
16pub 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    // Convert parent filter to Option<Option<i64>>
37    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    // Parse sort_by parameter
46    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), // Default to FocusAware
52    };
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
80/// Get a single task by ID
81pub 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
121/// Create a new task
122pub 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    // Dashboard creates human-owned tasks (owner=None defaults to 'human')
153    // This distinguishes from CLI-created tasks (owner='ai')
154    // Note: Priority is set separately via update_task if needed
155    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 priority was requested, update it
162            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                // Ignore priority update errors
170            }
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
185/// Update a task
186pub 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    // First check if task exists
218    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    // Update task fields
245    // Signature: update_task(id, name, spec, parent_id, status, complexity, priority)
246    match task_mgr
247        .update_task(
248            id,
249            req.name.as_deref(),
250            req.spec.as_deref(),
251            None, // parent_id - not supported via update API
252            req.status.as_deref(),
253            None, // complexity - not exposed in API
254            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
271/// Delete a task
272pub 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
322/// Start a task (set as current)
323pub 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
373/// Complete the current task
374pub 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    // Dashboard = human caller, no passphrase needed
402    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
425/// Spawn a subtask and switch to it
426/// Note: This creates a subtask of the CURRENT task, not an arbitrary parent
427pub async fn spawn_subtask(
428    State(state): State<AppState>,
429    Path(_parent_id): Path<i64>, // Ignored - uses current task
430    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    // spawn_subtask uses the current task as parent automatically
454    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
477/// Get current task
478pub 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
522/// Pick next task recommendation
523pub 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
554/// List events for a task
555pub 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    // Signature: list_events(task_id, limit, log_type, since)
577    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
599/// Add an event to a task
600pub 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    // Validate event type
627    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    // add_event signature: (task_id, log_type, discussion_data)
640    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
657/// Update an event
658pub 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    // Validate event type if provided
685    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            // Verify the event belongs to the specified task
705            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
730/// Delete an event
731pub 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    // First verify the event exists and belongs to the task
757    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
813/// Unified search across tasks and events
814pub 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
858/// List all registered projects (from known_projects state loaded from global registry)
859pub async fn list_projects(State(state): State<AppState>) -> impl IntoResponse {
860    let port = state.port;
861    let pid = std::process::id();
862    let host_path = state.host_project.path.clone();
863
864    // Read from known_projects (loaded from global registry at startup)
865    let known_projects = state.known_projects.read().await;
866
867    let projects: Vec<serde_json::Value> = known_projects
868        .values()
869        .map(|proj| {
870            let is_host = proj.path.to_string_lossy() == host_path;
871            json!({
872                "name": proj.name,
873                "path": proj.path.to_string_lossy(),
874                "port": port,
875                "pid": pid,
876                "url": format!("http://127.0.0.1:{}", port),
877                "started_at": chrono::Utc::now().to_rfc3339(),
878                "mcp_connected": false,
879                "is_online": is_host,  // Only host project is "online"
880                "mcp_agent": None::<String>,
881                "mcp_last_seen": None::<String>,
882            })
883        })
884        .collect();
885
886    (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
887}
888
889/// Switch to a different project database dynamically
890pub async fn switch_project(
891    State(state): State<AppState>,
892    Json(req): Json<SwitchProjectRequest>,
893) -> impl IntoResponse {
894    use std::path::PathBuf;
895
896    // Parse and validate project path
897    let project_path = PathBuf::from(&req.project_path);
898
899    // Add project to known projects (validates path and db existence)
900    if let Err(e) = state.add_project(project_path.clone()).await {
901        return (
902            StatusCode::NOT_FOUND,
903            Json(ApiError {
904                code: "PROJECT_NOT_FOUND".to_string(),
905                message: e,
906                details: None,
907            }),
908        )
909            .into_response();
910    }
911
912    // Switch to the new project
913    if let Err(e) = state.switch_active_project(project_path.clone()).await {
914        return (
915            StatusCode::INTERNAL_SERVER_ERROR,
916            Json(ApiError {
917                code: "SWITCH_ERROR".to_string(),
918                message: e,
919                details: None,
920            }),
921        )
922            .into_response();
923    }
924
925    // Get project info for response
926    let project_name = project_path
927        .file_name()
928        .and_then(|n| n.to_str())
929        .unwrap_or("unknown")
930        .to_string();
931    let db_path = project_path.join(".intent-engine").join("project.db");
932
933    tracing::info!(
934        "Switched to project: {} at {}",
935        project_name,
936        project_path.display()
937    );
938
939    (
940        StatusCode::OK,
941        Json(ApiResponse {
942            data: json!({
943                "success": true,
944                "project_name": project_name,
945                "project_path": project_path.display().to_string(),
946                "database": db_path.display().to_string(),
947            }),
948        }),
949    )
950        .into_response()
951}
952
953/// Remove a project from the Dashboard and global registry
954/// DELETE /api/projects
955pub async fn remove_project(
956    State(state): State<AppState>,
957    Json(req): Json<SwitchProjectRequest>,
958) -> impl IntoResponse {
959    use std::path::PathBuf;
960
961    let project_path = PathBuf::from(&req.project_path);
962
963    match state.remove_project(&project_path).await {
964        Ok(()) => {
965            tracing::info!("Removed project: {}", req.project_path);
966            (
967                StatusCode::OK,
968                Json(ApiResponse {
969                    data: json!({
970                        "success": true,
971                        "removed_path": req.project_path,
972                    }),
973                }),
974            )
975                .into_response()
976        },
977        Err(e) => (
978            StatusCode::BAD_REQUEST,
979            Json(ApiError {
980                code: "REMOVE_FAILED".to_string(),
981                message: e,
982                details: None,
983            }),
984        )
985            .into_response(),
986    }
987}
988
989/// Get task context (ancestors, siblings, children)
990pub async fn get_task_context(
991    State(state): State<AppState>,
992    Path(id): Path<i64>,
993) -> impl IntoResponse {
994    let db_pool = match state.get_active_db_pool().await {
995        Ok(pool) => pool,
996        Err(e) => {
997            return (
998                StatusCode::INTERNAL_SERVER_ERROR,
999                Json(ApiError {
1000                    code: "DATABASE_ERROR".to_string(),
1001                    message: e,
1002                    details: None,
1003                }),
1004            )
1005                .into_response()
1006        },
1007    };
1008    let task_mgr = TaskManager::new(&db_pool);
1009
1010    match task_mgr.get_task_context(id).await {
1011        Ok(context) => (StatusCode::OK, Json(ApiResponse { data: context })).into_response(),
1012        Err(e) if e.to_string().contains("not found") => (
1013            StatusCode::NOT_FOUND,
1014            Json(ApiError {
1015                code: "TASK_NOT_FOUND".to_string(),
1016                message: format!("Task {} not found", id),
1017                details: None,
1018            }),
1019        )
1020            .into_response(),
1021        Err(e) => (
1022            StatusCode::INTERNAL_SERVER_ERROR,
1023            Json(ApiError {
1024                code: "DATABASE_ERROR".to_string(),
1025                message: format!("Failed to get task context: {}", e),
1026                details: None,
1027            }),
1028        )
1029            .into_response(),
1030    }
1031}
1032
1033/// Handle CLI notification (internal endpoint for CLI → Dashboard sync)
1034pub async fn handle_cli_notification(
1035    State(state): State<AppState>,
1036    Json(message): Json<crate::dashboard::cli_notifier::NotificationMessage>,
1037) -> impl IntoResponse {
1038    use crate::dashboard::cli_notifier::NotificationMessage;
1039    use std::path::PathBuf;
1040
1041    tracing::debug!("Received CLI notification: {:?}", message);
1042
1043    // Extract project_path from notification
1044    let project_path = match &message {
1045        NotificationMessage::TaskChanged { project_path, .. } => project_path.clone(),
1046        NotificationMessage::EventAdded { project_path, .. } => project_path.clone(),
1047        NotificationMessage::WorkspaceChanged { project_path, .. } => project_path.clone(),
1048    };
1049
1050    // If project_path is provided, register it as a known project
1051    if let Some(ref path_str) = project_path {
1052        let project_path = PathBuf::from(path_str);
1053
1054        // Add project to known projects (this is idempotent - safe to call multiple times)
1055        if let Err(e) = state.add_project(project_path.clone()).await {
1056            tracing::warn!("Failed to add project from CLI notification: {}", e);
1057        } else {
1058            // Switch to this project as the active one
1059            if let Err(e) = state.switch_active_project(project_path.clone()).await {
1060                tracing::warn!("Failed to switch to project from CLI notification: {}", e);
1061            } else {
1062                let project_name = project_path
1063                    .file_name()
1064                    .and_then(|n| n.to_str())
1065                    .unwrap_or("unknown");
1066                tracing::info!(
1067                    "Auto-switched to project: {} (from CLI notification)",
1068                    project_name
1069                );
1070            }
1071        }
1072    }
1073
1074    // Convert CLI notification to frontend-compatible format and broadcast
1075    let ui_message = match &message {
1076        NotificationMessage::TaskChanged {
1077            task_id,
1078            operation,
1079            project_path,
1080        } => {
1081            // Convert to db_operation format that frontend already handles
1082            json!({
1083                "type": "db_operation",
1084                "payload": {
1085                    "entity": "task",
1086                    "operation": operation,
1087                    "affected_ids": task_id.map(|id| vec![id]).unwrap_or_default(),
1088                    "project_path": project_path
1089                }
1090            })
1091        },
1092        NotificationMessage::EventAdded {
1093            task_id,
1094            event_id,
1095            project_path,
1096        } => {
1097            json!({
1098                "type": "db_operation",
1099                "payload": {
1100                    "entity": "event",
1101                    "operation": "created",
1102                    "affected_ids": vec![*event_id],
1103                    "task_id": task_id,
1104                    "project_path": project_path
1105                }
1106            })
1107        },
1108        NotificationMessage::WorkspaceChanged {
1109            current_task_id,
1110            project_path,
1111        } => {
1112            json!({
1113                "type": "db_operation",
1114                "payload": {
1115                    "entity": "workspace",
1116                    "operation": "updated",
1117                    "current_task_id": current_task_id,
1118                    "project_path": project_path
1119                }
1120            })
1121        },
1122    };
1123
1124    let notification_json = serde_json::to_string(&ui_message).unwrap_or_default();
1125    state.ws_state.broadcast_to_ui(&notification_json).await;
1126
1127    (StatusCode::OK, Json(json!({"success": true}))).into_response()
1128}
1129
1130/// Shutdown the Dashboard server gracefully
1131/// POST /api/internal/shutdown
1132pub async fn shutdown_handler(State(state): State<AppState>) -> impl IntoResponse {
1133    tracing::info!("Shutdown requested via HTTP endpoint");
1134
1135    // Trigger shutdown signal
1136    let mut shutdown = state.shutdown_tx.lock().await;
1137    if let Some(tx) = shutdown.take() {
1138        if tx.send(()).is_ok() {
1139            tracing::info!("Shutdown signal sent successfully");
1140            (
1141                StatusCode::OK,
1142                Json(json!({
1143                    "status": "ok",
1144                    "message": "Dashboard is shutting down gracefully"
1145                })),
1146            )
1147                .into_response()
1148        } else {
1149            tracing::error!("Failed to send shutdown signal");
1150            (
1151                StatusCode::INTERNAL_SERVER_ERROR,
1152                Json(json!({
1153                    "status": "error",
1154                    "message": "Failed to initiate shutdown"
1155                })),
1156            )
1157                .into_response()
1158        }
1159    } else {
1160        tracing::warn!("Shutdown already initiated");
1161        (
1162            StatusCode::CONFLICT,
1163            Json(json!({
1164                "status": "error",
1165                "message": "Shutdown already in progress"
1166            })),
1167        )
1168            .into_response()
1169    }
1170}