FMT
This commit is contained in:
@@ -2,7 +2,7 @@ use axum::{
|
|||||||
async_trait,
|
async_trait,
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::FromRequestParts,
|
extract::FromRequestParts,
|
||||||
http::{request::Parts, StatusCode, Request},
|
http::{request::Parts, Request, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
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 jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string());
|
||||||
|
|
||||||
let expiration = chrono::Utc::now()
|
let expiration = chrono::Utc::now()
|
||||||
.checked_add_signed(chrono::Duration::hours(24))
|
.checked_add_signed(chrono::Duration::hours(24))
|
||||||
.expect("valid timestamp")
|
.expect("valid timestamp")
|
||||||
@@ -79,7 +83,7 @@ pub async fn auth_middleware(
|
|||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let headers = request.headers();
|
let headers = request.headers();
|
||||||
|
|
||||||
let auth_header = headers
|
let auth_header = headers
|
||||||
.get("authorization")
|
.get("authorization")
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
@@ -90,7 +94,7 @@ pub async fn auth_middleware(
|
|||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string());
|
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string());
|
||||||
|
|
||||||
let claims = decode::<Claims>(
|
let claims = decode::<Claims>(
|
||||||
token,
|
token,
|
||||||
&DecodingKey::from_secret(jwt_secret.as_ref()),
|
&DecodingKey::from_secret(jwt_secret.as_ref()),
|
||||||
@@ -106,13 +110,10 @@ pub async fn auth_middleware(
|
|||||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// Verify user exists in database
|
// Verify user exists in database
|
||||||
let user_exists = sqlx::query!(
|
let user_exists = sqlx::query!("SELECT id FROM users WHERE id = $1", claims.user_id)
|
||||||
"SELECT id FROM users WHERE id = $1",
|
.fetch_optional(pool)
|
||||||
claims.user_id
|
.await
|
||||||
)
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
if user_exists.is_none() {
|
if user_exists.is_none() {
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use tokio::sync::Mutex;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
|
task_attempt::{TaskAttempt, TaskAttemptStatus},
|
||||||
task_attempt::{TaskAttempt, TaskAttemptStatus}
|
task_attempt_activity::{CreateTaskAttemptActivity, TaskAttemptActivity},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -25,24 +25,31 @@ pub struct AppState {
|
|||||||
|
|
||||||
pub async fn execution_monitor(app_state: AppState) {
|
pub async fn execution_monitor(app_state: AppState) {
|
||||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
|
||||||
// Check for orphaned task attempts with latest activity status = InProgress but no running execution
|
// 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 =
|
||||||
Ok(attempts) => attempts,
|
match TaskAttemptActivity::find_attempts_with_latest_inprogress_status(
|
||||||
Err(e) => {
|
&app_state.db_pool,
|
||||||
tracing::error!("Failed to query inprogress attempts: {}", e);
|
)
|
||||||
continue;
|
.await
|
||||||
}
|
{
|
||||||
};
|
Ok(attempts) => attempts,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to query inprogress attempts: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for attempt_id in inprogress_attempt_ids {
|
for attempt_id in inprogress_attempt_ids {
|
||||||
// Check if this attempt has a running execution
|
// Check if this attempt has a running execution
|
||||||
let has_running_execution = {
|
let has_running_execution = {
|
||||||
let executions = app_state.running_executions.lock().await;
|
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 {
|
if !has_running_execution {
|
||||||
@@ -59,29 +66,39 @@ pub async fn execution_monitor(app_state: AppState) {
|
|||||||
&create_activity,
|
&create_activity,
|
||||||
activity_id,
|
activity_id,
|
||||||
TaskAttemptStatus::Paused,
|
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 {
|
} else {
|
||||||
tracing::info!("Marked orphaned task attempt {} as paused", attempt_id);
|
tracing::info!("Marked orphaned task attempt {} as paused", attempt_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for task attempts with latest activity status = Init
|
// 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 =
|
||||||
Ok(attempts) => attempts,
|
match TaskAttemptActivity::find_attempts_with_latest_init_status(&app_state.db_pool)
|
||||||
Err(e) => {
|
.await
|
||||||
tracing::error!("Failed to query init attempts: {}", e);
|
{
|
||||||
continue;
|
Ok(attempts) => attempts,
|
||||||
}
|
Err(e) => {
|
||||||
};
|
tracing::error!("Failed to query init attempts: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for attempt_id in init_attempt_ids {
|
for attempt_id in init_attempt_ids {
|
||||||
|
|
||||||
// Check if we already have a running execution for this attempt
|
// Check if we already have a running execution for this attempt
|
||||||
{
|
{
|
||||||
let executions = app_state.running_executions.lock().await;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,10 +118,22 @@ pub async fn execution_monitor(app_state: AppState) {
|
|||||||
|
|
||||||
// Get the executor and start streaming execution
|
// Get the executor and start streaming execution
|
||||||
let executor = task_attempt.get_executor();
|
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,
|
Ok(child) => child,
|
||||||
Err(e) => {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -113,11 +142,14 @@ pub async fn execution_monitor(app_state: AppState) {
|
|||||||
let execution_id = Uuid::new_v4();
|
let execution_id = Uuid::new_v4();
|
||||||
{
|
{
|
||||||
let mut executions = app_state.running_executions.lock().await;
|
let mut executions = app_state.running_executions.lock().await;
|
||||||
executions.insert(execution_id, RunningExecution {
|
executions.insert(
|
||||||
task_attempt_id: attempt_id,
|
execution_id,
|
||||||
child,
|
RunningExecution {
|
||||||
started_at: Utc::now(),
|
task_attempt_id: attempt_id,
|
||||||
});
|
child,
|
||||||
|
started_at: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update task attempt activity to InProgress
|
// Update task attempt activity to InProgress
|
||||||
@@ -133,11 +165,17 @@ pub async fn execution_monitor(app_state: AppState) {
|
|||||||
&create_activity,
|
&create_activity,
|
||||||
activity_id,
|
activity_id,
|
||||||
TaskAttemptStatus::InProgress,
|
TaskAttemptStatus::InProgress,
|
||||||
).await {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::error!("Failed to create in-progress activity: {}", e);
|
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
|
// Check for completed processes
|
||||||
@@ -149,14 +187,24 @@ pub async fn execution_monitor(app_state: AppState) {
|
|||||||
Ok(Some(status)) => {
|
Ok(Some(status)) => {
|
||||||
let success = status.success();
|
let success = status.success();
|
||||||
let exit_code = status.code();
|
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) => {
|
Ok(None) => {
|
||||||
// Still running
|
// Still running
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Error checking process status: {}", 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
|
// Handle completed executions
|
||||||
for (execution_id, task_attempt_id, success, exit_code) in 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 {
|
let exit_text = if let Some(code) = exit_code {
|
||||||
format!(" with exit code {}", code)
|
format!(" with exit code {}", code)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!("Execution {} {}{}", execution_id, status_text, exit_text);
|
tracing::info!("Execution {} {}{}", execution_id, status_text, exit_text);
|
||||||
|
|
||||||
// Create task attempt activity with Paused status
|
// Create task attempt activity with Paused status
|
||||||
@@ -191,10 +243,15 @@ pub async fn execution_monitor(app_state: AppState) {
|
|||||||
&create_activity,
|
&create_activity,
|
||||||
activity_id,
|
activity_id,
|
||||||
TaskAttemptStatus::Paused,
|
TaskAttemptStatus::Paused,
|
||||||
).await {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::error!("Failed to create paused activity: {}", e);
|
tracing::error!("Failed to create paused activity: {}", e);
|
||||||
} else {
|
} 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 claude;
|
||||||
|
pub mod echo;
|
||||||
|
|
||||||
pub use echo::EchoExecutor;
|
|
||||||
pub use claude::ClaudeExecutor;
|
pub use claude::ClaudeExecutor;
|
||||||
|
pub use echo::EchoExecutor;
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ mod routes;
|
|||||||
|
|
||||||
use auth::{auth_middleware, hash_password};
|
use auth::{auth_middleware, hash_password};
|
||||||
use execution_monitor::{execution_monitor, AppState};
|
use execution_monitor::{execution_monitor, AppState};
|
||||||
use models::{ApiResponse, user::User};
|
use models::{user::User, ApiResponse};
|
||||||
use routes::{health, projects, tasks, users, filesystem};
|
use routes::{filesystem, health, projects, tasks, users};
|
||||||
|
|
||||||
async fn echo_handler(
|
async fn echo_handler(
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ impl Project {
|
|||||||
.await
|
.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!(
|
sqlx::query_as!(
|
||||||
Project,
|
Project,
|
||||||
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE git_repo_path = $1",
|
"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
|
.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!(
|
sqlx::query_as!(
|
||||||
Project,
|
Project,
|
||||||
"SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE git_repo_path = $1 AND id != $2",
|
"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
|
.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!(
|
sqlx::query_as!(
|
||||||
Project,
|
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",
|
"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
|
.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!(
|
sqlx::query_as!(
|
||||||
Project,
|
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",
|
"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 chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, Type, PgPool};
|
use sqlx::{FromRow, PgPool, Type};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -58,7 +58,10 @@ pub struct UpdateTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Task {
|
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!(
|
sqlx::query_as!(
|
||||||
Task,
|
Task,
|
||||||
r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at
|
r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at
|
||||||
@@ -71,7 +74,10 @@ impl Task {
|
|||||||
.await
|
.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!(
|
let records = sqlx::query!(
|
||||||
r#"SELECT
|
r#"SELECT
|
||||||
t.id,
|
t.id,
|
||||||
@@ -102,16 +108,19 @@ impl Task {
|
|||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let tasks = records.into_iter().map(|record| TaskWithAttemptStatus {
|
let tasks = records
|
||||||
id: record.id,
|
.into_iter()
|
||||||
project_id: record.project_id,
|
.map(|record| TaskWithAttemptStatus {
|
||||||
title: record.title,
|
id: record.id,
|
||||||
description: record.description,
|
project_id: record.project_id,
|
||||||
status: record.status,
|
title: record.title,
|
||||||
created_at: record.created_at,
|
description: record.description,
|
||||||
updated_at: record.updated_at,
|
status: record.status,
|
||||||
has_in_progress_attempt: record.has_in_progress_attempt,
|
created_at: record.created_at,
|
||||||
}).collect();
|
updated_at: record.updated_at,
|
||||||
|
has_in_progress_attempt: record.has_in_progress_attempt,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
@@ -128,7 +137,11 @@ impl Task {
|
|||||||
.await
|
.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!(
|
sqlx::query_as!(
|
||||||
Task,
|
Task,
|
||||||
r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at
|
r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at
|
||||||
@@ -141,7 +154,11 @@ impl Task {
|
|||||||
.await
|
.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!(
|
sqlx::query_as!(
|
||||||
Task,
|
Task,
|
||||||
r#"INSERT INTO tasks (id, project_id, title, description, status)
|
r#"INSERT INTO tasks (id, project_id, title, description, status)
|
||||||
@@ -157,7 +174,14 @@ impl Task {
|
|||||||
.await
|
.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!(
|
sqlx::query_as!(
|
||||||
Task,
|
Task,
|
||||||
r#"UPDATE tasks
|
r#"UPDATE tasks
|
||||||
@@ -176,8 +200,8 @@ impl Task {
|
|||||||
|
|
||||||
pub async fn delete(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result<u64, sqlx::Error> {
|
pub async fn delete(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result<u64, sqlx::Error> {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
"DELETE FROM tasks WHERE id = $1 AND project_id = $2",
|
"DELETE FROM tasks WHERE id = $1 AND project_id = $2",
|
||||||
id,
|
id,
|
||||||
project_id
|
project_id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -187,8 +211,8 @@ impl Task {
|
|||||||
|
|
||||||
pub async fn exists(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result<bool, sqlx::Error> {
|
pub async fn exists(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
"SELECT id FROM tasks WHERE id = $1 AND project_id = $2",
|
"SELECT id FROM tasks WHERE id = $1 AND project_id = $2",
|
||||||
id,
|
id,
|
||||||
project_id
|
project_id
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ pub struct CreateTaskAttemptActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TaskAttemptActivity {
|
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!(
|
sqlx::query_as!(
|
||||||
TaskAttemptActivity,
|
TaskAttemptActivity,
|
||||||
r#"SELECT id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at
|
r#"SELECT id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at
|
||||||
@@ -38,7 +41,12 @@ impl TaskAttemptActivity {
|
|||||||
.await
|
.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!(
|
sqlx::query_as!(
|
||||||
TaskAttemptActivity,
|
TaskAttemptActivity,
|
||||||
r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note)
|
r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note)
|
||||||
@@ -53,7 +61,11 @@ impl TaskAttemptActivity {
|
|||||||
.await
|
.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!(
|
sqlx::query!(
|
||||||
r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note)
|
r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note)
|
||||||
VALUES ($1, $2, $3, $4)"#,
|
VALUES ($1, $2, $3, $4)"#,
|
||||||
@@ -67,7 +79,9 @@ impl TaskAttemptActivity {
|
|||||||
Ok(())
|
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!(
|
let records = sqlx::query!(
|
||||||
r#"SELECT DISTINCT ta.id
|
r#"SELECT DISTINCT ta.id
|
||||||
FROM task_attempts ta
|
FROM task_attempts ta
|
||||||
@@ -83,11 +97,13 @@ impl TaskAttemptActivity {
|
|||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(records.into_iter().map(|r| r.id).collect())
|
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!(
|
let records = sqlx::query!(
|
||||||
r#"SELECT DISTINCT ta.id
|
r#"SELECT DISTINCT ta.id
|
||||||
FROM task_attempts ta
|
FROM task_attempts ta
|
||||||
@@ -103,7 +119,7 @@ impl TaskAttemptActivity {
|
|||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(records.into_iter().map(|r| r.id).collect())
|
Ok(records.into_iter().map(|r| r.id).collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ impl User {
|
|||||||
.await
|
.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);
|
let is_admin = data.is_admin.unwrap_or(false);
|
||||||
|
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
@@ -115,7 +120,13 @@ impl User {
|
|||||||
.await
|
.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!(
|
sqlx::query_as!(
|
||||||
User,
|
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",
|
"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())
|
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;
|
use chrono::Utc;
|
||||||
|
|
||||||
// Check if admin already exists
|
// Check if admin already exists
|
||||||
let existing_admin = sqlx::query!(
|
let existing_admin = sqlx::query!(
|
||||||
"SELECT id, password_hash FROM users WHERE email = $1",
|
"SELECT id, password_hash FROM users WHERE email = $1",
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
routing::get,
|
extract::{Extension, Query},
|
||||||
Router,
|
|
||||||
Json,
|
|
||||||
response::Json as ResponseJson,
|
|
||||||
extract::{Query, Extension},
|
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::Json as ResponseJson,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|
||||||
use crate::models::ApiResponse;
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
use crate::models::ApiResponse;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, TS)]
|
#[derive(Debug, Serialize, TS)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
@@ -61,25 +60,25 @@ pub async fn list_directory(
|
|||||||
match fs::read_dir(path) {
|
match fs::read_dir(path) {
|
||||||
Ok(entries) => {
|
Ok(entries) => {
|
||||||
let mut directory_entries = Vec::new();
|
let mut directory_entries = Vec::new();
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
if let Ok(entry) = entry {
|
if let Ok(entry) = entry {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let metadata = entry.metadata().ok();
|
let metadata = entry.metadata().ok();
|
||||||
|
|
||||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
// Skip hidden files/directories
|
// Skip hidden files/directories
|
||||||
if name.starts_with('.') && name != ".." {
|
if name.starts_with('.') && name != ".." {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_directory = metadata.map_or(false, |m| m.is_dir());
|
let is_directory = metadata.map_or(false, |m| m.is_dir());
|
||||||
let is_git_repo = if is_directory {
|
let is_git_repo = if is_directory {
|
||||||
path.join(".git").exists()
|
path.join(".git").exists()
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
directory_entries.push(DirectoryEntry {
|
directory_entries.push(DirectoryEntry {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
path: path.to_string_lossy().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
|
// Sort: directories first, then files, both alphabetically
|
||||||
directory_entries.sort_by(|a, b| {
|
directory_entries.sort_by(|a, b| match (a.is_directory, b.is_directory) {
|
||||||
match (a.is_directory, b.is_directory) {
|
(true, false) => std::cmp::Ordering::Less,
|
||||||
(true, false) => std::cmp::Ordering::Less,
|
(false, true) => std::cmp::Ordering::Greater,
|
||||||
(false, true) => std::cmp::Ordering::Greater,
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(ResponseJson(ApiResponse {
|
Ok(ResponseJson(ApiResponse {
|
||||||
success: true,
|
success: true,
|
||||||
data: Some(directory_entries),
|
data: Some(directory_entries),
|
||||||
@@ -125,7 +122,7 @@ pub async fn validate_git_path(
|
|||||||
|
|
||||||
// Check if path exists and is a git repo
|
// Check if path exists and is a git repo
|
||||||
let is_valid_git_repo = path.exists() && path.is_dir() && path.join(".git").exists();
|
let is_valid_git_repo = path.exists() && path.is_dir() && path.join(".git").exists();
|
||||||
|
|
||||||
Ok(ResponseJson(ApiResponse {
|
Ok(ResponseJson(ApiResponse {
|
||||||
success: true,
|
success: true,
|
||||||
data: Some(is_valid_git_repo),
|
data: Some(is_valid_git_repo),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use axum::response::Json;
|
|
||||||
use crate::models::ApiResponse;
|
use crate::models::ApiResponse;
|
||||||
|
use axum::response::Json;
|
||||||
|
|
||||||
pub async fn health_check() -> Json<ApiResponse<String>> {
|
pub async fn health_check() -> Json<ApiResponse<String>> {
|
||||||
Json(ApiResponse {
|
Json(ApiResponse {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
pub mod filesystem;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod filesystem;
|
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
routing::get,
|
extract::{Extension, Path},
|
||||||
Router,
|
|
||||||
Json,
|
|
||||||
response::Json as ResponseJson,
|
|
||||||
extract::{Path, Extension},
|
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::Json as ResponseJson,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{ApiResponse, project::{Project, CreateProject, UpdateProject}};
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
use crate::models::{
|
||||||
|
project::{CreateProject, Project, UpdateProject},
|
||||||
|
ApiResponse,
|
||||||
|
};
|
||||||
|
|
||||||
pub async fn get_projects(
|
pub async fn get_projects(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, StatusCode> {
|
||||||
match Project::find_all(&pool).await {
|
match Project::find_all(&pool).await {
|
||||||
Ok(projects) => Ok(ResponseJson(ApiResponse {
|
Ok(projects) => Ok(ResponseJson(ApiResponse {
|
||||||
@@ -32,7 +34,7 @@ pub async fn get_projects(
|
|||||||
pub async fn get_project(
|
pub async fn get_project(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
||||||
match Project::find_by_id(&pool, id).await {
|
match Project::find_by_id(&pool, id).await {
|
||||||
Ok(Some(project)) => Ok(ResponseJson(ApiResponse {
|
Ok(Some(project)) => Ok(ResponseJson(ApiResponse {
|
||||||
@@ -51,11 +53,15 @@ pub async fn get_project(
|
|||||||
pub async fn create_project(
|
pub async fn create_project(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Json(payload): Json<CreateProject>
|
Json(payload): Json<CreateProject>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
||||||
let id = Uuid::new_v4();
|
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
|
// Check if git repo path is already used by another project
|
||||||
match Project::find_by_git_repo_path(&pool, &payload.git_repo_path).await {
|
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
|
// Validate and setup git repository
|
||||||
let path = std::path::Path::new(&payload.git_repo_path);
|
let path = std::path::Path::new(&payload.git_repo_path);
|
||||||
|
|
||||||
if payload.use_existing_repo {
|
if payload.use_existing_repo {
|
||||||
// For existing repos, validate that the path exists and is a git repository
|
// For existing repos, validate that the path exists and is a git repository
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -105,7 +111,7 @@ pub async fn create_project(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For new repos, create directory and initialize git
|
// For new repos, create directory and initialize git
|
||||||
|
|
||||||
// Create directory if it doesn't exist
|
// Create directory if it doesn't exist
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
if let Err(e) = std::fs::create_dir_all(path) {
|
if let Err(e) = std::fs::create_dir_all(path) {
|
||||||
@@ -164,7 +170,7 @@ pub async fn create_project(
|
|||||||
pub async fn update_project(
|
pub async fn update_project(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Json(payload): Json<UpdateProject>
|
Json(payload): Json<UpdateProject>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
|
||||||
// Check if project exists first
|
// Check if project exists first
|
||||||
let existing_project = match Project::find_by_id(&pool, id).await {
|
let existing_project = match Project::find_by_id(&pool, id).await {
|
||||||
@@ -184,7 +190,9 @@ pub async fn update_project(
|
|||||||
return Ok(ResponseJson(ApiResponse {
|
return Ok(ResponseJson(ApiResponse {
|
||||||
success: false,
|
success: false,
|
||||||
data: None,
|
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) => {
|
Ok(None) => {
|
||||||
@@ -200,7 +208,9 @@ pub async fn update_project(
|
|||||||
|
|
||||||
// Use existing values if not provided in update
|
// Use existing values if not provided in update
|
||||||
let name = payload.name.unwrap_or(existing_project.name);
|
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 {
|
match Project::update(&pool, id, name, git_repo_path).await {
|
||||||
Ok(project) => Ok(ResponseJson(ApiResponse {
|
Ok(project) => Ok(ResponseJson(ApiResponse {
|
||||||
@@ -217,7 +227,7 @@ pub async fn update_project(
|
|||||||
|
|
||||||
pub async fn delete_project(
|
pub async fn delete_project(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
||||||
match Project::delete(&pool, id).await {
|
match Project::delete(&pool, id).await {
|
||||||
Ok(rows_affected) => {
|
Ok(rows_affected) => {
|
||||||
@@ -241,18 +251,24 @@ pub async fn delete_project(
|
|||||||
pub fn projects_router() -> Router {
|
pub fn projects_router() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/projects", get(get_projects).post(create_project))
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auth::{hash_password, AuthUser};
|
||||||
|
use crate::models::{
|
||||||
|
project::{CreateProject, UpdateProject},
|
||||||
|
user::User,
|
||||||
|
};
|
||||||
use axum::extract::Extension;
|
use axum::extract::Extension;
|
||||||
|
use chrono::Utc;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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 {
|
async fn create_test_user(pool: &PgPool, email: &str, password: &str, is_admin: bool) -> User {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
@@ -274,7 +290,12 @@ mod tests {
|
|||||||
.unwrap()
|
.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 id = Uuid::new_v4();
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
@@ -296,7 +317,7 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_get_projects_success(pool: PgPool) {
|
async fn test_get_projects_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
|
|
||||||
// Create multiple projects
|
// Create multiple projects
|
||||||
create_test_project(&pool, "Project 1", "/tmp/test1", user.id).await;
|
create_test_project(&pool, "Project 1", "/tmp/test1", user.id).await;
|
||||||
create_test_project(&pool, "Project 2", "/tmp/test2", 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;
|
let result = get_projects(auth, Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -329,7 +350,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = get_projects(auth, Extension(pool)).await;
|
let result = get_projects(auth, Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -349,7 +370,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = get_project(auth, Path(project.id), Extension(pool)).await;
|
let result = get_project(auth, Path(project.id), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -393,7 +414,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
|
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -421,7 +442,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
|
let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -442,7 +463,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
|
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -465,7 +486,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
|
let result = update_project(Path(project.id), Extension(pool), Json(update_request)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -483,7 +504,12 @@ mod tests {
|
|||||||
git_repo_path: None,
|
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!(result.is_err());
|
||||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -491,11 +517,12 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_delete_project_success(pool: PgPool) {
|
async fn test_delete_project_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
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;
|
let result = delete_project(Path(project.id), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert_eq!(response.message.unwrap(), "Project deleted successfully");
|
assert_eq!(response.message.unwrap(), "Project deleted successfully");
|
||||||
@@ -513,10 +540,11 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_delete_project_cascades_to_tasks(pool: PgPool) {
|
async fn test_delete_project_cascades_to_tasks(pool: PgPool) {
|
||||||
use crate::models::task::{Task, TaskStatus};
|
use crate::models::task::{Task, TaskStatus};
|
||||||
|
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
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
|
// Create a task in the project
|
||||||
let task_id = Uuid::new_v4();
|
let task_id = Uuid::new_v4();
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
@@ -563,7 +591,7 @@ mod tests {
|
|||||||
async fn test_projects_belong_to_users(pool: PgPool) {
|
async fn test_projects_belong_to_users(pool: PgPool) {
|
||||||
let user1 = create_test_user(&pool, "user1@example.com", "password123", false).await;
|
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 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 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;
|
let project2 = create_test_project(&pool, "User 2 Project", "/tmp/user2", user2.id).await;
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
routing::get,
|
extract::{Extension, Path},
|
||||||
Router,
|
|
||||||
Json,
|
|
||||||
response::Json as ResponseJson,
|
|
||||||
extract::{Path, Extension},
|
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::Json as ResponseJson,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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::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(
|
pub async fn get_project_tasks(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Path(project_id): Path<Uuid>,
|
Path(project_id): Path<Uuid>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Vec<TaskWithAttemptStatus>>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Vec<TaskWithAttemptStatus>>>, StatusCode> {
|
||||||
match Task::find_by_project_id_with_attempt_status(&pool, project_id).await {
|
match Task::find_by_project_id_with_attempt_status(&pool, project_id).await {
|
||||||
Ok(tasks) => Ok(ResponseJson(ApiResponse {
|
Ok(tasks) => Ok(ResponseJson(ApiResponse {
|
||||||
@@ -39,7 +38,7 @@ pub async fn get_project_tasks(
|
|||||||
pub async fn get_task(
|
pub async fn get_task(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
||||||
match Task::find_by_id_and_project_id(&pool, task_id, project_id).await {
|
match Task::find_by_id_and_project_id(&pool, task_id, project_id).await {
|
||||||
Ok(Some(task)) => Ok(ResponseJson(ApiResponse {
|
Ok(Some(task)) => Ok(ResponseJson(ApiResponse {
|
||||||
@@ -49,7 +48,12 @@ pub async fn get_task(
|
|||||||
})),
|
})),
|
||||||
Ok(None) => Err(StatusCode::NOT_FOUND),
|
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||||
Err(e) => {
|
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)
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,13 +63,13 @@ pub async fn create_task(
|
|||||||
Path(project_id): Path<Uuid>,
|
Path(project_id): Path<Uuid>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Json(mut payload): Json<CreateTask>
|
Json(mut payload): Json<CreateTask>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
// Ensure the project_id in the payload matches the path parameter
|
// Ensure the project_id in the payload matches the path parameter
|
||||||
payload.project_id = project_id;
|
payload.project_id = project_id;
|
||||||
|
|
||||||
// Verify project exists first
|
// Verify project exists first
|
||||||
match Project::exists(&pool, project_id).await {
|
match Project::exists(&pool, project_id).await {
|
||||||
Ok(false) => return Err(StatusCode::NOT_FOUND),
|
Ok(false) => return Err(StatusCode::NOT_FOUND),
|
||||||
@@ -76,7 +80,12 @@ pub async fn create_task(
|
|||||||
Ok(true) => {}
|
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 {
|
match Task::create(&pool, &payload, id).await {
|
||||||
Ok(task) => Ok(ResponseJson(ApiResponse {
|
Ok(task) => Ok(ResponseJson(ApiResponse {
|
||||||
@@ -94,7 +103,7 @@ pub async fn create_task(
|
|||||||
pub async fn update_task(
|
pub async fn update_task(
|
||||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Json(payload): Json<UpdateTask>
|
Json(payload): Json<UpdateTask>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Task>>, StatusCode> {
|
||||||
// Check if task exists in the specified project
|
// 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 {
|
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(
|
pub async fn delete_task(
|
||||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
||||||
match Task::delete(&pool, task_id, project_id).await {
|
match Task::delete(&pool, task_id, project_id).await {
|
||||||
Ok(rows_affected) => {
|
Ok(rows_affected) => {
|
||||||
@@ -151,7 +160,7 @@ pub async fn delete_task(
|
|||||||
pub async fn get_task_attempts(
|
pub async fn get_task_attempts(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttempt>>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttempt>>>, StatusCode> {
|
||||||
// Verify task exists in project first
|
// Verify task exists in project first
|
||||||
match Task::exists(&pool, task_id, project_id).await {
|
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(
|
pub async fn get_task_attempt_activities(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
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> {
|
) -> Result<ResponseJson<ApiResponse<Vec<TaskAttemptActivity>>>, StatusCode> {
|
||||||
// Verify task attempt exists and belongs to the correct task
|
// Verify task attempt exists and belongs to the correct task
|
||||||
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
|
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,
|
message: None,
|
||||||
})),
|
})),
|
||||||
Err(e) => {
|
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)
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,7 +221,7 @@ pub async fn create_task_attempt(
|
|||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
Path((project_id, task_id)): Path<(Uuid, Uuid)>,
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Json(mut payload): Json<CreateTaskAttempt>
|
Json(mut payload): Json<CreateTaskAttempt>,
|
||||||
) -> Result<ResponseJson<ApiResponse<TaskAttempt>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<TaskAttempt>>, StatusCode> {
|
||||||
// Verify task exists in project first
|
// Verify task exists in project first
|
||||||
match Task::exists(&pool, task_id, project_id).await {
|
match Task::exists(&pool, task_id, project_id).await {
|
||||||
@@ -221,7 +234,7 @@ pub async fn create_task_attempt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
// Ensure the task_id in the payload matches the path parameter
|
// Ensure the task_id in the payload matches the path parameter
|
||||||
payload.task_id = task_id;
|
payload.task_id = task_id;
|
||||||
|
|
||||||
@@ -248,7 +261,7 @@ pub async fn create_task_attempt_activity(
|
|||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Json(mut payload): Json<CreateTaskAttemptActivity>
|
Json(mut payload): Json<CreateTaskAttemptActivity>,
|
||||||
) -> Result<ResponseJson<ApiResponse<TaskAttemptActivity>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<TaskAttemptActivity>>, StatusCode> {
|
||||||
// Verify task attempt exists and belongs to the correct task
|
// Verify task attempt exists and belongs to the correct task
|
||||||
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
|
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();
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
// Ensure the task_attempt_id in the payload matches the path parameter
|
// Ensure the task_attempt_id in the payload matches the path parameter
|
||||||
payload.task_attempt_id = attempt_id;
|
payload.task_attempt_id = attempt_id;
|
||||||
|
|
||||||
// Default to Init status if not provided
|
// Default to Init status if not provided
|
||||||
let status = payload.status.clone().unwrap_or(TaskAttemptStatus::Init);
|
let status = payload.status.clone().unwrap_or(TaskAttemptStatus::Init);
|
||||||
|
|
||||||
@@ -285,7 +298,7 @@ pub async fn stop_task_attempt(
|
|||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>,
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Extension(app_state): Extension<crate::execution_monitor::AppState>
|
Extension(app_state): Extension<crate::execution_monitor::AppState>,
|
||||||
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
||||||
// Verify task attempt exists and belongs to the correct task
|
// Verify task attempt exists and belongs to the correct task
|
||||||
match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await {
|
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 executions = app_state.running_executions.lock().await;
|
||||||
let mut execution_id_to_remove = None;
|
let mut execution_id_to_remove = None;
|
||||||
|
|
||||||
// Find the execution for this attempt
|
// Find the execution for this attempt
|
||||||
for (exec_id, execution) in executions.iter_mut() {
|
for (exec_id, execution) in executions.iter_mut() {
|
||||||
if execution.task_attempt_id == attempt_id {
|
if execution.task_attempt_id == attempt_id {
|
||||||
@@ -321,7 +334,7 @@ pub async fn stop_task_attempt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the stopped execution from the map
|
// Remove the stopped execution from the map
|
||||||
if let Some(exec_id) = execution_id_to_remove {
|
if let Some(exec_id) = execution_id_to_remove {
|
||||||
executions.remove(&exec_id);
|
executions.remove(&exec_id);
|
||||||
@@ -349,7 +362,9 @@ pub async fn stop_task_attempt(
|
|||||||
&create_activity,
|
&create_activity,
|
||||||
activity_id,
|
activity_id,
|
||||||
TaskAttemptStatus::Paused,
|
TaskAttemptStatus::Paused,
|
||||||
).await {
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::error!("Failed to create stopped activity: {}", e);
|
tracing::error!("Failed to create stopped activity: {}", e);
|
||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
@@ -362,25 +377,44 @@ pub async fn stop_task_attempt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn tasks_router() -> Router {
|
pub fn tasks_router() -> Router {
|
||||||
use axum::routing::{post, put, delete};
|
use axum::routing::{delete, post, put};
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/projects/:project_id/tasks", get(get_project_tasks).post(create_task))
|
.route(
|
||||||
.route("/projects/:project_id/tasks/:task_id", get(get_task).put(update_task).delete(delete_task))
|
"/projects/:project_id/tasks",
|
||||||
.route("/projects/:project_id/tasks/:task_id/attempts", get(get_task_attempts).post(create_task_attempt))
|
get(get_project_tasks).post(create_task),
|
||||||
.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/: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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auth::{hash_password, AuthUser};
|
||||||
|
use crate::models::{
|
||||||
|
project::Project,
|
||||||
|
task::{CreateTask, TaskStatus, UpdateTask},
|
||||||
|
user::User,
|
||||||
|
};
|
||||||
use axum::extract::Extension;
|
use axum::extract::Extension;
|
||||||
|
use chrono::Utc;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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 {
|
async fn create_test_user(pool: &PgPool, email: &str, password: &str, is_admin: bool) -> User {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
@@ -422,7 +456,13 @@ mod tests {
|
|||||||
.unwrap()
|
.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 id = Uuid::new_v4();
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
@@ -446,11 +486,25 @@ mod tests {
|
|||||||
async fn test_get_project_tasks_success(pool: PgPool) {
|
async fn test_get_project_tasks_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
let project = create_test_project(&pool, "Test Project", user.id).await;
|
let project = create_test_project(&pool, "Test Project", user.id).await;
|
||||||
|
|
||||||
// Create multiple tasks
|
// 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 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 {
|
let auth = AuthUser {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
@@ -460,7 +514,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = get_project_tasks(auth, Path(project.id), Extension(pool)).await;
|
let result = get_project_tasks(auth, Path(project.id), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -480,7 +534,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = get_project_tasks(auth, Path(project.id), Extension(pool)).await;
|
let result = get_project_tasks(auth, Path(project.id), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -491,7 +545,14 @@ mod tests {
|
|||||||
async fn test_get_task_success(pool: PgPool) {
|
async fn test_get_task_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
let project = create_test_project(&pool, "Test Project", user.id).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 {
|
let auth = AuthUser {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
@@ -501,7 +562,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = get_task(auth, Path((project.id, task.id)), Extension(pool)).await;
|
let result = get_task(auth, Path((project.id, task.id)), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -524,7 +585,12 @@ mod tests {
|
|||||||
is_admin: false,
|
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!(result.is_err());
|
||||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -565,15 +631,24 @@ mod tests {
|
|||||||
description: Some("Task description".to_string()),
|
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());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
let created_task = response.data.unwrap();
|
let created_task = response.data.unwrap();
|
||||||
assert_eq!(created_task.title, "New Task");
|
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.status, TaskStatus::Todo);
|
||||||
assert_eq!(created_task.project_id, project.id);
|
assert_eq!(created_task.project_id, project.id);
|
||||||
}
|
}
|
||||||
@@ -595,7 +670,13 @@ mod tests {
|
|||||||
description: None,
|
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!(result.is_err());
|
||||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -604,7 +685,14 @@ mod tests {
|
|||||||
async fn test_update_task_success(pool: PgPool) {
|
async fn test_update_task_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
let project = create_test_project(&pool, "Test Project", user.id).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 {
|
let update_request = UpdateTask {
|
||||||
title: Some("Updated Title".to_string()),
|
title: Some("Updated Title".to_string()),
|
||||||
@@ -612,15 +700,23 @@ mod tests {
|
|||||||
status: Some(TaskStatus::InProgress),
|
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());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
let updated_task = response.data.unwrap();
|
let updated_task = response.data.unwrap();
|
||||||
assert_eq!(updated_task.title, "Updated Title");
|
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);
|
assert_eq!(updated_task.status, TaskStatus::InProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,7 +724,14 @@ mod tests {
|
|||||||
async fn test_update_task_partial(pool: PgPool) {
|
async fn test_update_task_partial(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
let project = create_test_project(&pool, "Test Project", user.id).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
|
// Only update status
|
||||||
let update_request = UpdateTask {
|
let update_request = UpdateTask {
|
||||||
@@ -637,15 +740,23 @@ mod tests {
|
|||||||
status: Some(TaskStatus::Done),
|
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());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
let updated_task = response.data.unwrap();
|
let updated_task = response.data.unwrap();
|
||||||
assert_eq!(updated_task.title, "Original Title"); // Should remain unchanged
|
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
|
assert_eq!(updated_task.status, TaskStatus::Done); // Should be updated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,7 +772,12 @@ mod tests {
|
|||||||
status: None,
|
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!(result.is_err());
|
||||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -680,7 +796,12 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Try to update task in wrong project
|
// 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!(result.is_err());
|
||||||
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
assert_eq!(result.unwrap_err(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -689,11 +810,12 @@ mod tests {
|
|||||||
async fn test_delete_task_success(pool: PgPool) {
|
async fn test_delete_task_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
let project = create_test_project(&pool, "Test Project", user.id).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;
|
let result = delete_task(Path((project.id, task.id)), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert_eq!(response.message.unwrap(), "Task deleted successfully");
|
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 user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
let project1 = create_test_project(&pool, "Project 1", user.id).await;
|
let project1 = create_test_project(&pool, "Project 1", user.id).await;
|
||||||
let project2 = create_test_project(&pool, "Project 2", 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
|
// Try to delete task from wrong project
|
||||||
let result = delete_task(Path((project2.id, task.id)), Extension(pool)).await;
|
let result = delete_task(Path((project2.id, task.id)), Extension(pool)).await;
|
||||||
|
|||||||
@@ -1,49 +1,45 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post},
|
extract::{Extension, Path},
|
||||||
Router,
|
|
||||||
Json,
|
|
||||||
response::Json as ResponseJson,
|
|
||||||
extract::{Path, Extension},
|
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::Json as ResponseJson,
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{ApiResponse, user::{User, CreateUser, UpdateUser, LoginRequest, LoginResponse, UserResponse}};
|
use crate::auth::{create_token, hash_password, verify_password, AuthUser};
|
||||||
use crate::auth::{AuthUser, create_token, hash_password, verify_password};
|
use crate::models::{
|
||||||
|
user::{CreateUser, LoginRequest, LoginResponse, UpdateUser, User, UserResponse},
|
||||||
|
ApiResponse,
|
||||||
|
};
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Json(payload): Json<LoginRequest>
|
Json(payload): Json<LoginRequest>,
|
||||||
) -> Result<ResponseJson<ApiResponse<LoginResponse>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<LoginResponse>>, StatusCode> {
|
||||||
match User::find_by_email(&pool, &payload.email).await {
|
match User::find_by_email(&pool, &payload.email).await {
|
||||||
Ok(Some(user)) => {
|
Ok(Some(user)) => match verify_password(&payload.password, &user.password_hash) {
|
||||||
match verify_password(&payload.password, &user.password_hash) {
|
Ok(true) => match create_token(user.id, user.email.clone(), user.is_admin) {
|
||||||
Ok(true) => {
|
Ok(token) => Ok(ResponseJson(ApiResponse {
|
||||||
match create_token(user.id, user.email.clone(), user.is_admin) {
|
success: true,
|
||||||
Ok(token) => {
|
data: Some(LoginResponse {
|
||||||
Ok(ResponseJson(ApiResponse {
|
user: user.into(),
|
||||||
success: true,
|
token,
|
||||||
data: Some(LoginResponse {
|
}),
|
||||||
user: user.into(),
|
message: Some("Login successful".to_string()),
|
||||||
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) => {
|
Err(e) => {
|
||||||
tracing::error!("Password verification error: {}", e);
|
tracing::error!("Failed to create token: {}", e);
|
||||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
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),
|
Ok(None) => Err(StatusCode::UNAUTHORIZED),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to fetch user: {}", e);
|
tracing::error!("Failed to fetch user: {}", e);
|
||||||
@@ -54,7 +50,7 @@ pub async fn login(
|
|||||||
|
|
||||||
pub async fn get_users(
|
pub async fn get_users(
|
||||||
_auth: AuthUser,
|
_auth: AuthUser,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<Vec<UserResponse>>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<Vec<UserResponse>>>, StatusCode> {
|
||||||
match User::find_all(&pool).await {
|
match User::find_all(&pool).await {
|
||||||
Ok(users) => {
|
Ok(users) => {
|
||||||
@@ -75,7 +71,7 @@ pub async fn get_users(
|
|||||||
pub async fn get_user(
|
pub async fn get_user(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
||||||
// Users can only view their own profile unless they're admin
|
// Users can only view their own profile unless they're admin
|
||||||
if auth.user_id != id && !auth.is_admin {
|
if auth.user_id != id && !auth.is_admin {
|
||||||
@@ -99,7 +95,7 @@ pub async fn get_user(
|
|||||||
pub async fn create_user(
|
pub async fn create_user(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Json(payload): Json<CreateUser>
|
Json(payload): Json<CreateUser>,
|
||||||
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
||||||
// Only admins can create users
|
// Only admins can create users
|
||||||
if !auth.is_admin {
|
if !auth.is_admin {
|
||||||
@@ -134,7 +130,7 @@ pub async fn update_user(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Extension(pool): Extension<PgPool>,
|
Extension(pool): Extension<PgPool>,
|
||||||
Json(payload): Json<UpdateUser>
|
Json(payload): Json<UpdateUser>,
|
||||||
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
||||||
// Users can only update their own profile unless they're admin
|
// Users can only update their own profile unless they're admin
|
||||||
if auth.user_id != id && !auth.is_admin {
|
if auth.user_id != id && !auth.is_admin {
|
||||||
@@ -183,7 +179,7 @@ pub async fn update_user(
|
|||||||
pub async fn delete_user(
|
pub async fn delete_user(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
|
||||||
// Only admins can delete users, and they can't delete themselves
|
// Only admins can delete users, and they can't delete themselves
|
||||||
if !auth.is_admin || auth.user_id == id {
|
if !auth.is_admin || auth.user_id == id {
|
||||||
@@ -211,7 +207,7 @@ pub async fn delete_user(
|
|||||||
|
|
||||||
pub async fn get_current_user(
|
pub async fn get_current_user(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Extension(pool): Extension<PgPool>
|
Extension(pool): Extension<PgPool>,
|
||||||
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<UserResponse>>, StatusCode> {
|
||||||
match User::find_by_id(&pool, auth.user_id).await {
|
match User::find_by_id(&pool, auth.user_id).await {
|
||||||
Ok(Some(user)) => Ok(ResponseJson(ApiResponse {
|
Ok(Some(user)) => Ok(ResponseJson(ApiResponse {
|
||||||
@@ -227,9 +223,7 @@ pub async fn get_current_user(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_auth_status(
|
pub async fn check_auth_status(auth: AuthUser) -> ResponseJson<ApiResponse<serde_json::Value>> {
|
||||||
auth: AuthUser,
|
|
||||||
) -> ResponseJson<ApiResponse<serde_json::Value>> {
|
|
||||||
ResponseJson(ApiResponse {
|
ResponseJson(ApiResponse {
|
||||||
success: true,
|
success: true,
|
||||||
data: Some(serde_json::json!({
|
data: Some(serde_json::json!({
|
||||||
@@ -243,8 +237,7 @@ pub async fn check_auth_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn public_users_router() -> Router {
|
pub fn public_users_router() -> Router {
|
||||||
Router::new()
|
Router::new().route("/auth/login", post(login))
|
||||||
.route("/auth/login", post(login))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn protected_users_router() -> Router {
|
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/status", get(check_auth_status))
|
||||||
.route("/auth/me", get(get_current_user))
|
.route("/auth/me", get(get_current_user))
|
||||||
.route("/users", get(get_users).post(create_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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auth::{hash_password, AuthUser};
|
||||||
|
use crate::models::user::{CreateUser, LoginRequest, UpdateUser};
|
||||||
use axum::extract::Extension;
|
use axum::extract::Extension;
|
||||||
|
use chrono::Utc;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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 {
|
async fn create_test_user(pool: &PgPool, email: &str, password: &str, is_admin: bool) -> User {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
@@ -288,7 +284,7 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_login_success(pool: PgPool) {
|
async fn test_login_success(pool: PgPool) {
|
||||||
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
let user = create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
|
|
||||||
let login_request = LoginRequest {
|
let login_request = LoginRequest {
|
||||||
email: "test@example.com".to_string(),
|
email: "test@example.com".to_string(),
|
||||||
password: "password123".to_string(),
|
password: "password123".to_string(),
|
||||||
@@ -296,7 +292,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = login(Extension(pool), Json(login_request)).await;
|
let result = login(Extension(pool), Json(login_request)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -306,7 +302,7 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_login_invalid_password(pool: PgPool) {
|
async fn test_login_invalid_password(pool: PgPool) {
|
||||||
create_test_user(&pool, "test@example.com", "password123", false).await;
|
create_test_user(&pool, "test@example.com", "password123", false).await;
|
||||||
|
|
||||||
let login_request = LoginRequest {
|
let login_request = LoginRequest {
|
||||||
email: "test@example.com".to_string(),
|
email: "test@example.com".to_string(),
|
||||||
password: "wrongpassword".to_string(),
|
password: "wrongpassword".to_string(),
|
||||||
@@ -343,7 +339,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = get_users(auth, Extension(pool)).await;
|
let result = get_users(auth, Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -362,7 +358,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = get_user(auth, Path(user.id), Extension(pool)).await;
|
let result = get_user(auth, Path(user.id), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -398,7 +394,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = get_user(auth, Path(regular_user.id), Extension(pool)).await;
|
let result = get_user(auth, Path(regular_user.id), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -423,7 +419,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = create_user(auth, Extension(pool), Json(create_request)).await;
|
let result = create_user(auth, Extension(pool), Json(create_request)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
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;
|
let result = update_user(auth, Path(user.id), Extension(pool), Json(update_request)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -523,7 +519,8 @@ mod tests {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn test_delete_user_as_admin(pool: PgPool) {
|
async fn test_delete_user_as_admin(pool: PgPool) {
|
||||||
let admin_user = create_test_user(&pool, "admin@example.com", "password123", true).await;
|
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 {
|
let auth = AuthUser {
|
||||||
user_id: admin_user.id,
|
user_id: admin_user.id,
|
||||||
@@ -533,7 +530,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = delete_user(auth, Path(user_to_delete.id), Extension(pool)).await;
|
let result = delete_user(auth, Path(user_to_delete.id), Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert_eq!(response.message.unwrap(), "User deleted successfully");
|
assert_eq!(response.message.unwrap(), "User deleted successfully");
|
||||||
@@ -582,7 +579,7 @@ mod tests {
|
|||||||
|
|
||||||
let result = get_current_user(auth, Extension(pool)).await;
|
let result = get_current_user(auth, Extension(pool)).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let response = result.unwrap().0;
|
let response = result.unwrap().0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
@@ -600,7 +597,7 @@ mod tests {
|
|||||||
let response = check_auth_status(auth.clone()).await.0;
|
let response = check_auth_status(auth.clone()).await.0;
|
||||||
assert!(response.success);
|
assert!(response.success);
|
||||||
assert!(response.data.is_some());
|
assert!(response.data.is_some());
|
||||||
|
|
||||||
let data = response.data.unwrap();
|
let data = response.data.unwrap();
|
||||||
assert_eq!(data["authenticated"], true);
|
assert_eq!(data["authenticated"], true);
|
||||||
assert_eq!(data["user_id"], auth.user_id.to_string());
|
assert_eq!(data["user_id"], auth.user_id.to_string());
|
||||||
|
|||||||
Reference in New Issue
Block a user