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 host_path = state.host_project.path.clone();
861
862    // Read from known_projects (loaded from global registry at startup)
863    let known_projects = state.known_projects.read().await;
864
865    let projects: Vec<serde_json::Value> = known_projects
866        .values()
867        .map(|proj| {
868            let is_host = proj.path.to_string_lossy() == host_path;
869            json!({
870                "name": proj.name,
871                "path": proj.path.to_string_lossy(),
872                "is_online": is_host,  // Only host project is "online"
873                "mcp_connected": false, // MCP removed, always false
874            })
875        })
876        .collect();
877
878    (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
879}
880
881/// Switch to a different project database dynamically
882pub async fn switch_project(
883    State(state): State<AppState>,
884    Json(req): Json<SwitchProjectRequest>,
885) -> impl IntoResponse {
886    use std::path::PathBuf;
887
888    // Parse and validate project path
889    let project_path = PathBuf::from(&req.project_path);
890
891    // Add project to known projects (validates path and db existence)
892    if let Err(e) = state.add_project(project_path.clone()).await {
893        return (
894            StatusCode::NOT_FOUND,
895            Json(ApiError {
896                code: "PROJECT_NOT_FOUND".to_string(),
897                message: e,
898                details: None,
899            }),
900        )
901            .into_response();
902    }
903
904    // Switch to the new project
905    if let Err(e) = state.switch_active_project(project_path.clone()).await {
906        return (
907            StatusCode::INTERNAL_SERVER_ERROR,
908            Json(ApiError {
909                code: "SWITCH_ERROR".to_string(),
910                message: e,
911                details: None,
912            }),
913        )
914            .into_response();
915    }
916
917    // Get project info for response
918    let project_name = project_path
919        .file_name()
920        .and_then(|n| n.to_str())
921        .unwrap_or("unknown")
922        .to_string();
923    let db_path = project_path.join(".intent-engine").join("project.db");
924
925    tracing::info!(
926        "Switched to project: {} at {}",
927        project_name,
928        project_path.display()
929    );
930
931    (
932        StatusCode::OK,
933        Json(ApiResponse {
934            data: json!({
935                "success": true,
936                "project_name": project_name,
937                "project_path": project_path.display().to_string(),
938                "database": db_path.display().to_string(),
939            }),
940        }),
941    )
942        .into_response()
943}
944
945/// Remove a project from the Dashboard and global registry
946/// DELETE /api/projects
947pub async fn remove_project(
948    State(state): State<AppState>,
949    Json(req): Json<SwitchProjectRequest>,
950) -> impl IntoResponse {
951    use std::path::PathBuf;
952
953    let project_path = PathBuf::from(&req.project_path);
954
955    match state.remove_project(&project_path).await {
956        Ok(()) => {
957            tracing::info!("Removed project: {}", req.project_path);
958            (
959                StatusCode::OK,
960                Json(ApiResponse {
961                    data: json!({
962                        "success": true,
963                        "removed_path": req.project_path,
964                    }),
965                }),
966            )
967                .into_response()
968        },
969        Err(e) => (
970            StatusCode::BAD_REQUEST,
971            Json(ApiError {
972                code: "REMOVE_FAILED".to_string(),
973                message: e,
974                details: None,
975            }),
976        )
977            .into_response(),
978    }
979}
980
981/// Get task context (ancestors, siblings, children)
982pub async fn get_task_context(
983    State(state): State<AppState>,
984    Path(id): Path<i64>,
985) -> impl IntoResponse {
986    let db_pool = match state.get_active_db_pool().await {
987        Ok(pool) => pool,
988        Err(e) => {
989            return (
990                StatusCode::INTERNAL_SERVER_ERROR,
991                Json(ApiError {
992                    code: "DATABASE_ERROR".to_string(),
993                    message: e,
994                    details: None,
995                }),
996            )
997                .into_response()
998        },
999    };
1000    let task_mgr = TaskManager::new(&db_pool);
1001
1002    match task_mgr.get_task_context(id).await {
1003        Ok(context) => (StatusCode::OK, Json(ApiResponse { data: context })).into_response(),
1004        Err(e) if e.to_string().contains("not found") => (
1005            StatusCode::NOT_FOUND,
1006            Json(ApiError {
1007                code: "TASK_NOT_FOUND".to_string(),
1008                message: format!("Task {} not found", id),
1009                details: None,
1010            }),
1011        )
1012            .into_response(),
1013        Err(e) => (
1014            StatusCode::INTERNAL_SERVER_ERROR,
1015            Json(ApiError {
1016                code: "DATABASE_ERROR".to_string(),
1017                message: format!("Failed to get task context: {}", e),
1018                details: None,
1019            }),
1020        )
1021            .into_response(),
1022    }
1023}
1024
1025/// Handle CLI notification (internal endpoint for CLI → Dashboard sync)
1026pub async fn handle_cli_notification(
1027    State(state): State<AppState>,
1028    Json(message): Json<crate::dashboard::cli_notifier::NotificationMessage>,
1029) -> impl IntoResponse {
1030    use crate::dashboard::cli_notifier::NotificationMessage;
1031    use std::path::PathBuf;
1032
1033    tracing::debug!("Received CLI notification: {:?}", message);
1034
1035    // Extract project_path from notification
1036    let project_path = match &message {
1037        NotificationMessage::TaskChanged { project_path, .. } => project_path.clone(),
1038        NotificationMessage::EventAdded { project_path, .. } => project_path.clone(),
1039        NotificationMessage::WorkspaceChanged { project_path, .. } => project_path.clone(),
1040    };
1041
1042    // If project_path is provided, register it as a known project
1043    if let Some(ref path_str) = project_path {
1044        let project_path = PathBuf::from(path_str);
1045
1046        // Add project to known projects (this is idempotent - safe to call multiple times)
1047        if let Err(e) = state.add_project(project_path.clone()).await {
1048            tracing::warn!("Failed to add project from CLI notification: {}", e);
1049        } else {
1050            // Switch to this project as the active one
1051            if let Err(e) = state.switch_active_project(project_path.clone()).await {
1052                tracing::warn!("Failed to switch to project from CLI notification: {}", e);
1053            } else {
1054                let project_name = project_path
1055                    .file_name()
1056                    .and_then(|n| n.to_str())
1057                    .unwrap_or("unknown");
1058                tracing::info!(
1059                    "Auto-switched to project: {} (from CLI notification)",
1060                    project_name
1061                );
1062            }
1063        }
1064    }
1065
1066    // Convert CLI notification to frontend-compatible format and broadcast
1067    let ui_message = match &message {
1068        NotificationMessage::TaskChanged {
1069            task_id,
1070            operation,
1071            project_path,
1072        } => {
1073            // Convert to db_operation format that frontend already handles
1074            json!({
1075                "type": "db_operation",
1076                "payload": {
1077                    "entity": "task",
1078                    "operation": operation,
1079                    "affected_ids": task_id.map(|id| vec![id]).unwrap_or_default(),
1080                    "project_path": project_path
1081                }
1082            })
1083        },
1084        NotificationMessage::EventAdded {
1085            task_id,
1086            event_id,
1087            project_path,
1088        } => {
1089            json!({
1090                "type": "db_operation",
1091                "payload": {
1092                    "entity": "event",
1093                    "operation": "created",
1094                    "affected_ids": vec![*event_id],
1095                    "task_id": task_id,
1096                    "project_path": project_path
1097                }
1098            })
1099        },
1100        NotificationMessage::WorkspaceChanged {
1101            current_task_id,
1102            project_path,
1103        } => {
1104            json!({
1105                "type": "db_operation",
1106                "payload": {
1107                    "entity": "workspace",
1108                    "operation": "updated",
1109                    "current_task_id": current_task_id,
1110                    "project_path": project_path
1111                }
1112            })
1113        },
1114    };
1115
1116    let notification_json = serde_json::to_string(&ui_message).unwrap_or_default();
1117    state.ws_state.broadcast_to_ui(&notification_json).await;
1118
1119    (StatusCode::OK, Json(json!({"success": true}))).into_response()
1120}
1121
1122/// Shutdown the Dashboard server gracefully
1123/// POST /api/internal/shutdown
1124pub async fn shutdown_handler(State(state): State<AppState>) -> impl IntoResponse {
1125    tracing::info!("Shutdown requested via HTTP endpoint");
1126
1127    // Trigger shutdown signal
1128    let mut shutdown = state.shutdown_tx.lock().await;
1129    if let Some(tx) = shutdown.take() {
1130        if tx.send(()).is_ok() {
1131            tracing::info!("Shutdown signal sent successfully");
1132            (
1133                StatusCode::OK,
1134                Json(json!({
1135                    "status": "ok",
1136                    "message": "Dashboard is shutting down gracefully"
1137                })),
1138            )
1139                .into_response()
1140        } else {
1141            tracing::error!("Failed to send shutdown signal");
1142            (
1143                StatusCode::INTERNAL_SERVER_ERROR,
1144                Json(json!({
1145                    "status": "error",
1146                    "message": "Failed to initiate shutdown"
1147                })),
1148            )
1149                .into_response()
1150        }
1151    } else {
1152        tracing::warn!("Shutdown already initiated");
1153        (
1154            StatusCode::CONFLICT,
1155            Json(json!({
1156                "status": "error",
1157                "message": "Shutdown already in progress"
1158            })),
1159        )
1160            .into_response()
1161    }
1162}