FMT
This commit is contained in:
@@ -2,7 +2,7 @@ use axum::{
|
||||
async_trait,
|
||||
body::Body,
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode, Request},
|
||||
http::{request::Parts, Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
@@ -43,7 +43,11 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_token(user_id: Uuid, email: String, is_admin: bool) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
pub fn create_token(
|
||||
user_id: Uuid,
|
||||
email: String,
|
||||
is_admin: bool,
|
||||
) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string());
|
||||
|
||||
let expiration = chrono::Utc::now()
|
||||
@@ -106,10 +110,7 @@ pub async fn auth_middleware(
|
||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Verify user exists in database
|
||||
let user_exists = sqlx::query!(
|
||||
"SELECT id FROM users WHERE id = $1",
|
||||
claims.user_id
|
||||
)
|
||||
let user_exists = sqlx::query!("SELECT id FROM users WHERE id = $1", claims.user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
@@ -6,8 +6,8 @@ use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{
|
||||
task_attempt::{TaskAttempt, TaskAttemptStatus},
|
||||
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
|
||||
task_attempt::{TaskAttempt, TaskAttemptStatus}
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -30,7 +30,12 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
interval.tick().await;
|
||||
|
||||
// Check for orphaned task attempts with latest activity status = InProgress but no running execution
|
||||
let inprogress_attempt_ids = match TaskAttemptActivity::find_attempts_with_latest_inprogress_status(&app_state.db_pool).await {
|
||||
let inprogress_attempt_ids =
|
||||
match TaskAttemptActivity::find_attempts_with_latest_inprogress_status(
|
||||
&app_state.db_pool,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(attempts) => attempts,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to query inprogress attempts: {}", e);
|
||||
@@ -42,7 +47,9 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
// Check if this attempt has a running execution
|
||||
let has_running_execution = {
|
||||
let executions = app_state.running_executions.lock().await;
|
||||
executions.values().any(|exec| exec.task_attempt_id == attempt_id)
|
||||
executions
|
||||
.values()
|
||||
.any(|exec| exec.task_attempt_id == attempt_id)
|
||||
};
|
||||
|
||||
if !has_running_execution {
|
||||
@@ -59,8 +66,13 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
&create_activity,
|
||||
activity_id,
|
||||
TaskAttemptStatus::Paused,
|
||||
).await {
|
||||
tracing::error!("Failed to create paused activity for orphaned attempt: {}", e);
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to create paused activity for orphaned attempt: {}",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::info!("Marked orphaned task attempt {} as paused", attempt_id);
|
||||
}
|
||||
@@ -68,7 +80,10 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
}
|
||||
|
||||
// Check for task attempts with latest activity status = Init
|
||||
let init_attempt_ids = match TaskAttemptActivity::find_attempts_with_latest_init_status(&app_state.db_pool).await {
|
||||
let init_attempt_ids =
|
||||
match TaskAttemptActivity::find_attempts_with_latest_init_status(&app_state.db_pool)
|
||||
.await
|
||||
{
|
||||
Ok(attempts) => attempts,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to query init attempts: {}", e);
|
||||
@@ -77,11 +92,13 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
};
|
||||
|
||||
for attempt_id in init_attempt_ids {
|
||||
|
||||
// Check if we already have a running execution for this attempt
|
||||
{
|
||||
let executions = app_state.running_executions.lock().await;
|
||||
if executions.values().any(|exec| exec.task_attempt_id == attempt_id) {
|
||||
if executions
|
||||
.values()
|
||||
.any(|exec| exec.task_attempt_id == attempt_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -101,10 +118,22 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
|
||||
// Get the executor and start streaming execution
|
||||
let executor = task_attempt.get_executor();
|
||||
let child = match executor.execute_streaming(&app_state.db_pool, task_attempt.task_id, attempt_id, &task_attempt.worktree_path).await {
|
||||
let child = match executor
|
||||
.execute_streaming(
|
||||
&app_state.db_pool,
|
||||
task_attempt.task_id,
|
||||
attempt_id,
|
||||
&task_attempt.worktree_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to start streaming execution for task attempt {}: {}", attempt_id, e);
|
||||
tracing::error!(
|
||||
"Failed to start streaming execution for task attempt {}: {}",
|
||||
attempt_id,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -113,11 +142,14 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
let execution_id = Uuid::new_v4();
|
||||
{
|
||||
let mut executions = app_state.running_executions.lock().await;
|
||||
executions.insert(execution_id, RunningExecution {
|
||||
executions.insert(
|
||||
execution_id,
|
||||
RunningExecution {
|
||||
task_attempt_id: attempt_id,
|
||||
child,
|
||||
started_at: Utc::now(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Update task attempt activity to InProgress
|
||||
@@ -133,11 +165,17 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
&create_activity,
|
||||
activity_id,
|
||||
TaskAttemptStatus::InProgress,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to create in-progress activity: {}", e);
|
||||
}
|
||||
|
||||
tracing::info!("Started execution {} for task attempt {}", execution_id, attempt_id);
|
||||
tracing::info!(
|
||||
"Started execution {} for task attempt {}",
|
||||
execution_id,
|
||||
attempt_id
|
||||
);
|
||||
}
|
||||
|
||||
// Check for completed processes
|
||||
@@ -149,14 +187,24 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
Ok(Some(status)) => {
|
||||
let success = status.success();
|
||||
let exit_code = status.code();
|
||||
completed_executions.push((*execution_id, running_exec.task_attempt_id, success, exit_code));
|
||||
completed_executions.push((
|
||||
*execution_id,
|
||||
running_exec.task_attempt_id,
|
||||
success,
|
||||
exit_code,
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
// Still running
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error checking process status: {}", e);
|
||||
completed_executions.push((*execution_id, running_exec.task_attempt_id, false, None));
|
||||
completed_executions.push((
|
||||
*execution_id,
|
||||
running_exec.task_attempt_id,
|
||||
false,
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +217,11 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
|
||||
// Handle completed executions
|
||||
for (execution_id, task_attempt_id, success, exit_code) in completed_executions {
|
||||
let status_text = if success { "completed successfully" } else { "failed" };
|
||||
let status_text = if success {
|
||||
"completed successfully"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
let exit_text = if let Some(code) = exit_code {
|
||||
format!(" with exit code {}", code)
|
||||
} else {
|
||||
@@ -191,10 +243,15 @@ pub async fn execution_monitor(app_state: AppState) {
|
||||
&create_activity,
|
||||
activity_id,
|
||||
TaskAttemptStatus::Paused,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to create paused activity: {}", e);
|
||||
} else {
|
||||
tracing::info!("Task attempt {} set to paused after execution completion", task_attempt_id);
|
||||
tracing::info!(
|
||||
"Task attempt {} set to paused after execution completion",
|
||||
task_attempt_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod echo;
|
||||
pub mod claude;
|
||||
pub mod echo;
|
||||
|
||||
pub use echo::EchoExecutor;
|
||||
pub use claude::ClaudeExecutor;
|
||||
pub use echo::EchoExecutor;
|
||||
|
||||
@@ -19,8 +19,8 @@ mod routes;
|
||||
|
||||
use auth::{auth_middleware, hash_password};
|
||||
use execution_monitor::{execution_monitor, AppState};
|
||||
use models::{ApiResponse, user::User};
|
||||
use routes::{health, projects, tasks, users, filesystem};
|
||||
use models::{user::User, ApiResponse};
|
||||
use routes::{filesystem, health, projects, tasks, users};
|
||||
|
||||
async fn echo_handler(
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
|
||||
@@ -52,7 +52,10 @@ impl Project {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_git_repo_path(pool: &PgPool, git_repo_path: &str) -> Result<Option<Self>, sqlx::Error> {
|
||||
pub async fn find_by_git_repo_path(
|
||||
pool: &PgPool,
|
||||
git_repo_path: &str,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Project,
|
||||
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE git_repo_path = $1",
|
||||
@@ -62,7 +65,11 @@ impl Project {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_git_repo_path_excluding_id(pool: &PgPool, git_repo_path: &str, exclude_id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||
pub async fn find_by_git_repo_path_excluding_id(
|
||||
pool: &PgPool,
|
||||
git_repo_path: &str,
|
||||
exclude_id: Uuid,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Project,
|
||||
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE git_repo_path = $1 AND id != $2",
|
||||
@@ -73,7 +80,12 @@ impl Project {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, data: &CreateProject, owner_id: Uuid, project_id: Uuid) -> Result<Self, sqlx::Error> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
data: &CreateProject,
|
||||
owner_id: Uuid,
|
||||
project_id: Uuid,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Project,
|
||||
"INSERT INTO projects (id, name, git_repo_path, owner_id) VALUES ($1, $2, $3, $4) RETURNING id, name, git_repo_path, owner_id, created_at, updated_at",
|
||||
@@ -86,7 +98,12 @@ impl Project {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, name: String, git_repo_path: String) -> Result<Self, sqlx::Error> {
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
name: String,
|
||||
git_repo_path: String,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Project,
|
||||
"UPDATE projects SET name = $2, git_repo_path = $3 WHERE id = $1 RETURNING id, name, git_repo_path, owner_id, created_at, updated_at",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Type, PgPool};
|
||||
use sqlx::{FromRow, PgPool, Type};
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -58,7 +58,10 @@ pub struct UpdateTask {
|
||||
}
|
||||
|
||||
impl Task {
|
||||
pub async fn find_by_project_id(pool: &PgPool, project_id: Uuid) -> Result<Vec<Self>, sqlx::Error> {
|
||||
pub async fn find_by_project_id(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Self>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Task,
|
||||
r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at
|
||||
@@ -71,7 +74,10 @@ impl Task {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_project_id_with_attempt_status(pool: &PgPool, project_id: Uuid) -> Result<Vec<TaskWithAttemptStatus>, sqlx::Error> {
|
||||
pub async fn find_by_project_id_with_attempt_status(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<TaskWithAttemptStatus>, sqlx::Error> {
|
||||
let records = sqlx::query!(
|
||||
r#"SELECT
|
||||
t.id,
|
||||
@@ -102,7 +108,9 @@ impl Task {
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let tasks = records.into_iter().map(|record| TaskWithAttemptStatus {
|
||||
let tasks = records
|
||||
.into_iter()
|
||||
.map(|record| TaskWithAttemptStatus {
|
||||
id: record.id,
|
||||
project_id: record.project_id,
|
||||
title: record.title,
|
||||
@@ -111,7 +119,8 @@ impl Task {
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
has_in_progress_attempt: record.has_in_progress_attempt,
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
@@ -128,7 +137,11 @@ impl Task {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id_and_project_id(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||
pub async fn find_by_id_and_project_id(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
project_id: Uuid,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Task,
|
||||
r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at
|
||||
@@ -141,7 +154,11 @@ impl Task {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, data: &CreateTask, task_id: Uuid) -> Result<Self, sqlx::Error> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
data: &CreateTask,
|
||||
task_id: Uuid,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Task,
|
||||
r#"INSERT INTO tasks (id, project_id, title, description, status)
|
||||
@@ -157,7 +174,14 @@ impl Task {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, project_id: Uuid, title: String, description: Option<String>, status: TaskStatus) -> Result<Self, sqlx::Error> {
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
project_id: Uuid,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
status: TaskStatus,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Task,
|
||||
r#"UPDATE tasks
|
||||
|
||||
@@ -25,7 +25,10 @@ pub struct CreateTaskAttemptActivity {
|
||||
}
|
||||
|
||||
impl TaskAttemptActivity {
|
||||
pub async fn find_by_attempt_id(pool: &PgPool, attempt_id: Uuid) -> Result<Vec<Self>, sqlx::Error> {
|
||||
pub async fn find_by_attempt_id(
|
||||
pool: &PgPool,
|
||||
attempt_id: Uuid,
|
||||
) -> Result<Vec<Self>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
TaskAttemptActivity,
|
||||
r#"SELECT id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at
|
||||
@@ -38,7 +41,12 @@ impl TaskAttemptActivity {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, data: &CreateTaskAttemptActivity, activity_id: Uuid, status: TaskAttemptStatus) -> Result<Self, sqlx::Error> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
data: &CreateTaskAttemptActivity,
|
||||
activity_id: Uuid,
|
||||
status: TaskAttemptStatus,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
TaskAttemptActivity,
|
||||
r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note)
|
||||
@@ -53,7 +61,11 @@ impl TaskAttemptActivity {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_initial(pool: &PgPool, attempt_id: Uuid, activity_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
pub async fn create_initial(
|
||||
pool: &PgPool,
|
||||
attempt_id: Uuid,
|
||||
activity_id: Uuid,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note)
|
||||
VALUES ($1, $2, $3, $4)"#,
|
||||
@@ -67,7 +79,9 @@ impl TaskAttemptActivity {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_attempts_with_latest_init_status(pool: &PgPool) -> Result<Vec<uuid::Uuid>, sqlx::Error> {
|
||||
pub async fn find_attempts_with_latest_init_status(
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<uuid::Uuid>, sqlx::Error> {
|
||||
let records = sqlx::query!(
|
||||
r#"SELECT DISTINCT ta.id
|
||||
FROM task_attempts ta
|
||||
@@ -87,7 +101,9 @@ impl TaskAttemptActivity {
|
||||
Ok(records.into_iter().map(|r| r.id).collect())
|
||||
}
|
||||
|
||||
pub async fn find_attempts_with_latest_inprogress_status(pool: &PgPool) -> Result<Vec<uuid::Uuid>, sqlx::Error> {
|
||||
pub async fn find_attempts_with_latest_inprogress_status(
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<uuid::Uuid>, sqlx::Error> {
|
||||
let records = sqlx::query!(
|
||||
r#"SELECT DISTINCT ta.id
|
||||
FROM task_attempts ta
|
||||
|
||||
@@ -100,7 +100,12 @@ impl User {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, data: &CreateUser, password_hash: String, user_id: Uuid) -> Result<Self, sqlx::Error> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
data: &CreateUser,
|
||||
password_hash: String,
|
||||
user_id: Uuid,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
let is_admin = data.is_admin.unwrap_or(false);
|
||||
|
||||
sqlx::query_as!(
|
||||
@@ -115,7 +120,13 @@ impl User {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, email: String, password_hash: String, is_admin: bool) -> Result<Self, sqlx::Error> {
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
is_admin: bool,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
"UPDATE users SET email = $2, password_hash = $3, is_admin = $4 WHERE id = $1 RETURNING id, email, password_hash, is_admin, created_at, updated_at",
|
||||
@@ -135,7 +146,11 @@ impl User {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn create_or_update_admin(pool: &PgPool, email: &str, password_hash: &str) -> Result<(), sqlx::Error> {
|
||||
pub async fn create_or_update_admin(
|
||||
pool: &PgPool,
|
||||
email: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
use chrono::Utc;
|
||||
|
||||
// Check if admin already exists
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use axum::{
|
||||
routing::get,
|
||||
Router,
|
||||
Json,
|
||||
response::Json as ResponseJson,
|
||||
extract::{Query, Extension},
|
||||
extract::{Extension, Query},
|
||||
http::StatusCode,
|
||||
response::Json as ResponseJson,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::models::ApiResponse;
|
||||
use crate::auth::AuthUser;
|
||||
use crate::models::ApiResponse;
|
||||
|
||||
#[derive(Debug, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
@@ -91,12 +90,10 @@ pub async fn list_directory(
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, both alphabetically
|
||||
directory_entries.sort_by(|a, b| {
|
||||
match (a.is_directory, b.is_directory) {
|
||||
directory_entries.sort_by(|a, b| match (a.is_directory, b.is_directory) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ResponseJson(ApiResponse {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::response::Json;
|
||||
use crate::models::ApiResponse;
|
||||
use axum::response::Json;
|
||||
|
||||
pub async fn health_check() -> Json<ApiResponse<String>> {
|
||||
Json(ApiResponse {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod filesystem;
|
||||
pub mod health;
|
||||
pub mod projects;
|
||||
pub mod tasks;
|
||||
pub mod users;
|
||||
pub mod filesystem;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
use axum::{
|
||||
routing::get,
|
||||
Router,
|
||||
Json,
|
||||
response::Json as ResponseJson,
|
||||
extract::{Path, Extension},
|
||||
extract::{Extension, Path},
|
||||
http::StatusCode,
|
||||
response::Json as ResponseJson,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{ApiResponse, project::{Project, CreateProject, UpdateProject}};
|
||||
use crate::auth::AuthUser;
|
||||
use crate::models::{
|
||||
project::{CreateProject, Project, UpdateProject},
|
||||
ApiResponse,
|
||||
};
|
||||
|
||||
pub async fn get_projects(
|
||||
_auth: AuthUser,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, StatusCode> {
|
||||
match Project::find_all(&pool).await {
|
||||
Ok(projects) => Ok(ResponseJson(ApiResponse {
|
||||
@@ -32,7 +34,7 @@ pub async fn get_projects(
|
||||
pub async fn get_project(
|
||||
_auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
||||
match Project::find_by_id(&pool, id).await {
|
||||
Ok(Some(project)) => Ok(ResponseJson(ApiResponse {
|
||||
@@ -51,11 +53,15 @@ pub async fn get_project(
|
||||
pub async fn create_project(
|
||||
auth: AuthUser,
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Json(payload): Json<CreateProject>
|
||||
Json(payload): Json<CreateProject>,
|
||||
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
tracing::debug!("Creating project '{}' for user {}", payload.name, auth.user_id);
|
||||
tracing::debug!(
|
||||
"Creating project '{}' for user {}",
|
||||
payload.name,
|
||||
auth.user_id
|
||||
);
|
||||
|
||||
// Check if git repo path is already used by another project
|
||||
match Project::find_by_git_repo_path(&pool, &payload.git_repo_path).await {
|
||||
@@ -164,7 +170,7 @@ pub async fn create_project(
|
||||
pub async fn update_project(
|
||||
Path(id): Path<Uuid>,
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Json(payload): Json<UpdateProject>
|
||||
Json(payload): Json<UpdateProject>,
|
||||
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
||||
// Check if project exists first
|
||||
let existing_project = match Project::find_by_id(&pool, id).await {
|
||||
@@ -184,7 +190,9 @@ pub async fn update_project(
|
||||
return Ok(ResponseJson(ApiResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
message: Some("A project with this git repository path already exists".to_string()),
|
||||
message: Some(
|
||||
"A project with this git repository path already exists".to_string(),
|
||||
),
|
||||
}));
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -200,7 +208,9 @@ pub async fn update_project(
|
||||
|
||||
// Use existing values if not provided in update
|
||||
let name = payload.name.unwrap_or(existing_project.name);
|
||||
let git_repo_path = payload.git_repo_path.unwrap_or(existing_project.git_repo_path.clone());
|
||||
let git_repo_path = payload
|
||||
.git_repo_path
|
||||
.unwrap_or(existing_project.git_repo_path.clone());
|
||||
|
||||
match Project::update(&pool, id, name, git_repo_path).await {
|
||||
Ok(project) => Ok(ResponseJson(ApiResponse {
|
||||
@@ -217,7 +227,7 @@ pub async fn update_project(
|
||||
|
||||
pub async fn delete_project(
|
||||
Path(id): Path<Uuid>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
||||
match Project::delete(&pool, id).await {
|
||||
Ok(rows_affected) => {
|
||||
@@ -241,18 +251,24 @@ pub async fn delete_project(
|
||||
pub fn projects_router() -> Router {
|
||||
Router::new()
|
||||
.route("/projects", get(get_projects).post(create_project))
|
||||
.route("/projects/:id", get(get_project).put(update_project).delete(delete_project))
|
||||
.route(
|
||||
"/projects/:id",
|
||||
get(get_project).put(update_project).delete(delete_project),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{hash_password, AuthUser};
|
||||
use crate::models::{
|
||||
project::{CreateProject, UpdateProject},
|
||||
user::User,
|
||||
};
|
||||
use axum::extract::Extension;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
use crate::models::{user::User, project::{CreateProject, UpdateProject}};
|
||||
use crate::auth::{AuthUser, hash_password};
|
||||
|
||||
async fn create_test_user(pool: &PgPool, email: &str, password: &str, is_admin: bool) -> User {
|
||||
let id = Uuid::new_v4();
|
||||
@@ -274,7 +290,12 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn create_test_project(pool: &PgPool, name: &str, git_repo_path: &str, owner_id: Uuid) -> Project {
|
||||
async fn create_test_project(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
git_repo_path: &str,
|
||||
owner_id: Uuid,
|
||||
) -> Project {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
|
||||
@@ -483,7 +504,12 @@ mod tests {
|
||||
git_repo_path: None,
|
||||
};
|
||||
|
||||
let result = update_project(Path(nonexistent_project_id), Extension(pool), Json(update_request)).await;
|
||||
let result = update_project(
|
||||
Path(nonexistent_project_id),
|
||||
Extension(pool),
|
||||
Json(update_request),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -491,7 +517,8 @@ mod tests {
|
||||
#[sqlx::test]
|
||||
async fn test_delete_project_success(pool: PgPool) {
|
||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||
let project = create_test_project(&pool, "Project to Delete", "/tmp/to-delete", user.id).await;
|
||||
let project =
|
||||
create_test_project(&pool, "Project to Delete", "/tmp/to-delete", user.id).await;
|
||||
|
||||
let result = delete_project(Path(project.id), Extension(pool)).await;
|
||||
assert!(result.is_ok());
|
||||
@@ -515,7 +542,8 @@ mod tests {
|
||||
use crate::models::task::{Task, TaskStatus};
|
||||
|
||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||
let project = create_test_project(&pool, "Project with Tasks", "/tmp/with-tasks", user.id).await;
|
||||
let project =
|
||||
create_test_project(&pool, "Project with Tasks", "/tmp/with-tasks", user.id).await;
|
||||
|
||||
// Create a task in the project
|
||||
let task_id = Uuid::new_v4();
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
use axum::{
|
||||
routing::get,
|
||||
Router,
|
||||
Json,
|
||||
response::Json as ResponseJson,
|
||||
extract::{Path, Extension},
|
||||
extract::{Extension, Path},
|
||||
http::StatusCode,
|
||||
response::Json as ResponseJson,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{
|
||||
ApiResponse,
|
||||
project::Project,
|
||||
task::{Task, CreateTask, UpdateTask, TaskWithAttemptStatus},
|
||||
task_attempt::{TaskAttempt, CreateTaskAttempt, TaskAttemptStatus},
|
||||
task_attempt_activity::{TaskAttemptActivity, CreateTaskAttemptActivity}
|
||||
};
|
||||
use crate::auth::AuthUser;
|
||||
use crate::models::{
|
||||
project::Project,
|
||||
task::{CreateTask, Task, TaskWithAttemptStatus, UpdateTask},
|
||||
task_attempt::{CreateTaskAttempt, TaskAttempt, TaskAttemptStatus},
|
||||
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
|
||||
ApiResponse,
|
||||
};
|
||||
|
||||
pub async fn get_project_tasks(
|
||||
_auth: AuthUser,
|
||||
Path(project_id): Path<Uuid>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<Vec<TaskWithAttemptStatus>>>, StatusCode> {
|
||||
match Task::find_by_project_id_with_attempt_status(&pool, project_id).await {
|
||||
Ok(tasks) => Ok(ResponseJson(ApiResponse {
|
||||
@@ -39,7 +38,7 @@ pub async fn get_project_tasks(
|
||||
pub async fn get_task(
|
||||
_auth: AuthUser,
|
||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
||||
match Task::find_by_id_and_project_id(&pool, task_id, project_id).await {
|
||||
Ok(Some(task)) => Ok(ResponseJson(ApiResponse {
|
||||
@@ -49,7 +48,12 @@ pub async fn get_task(
|
||||
})),
|
||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch task {} in project {}: {}", task_id, project_id, e);
|
||||
tracing::error!(
|
||||
"Failed to fetch task {} in project {}: {}",
|
||||
task_id,
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +63,7 @@ pub async fn create_task(
|
||||
Path(project_id): Path<Uuid>,
|
||||
auth: AuthUser,
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Json(mut payload): Json<CreateTask>
|
||||
Json(mut payload): Json<CreateTask>,
|
||||
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
@@ -76,7 +80,12 @@ pub async fn create_task(
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
tracing::debug!("Creating task '{}' in project {} for user {}", payload.title, project_id, auth.user_id);
|
||||
tracing::debug!(
|
||||
"Creating task '{}' in project {} for user {}",
|
||||
payload.title,
|
||||
project_id,
|
||||
auth.user_id
|
||||
);
|
||||
|
||||
match Task::create(&pool, &payload, id).await {
|
||||
Ok(task) => Ok(ResponseJson(ApiResponse {
|
||||
@@ -94,7 +103,7 @@ pub async fn create_task(
|
||||
pub async fn update_task(
|
||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Json(payload): Json<UpdateTask>
|
||||
Json(payload): Json<UpdateTask>,
|
||||
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
||||
// Check if task exists in the specified project
|
||||
let existing_task = match Task::find_by_id_and_project_id(&pool, task_id, project_id).await {
|
||||
@@ -126,7 +135,7 @@ pub async fn update_task(
|
||||
|
||||
pub async fn delete_task(
|
||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
||||
match Task::delete(&pool, task_id, project_id).await {
|
||||
Ok(rows_affected) => {
|
||||
@@ -151,7 +160,7 @@ pub async fn delete_task(
|
||||
pub async fn get_task_attempts(
|
||||
_auth: AuthUser,
|
||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttempt>>>, StatusCode> {
|
||||
// Verify task exists in project first
|
||||
match Task::exists(&pool, task_id, project_id).await {
|
||||
@@ -179,7 +188,7 @@ pub async fn get_task_attempts(
|
||||
pub async fn get_task_attempt_activities(
|
||||
_auth: AuthUser,
|
||||
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttemptActivity>>>, StatusCode> {
|
||||
// Verify task attempt exists and belongs to the correct task
|
||||
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
|
||||
@@ -198,7 +207,11 @@ pub async fn get_task_attempt_activities(
|
||||
message: None,
|
||||
})),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch task attempt activities for attempt {}: {}", attempt_id, e);
|
||||
tracing::error!(
|
||||
"Failed to fetch task attempt activities for attempt {}: {}",
|
||||
attempt_id,
|
||||
e
|
||||
);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
@@ -208,7 +221,7 @@ pub async fn create_task_attempt(
|
||||
_auth: AuthUser,
|
||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Json(mut payload): Json<CreateTaskAttempt>
|
||||
Json(mut payload): Json<CreateTaskAttempt>,
|
||||
) -> Result<ResponseJson<ApiResponse<TaskAttempt>>, StatusCode> {
|
||||
// Verify task exists in project first
|
||||
match Task::exists(&pool, task_id, project_id).await {
|
||||
@@ -248,7 +261,7 @@ pub async fn create_task_attempt_activity(
|
||||
_auth: AuthUser,
|
||||
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Json(mut payload): Json<CreateTaskAttemptActivity>
|
||||
Json(mut payload): Json<CreateTaskAttemptActivity>,
|
||||
) -> Result<ResponseJson<ApiResponse<TaskAttemptActivity>>, StatusCode> {
|
||||
// Verify task attempt exists and belongs to the correct task
|
||||
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
|
||||
@@ -285,7 +298,7 @@ pub async fn stop_task_attempt(
|
||||
_auth: AuthUser,
|
||||
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Extension(app_state): Extension<crate::execution_monitor::AppState>
|
||||
Extension(app_state): Extension<crate::execution_monitor::AppState>,
|
||||
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
||||
// Verify task attempt exists and belongs to the correct task
|
||||
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
|
||||
@@ -349,7 +362,9 @@ pub async fn stop_task_attempt(
|
||||
&create_activity,
|
||||
activity_id,
|
||||
TaskAttemptStatus::Paused,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to create stopped activity: {}", e);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
@@ -362,25 +377,44 @@ pub async fn stop_task_attempt(
|
||||
}
|
||||
|
||||
pub fn tasks_router() -> Router {
|
||||
use axum::routing::{post, put, delete};
|
||||
use axum::routing::{delete, post, put};
|
||||
|
||||
Router::new()
|
||||
.route("/projects/:project_id/tasks", get(get_project_tasks).post(create_task))
|
||||
.route("/projects/:project_id/tasks/:task_id", get(get_task).put(update_task).delete(delete_task))
|
||||
.route("/projects/:project_id/tasks/:task_id/attempts", get(get_task_attempts).post(create_task_attempt))
|
||||
.route("/projects/:project_id/tasks/:task_id/attempts/:attempt_id/activities", get(get_task_attempt_activities).post(create_task_attempt_activity))
|
||||
.route("/projects/:project_id/tasks/:task_id/attempts/:attempt_id/stop", post(stop_task_attempt))
|
||||
.route(
|
||||
"/projects/:project_id/tasks",
|
||||
get(get_project_tasks).post(create_task),
|
||||
)
|
||||
.route(
|
||||
"/projects/:project_id/tasks/:task_id",
|
||||
get(get_task).put(update_task).delete(delete_task),
|
||||
)
|
||||
.route(
|
||||
"/projects/:project_id/tasks/:task_id/attempts",
|
||||
get(get_task_attempts).post(create_task_attempt),
|
||||
)
|
||||
.route(
|
||||
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/activities",
|
||||
get(get_task_attempt_activities).post(create_task_attempt_activity),
|
||||
)
|
||||
.route(
|
||||
"/projects/:project_id/tasks/:task_id/attempts/:attempt_id/stop",
|
||||
post(stop_task_attempt),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{hash_password, AuthUser};
|
||||
use crate::models::{
|
||||
project::Project,
|
||||
task::{CreateTask, TaskStatus, UpdateTask},
|
||||
user::User,
|
||||
};
|
||||
use axum::extract::Extension;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
use crate::models::{user::User, project::Project, task::{CreateTask, UpdateTask, TaskStatus}};
|
||||
use crate::auth::{AuthUser, hash_password};
|
||||
|
||||
async fn create_test_user(pool: &PgPool, email: &str, password: &str, is_admin: bool) -> User {
|
||||
let id = Uuid::new_v4();
|
||||
@@ -422,7 +456,13 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn create_test_task(pool: &PgPool, project_id: Uuid, title: &str, description: Option<String>, status: TaskStatus) -> Task {
|
||||
async fn create_test_task(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
title: &str,
|
||||
description: Option<String>,
|
||||
status: TaskStatus,
|
||||
) -> Task {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
|
||||
@@ -448,9 +488,23 @@ mod tests {
|
||||
let project = create_test_project(&pool, "Test Project", user.id).await;
|
||||
|
||||
// Create multiple tasks
|
||||
create_test_task(&pool, project.id, "Task 1", Some("Description 1".to_string()), TaskStatus::Todo).await;
|
||||
create_test_task(
|
||||
&pool,
|
||||
project.id,
|
||||
"Task 1",
|
||||
Some("Description 1".to_string()),
|
||||
TaskStatus::Todo,
|
||||
)
|
||||
.await;
|
||||
create_test_task(&pool, project.id, "Task 2", None, TaskStatus::InProgress).await;
|
||||
create_test_task(&pool, project.id, "Task 3", Some("Description 3".to_string()), TaskStatus::Done).await;
|
||||
create_test_task(
|
||||
&pool,
|
||||
project.id,
|
||||
"Task 3",
|
||||
Some("Description 3".to_string()),
|
||||
TaskStatus::Done,
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth = AuthUser {
|
||||
user_id: user.id,
|
||||
@@ -491,7 +545,14 @@ mod tests {
|
||||
async fn test_get_task_success(pool: PgPool) {
|
||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||
let project = create_test_project(&pool, "Test Project", user.id).await;
|
||||
let task = create_test_task(&pool, project.id, "Test Task", Some("Test Description".to_string()), TaskStatus::Todo).await;
|
||||
let task = create_test_task(
|
||||
&pool,
|
||||
project.id,
|
||||
"Test Task",
|
||||
Some("Test Description".to_string()),
|
||||
TaskStatus::Todo,
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth = AuthUser {
|
||||
user_id: user.id,
|
||||
@@ -524,7 +585,12 @@ mod tests {
|
||||
is_admin: false,
|
||||
};
|
||||
|
||||
let result = get_task(auth, Path((project.id, nonexistent_task_id)), Extension(pool)).await;
|
||||
let result = get_task(
|
||||
auth,
|
||||
Path((project.id, nonexistent_task_id)),
|
||||
Extension(pool),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -565,7 +631,13 @@ mod tests {
|
||||
description: Some("Task description".to_string()),
|
||||
};
|
||||
|
||||
let result = create_task(Path(project.id), auth, Extension(pool), Json(create_request)).await;
|
||||
let result = create_task(
|
||||
Path(project.id),
|
||||
auth,
|
||||
Extension(pool),
|
||||
Json(create_request),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let response = result.unwrap().0;
|
||||
@@ -573,7 +645,10 @@ mod tests {
|
||||
assert!(response.data.is_some());
|
||||
let created_task = response.data.unwrap();
|
||||
assert_eq!(created_task.title, "New Task");
|
||||
assert_eq!(created_task.description, Some("Task description".to_string()));
|
||||
assert_eq!(
|
||||
created_task.description,
|
||||
Some("Task description".to_string())
|
||||
);
|
||||
assert_eq!(created_task.status, TaskStatus::Todo);
|
||||
assert_eq!(created_task.project_id, project.id);
|
||||
}
|
||||
@@ -595,7 +670,13 @@ mod tests {
|
||||
description: None,
|
||||
};
|
||||
|
||||
let result = create_task(Path(nonexistent_project_id), auth, Extension(pool), Json(create_request)).await;
|
||||
let result = create_task(
|
||||
Path(nonexistent_project_id),
|
||||
auth,
|
||||
Extension(pool),
|
||||
Json(create_request),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -604,7 +685,14 @@ mod tests {
|
||||
async fn test_update_task_success(pool: PgPool) {
|
||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||
let project = create_test_project(&pool, "Test Project", user.id).await;
|
||||
let task = create_test_task(&pool, project.id, "Original Title", Some("Original Description".to_string()), TaskStatus::Todo).await;
|
||||
let task = create_test_task(
|
||||
&pool,
|
||||
project.id,
|
||||
"Original Title",
|
||||
Some("Original Description".to_string()),
|
||||
TaskStatus::Todo,
|
||||
)
|
||||
.await;
|
||||
|
||||
let update_request = UpdateTask {
|
||||
title: Some("Updated Title".to_string()),
|
||||
@@ -612,7 +700,12 @@ mod tests {
|
||||
status: Some(TaskStatus::InProgress),
|
||||
};
|
||||
|
||||
let result = update_task(Path((project.id, task.id)), Extension(pool), Json(update_request)).await;
|
||||
let result = update_task(
|
||||
Path((project.id, task.id)),
|
||||
Extension(pool),
|
||||
Json(update_request),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let response = result.unwrap().0;
|
||||
@@ -620,7 +713,10 @@ mod tests {
|
||||
assert!(response.data.is_some());
|
||||
let updated_task = response.data.unwrap();
|
||||
assert_eq!(updated_task.title, "Updated Title");
|
||||
assert_eq!(updated_task.description, Some("Updated Description".to_string()));
|
||||
assert_eq!(
|
||||
updated_task.description,
|
||||
Some("Updated Description".to_string())
|
||||
);
|
||||
assert_eq!(updated_task.status, TaskStatus::InProgress);
|
||||
}
|
||||
|
||||
@@ -628,7 +724,14 @@ mod tests {
|
||||
async fn test_update_task_partial(pool: PgPool) {
|
||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||
let project = create_test_project(&pool, "Test Project", user.id).await;
|
||||
let task = create_test_task(&pool, project.id, "Original Title", Some("Original Description".to_string()), TaskStatus::Todo).await;
|
||||
let task = create_test_task(
|
||||
&pool,
|
||||
project.id,
|
||||
"Original Title",
|
||||
Some("Original Description".to_string()),
|
||||
TaskStatus::Todo,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Only update status
|
||||
let update_request = UpdateTask {
|
||||
@@ -637,7 +740,12 @@ mod tests {
|
||||
status: Some(TaskStatus::Done),
|
||||
};
|
||||
|
||||
let result = update_task(Path((project.id, task.id)), Extension(pool), Json(update_request)).await;
|
||||
let result = update_task(
|
||||
Path((project.id, task.id)),
|
||||
Extension(pool),
|
||||
Json(update_request),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let response = result.unwrap().0;
|
||||
@@ -645,7 +753,10 @@ mod tests {
|
||||
assert!(response.data.is_some());
|
||||
let updated_task = response.data.unwrap();
|
||||
assert_eq!(updated_task.title, "Original Title"); // Should remain unchanged
|
||||
assert_eq!(updated_task.description, Some("Original Description".to_string())); // Should remain unchanged
|
||||
assert_eq!(
|
||||
updated_task.description,
|
||||
Some("Original Description".to_string())
|
||||
); // Should remain unchanged
|
||||
assert_eq!(updated_task.status, TaskStatus::Done); // Should be updated
|
||||
}
|
||||
|
||||
@@ -661,7 +772,12 @@ mod tests {
|
||||
status: None,
|
||||
};
|
||||
|
||||
let result = update_task(Path((project.id, nonexistent_task_id)), Extension(pool), Json(update_request)).await;
|
||||
let result = update_task(
|
||||
Path((project.id, nonexistent_task_id)),
|
||||
Extension(pool),
|
||||
Json(update_request),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -680,7 +796,12 @@ mod tests {
|
||||
};
|
||||
|
||||
// Try to update task in wrong project
|
||||
let result = update_task(Path((project2.id, task.id)), Extension(pool), Json(update_request)).await;
|
||||
let result = update_task(
|
||||
Path((project2.id, task.id)),
|
||||
Extension(pool),
|
||||
Json(update_request),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -689,7 +810,8 @@ mod tests {
|
||||
async fn test_delete_task_success(pool: PgPool) {
|
||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||
let project = create_test_project(&pool, "Test Project", user.id).await;
|
||||
let task = create_test_task(&pool, project.id, "Task to Delete", None, TaskStatus::Todo).await;
|
||||
let task =
|
||||
create_test_task(&pool, project.id, "Task to Delete", None, TaskStatus::Todo).await;
|
||||
|
||||
let result = delete_task(Path((project.id, task.id)), Extension(pool)).await;
|
||||
assert!(result.is_ok());
|
||||
@@ -715,7 +837,8 @@ mod tests {
|
||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||
let project1 = create_test_project(&pool, "Project 1", user.id).await;
|
||||
let project2 = create_test_project(&pool, "Project 2", user.id).await;
|
||||
let task = create_test_task(&pool, project1.id, "Task to Delete", None, TaskStatus::Todo).await;
|
||||
let task =
|
||||
create_test_task(&pool, project1.id, "Task to Delete", None, TaskStatus::Todo).await;
|
||||
|
||||
// Try to delete task from wrong project
|
||||
let result = delete_task(Path((project2.id, task.id)), Extension(pool)).await;
|
||||
|
||||
@@ -1,49 +1,45 @@
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
Json,
|
||||
response::Json as ResponseJson,
|
||||
extract::{Path, Extension},
|
||||
extract::{Extension, Path},
|
||||
http::StatusCode,
|
||||
response::Json as ResponseJson,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{ApiResponse, user::{User, CreateUser, UpdateUser, LoginRequest, LoginResponse, UserResponse}};
|
||||
use crate::auth::{AuthUser, create_token, hash_password, verify_password};
|
||||
use crate::auth::{create_token, hash_password, verify_password, AuthUser};
|
||||
use crate::models::{
|
||||
user::{CreateUser, LoginRequest, LoginResponse, UpdateUser, User, UserResponse},
|
||||
ApiResponse,
|
||||
};
|
||||
|
||||
pub async fn login(
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Json(payload): Json<LoginRequest>
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<ResponseJson<ApiResponse<LoginResponse>>, StatusCode> {
|
||||
match User::find_by_email(&pool, &payload.email).await {
|
||||
Ok(Some(user)) => {
|
||||
match verify_password(&payload.password, &user.password_hash) {
|
||||
Ok(true) => {
|
||||
match create_token(user.id, user.email.clone(), user.is_admin) {
|
||||
Ok(token) => {
|
||||
Ok(ResponseJson(ApiResponse {
|
||||
Ok(Some(user)) => match verify_password(&payload.password, &user.password_hash) {
|
||||
Ok(true) => match create_token(user.id, user.email.clone(), user.is_admin) {
|
||||
Ok(token) => Ok(ResponseJson(ApiResponse {
|
||||
success: true,
|
||||
data: Some(LoginResponse {
|
||||
user: user.into(),
|
||||
token,
|
||||
}),
|
||||
message: Some("Login successful".to_string()),
|
||||
}))
|
||||
}
|
||||
})),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create token: {}", e);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(false) => Err(StatusCode::UNAUTHORIZED),
|
||||
Err(e) => {
|
||||
tracing::error!("Password verification error: {}", e);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(None) => Err(StatusCode::UNAUTHORIZED),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch user: {}", e);
|
||||
@@ -54,7 +50,7 @@ pub async fn login(
|
||||
|
||||
pub async fn get_users(
|
||||
_auth: AuthUser,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<Vec<UserResponse>>>, StatusCode> {
|
||||
match User::find_all(&pool).await {
|
||||
Ok(users) => {
|
||||
@@ -75,7 +71,7 @@ pub async fn get_users(
|
||||
pub async fn get_user(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
||||
// Users can only view their own profile unless they're admin
|
||||
if auth.user_id != id && !auth.is_admin {
|
||||
@@ -99,7 +95,7 @@ pub async fn get_user(
|
||||
pub async fn create_user(
|
||||
auth: AuthUser,
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Json(payload): Json<CreateUser>
|
||||
Json(payload): Json<CreateUser>,
|
||||
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
||||
// Only admins can create users
|
||||
if !auth.is_admin {
|
||||
@@ -134,7 +130,7 @@ pub async fn update_user(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Extension(pool): Extension<PgPool>,
|
||||
Json(payload): Json<UpdateUser>
|
||||
Json(payload): Json<UpdateUser>,
|
||||
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
||||
// Users can only update their own profile unless they're admin
|
||||
if auth.user_id != id && !auth.is_admin {
|
||||
@@ -183,7 +179,7 @@ pub async fn update_user(
|
||||
pub async fn delete_user(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
||||
// Only admins can delete users, and they can't delete themselves
|
||||
if !auth.is_admin || auth.user_id == id {
|
||||
@@ -211,7 +207,7 @@ pub async fn delete_user(
|
||||
|
||||
pub async fn get_current_user(
|
||||
auth: AuthUser,
|
||||
Extension(pool): Extension<PgPool>
|
||||
Extension(pool): Extension<PgPool>,
|
||||
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
||||
match User::find_by_id(&pool, auth.user_id).await {
|
||||
Ok(Some(user)) => Ok(ResponseJson(ApiResponse {
|
||||
@@ -227,9 +223,7 @@ pub async fn get_current_user(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_auth_status(
|
||||
auth: AuthUser,
|
||||
) -> ResponseJson<ApiResponse<serde_json::Value>> {
|
||||
pub async fn check_auth_status(auth: AuthUser) -> ResponseJson<ApiResponse<serde_json::Value>> {
|
||||
ResponseJson(ApiResponse {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({
|
||||
@@ -243,8 +237,7 @@ pub async fn check_auth_status(
|
||||
}
|
||||
|
||||
pub fn public_users_router() -> Router {
|
||||
Router::new()
|
||||
.route("/auth/login", post(login))
|
||||
Router::new().route("/auth/login", post(login))
|
||||
}
|
||||
|
||||
pub fn protected_users_router() -> Router {
|
||||
@@ -252,18 +245,21 @@ pub fn protected_users_router() -> Router {
|
||||
.route("/auth/status", get(check_auth_status))
|
||||
.route("/auth/me", get(get_current_user))
|
||||
.route("/users", get(get_users).post(create_user))
|
||||
.route("/users/:id", get(get_user).put(update_user).delete(delete_user))
|
||||
.route(
|
||||
"/users/:id",
|
||||
get(get_user).put(update_user).delete(delete_user),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{hash_password, AuthUser};
|
||||
use crate::models::user::{CreateUser, LoginRequest, UpdateUser};
|
||||
use axum::extract::Extension;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
use crate::models::user::{LoginRequest, CreateUser, UpdateUser};
|
||||
use crate::auth::{AuthUser, hash_password};
|
||||
|
||||
async fn create_test_user(pool: &PgPool, email: &str, password: &str, is_admin: bool) -> User {
|
||||
let id = Uuid::new_v4();
|
||||
@@ -523,7 +519,8 @@ mod tests {
|
||||
#[sqlx::test]
|
||||
async fn test_delete_user_as_admin(pool: PgPool) {
|
||||
let admin_user = create_test_user(&pool, "admin@example.com", "password123", true).await;
|
||||
let user_to_delete = create_test_user(&pool, "delete@example.com", "password123", false).await;
|
||||
let user_to_delete =
|
||||
create_test_user(&pool, "delete@example.com", "password123", false).await;
|
||||
|
||||
let auth = AuthUser {
|
||||
user_id: admin_user.id,
|
||||
|
||||
Reference in New Issue
Block a user