This commit is contained in:
Louis Knight-Webb
2025-06-16 23:13:33 -04:00
parent 0ce5032830
commit e21b2b9662
14 changed files with 550 additions and 275 deletions

View File

@@ -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,9 +43,13 @@ 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()
.checked_add_signed(chrono::Duration::hours(24))
.expect("valid timestamp")
@@ -79,7 +83,7 @@ pub async fn auth_middleware(
next: Next,
) -> Result<Response, StatusCode> {
let headers = request.headers();
let auth_header = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
@@ -90,7 +94,7 @@ pub async fn auth_middleware(
.ok_or(StatusCode::UNAUTHORIZED)?;
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string());
let claims = decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_secret.as_ref()),
@@ -106,13 +110,10 @@ 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
)
.fetch_optional(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
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)?;
if user_exists.is_none() {
return Err(StatusCode::UNAUTHORIZED);

View File

@@ -6,8 +6,8 @@ use tokio::sync::Mutex;
use uuid::Uuid;
use crate::models::{
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
task_attempt::{TaskAttempt, TaskAttemptStatus}
task_attempt::{TaskAttempt, TaskAttemptStatus},
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
};
#[derive(Debug)]
@@ -25,24 +25,31 @@ pub struct AppState {
pub async fn execution_monitor(app_state: AppState) {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
loop {
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 {
Ok(attempts) => attempts,
Err(e) => {
tracing::error!("Failed to query inprogress attempts: {}", e);
continue;
}
};
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);
continue;
}
};
for attempt_id in inprogress_attempt_ids {
// 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,29 +66,39 @@ 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);
}
}
}
// 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 {
Ok(attempts) => attempts,
Err(e) => {
tracing::error!("Failed to query init attempts: {}", e);
continue;
}
};
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);
continue;
}
};
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 {
task_attempt_id: attempt_id,
child,
started_at: Utc::now(),
});
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,13 +217,17 @@ 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 {
String::new()
};
tracing::info!("Execution {} {}{}", execution_id, status_text, exit_text);
// Create task attempt activity with Paused status
@@ -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
);
}
}
}

View File

@@ -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;

View File

@@ -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>,

View File

@@ -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",

View File

@@ -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,16 +108,19 @@ impl Task {
.fetch_all(pool)
.await?;
let tasks = records.into_iter().map(|record| TaskWithAttemptStatus {
id: record.id,
project_id: record.project_id,
title: record.title,
description: record.description,
status: record.status,
created_at: record.created_at,
updated_at: record.updated_at,
has_in_progress_attempt: record.has_in_progress_attempt,
}).collect();
let tasks = records
.into_iter()
.map(|record| TaskWithAttemptStatus {
id: record.id,
project_id: record.project_id,
title: record.title,
description: record.description,
status: record.status,
created_at: record.created_at,
updated_at: record.updated_at,
has_in_progress_attempt: record.has_in_progress_attempt,
})
.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
@@ -176,8 +200,8 @@ impl Task {
pub async fn delete(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
"DELETE FROM tasks WHERE id = $1 AND project_id = $2",
id,
"DELETE FROM tasks WHERE id = $1 AND project_id = $2",
id,
project_id
)
.execute(pool)
@@ -187,8 +211,8 @@ impl Task {
pub async fn exists(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result<bool, sqlx::Error> {
let result = sqlx::query!(
"SELECT id FROM tasks WHERE id = $1 AND project_id = $2",
id,
"SELECT id FROM tasks WHERE id = $1 AND project_id = $2",
id,
project_id
)
.fetch_optional(pool)

View File

@@ -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
@@ -83,11 +97,13 @@ impl TaskAttemptActivity {
)
.fetch_all(pool)
.await?;
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
@@ -103,7 +119,7 @@ impl TaskAttemptActivity {
)
.fetch_all(pool)
.await?;
Ok(records.into_iter().map(|r| r.id).collect())
}
}

View File

@@ -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,9 +146,13 @@ 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
let existing_admin = sqlx::query!(
"SELECT id, password_hash FROM users WHERE email = $1",

View File

@@ -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)]
@@ -61,25 +60,25 @@ pub async fn list_directory(
match fs::read_dir(path) {
Ok(entries) => {
let mut directory_entries = Vec::new();
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
let metadata = entry.metadata().ok();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
// Skip hidden files/directories
if name.starts_with('.') && name != ".." {
continue;
}
let is_directory = metadata.map_or(false, |m| m.is_dir());
let is_git_repo = if is_directory {
path.join(".git").exists()
} else {
false
};
directory_entries.push(DirectoryEntry {
name: name.to_string(),
path: path.to_string_lossy().to_string(),
@@ -89,16 +88,14 @@ 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) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
}
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 {
success: true,
data: Some(directory_entries),
@@ -125,7 +122,7 @@ pub async fn validate_git_path(
// Check if path exists and is a git repo
let is_valid_git_repo = path.exists() && path.is_dir() && path.join(".git").exists();
Ok(ResponseJson(ApiResponse {
success: true,
data: Some(is_valid_git_repo),

View File

@@ -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 {

View File

@@ -1,5 +1,5 @@
pub mod filesystem;
pub mod health;
pub mod projects;
pub mod tasks;
pub mod users;
pub mod filesystem;

View File

@@ -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 {
@@ -77,7 +83,7 @@ pub async fn create_project(
// Validate and setup git repository
let path = std::path::Path::new(&payload.git_repo_path);
if payload.use_existing_repo {
// For existing repos, validate that the path exists and is a git repository
if !path.exists() {
@@ -105,7 +111,7 @@ pub async fn create_project(
}
} else {
// For new repos, create directory and initialize git
// Create directory if it doesn't exist
if !path.exists() {
if let Err(e) = std::fs::create_dir_all(path) {
@@ -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();
@@ -296,7 +317,7 @@ mod tests {
#[sqlx::test]
async fn test_get_projects_success(pool: PgPool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
// Create multiple projects
create_test_project(&pool, "Project 1", "/tmp/test1", user.id).await;
create_test_project(&pool, "Project 2", "/tmp/test2", user.id).await;
@@ -310,7 +331,7 @@ mod tests {
let result = get_projects(auth, Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -329,7 +350,7 @@ mod tests {
let result = get_projects(auth, Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -349,7 +370,7 @@ mod tests {
let result = get_project(auth, Path(project.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -393,7 +414,7 @@ mod tests {
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -421,7 +442,7 @@ mod tests {
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -442,7 +463,7 @@ mod tests {
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -465,7 +486,7 @@ mod tests {
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -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,11 +517,12 @@ 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());
let response = result.unwrap().0;
assert!(response.success);
assert_eq!(response.message.unwrap(), "Project deleted successfully");
@@ -513,10 +540,11 @@ mod tests {
#[sqlx::test]
async fn test_delete_project_cascades_to_tasks(pool: PgPool) {
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();
let now = Utc::now();
@@ -563,7 +591,7 @@ mod tests {
async fn test_projects_belong_to_users(pool: PgPool) {
let user1 = create_test_user(&pool, "user1@example.com", "password123", false).await;
let user2 = create_test_user(&pool, "user2@example.com", "password123", false).await;
let project1 = create_test_project(&pool, "User 1 Project", "/tmp/user1", user1.id).await;
let project2 = create_test_project(&pool, "User 2 Project", "/tmp/user2", user2.id).await;

View File

@@ -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,13 +63,13 @@ 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();
// Ensure the project_id in the payload matches the path parameter
payload.project_id = project_id;
// Verify project exists first
match Project::exists(&pool, project_id).await {
Ok(false) => return Err(StatusCode::NOT_FOUND),
@@ -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 {
@@ -221,7 +234,7 @@ pub async fn create_task_attempt(
}
let id = Uuid::new_v4();
// Ensure the task_id in the payload matches the path parameter
payload.task_id = task_id;
@@ -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 {
@@ -261,10 +274,10 @@ pub async fn create_task_attempt_activity(
}
let id = Uuid::new_v4();
// Ensure the task_attempt_id in the payload matches the path parameter
payload.task_attempt_id = attempt_id;
// Default to Init status if not provided
let status = payload.status.clone().unwrap_or(TaskAttemptStatus::Init);
@@ -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 {
@@ -302,7 +315,7 @@ pub async fn stop_task_attempt(
{
let mut executions = app_state.running_executions.lock().await;
let mut execution_id_to_remove = None;
// Find the execution for this attempt
for (exec_id, execution) in executions.iter_mut() {
if execution.task_attempt_id == attempt_id {
@@ -321,7 +334,7 @@ pub async fn stop_task_attempt(
}
}
}
// Remove the stopped execution from the map
if let Some(exec_id) = execution_id_to_remove {
executions.remove(&exec_id);
@@ -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();
@@ -446,11 +486,25 @@ mod tests {
async fn test_get_project_tasks_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;
// 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,
@@ -460,7 +514,7 @@ mod tests {
let result = get_project_tasks(auth, Path(project.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -480,7 +534,7 @@ mod tests {
let result = get_project_tasks(auth, Path(project.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -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,
@@ -501,7 +562,7 @@ mod tests {
let result = get_task(auth, Path((project.id, task.id)), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -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,15 +631,24 @@ 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;
assert!(response.success);
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,15 +700,23 @@ 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;
assert!(response.success);
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,15 +740,23 @@ 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;
assert!(response.success);
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,11 +810,12 @@ 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());
let response = result.unwrap().0;
assert!(response.success);
assert_eq!(response.message.unwrap(), "Task deleted successfully");
@@ -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;

View File

@@ -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 {
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),
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!("Password verification error: {}", 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();
@@ -288,7 +284,7 @@ mod tests {
#[sqlx::test]
async fn test_login_success(pool: PgPool) {
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
let login_request = LoginRequest {
email: "test@example.com".to_string(),
password: "password123".to_string(),
@@ -296,7 +292,7 @@ mod tests {
let result = login(Extension(pool), Json(login_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -306,7 +302,7 @@ mod tests {
#[sqlx::test]
async fn test_login_invalid_password(pool: PgPool) {
create_test_user(&pool, "test@example.com", "password123", false).await;
let login_request = LoginRequest {
email: "test@example.com".to_string(),
password: "wrongpassword".to_string(),
@@ -343,7 +339,7 @@ mod tests {
let result = get_users(auth, Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -362,7 +358,7 @@ mod tests {
let result = get_user(auth, Path(user.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -398,7 +394,7 @@ mod tests {
let result = get_user(auth, Path(regular_user.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -423,7 +419,7 @@ mod tests {
let result = create_user(auth, Extension(pool), Json(create_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -491,7 +487,7 @@ mod tests {
let result = update_user(auth, Path(user.id), Extension(pool), Json(update_request)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -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,
@@ -533,7 +530,7 @@ mod tests {
let result = delete_user(auth, Path(user_to_delete.id), Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert_eq!(response.message.unwrap(), "User deleted successfully");
@@ -582,7 +579,7 @@ mod tests {
let result = get_current_user(auth, Extension(pool)).await;
assert!(result.is_ok());
let response = result.unwrap().0;
assert!(response.success);
assert!(response.data.is_some());
@@ -600,7 +597,7 @@ mod tests {
let response = check_auth_status(auth.clone()).await.0;
assert!(response.success);
assert!(response.data.is_some());
let data = response.data.unwrap();
assert_eq!(data["authenticated"], true);
assert_eq!(data["user_id"], auth.user_id.to_string());