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 db_pool = state.current_project.read().await.db_pool.clone();
82    let task_mgr = TaskManager::new(&db_pool);
83
84    // Note: add_task doesn't support priority - it's set separately via update_task
85    let result = task_mgr
86        .add_task(&req.name, req.spec.as_deref(), req.parent_id)
87        .await;
88
89    match result {
90        Ok(mut task) => {
91            // If priority was requested, update it
92            if let Some(priority) = req.priority {
93                if let Ok(updated_task) = task_mgr
94                    .update_task(task.id, None, None, None, None, None, Some(priority))
95                    .await
96                {
97                    task = updated_task;
98                }
99                // Ignore priority update errors
100            }
101            (StatusCode::CREATED, Json(ApiResponse { data: task })).into_response()
102        },
103        Err(e) => (
104            StatusCode::BAD_REQUEST,
105            Json(ApiError {
106                code: "INVALID_REQUEST".to_string(),
107                message: format!("Failed to create task: {}", e),
108                details: None,
109            }),
110        )
111            .into_response(),
112    }
113}
114
115/// Update a task
116pub async fn update_task(
117    State(state): State<AppState>,
118    Path(id): Path<i64>,
119    Json(req): Json<UpdateTaskRequest>,
120) -> impl IntoResponse {
121    let db_pool = state.current_project.read().await.db_pool.clone();
122    let task_mgr = TaskManager::new(&db_pool);
123
124    // First check if task exists
125    match task_mgr.get_task(id).await {
126        Err(e) if e.to_string().contains("not found") => {
127            return (
128                StatusCode::NOT_FOUND,
129                Json(ApiError {
130                    code: "TASK_NOT_FOUND".to_string(),
131                    message: format!("Task {} not found", id),
132                    details: None,
133                }),
134            )
135                .into_response()
136        },
137        Err(e) => {
138            return (
139                StatusCode::INTERNAL_SERVER_ERROR,
140                Json(ApiError {
141                    code: "DATABASE_ERROR".to_string(),
142                    message: format!("Database error: {}", e),
143                    details: None,
144                }),
145            )
146                .into_response()
147        },
148        Ok(_) => {},
149    }
150
151    // Update task fields
152    // Signature: update_task(id, name, spec, parent_id, status, complexity, priority)
153    match task_mgr
154        .update_task(
155            id,
156            req.name.as_deref(),
157            req.spec.as_deref(),
158            None, // parent_id - not supported via update API
159            req.status.as_deref(),
160            None, // complexity - not exposed in API
161            req.priority,
162        )
163        .await
164    {
165        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
166        Err(e) => (
167            StatusCode::BAD_REQUEST,
168            Json(ApiError {
169                code: "INVALID_REQUEST".to_string(),
170                message: format!("Failed to update task: {}", e),
171                details: None,
172            }),
173        )
174            .into_response(),
175    }
176}
177
178/// Delete a task
179pub async fn delete_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
180    let db_pool = state.current_project.read().await.db_pool.clone();
181    let task_mgr = TaskManager::new(&db_pool);
182
183    match task_mgr.delete_task(id).await {
184        Ok(_) => (StatusCode::NO_CONTENT).into_response(),
185        Err(e) if e.to_string().contains("not found") => (
186            StatusCode::NOT_FOUND,
187            Json(ApiError {
188                code: "TASK_NOT_FOUND".to_string(),
189                message: format!("Task {} not found", id),
190                details: None,
191            }),
192        )
193            .into_response(),
194        Err(e) => (
195            StatusCode::BAD_REQUEST,
196            Json(ApiError {
197                code: "INVALID_REQUEST".to_string(),
198                message: format!("Failed to delete task: {}", e),
199                details: None,
200            }),
201        )
202            .into_response(),
203    }
204}
205
206/// Start a task (set as current)
207pub async fn start_task(State(state): State<AppState>, Path(id): Path<i64>) -> impl IntoResponse {
208    let db_pool = state.current_project.read().await.db_pool.clone();
209    let task_mgr = TaskManager::new(&db_pool);
210
211    match task_mgr.start_task(id, false).await {
212        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
213        Err(e) if e.to_string().contains("not found") => (
214            StatusCode::NOT_FOUND,
215            Json(ApiError {
216                code: "TASK_NOT_FOUND".to_string(),
217                message: format!("Task {} not found", id),
218                details: None,
219            }),
220        )
221            .into_response(),
222        Err(e) => (
223            StatusCode::BAD_REQUEST,
224            Json(ApiError {
225                code: "INVALID_REQUEST".to_string(),
226                message: format!("Failed to start task: {}", e),
227                details: None,
228            }),
229        )
230            .into_response(),
231    }
232}
233
234/// Complete the current task
235pub async fn done_task(State(state): State<AppState>) -> impl IntoResponse {
236    let db_pool = state.current_project.read().await.db_pool.clone();
237    let task_mgr = TaskManager::new(&db_pool);
238
239    match task_mgr.done_task().await {
240        Ok(task) => (StatusCode::OK, Json(ApiResponse { data: task })).into_response(),
241        Err(e) if e.to_string().contains("No current task") => (
242            StatusCode::BAD_REQUEST,
243            Json(ApiError {
244                code: "NO_CURRENT_TASK".to_string(),
245                message: "No current task to complete".to_string(),
246                details: None,
247            }),
248        )
249            .into_response(),
250        Err(e) => (
251            StatusCode::BAD_REQUEST,
252            Json(ApiError {
253                code: "INVALID_REQUEST".to_string(),
254                message: format!("Failed to complete task: {}", e),
255                details: None,
256            }),
257        )
258            .into_response(),
259    }
260}
261
262/// Spawn a subtask and switch to it
263/// Note: This creates a subtask of the CURRENT task, not an arbitrary parent
264pub async fn spawn_subtask(
265    State(state): State<AppState>,
266    Path(_parent_id): Path<i64>, // Ignored - uses current task
267    Json(req): Json<SpawnSubtaskRequest>,
268) -> impl IntoResponse {
269    let db_pool = state.current_project.read().await.db_pool.clone();
270    let task_mgr = TaskManager::new(&db_pool);
271
272    // spawn_subtask uses the current task as parent automatically
273    match task_mgr.spawn_subtask(&req.name, req.spec.as_deref()).await {
274        Ok(response) => (StatusCode::CREATED, Json(ApiResponse { data: response })).into_response(),
275        Err(e) if e.to_string().contains("No current task") => (
276            StatusCode::BAD_REQUEST,
277            Json(ApiError {
278                code: "NO_CURRENT_TASK".to_string(),
279                message: "No current task to spawn subtask from".to_string(),
280                details: None,
281            }),
282        )
283            .into_response(),
284        Err(e) => (
285            StatusCode::BAD_REQUEST,
286            Json(ApiError {
287                code: "INVALID_REQUEST".to_string(),
288                message: format!("Failed to spawn subtask: {}", e),
289                details: None,
290            }),
291        )
292            .into_response(),
293    }
294}
295
296/// Get current task
297pub async fn get_current_task(State(state): State<AppState>) -> impl IntoResponse {
298    let db_pool = state.current_project.read().await.db_pool.clone();
299    let workspace_mgr = WorkspaceManager::new(&db_pool);
300
301    match workspace_mgr.get_current_task().await {
302        Ok(response) => {
303            if response.task.is_some() {
304                (StatusCode::OK, Json(ApiResponse { data: response })).into_response()
305            } else {
306                (
307                    StatusCode::OK,
308                    Json(json!({
309                        "data": null,
310                        "message": "No current task"
311                    })),
312                )
313                    .into_response()
314            }
315        },
316        Err(e) => (
317            StatusCode::INTERNAL_SERVER_ERROR,
318            Json(ApiError {
319                code: "DATABASE_ERROR".to_string(),
320                message: format!("Failed to get current task: {}", e),
321                details: None,
322            }),
323        )
324            .into_response(),
325    }
326}
327
328/// Pick next task recommendation
329pub async fn pick_next_task(State(state): State<AppState>) -> impl IntoResponse {
330    let db_pool = state.current_project.read().await.db_pool.clone();
331    let task_mgr = TaskManager::new(&db_pool);
332
333    match task_mgr.pick_next().await {
334        Ok(response) => (StatusCode::OK, Json(ApiResponse { data: response })).into_response(),
335        Err(e) => (
336            StatusCode::INTERNAL_SERVER_ERROR,
337            Json(ApiError {
338                code: "DATABASE_ERROR".to_string(),
339                message: format!("Failed to pick next task: {}", e),
340                details: None,
341            }),
342        )
343            .into_response(),
344    }
345}
346
347/// List events for a task
348pub async fn list_events(
349    State(state): State<AppState>,
350    Path(task_id): Path<i64>,
351    Query(query): Query<EventListQuery>,
352) -> impl IntoResponse {
353    let db_pool = state.current_project.read().await.db_pool.clone();
354    let event_mgr = EventManager::new(&db_pool);
355
356    // Signature: list_events(task_id, limit, log_type, since)
357    match event_mgr
358        .list_events(
359            Some(task_id),
360            query.limit.map(|l| l as i64),
361            query.event_type,
362            query.since,
363        )
364        .await
365    {
366        Ok(events) => (StatusCode::OK, Json(ApiResponse { data: events })).into_response(),
367        Err(e) => (
368            StatusCode::INTERNAL_SERVER_ERROR,
369            Json(ApiError {
370                code: "DATABASE_ERROR".to_string(),
371                message: format!("Failed to list events: {}", e),
372                details: None,
373            }),
374        )
375            .into_response(),
376    }
377}
378
379/// Add an event to a task
380pub async fn create_event(
381    State(state): State<AppState>,
382    Path(task_id): Path<i64>,
383    Json(req): Json<CreateEventRequest>,
384) -> impl IntoResponse {
385    let db_pool = state.current_project.read().await.db_pool.clone();
386    let event_mgr = EventManager::new(&db_pool);
387
388    // Validate event type
389    if !["decision", "blocker", "milestone", "note"].contains(&req.event_type.as_str()) {
390        return (
391            StatusCode::BAD_REQUEST,
392            Json(ApiError {
393                code: "INVALID_REQUEST".to_string(),
394                message: format!("Invalid event type: {}", req.event_type),
395                details: None,
396            }),
397        )
398            .into_response();
399    }
400
401    // add_event signature: (task_id, log_type, discussion_data)
402    match event_mgr
403        .add_event(task_id, &req.event_type, &req.data)
404        .await
405    {
406        Ok(event) => (StatusCode::CREATED, Json(ApiResponse { data: event })).into_response(),
407        Err(e) => (
408            StatusCode::BAD_REQUEST,
409            Json(ApiError {
410                code: "INVALID_REQUEST".to_string(),
411                message: format!("Failed to create event: {}", e),
412                details: None,
413            }),
414        )
415            .into_response(),
416    }
417}
418
419/// Unified search across tasks and events
420pub async fn search(
421    State(state): State<AppState>,
422    Query(query): Query<SearchQuery>,
423) -> impl IntoResponse {
424    let db_pool = state.current_project.read().await.db_pool.clone();
425    let search_mgr = SearchManager::new(&db_pool);
426
427    match search_mgr
428        .unified_search(
429            &query.query,
430            query.include_tasks,
431            query.include_events,
432            query.limit.map(|l| l as i64),
433        )
434        .await
435    {
436        Ok(results) => (StatusCode::OK, Json(ApiResponse { data: results })).into_response(),
437        Err(e) => (
438            StatusCode::INTERNAL_SERVER_ERROR,
439            Json(ApiError {
440                code: "DATABASE_ERROR".to_string(),
441                message: format!("Search failed: {}", e),
442                details: None,
443            }),
444        )
445            .into_response(),
446    }
447}
448
449/// List all registered projects (from in-memory state)
450pub async fn list_projects(State(state): State<AppState>) -> impl IntoResponse {
451    // Use the same method as WebSocket init for consistency
452    let projects_info = {
453        let current_project = state.current_project.read().await;
454        state
455            .ws_state
456            .get_online_projects_with_current(
457                &current_project.project_name,
458                &current_project.project_path,
459                &current_project.db_path,
460                state.port,
461            )
462            .await
463    };
464
465    // Convert ProjectInfo to API response format with additional metadata
466    let port = state.port;
467    let pid = std::process::id();
468
469    let projects: Vec<serde_json::Value> = projects_info
470        .iter()
471        .map(|proj| {
472            json!({
473                "name": proj.name,
474                "path": proj.path,
475                "port": port,
476                "pid": pid,
477                "url": format!("http://127.0.0.1:{}", port),
478                "started_at": chrono::Utc::now().to_rfc3339(),
479                "mcp_connected": proj.mcp_connected,
480                "is_online": proj.is_online,  // Now included!
481                "mcp_agent": proj.agent,
482                "mcp_last_seen": if proj.mcp_connected {
483                    Some(chrono::Utc::now().to_rfc3339())
484                } else {
485                    None::<String>
486                },
487            })
488        })
489        .collect();
490
491    (StatusCode::OK, Json(ApiResponse { data: projects })).into_response()
492}
493
494/// Switch to a different project database dynamically
495pub async fn switch_project(
496    State(state): State<AppState>,
497    Json(req): Json<SwitchProjectRequest>,
498) -> impl IntoResponse {
499    use super::server::ProjectContext;
500    use sqlx::SqlitePool;
501    use std::path::PathBuf;
502
503    // Parse and validate project path
504    let project_path = PathBuf::from(&req.project_path);
505
506    if !project_path.exists() {
507        return (
508            StatusCode::NOT_FOUND,
509            Json(ApiError {
510                code: "PROJECT_NOT_FOUND".to_string(),
511                message: format!("Project path does not exist: {}", project_path.display()),
512                details: None,
513            }),
514        )
515            .into_response();
516    }
517
518    // Construct database path
519    let db_path = project_path.join(".intent-engine").join("project.db");
520
521    if !db_path.exists() {
522        return (
523            StatusCode::NOT_FOUND,
524            Json(ApiError {
525                code: "DATABASE_NOT_FOUND".to_string(),
526                message: format!(
527                    "Database not found at {}. Is this an Intent-Engine project?",
528                    db_path.display()
529                ),
530                details: None,
531            }),
532        )
533            .into_response();
534    }
535
536    // Create new database connection
537    let db_url = format!("sqlite://{}", db_path.display());
538    let new_db_pool = match SqlitePool::connect(&db_url).await {
539        Ok(pool) => pool,
540        Err(e) => {
541            return (
542                StatusCode::INTERNAL_SERVER_ERROR,
543                Json(ApiError {
544                    code: "DATABASE_CONNECTION_ERROR".to_string(),
545                    message: format!("Failed to connect to database: {}", e),
546                    details: None,
547                }),
548            )
549                .into_response();
550        },
551    };
552
553    // Extract project name
554    let project_name = project_path
555        .file_name()
556        .and_then(|n| n.to_str())
557        .unwrap_or("unknown")
558        .to_string();
559
560    // Create new project context
561    let new_context = ProjectContext {
562        db_pool: new_db_pool,
563        project_name: project_name.clone(),
564        project_path: project_path.clone(),
565        db_path: db_path.clone(),
566    };
567
568    // Update the current project (write lock)
569    {
570        let mut current = state.current_project.write().await;
571        *current = new_context;
572    }
573
574    tracing::info!(
575        "Switched to project: {} at {}",
576        project_name,
577        project_path.display()
578    );
579
580    (
581        StatusCode::OK,
582        Json(ApiResponse {
583            data: json!({
584                "success": true,
585                "project_name": project_name,
586                "project_path": project_path.display().to_string(),
587                "database": db_path.display().to_string(),
588            }),
589        }),
590    )
591        .into_response()
592}