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 = state.current_project.read().await.db_pool.clone();
21    let task_mgr = TaskManager::new(&db_pool);
22
23    // Convert parent filter to Option<Option<i64>>
24    let parent_filter = query.parent.as_deref().map(|p| {
25        if p == "null" {
26            None
27        } else {
28            p.parse::<i64>().ok()
29        }
30    });
31
32    // Parse sort_by parameter
33    let sort_by = match query.sort_by.as_deref() {
34        Some("id") => Some(TaskSortBy::Id),
35        Some("priority") => Some(TaskSortBy::Priority),
36        Some("time") => Some(TaskSortBy::Time),
37        Some("focus") => Some(TaskSortBy::FocusAware),
38        _ => Some(TaskSortBy::FocusAware), // Default to FocusAware
39    };
40
41    match task_mgr
42        .find_tasks(
43            query.status.as_deref(),
44            parent_filter,
45            sort_by,
46            query.limit,
47            query.offset,
48        )
49        .await
50    {
51        Ok(result) => (StatusCode::OK, Json(ApiResponse { data: result })).into_response(),
52        Err(e) => {
53            tracing::error!("Failed to fetch tasks: {}", e);
54            (
55                StatusCode::INTERNAL_SERVER_ERROR,
56                Json(ApiError {
57                    code: "DATABASE_ERROR".to_string(),
58                    message: format!("Failed to list tasks: {}", e),
59                    details: None,
60                }),
61            )
62                .into_response()
63        },
64    }
65}
66
67/// Get a single task by ID
68pub async fn get_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
69    let db_pool = state.current_project.read().await.db_pool.clone();
70    let task_mgr = TaskManager::new(&db_pool);
71
72    match task_mgr.get_task(id).await {
73        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
74        Err(e) if e.to_string().contains("not found") => (
75            StatusCode::NOT_FOUND,
76            Json(ApiError {
77                code: "TASK_NOT_FOUND".to_string(),
78                message: format!("Task {} not found", id),
79                details: None,
80            }),
81        )
82            .into_response(),
83        Err(e) => (
84            StatusCode::INTERNAL_SERVER_ERROR,
85            Json(ApiError {
86                code: "DATABASE_ERROR".to_string(),
87                message: format!("Failed to get task: {}", e),
88                details: None,
89            }),
90        )
91            .into_response(),
92    }
93}
94
95/// Create a new task
96pub async fn create_task(
97    State(state): State<AppState>,
98    Json(req): Json<CreateTaskRequest>,
99) -> impl IntoResponse {
100    let project = state.current_project.read().await;
101    let db_pool = project.db_pool.clone();
102    let project_path = project.project_path.to_string_lossy().to_string();
103    drop(project);
104
105    let task_mgr = TaskManager::with_websocket(
106        &db_pool,
107        std::sync::Arc::new(state.ws_state.clone()),
108        project_path,
109    );
110
111    // Note: add_task doesn't support priority - it's set separately via update_task
112    let result = task_mgr
113        .add_task(&req.name, req.spec.as_deref(), req.parent_id)
114        .await;
115
116    match result {
117        Ok(mut task) => {
118            // If priority was requested, update it
119            if let Some(priority) = req.priority {
120                if let Ok(updated_task) = task_mgr
121                    .update_task(task.id, None, None, None, None, None, Some(priority))
122                    .await
123                {
124                    task = updated_task;
125                }
126                // Ignore priority update errors
127            }
128            (StatusCode::CREATED, Json(ApiResponse { data: task })).into_response()
129        },
130        Err(e) => (
131            StatusCode::BAD_REQUEST,
132            Json(ApiError {
133                code: "INVALID_REQUEST".to_string(),
134                message: format!("Failed to create task: {}", e),
135                details: None,
136            }),
137        )
138            .into_response(),
139    }
140}
141
142/// Update a task
143pub async fn update_task(
144    State(state): State<AppState>,
145    Path(id): Path<i64>,
146    Json(req): Json<UpdateTaskRequest>,
147) -> impl IntoResponse {
148    let project = state.current_project.read().await;
149    let db_pool = project.db_pool.clone();
150    let project_path = project.project_path.to_string_lossy().to_string();
151    drop(project);
152
153    let task_mgr = TaskManager::with_websocket(
154        &db_pool,
155        std::sync::Arc::new(state.ws_state.clone()),
156        project_path,
157    );
158
159    // First check if task exists
160    match task_mgr.get_task(id).await {
161        Err(e) if e.to_string().contains("not found") => {
162            return (
163                StatusCode::NOT_FOUND,
164                Json(ApiError {
165                    code: "TASK_NOT_FOUND".to_string(),
166                    message: format!("Task {} not found", id),
167                    details: None,
168                }),
169            )
170                .into_response()
171        },
172        Err(e) => {
173            return (
174                StatusCode::INTERNAL_SERVER_ERROR,
175                Json(ApiError {
176                    code: "DATABASE_ERROR".to_string(),
177                    message: format!("Database error: {}", e),
178                    details: None,
179                }),
180            )
181                .into_response()
182        },
183        Ok(_) => {},
184    }
185
186    // Update task fields
187    // Signature: update_task(id, name, spec, parent_id, status, complexity, priority)
188    match task_mgr
189        .update_task(
190            id,
191            req.name.as_deref(),
192            req.spec.as_deref(),
193            None, // parent_id - not supported via update API
194            req.status.as_deref(),
195            None, // complexity - not exposed in API
196            req.priority,
197        )
198        .await
199    {
200        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
201        Err(e) => (
202            StatusCode::BAD_REQUEST,
203            Json(ApiError {
204                code: "INVALID_REQUEST".to_string(),
205                message: format!("Failed to update task: {}", e),
206                details: None,
207            }),
208        )
209            .into_response(),
210    }
211}
212
213/// Delete a task
214pub async fn delete_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
215    let project = state.current_project.read().await;
216    let db_pool = project.db_pool.clone();
217    let project_path = project.project_path.to_string_lossy().to_string();
218    drop(project);
219
220    let task_mgr = TaskManager::with_websocket(
221        &db_pool,
222        std::sync::Arc::new(state.ws_state.clone()),
223        project_path,
224    );
225
226    match task_mgr.delete_task(id).await {
227        Ok(_) => (StatusCode::NO_CONTENT).into_response(),
228        Err(e) if e.to_string().contains("not found") => (
229            StatusCode::NOT_FOUND,
230            Json(ApiError {
231                code: "TASK_NOT_FOUND".to_string(),
232                message: format!("Task {} not found", id),
233                details: None,
234            }),
235        )
236            .into_response(),
237        Err(e) => (
238            StatusCode::BAD_REQUEST,
239            Json(ApiError {
240                code: "INVALID_REQUEST".to_string(),
241                message: format!("Failed to delete task: {}", e),
242                details: None,
243            }),
244        )
245            .into_response(),
246    }
247}
248
249/// Start a task (set as current)
250pub async fn start_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
251    let project = state.current_project.read().await;
252    let db_pool = project.db_pool.clone();
253    let project_path = project.project_path.to_string_lossy().to_string();
254    drop(project);
255
256    let task_mgr = TaskManager::with_websocket(
257        &db_pool,
258        std::sync::Arc::new(state.ws_state.clone()),
259        project_path,
260    );
261
262    match task_mgr.start_task(id, false).await {
263        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
264        Err(e) if e.to_string().contains("not found") => (
265            StatusCode::NOT_FOUND,
266            Json(ApiError {
267                code: "TASK_NOT_FOUND".to_string(),
268                message: format!("Task {} not found", id),
269                details: None,
270            }),
271        )
272            .into_response(),
273        Err(e) => (
274            StatusCode::BAD_REQUEST,
275            Json(ApiError {
276                code: "INVALID_REQUEST".to_string(),
277                message: format!("Failed to start task: {}", e),
278                details: None,
279            }),
280        )
281            .into_response(),
282    }
283}
284
285/// Complete the current task
286pub async fn done_task(State(state): State<AppState>) -> impl IntoResponse {
287    let project = state.current_project.read().await;
288    let db_pool = project.db_pool.clone();
289    let project_path = project.project_path.to_string_lossy().to_string();
290    drop(project);
291
292    let task_mgr = TaskManager::with_websocket(
293        &db_pool,
294        std::sync::Arc::new(state.ws_state.clone()),
295        project_path,
296    );
297
298    match task_mgr.done_task().await {
299        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
300        Err(e) if e.to_string().contains("No current task") => (
301            StatusCode::BAD_REQUEST,
302            Json(ApiError {
303                code: "NO_CURRENT_TASK".to_string(),
304                message: "No current task to complete".to_string(),
305                details: None,
306            }),
307        )
308            .into_response(),
309        Err(e) => (
310            StatusCode::BAD_REQUEST,
311            Json(ApiError {
312                code: "INVALID_REQUEST".to_string(),
313                message: format!("Failed to complete task: {}", e),
314                details: None,
315            }),
316        )
317            .into_response(),
318    }
319}
320
321/// Spawn a subtask and switch to it
322/// Note: This creates a subtask of the CURRENT task, not an arbitrary parent
323pub async fn spawn_subtask(
324    State(state): State<AppState>,
325    Path(_parent_id): Path<i64>, // Ignored - uses current task
326    Json(req): Json<SpawnSubtaskRequest>,
327) -> impl IntoResponse {
328    let project = state.current_project.read().await;
329    let db_pool = project.db_pool.clone();
330    let project_path = project.project_path.to_string_lossy().to_string();
331    drop(project);
332
333    let task_mgr = TaskManager::with_websocket(
334        &db_pool,
335        std::sync::Arc::new(state.ws_state.clone()),
336        project_path,
337    );
338
339    // spawn_subtask uses the current task as parent automatically
340    match task_mgr.spawn_subtask(&req.name, req.spec.as_deref()).await {
341        Ok(response) => (StatusCode::CREATED, Json(ApiResponse { data: response })).into_response(),
342        Err(e) if e.to_string().contains("No current task") => (
343            StatusCode::BAD_REQUEST,
344            Json(ApiError {
345                code: "NO_CURRENT_TASK".to_string(),
346                message: "No current task to spawn subtask from".to_string(),
347                details: None,
348            }),
349        )
350            .into_response(),
351        Err(e) => (
352            StatusCode::BAD_REQUEST,
353            Json(ApiError {
354                code: "INVALID_REQUEST".to_string(),
355                message: format!("Failed to spawn subtask: {}", e),
356                details: None,
357            }),
358        )
359            .into_response(),
360    }
361}
362
363/// Get current task
364pub async fn get_current_task(State(state): State<AppState>) -> impl IntoResponse {
365    let db_pool = state.current_project.read().await.db_pool.clone();
366    let workspace_mgr = WorkspaceManager::new(&db_pool);
367
368    match workspace_mgr.get_current_task().await {
369        Ok(response) => {
370            if response.task.is_some() {
371                (StatusCode::OK, Json(ApiResponse { data: response })).into_response()
372            } else {
373                (
374                    StatusCode::OK,
375                    Json(json!({
376                        "data": null,
377                        "message": "No current task"
378                    })),
379                )
380                    .into_response()
381            }
382        },
383        Err(e) => (
384            StatusCode::INTERNAL_SERVER_ERROR,
385            Json(ApiError {
386                code: "DATABASE_ERROR".to_string(),
387                message: format!("Failed to get current task: {}", e),
388                details: None,
389            }),
390        )
391            .into_response(),
392    }
393}
394
395/// Pick next task recommendation
396pub async fn pick_next_task(State(state): State<AppState>) -> impl IntoResponse {
397    let db_pool = state.current_project.read().await.db_pool.clone();
398    let task_mgr = TaskManager::new(&db_pool);
399
400    match task_mgr.pick_next().await {
401        Ok(response) => (StatusCode::OK, Json(ApiResponse { data: response })).into_response(),
402        Err(e) => (
403            StatusCode::INTERNAL_SERVER_ERROR,
404            Json(ApiError {
405                code: "DATABASE_ERROR".to_string(),
406                message: format!("Failed to pick next task: {}", e),
407                details: None,
408            }),
409        )
410            .into_response(),
411    }
412}
413
414/// List events for a task
415pub async fn list_events(
416    State(state): State<AppState>,
417    Path(task_id): Path<i64>,
418    Query(query): Query<EventListQuery>,
419) -> impl IntoResponse {
420    let db_pool = state.current_project.read().await.db_pool.clone();
421    let event_mgr = EventManager::new(&db_pool);
422
423    // Signature: list_events(task_id, limit, log_type, since)
424    match event_mgr
425        .list_events(
426            Some(task_id),
427            query.limit.map(|l| l as i64),
428            query.event_type,
429            query.since,
430        )
431        .await
432    {
433        Ok(events) => (StatusCode::OK, Json(ApiResponse { data: events })).into_response(),
434        Err(e) => (
435            StatusCode::INTERNAL_SERVER_ERROR,
436            Json(ApiError {
437                code: "DATABASE_ERROR".to_string(),
438                message: format!("Failed to list events: {}", e),
439                details: None,
440            }),
441        )
442            .into_response(),
443    }
444}
445
446/// Add an event to a task
447pub async fn create_event(
448    State(state): State<AppState>,
449    Path(task_id): Path<i64>,
450    Json(req): Json<CreateEventRequest>,
451) -> impl IntoResponse {
452    let project = state.current_project.read().await;
453    let db_pool = project.db_pool.clone();
454    let project_path = project.project_path.to_string_lossy().to_string();
455    drop(project);
456
457    let event_mgr = EventManager::with_websocket(
458        &db_pool,
459        std::sync::Arc::new(state.ws_state.clone()),
460        project_path,
461    );
462
463    // Validate event type
464    if !["decision", "blocker", "milestone", "note"].contains(&req.event_type.as_str()) {
465        return (
466            StatusCode::BAD_REQUEST,
467            Json(ApiError {
468                code: "INVALID_REQUEST".to_string(),
469                message: format!("Invalid event type: {}", req.event_type),
470                details: None,
471            }),
472        )
473            .into_response();
474    }
475
476    // add_event signature: (task_id, log_type, discussion_data)
477    match event_mgr
478        .add_event(task_id, &req.event_type, &req.data)
479        .await
480    {
481        Ok(event) => (StatusCode::CREATED, Json(ApiResponse { data: event })).into_response(),
482        Err(e) => (
483            StatusCode::BAD_REQUEST,
484            Json(ApiError {
485                code: "INVALID_REQUEST".to_string(),
486                message: format!("Failed to create event: {}", e),
487                details: None,
488            }),
489        )
490            .into_response(),
491    }
492}
493
494/// Update an event
495pub async fn update_event(
496    State(state): State<AppState>,
497    Path((task_id, event_id)): Path<(i64, i64)>,
498    Json(req): Json<UpdateEventRequest>,
499) -> impl IntoResponse {
500    let project = state.current_project.read().await;
501    let db_pool = project.db_pool.clone();
502    let project_path = project.project_path.to_string_lossy().to_string();
503    drop(project);
504
505    let event_mgr = EventManager::with_websocket(
506        &db_pool,
507        std::sync::Arc::new(state.ws_state.clone()),
508        project_path,
509    );
510
511    // Validate event type if provided
512    if let Some(ref event_type) = req.event_type {
513        if !["decision", "blocker", "milestone", "note"].contains(&event_type.as_str()) {
514            return (
515                StatusCode::BAD_REQUEST,
516                Json(ApiError {
517                    code: "INVALID_REQUEST".to_string(),
518                    message: format!("Invalid event type: {}", event_type),
519                    details: None,
520                }),
521            )
522                .into_response();
523        }
524    }
525
526    match event_mgr
527        .update_event(event_id, req.event_type.as_deref(), req.data.as_deref())
528        .await
529    {
530        Ok(event) => {
531            // Verify the event belongs to the specified task
532            if event.task_id != task_id {
533                return (
534                    StatusCode::BAD_REQUEST,
535                    Json(ApiError {
536                        code: "INVALID_REQUEST".to_string(),
537                        message: format!("Event {} does not belong to task {}", event_id, task_id),
538                        details: None,
539                    }),
540                )
541                    .into_response();
542            }
543            (StatusCode::OK, Json(ApiResponse { data: event })).into_response()
544        },
545        Err(e) => (
546            StatusCode::BAD_REQUEST,
547            Json(ApiError {
548                code: "INVALID_REQUEST".to_string(),
549                message: format!("Failed to update event: {}", e),
550                details: None,
551            }),
552        )
553            .into_response(),
554    }
555}
556
557/// Delete an event
558pub async fn delete_event(
559    State(state): State<AppState>,
560    Path((task_id, event_id)): Path<(i64, i64)>,
561) -> impl IntoResponse {
562    let project = state.current_project.read().await;
563    let db_pool = project.db_pool.clone();
564    let project_path = project.project_path.to_string_lossy().to_string();
565    drop(project);
566
567    let event_mgr = EventManager::with_websocket(
568        &db_pool,
569        std::sync::Arc::new(state.ws_state.clone()),
570        project_path,
571    );
572
573    // First verify the event exists and belongs to the task
574    match sqlx::query_as::<_, crate::db::models::Event>(crate::sql_constants::SELECT_EVENT_BY_ID)
575        .bind(event_id)
576        .fetch_optional(&db_pool)
577        .await
578    {
579        Ok(Some(event)) => {
580            if event.task_id != task_id {
581                return (
582                    StatusCode::BAD_REQUEST,
583                    Json(ApiError {
584                        code: "INVALID_REQUEST".to_string(),
585                        message: format!("Event {} does not belong to task {}", event_id, task_id),
586                        details: None,
587                    }),
588                )
589                    .into_response();
590            }
591        },
592        Ok(None) => {
593            return (
594                StatusCode::NOT_FOUND,
595                Json(ApiError {
596                    code: "EVENT_NOT_FOUND".to_string(),
597                    message: format!("Event {} not found", event_id),
598                    details: None,
599                }),
600            )
601                .into_response();
602        },
603        Err(e) => {
604            return (
605                StatusCode::INTERNAL_SERVER_ERROR,
606                Json(ApiError {
607                    code: "DATABASE_ERROR".to_string(),
608                    message: format!("Database error: {}", e),
609                    details: None,
610                }),
611            )
612                .into_response();
613        },
614    }
615
616    match event_mgr.delete_event(event_id).await {
617        Ok(_) => (StatusCode::NO_CONTENT).into_response(),
618        Err(e) => (
619            StatusCode::BAD_REQUEST,
620            Json(ApiError {
621                code: "INVALID_REQUEST".to_string(),
622                message: format!("Failed to delete event: {}", e),
623                details: None,
624            }),
625        )
626            .into_response(),
627    }
628}
629
630/// Unified search across tasks and events
631pub async fn search(
632    State(state): State<AppState>,
633    Query(query): Query<SearchQuery>,
634) -> impl IntoResponse {
635    let db_pool = state.current_project.read().await.db_pool.clone();
636    let search_mgr = SearchManager::new(&db_pool);
637
638    match search_mgr
639        .search(
640            &query.query,
641            query.include_tasks,
642            query.include_events,
643            query.limit,
644            query.offset,
645            false,
646        )
647        .await
648    {
649        Ok(results) => (StatusCode::OK, Json(ApiResponse { data: results })).into_response(),
650        Err(e) => (
651            StatusCode::INTERNAL_SERVER_ERROR,
652            Json(ApiError {
653                code: "DATABASE_ERROR".to_string(),
654                message: format!("Search failed: {}", e),
655                details: None,
656            }),
657        )
658            .into_response(),
659    }
660}
661
662/// List all registered projects (from in-memory state)
663pub async fn list_projects(State(state): State<AppState>) -> impl IntoResponse {
664    // Use the same method as WebSocket init for consistency
665    let projects_info = {
666        let current_project = state.current_project.read().await;
667        state
668            .ws_state
669            .get_online_projects_with_current(
670                &current_project.project_name,
671                &current_project.project_path,
672                &current_project.db_path,
673                &state.host_project,
674                state.port,
675            )
676            .await
677    };
678
679    // Convert ProjectInfo to API response format with additional metadata
680    let port = state.port;
681    let pid = std::process::id();
682
683    let projects: Vec<serde_json::Value> = projects_info
684        .iter()
685        .map(|proj| {
686            json!({
687                "name": proj.name,
688                "path": proj.path,
689                "port": port,
690                "pid": pid,
691                "url": format!("http://127.0.0.1:{}", port),
692                "started_at": chrono::Utc::now().to_rfc3339(),
693                "mcp_connected": proj.mcp_connected,
694                "is_online": proj.is_online,  // Now included!
695                "mcp_agent": proj.agent,
696                "mcp_last_seen": if proj.mcp_connected {
697                    Some(chrono::Utc::now().to_rfc3339())
698                } else {
699                    None::<String>
700                },
701            })
702        })
703        .collect();
704
705    (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
706}
707
708/// Switch to a different project database dynamically
709pub async fn switch_project(
710    State(state): State<AppState>,
711    Json(req): Json<SwitchProjectRequest>,
712) -> impl IntoResponse {
713    use super::server::ProjectContext;
714    use std::path::PathBuf;
715
716    // Parse and validate project path
717    let project_path = PathBuf::from(&req.project_path);
718
719    if !project_path.exists() {
720        return (
721            StatusCode::NOT_FOUND,
722            Json(ApiError {
723                code: "PROJECT_NOT_FOUND".to_string(),
724                message: format!("Project path does not exist: {}", project_path.display()),
725                details: None,
726            }),
727        )
728            .into_response();
729    }
730
731    // Construct database path
732    let db_path = project_path.join(".intent-engine").join("project.db");
733
734    if !db_path.exists() {
735        return (
736            StatusCode::NOT_FOUND,
737            Json(ApiError {
738                code: "DATABASE_NOT_FOUND".to_string(),
739                message: format!(
740                    "Database not found at {}. Is this an Intent-Engine project?",
741                    db_path.display()
742                ),
743                details: None,
744            }),
745        )
746            .into_response();
747    }
748
749    // Create new database connection using the shared helper
750    // This ensures consistent configuration (WAL mode, timeouts) and correct path handling
751    let new_db_pool = match crate::db::create_pool(&db_path).await {
752        Ok(pool) => pool,
753        Err(e) => {
754            return (
755                StatusCode::INTERNAL_SERVER_ERROR,
756                Json(ApiError {
757                    code: "DATABASE_CONNECTION_ERROR".to_string(),
758                    message: format!("Failed to connect to database: {}", e),
759                    details: None,
760                }),
761            )
762                .into_response();
763        },
764    };
765
766    // Extract project name
767    let project_name = project_path
768        .file_name()
769        .and_then(|n| n.to_str())
770        .unwrap_or("unknown")
771        .to_string();
772
773    // Create new project context
774    let new_context = ProjectContext {
775        db_pool: new_db_pool,
776        project_name: project_name.clone(),
777        project_path: project_path.clone(),
778        db_path: db_path.clone(),
779    };
780
781    // Update the current project (write lock)
782    {
783        let mut current = state.current_project.write().await;
784        *current = new_context;
785    }
786
787    tracing::info!(
788        "Switched to project: {} at {}",
789        project_name,
790        project_path.display()
791    );
792
793    (
794        StatusCode::OK,
795        Json(ApiResponse {
796            data: json!({
797                "success": true,
798                "project_name": project_name,
799                "project_path": project_path.display().to_string(),
800                "database": db_path.display().to_string(),
801            }),
802        }),
803    )
804        .into_response()
805}