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