From f84e3bfd20e81c027425e76fbd1dad26bff52c01 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Mon, 16 Jun 2025 17:07:11 -0400 Subject: [PATCH] Separation of concerns --- backend/src/models/project.rs | 90 ++++++- backend/src/models/task.rs | 92 +++++++- backend/src/models/task_attempt.rs | 51 +++- backend/src/models/task_attempt_activity.rs | 52 ++++- backend/src/models/user.rs | 74 +++++- backend/src/routes/projects.rs | 91 ++------ backend/src/routes/tasks.rs | 245 +++----------------- backend/src/routes/users.rs | 85 +------ 8 files changed, 415 insertions(+), 365 deletions(-) diff --git a/backend/src/models/project.rs b/backend/src/models/project.rs index 84ff7c14..61674ba8 100644 --- a/backend/src/models/project.rs +++ b/backend/src/models/project.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::FromRow; +use sqlx::{FromRow, PgPool}; use ts_rs::TS; use uuid::Uuid; @@ -31,3 +31,91 @@ pub struct UpdateProject { pub name: Option, pub git_repo_path: Option, } + +impl Project { + pub async fn find_all(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as!( + Project, + "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC" + ) + .fetch_all(pool) + .await + } + + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + Project, + "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE id = $1", + id + ) + .fetch_optional(pool) + .await + } + + pub async fn find_by_git_repo_path(pool: &PgPool, git_repo_path: &str) -> Result, sqlx::Error> { + sqlx::query_as!( + Project, + "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE git_repo_path = $1", + git_repo_path + ) + .fetch_optional(pool) + .await + } + + pub async fn find_by_git_repo_path_excluding_id(pool: &PgPool, git_repo_path: &str, exclude_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + Project, + "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE git_repo_path = $1 AND id != $2", + git_repo_path, + exclude_id + ) + .fetch_optional(pool) + .await + } + + pub async fn create(pool: &PgPool, data: &CreateProject, owner_id: Uuid, project_id: Uuid) -> Result { + let now = Utc::now(); + + sqlx::query_as!( + Project, + "INSERT INTO projects (id, name, git_repo_path, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, git_repo_path, owner_id, created_at, updated_at", + project_id, + data.name, + data.git_repo_path, + owner_id, + now, + now + ) + .fetch_one(pool) + .await + } + + pub async fn update(pool: &PgPool, id: Uuid, name: String, git_repo_path: String) -> Result { + let now = Utc::now(); + + sqlx::query_as!( + Project, + "UPDATE projects SET name = $2, git_repo_path = $3, updated_at = $4 WHERE id = $1 RETURNING id, name, git_repo_path, owner_id, created_at, updated_at", + id, + name, + git_repo_path, + now + ) + .fetch_one(pool) + .await + } + + pub async fn delete(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query!("DELETE FROM projects WHERE id = $1", id) + .execute(pool) + .await?; + Ok(result.rows_affected()) + } + + pub async fn exists(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query!("SELECT id FROM projects WHERE id = $1", id) + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } +} diff --git a/backend/src/models/task.rs b/backend/src/models/task.rs index b96cad4b..830208a5 100644 --- a/backend/src/models/task.rs +++ b/backend/src/models/task.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{FromRow, Type}; +use sqlx::{FromRow, Type, PgPool}; use ts_rs::TS; use uuid::Uuid; @@ -43,3 +43,93 @@ pub struct UpdateTask { pub description: Option, pub status: Option, } + +impl Task { + pub async fn find_by_project_id(pool: &PgPool, project_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + Task, + r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at + FROM tasks + WHERE project_id = $1 + ORDER BY created_at DESC"#, + project_id + ) + .fetch_all(pool) + .await + } + + pub async fn find_by_id_and_project_id(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + Task, + r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at + FROM tasks + WHERE id = $1 AND project_id = $2"#, + id, + project_id + ) + .fetch_optional(pool) + .await + } + + pub async fn create(pool: &PgPool, data: &CreateTask, task_id: Uuid) -> Result { + let now = Utc::now(); + + sqlx::query_as!( + Task, + r#"INSERT INTO tasks (id, project_id, title, description, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at"#, + task_id, + data.project_id, + data.title, + data.description, + TaskStatus::Todo as TaskStatus, + now, + now + ) + .fetch_one(pool) + .await + } + + pub async fn update(pool: &PgPool, id: Uuid, project_id: Uuid, title: String, description: Option, status: TaskStatus) -> Result { + let now = Utc::now(); + + sqlx::query_as!( + Task, + r#"UPDATE tasks + SET title = $3, description = $4, status = $5, updated_at = $6 + WHERE id = $1 AND project_id = $2 + RETURNING id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at"#, + id, + project_id, + title, + description, + status as TaskStatus, + now + ) + .fetch_one(pool) + .await + } + + pub async fn delete(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result { + let result = sqlx::query!( + "DELETE FROM tasks WHERE id = $1 AND project_id = $2", + id, + project_id + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) + } + + pub async fn exists(pool: &PgPool, id: Uuid, project_id: Uuid) -> Result { + let result = sqlx::query!( + "SELECT id FROM tasks WHERE id = $1 AND project_id = $2", + id, + project_id + ) + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } +} diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 9a670171..ef891124 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{FromRow, Type}; +use sqlx::{FromRow, Type, PgPool}; use ts_rs::TS; use uuid::Uuid; @@ -42,3 +42,52 @@ pub struct UpdateTaskAttempt { pub base_commit: Option, pub merge_commit: Option, } + +impl TaskAttempt { + pub async fn find_by_task_id(pool: &PgPool, task_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + TaskAttempt, + r#"SELECT id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at + FROM task_attempts + WHERE task_id = $1 + ORDER BY created_at DESC"#, + task_id + ) + .fetch_all(pool) + .await + } + + pub async fn create(pool: &PgPool, data: &CreateTaskAttempt, attempt_id: Uuid) -> Result { + let now = Utc::now(); + + sqlx::query_as!( + TaskAttempt, + r#"INSERT INTO task_attempts (id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at"#, + attempt_id, + data.task_id, + data.worktree_path, + data.base_commit, + data.merge_commit, + now, + now + ) + .fetch_one(pool) + .await + } + + pub async fn exists_for_task(pool: &PgPool, attempt_id: Uuid, task_id: Uuid, project_id: Uuid) -> Result { + let result = sqlx::query!( + "SELECT ta.id FROM task_attempts ta + JOIN tasks t ON ta.task_id = t.id + WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3", + attempt_id, + task_id, + project_id + ) + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } +} diff --git a/backend/src/models/task_attempt_activity.rs b/backend/src/models/task_attempt_activity.rs index 91d466cc..b3adb1c2 100644 --- a/backend/src/models/task_attempt_activity.rs +++ b/backend/src/models/task_attempt_activity.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::FromRow; +use sqlx::{FromRow, PgPool}; use ts_rs::TS; use uuid::Uuid; @@ -23,3 +23,53 @@ pub struct CreateTaskAttemptActivity { pub status: Option, // Default to Init if not provided pub note: Option, } + +impl TaskAttemptActivity { + pub async fn find_by_attempt_id(pool: &PgPool, attempt_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + TaskAttemptActivity, + r#"SELECT id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at + FROM task_attempt_activities + WHERE task_attempt_id = $1 + ORDER BY created_at DESC"#, + attempt_id + ) + .fetch_all(pool) + .await + } + + pub async fn create(pool: &PgPool, data: &CreateTaskAttemptActivity, activity_id: Uuid, status: TaskAttemptStatus) -> Result { + let now = Utc::now(); + + sqlx::query_as!( + TaskAttemptActivity, + r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note, created_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at"#, + activity_id, + data.task_attempt_id, + status as TaskAttemptStatus, + data.note, + now + ) + .fetch_one(pool) + .await + } + + pub async fn create_initial(pool: &PgPool, attempt_id: Uuid, activity_id: Uuid) -> Result<(), sqlx::Error> { + let now = Utc::now(); + + sqlx::query!( + r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note, created_at) + VALUES ($1, $2, $3, $4, $5)"#, + activity_id, + attempt_id, + TaskAttemptStatus::Init as TaskAttemptStatus, + Option::::None, + now + ) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index f18959a3..51d5c01e 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::FromRow; +use sqlx::{FromRow, PgPool}; use ts_rs::TS; use uuid::Uuid; @@ -69,3 +69,75 @@ impl From for UserResponse { } } } + +impl User { + pub async fn find_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { + sqlx::query_as!( + User, + "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE email = $1", + email + ) + .fetch_optional(pool) + .await + } + + pub async fn find_all(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as!( + User, + "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users ORDER BY created_at DESC" + ) + .fetch_all(pool) + .await + } + + pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + User, + "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE id = $1", + id + ) + .fetch_optional(pool) + .await + } + + pub async fn create(pool: &PgPool, data: &CreateUser, password_hash: String, user_id: Uuid) -> Result { + let now = Utc::now(); + let is_admin = data.is_admin.unwrap_or(false); + + sqlx::query_as!( + User, + "INSERT INTO users (id, email, password_hash, is_admin, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, password_hash, is_admin, created_at, updated_at", + user_id, + data.email, + password_hash, + is_admin, + now, + now + ) + .fetch_one(pool) + .await + } + + pub async fn update(pool: &PgPool, id: Uuid, email: String, password_hash: String, is_admin: bool) -> Result { + let now = Utc::now(); + + sqlx::query_as!( + User, + "UPDATE users SET email = $2, password_hash = $3, is_admin = $4, updated_at = $5 WHERE id = $1 RETURNING id, email, password_hash, is_admin, created_at, updated_at", + id, + email, + password_hash, + is_admin, + now + ) + .fetch_one(pool) + .await + } + + pub async fn delete(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query!("DELETE FROM users WHERE id = $1", id) + .execute(pool) + .await?; + Ok(result.rows_affected()) + } +} diff --git a/backend/src/routes/projects.rs b/backend/src/routes/projects.rs index e22e8243..357c8c4f 100644 --- a/backend/src/routes/projects.rs +++ b/backend/src/routes/projects.rs @@ -8,22 +8,15 @@ use axum::{ }; use sqlx::PgPool; use uuid::Uuid; -use chrono::Utc; use crate::models::{ApiResponse, project::{Project, CreateProject, UpdateProject}}; use crate::auth::AuthUser; pub async fn get_projects( - auth: AuthUser, + _auth: AuthUser, Extension(pool): Extension ) -> Result>>, StatusCode> { - match sqlx::query_as!( - Project, - "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects ORDER BY created_at DESC" - ) - .fetch_all(&pool) - .await - { + match Project::find_all(&pool).await { Ok(projects) => Ok(ResponseJson(ApiResponse { success: true, data: Some(projects), @@ -37,18 +30,11 @@ pub async fn get_projects( } pub async fn get_project( - auth: AuthUser, + _auth: AuthUser, Path(id): Path, Extension(pool): Extension ) -> Result>, StatusCode> { - match sqlx::query_as!( - Project, - "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE id = $1", - id - ) - .fetch_optional(&pool) - .await - { + match Project::find_by_id(&pool, id).await { Ok(Some(project)) => Ok(ResponseJson(ApiResponse { success: true, data: Some(project), @@ -68,19 +54,11 @@ pub async fn create_project( Json(payload): Json ) -> Result>, StatusCode> { let id = Uuid::new_v4(); - let now = Utc::now(); tracing::debug!("Creating project '{}' for user {}", payload.name, auth.user_id); // Check if git repo path is already used by another project - let existing_project = sqlx::query!( - "SELECT id FROM projects WHERE git_repo_path = $1", - payload.git_repo_path - ) - .fetch_optional(&pool) - .await; - - match existing_project { + match Project::find_by_git_repo_path(&pool, &payload.git_repo_path).await { Ok(Some(_)) => { return Ok(ResponseJson(ApiResponse { success: false, @@ -170,19 +148,7 @@ pub async fn create_project( } } - match sqlx::query_as!( - Project, - "INSERT INTO projects (id, name, git_repo_path, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, git_repo_path, owner_id, created_at, updated_at", - id, - payload.name, - payload.git_repo_path, - auth.user_id, - now, - now - ) - .fetch_one(&pool) - .await - { + match Project::create(&pool, &payload, auth.user_id, id).await { Ok(project) => Ok(ResponseJson(ApiResponse { success: true, data: Some(project), @@ -200,18 +166,8 @@ pub async fn update_project( Extension(pool): Extension, Json(payload): Json ) -> Result>, StatusCode> { - let now = Utc::now(); - // Check if project exists first - let existing_project = sqlx::query_as!( - Project, - "SELECT id, name, git_repo_path, owner_id, created_at, updated_at FROM projects WHERE id = $1", - id - ) - .fetch_optional(&pool) - .await; - - let existing_project = match existing_project { + let existing_project = match Project::find_by_id(&pool, id).await { Ok(Some(project)) => project, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(e) => { @@ -223,15 +179,7 @@ pub async fn update_project( // If git_repo_path is being changed, check if the new path is already used by another project if let Some(new_git_repo_path) = &payload.git_repo_path { if new_git_repo_path != &existing_project.git_repo_path { - let duplicate_project = sqlx::query!( - "SELECT id FROM projects WHERE git_repo_path = $1 AND id != $2", - new_git_repo_path, - id - ) - .fetch_optional(&pool) - .await; - - match duplicate_project { + match Project::find_by_git_repo_path_excluding_id(&pool, new_git_repo_path, id).await { Ok(Some(_)) => { return Ok(ResponseJson(ApiResponse { success: false, @@ -254,17 +202,7 @@ pub async fn update_project( 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()); - match sqlx::query_as!( - Project, - "UPDATE projects SET name = $2, git_repo_path = $3, updated_at = $4 WHERE id = $1 RETURNING id, name, git_repo_path, owner_id, created_at, updated_at", - id, - name, - git_repo_path, - now - ) - .fetch_one(&pool) - .await - { + match Project::update(&pool, id, name, git_repo_path).await { Ok(project) => Ok(ResponseJson(ApiResponse { success: true, data: Some(project), @@ -281,12 +219,9 @@ pub async fn delete_project( Path(id): Path, Extension(pool): Extension ) -> Result>, StatusCode> { - match sqlx::query!("DELETE FROM projects WHERE id = $1", id) - .execute(&pool) - .await - { - Ok(result) => { - if result.rows_affected() == 0 { + match Project::delete(&pool, id).await { + Ok(rows_affected) => { + if rows_affected == 0 { Err(StatusCode::NOT_FOUND) } else { Ok(ResponseJson(ApiResponse { @@ -453,6 +388,7 @@ mod tests { let create_request = CreateProject { name: "New Project".to_string(), git_repo_path: "/tmp/new-project".to_string(), + use_existing_repo: false, }; let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await; @@ -480,6 +416,7 @@ mod tests { let create_request = CreateProject { name: "Admin Project".to_string(), git_repo_path: "/tmp/admin-project".to_string(), + use_existing_repo: false, }; let result = create_project(auth.clone(), Extension(pool), Json(create_request)).await; diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index be71eb3d..65b90e08 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -8,32 +8,22 @@ use axum::{ }; use sqlx::PgPool; use uuid::Uuid; -use chrono::Utc; use crate::models::{ ApiResponse, - task::{Task, CreateTask, UpdateTask, TaskStatus}, - task_attempt::{TaskAttempt, CreateTaskAttempt, UpdateTaskAttempt, TaskAttemptStatus}, + project::Project, + task::{Task, CreateTask, UpdateTask}, + task_attempt::{TaskAttempt, CreateTaskAttempt, TaskAttemptStatus}, task_attempt_activity::{TaskAttemptActivity, CreateTaskAttemptActivity} }; use crate::auth::AuthUser; pub async fn get_project_tasks( - auth: AuthUser, + _auth: AuthUser, Path(project_id): Path, Extension(pool): Extension ) -> Result>>, StatusCode> { - match sqlx::query_as!( - Task, - r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at - FROM tasks - WHERE project_id = $1 - ORDER BY created_at DESC"#, - project_id - ) - .fetch_all(&pool) - .await - { + match Task::find_by_project_id(&pool, project_id).await { Ok(tasks) => Ok(ResponseJson(ApiResponse { success: true, data: Some(tasks), @@ -47,21 +37,11 @@ pub async fn get_project_tasks( } pub async fn get_task( - auth: AuthUser, + _auth: AuthUser, Path((project_id, task_id)): Path<(Uuid, Uuid)>, Extension(pool): Extension ) -> Result>, StatusCode> { - match sqlx::query_as!( - Task, - r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at - FROM tasks - WHERE id = $1 AND project_id = $2"#, - task_id, - project_id - ) - .fetch_optional(&pool) - .await - { + match Task::find_by_id_and_project_id(&pool, task_id, project_id).await { Ok(Some(task)) => Ok(ResponseJson(ApiResponse { success: true, data: Some(task), @@ -82,43 +62,23 @@ pub async fn create_task( Json(mut payload): Json ) -> Result>, StatusCode> { let id = Uuid::new_v4(); - let now = Utc::now(); // Ensure the project_id in the payload matches the path parameter payload.project_id = project_id; // Verify project exists first - let project_exists = sqlx::query!("SELECT id FROM projects WHERE id = $1", project_id) - .fetch_optional(&pool) - .await; - - match project_exists { - Ok(None) => return Err(StatusCode::NOT_FOUND), + match Project::exists(&pool, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), Err(e) => { tracing::error!("Failed to check project existence: {}", e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - Ok(Some(_)) => {} + Ok(true) => {} } tracing::debug!("Creating task '{}' in project {} for user {}", payload.title, project_id, auth.user_id); - match sqlx::query_as!( - Task, - r#"INSERT INTO tasks (id, project_id, title, description, status, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at"#, - id, - payload.project_id, - payload.title, - payload.description, - TaskStatus::Todo as TaskStatus, - now, - now - ) - .fetch_one(&pool) - .await - { + match Task::create(&pool, &payload, id).await { Ok(task) => Ok(ResponseJson(ApiResponse { success: true, data: Some(task), @@ -136,21 +96,8 @@ pub async fn update_task( Extension(pool): Extension, Json(payload): Json ) -> Result>, StatusCode> { - let now = Utc::now(); - // Check if task exists in the specified project - let existing_task = sqlx::query_as!( - Task, - r#"SELECT id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at - FROM tasks - WHERE id = $1 AND project_id = $2"#, - task_id, - project_id - ) - .fetch_optional(&pool) - .await; - - let existing_task = match existing_task { + let existing_task = match Task::find_by_id_and_project_id(&pool, task_id, project_id).await { Ok(Some(task)) => task, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(e) => { @@ -164,22 +111,7 @@ pub async fn update_task( let description = payload.description.or(existing_task.description); let status = payload.status.unwrap_or(existing_task.status); - match sqlx::query_as!( - Task, - r#"UPDATE tasks - SET title = $3, description = $4, status = $5, updated_at = $6 - WHERE id = $1 AND project_id = $2 - RETURNING id, project_id, title, description, status as "status!: TaskStatus", created_at, updated_at"#, - task_id, - project_id, - title, - description, - status as TaskStatus, - now - ) - .fetch_one(&pool) - .await - { + match Task::update(&pool, task_id, project_id, title, description, status).await { Ok(task) => Ok(ResponseJson(ApiResponse { success: true, data: Some(task), @@ -196,16 +128,9 @@ pub async fn delete_task( Path((project_id, task_id)): Path<(Uuid, Uuid)>, Extension(pool): Extension ) -> Result>, StatusCode> { - match sqlx::query!( - "DELETE FROM tasks WHERE id = $1 AND project_id = $2", - task_id, - project_id - ) - .execute(&pool) - .await - { - Ok(result) => { - if result.rows_affected() == 0 { + match Task::delete(&pool, task_id, project_id).await { + Ok(rows_affected) => { + if rows_affected == 0 { Err(StatusCode::NOT_FOUND) } else { Ok(ResponseJson(ApiResponse { @@ -229,34 +154,16 @@ pub async fn get_task_attempts( Extension(pool): Extension ) -> Result>>, StatusCode> { // Verify task exists in project first - let task_exists = sqlx::query!( - "SELECT id FROM tasks WHERE id = $1 AND project_id = $2", - task_id, - project_id - ) - .fetch_optional(&pool) - .await; - - match task_exists { - Ok(None) => return Err(StatusCode::NOT_FOUND), + match Task::exists(&pool, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), Err(e) => { tracing::error!("Failed to check task existence: {}", e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - Ok(Some(_)) => {} + Ok(true) => {} } - match sqlx::query_as!( - TaskAttempt, - r#"SELECT id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at - FROM task_attempts - WHERE task_id = $1 - ORDER BY created_at DESC"#, - task_id - ) - .fetch_all(&pool) - .await - { + match TaskAttempt::find_by_task_id(&pool, task_id).await { Ok(attempts) => Ok(ResponseJson(ApiResponse { success: true, data: Some(attempts), @@ -275,37 +182,16 @@ pub async fn get_task_attempt_activities( Extension(pool): Extension ) -> Result>>, StatusCode> { // Verify task attempt exists and belongs to the correct task - let attempt_exists = sqlx::query!( - "SELECT ta.id FROM task_attempts ta - JOIN tasks t ON ta.task_id = t.id - WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3", - attempt_id, - task_id, - project_id - ) - .fetch_optional(&pool) - .await; - - match attempt_exists { - Ok(None) => return Err(StatusCode::NOT_FOUND), + match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), Err(e) => { tracing::error!("Failed to check task attempt existence: {}", e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - Ok(Some(_)) => {} + Ok(true) => {} } - match sqlx::query_as!( - TaskAttemptActivity, - r#"SELECT id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at - FROM task_attempt_activities - WHERE task_attempt_id = $1 - ORDER BY created_at DESC"#, - attempt_id - ) - .fetch_all(&pool) - .await - { + match TaskAttemptActivity::find_by_attempt_id(&pool, attempt_id).await { Ok(activities) => Ok(ResponseJson(ApiResponse { success: true, data: Some(activities), @@ -325,59 +211,25 @@ pub async fn create_task_attempt( Json(mut payload): Json ) -> Result>, StatusCode> { // Verify task exists in project first - let task_exists = sqlx::query!( - "SELECT id FROM tasks WHERE id = $1 AND project_id = $2", - task_id, - project_id - ) - .fetch_optional(&pool) - .await; - - match task_exists { - Ok(None) => return Err(StatusCode::NOT_FOUND), + match Task::exists(&pool, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), Err(e) => { tracing::error!("Failed to check task existence: {}", e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - Ok(Some(_)) => {} + Ok(true) => {} } let id = Uuid::new_v4(); - let now = Utc::now(); // Ensure the task_id in the payload matches the path parameter payload.task_id = task_id; - match sqlx::query_as!( - TaskAttempt, - r#"INSERT INTO task_attempts (id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, task_id, worktree_path, base_commit, merge_commit, created_at, updated_at"#, - id, - payload.task_id, - payload.worktree_path, - payload.base_commit, - payload.merge_commit, - now, - now - ) - .fetch_one(&pool) - .await - { + match TaskAttempt::create(&pool, &payload, id).await { Ok(attempt) => { // Create initial activity record let activity_id = Uuid::new_v4(); - let _ = sqlx::query!( - r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note, created_at) - VALUES ($1, $2, $3, $4, $5)"#, - activity_id, - attempt.id, - TaskAttemptStatus::Init as TaskAttemptStatus, - Option::::None, - now - ) - .execute(&pool) - .await; + let _ = TaskAttemptActivity::create_initial(&pool, attempt.id, activity_id).await; Ok(ResponseJson(ApiResponse { success: true, @@ -399,49 +251,24 @@ pub async fn create_task_attempt_activity( Json(mut payload): Json ) -> Result>, StatusCode> { // Verify task attempt exists and belongs to the correct task - let attempt_exists = sqlx::query!( - "SELECT ta.id FROM task_attempts ta - JOIN tasks t ON ta.task_id = t.id - WHERE ta.id = $1 AND t.id = $2 AND t.project_id = $3", - attempt_id, - task_id, - project_id - ) - .fetch_optional(&pool) - .await; - - match attempt_exists { - Ok(None) => return Err(StatusCode::NOT_FOUND), + match TaskAttempt::exists_for_task(&pool, attempt_id, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), Err(e) => { tracing::error!("Failed to check task attempt existence: {}", e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - Ok(Some(_)) => {} + Ok(true) => {} } let id = Uuid::new_v4(); - let now = Utc::now(); // Ensure the task_attempt_id in the payload matches the path parameter payload.task_attempt_id = attempt_id; // Default to Init status if not provided - let status = payload.status.unwrap_or(TaskAttemptStatus::Init); + let status = payload.status.clone().unwrap_or(TaskAttemptStatus::Init); - match sqlx::query_as!( - TaskAttemptActivity, - r#"INSERT INTO task_attempt_activities (id, task_attempt_id, status, note, created_at) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, task_attempt_id, status as "status!: TaskAttemptStatus", note, created_at"#, - id, - payload.task_attempt_id, - status as TaskAttemptStatus, - payload.note, - now - ) - .fetch_one(&pool) - .await - { + match TaskAttemptActivity::create(&pool, &payload, id, status).await { Ok(activity) => Ok(ResponseJson(ApiResponse { success: true, data: Some(activity), @@ -497,12 +324,14 @@ mod tests { async fn create_test_project(pool: &PgPool, name: &str, owner_id: Uuid) -> Project { let id = Uuid::new_v4(); let now = Utc::now(); + let git_repo_path = format!("/tmp/test-repo-{}", id); sqlx::query_as!( Project, - "INSERT INTO projects (id, name, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, owner_id, created_at, updated_at", + "INSERT INTO projects (id, name, git_repo_path, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, git_repo_path, owner_id, created_at, updated_at", id, name, + git_repo_path, owner_id, now, now diff --git a/backend/src/routes/users.rs b/backend/src/routes/users.rs index 38f334d7..25602aa6 100644 --- a/backend/src/routes/users.rs +++ b/backend/src/routes/users.rs @@ -8,7 +8,6 @@ use axum::{ }; use sqlx::PgPool; use uuid::Uuid; -use chrono::Utc; use crate::models::{ApiResponse, user::{User, CreateUser, UpdateUser, LoginRequest, LoginResponse, UserResponse}}; use crate::auth::{AuthUser, create_token, hash_password, verify_password}; @@ -17,14 +16,7 @@ pub async fn login( Extension(pool): Extension, Json(payload): Json ) -> Result>, StatusCode> { - match sqlx::query_as!( - User, - "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE email = $1", - payload.email - ) - .fetch_optional(&pool) - .await - { + match User::find_by_email(&pool, &payload.email).await { Ok(Some(user)) => { match verify_password(&payload.password, &user.password_hash) { Ok(true) => { @@ -64,13 +56,7 @@ pub async fn get_users( _auth: AuthUser, Extension(pool): Extension ) -> Result>>, StatusCode> { - match sqlx::query_as!( - User, - "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users ORDER BY created_at DESC" - ) - .fetch_all(&pool) - .await - { + match User::find_all(&pool).await { Ok(users) => { let user_responses: Vec = users.into_iter().map(|u| u.into()).collect(); Ok(ResponseJson(ApiResponse { @@ -96,14 +82,7 @@ pub async fn get_user( return Err(StatusCode::FORBIDDEN); } - match sqlx::query_as!( - User, - "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE id = $1", - id - ) - .fetch_optional(&pool) - .await - { + match User::find_by_id(&pool, id).await { Ok(Some(user)) => Ok(ResponseJson(ApiResponse { success: true, data: Some(user.into()), @@ -128,27 +107,13 @@ pub async fn create_user( } let id = Uuid::new_v4(); - let now = Utc::now(); - let is_admin = payload.is_admin.unwrap_or(false); let password_hash = match hash_password(&payload.password) { Ok(hash) => hash, Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - match sqlx::query_as!( - User, - "INSERT INTO users (id, email, password_hash, is_admin, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, email, password_hash, is_admin, created_at, updated_at", - id, - payload.email, - password_hash, - is_admin, - now, - now - ) - .fetch_one(&pool) - .await - { + match User::create(&pool, &payload, password_hash, id).await { Ok(user) => Ok(ResponseJson(ApiResponse { success: true, data: Some(user.into()), @@ -176,17 +141,8 @@ pub async fn update_user( return Err(StatusCode::FORBIDDEN); } - let now = Utc::now(); - // Get existing user - let existing_user = match sqlx::query_as!( - User, - "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE id = $1", - id - ) - .fetch_optional(&pool) - .await - { + let existing_user = match User::find_by_id(&pool, id).await { Ok(Some(user)) => user, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(e) => { @@ -211,18 +167,7 @@ pub async fn update_user( existing_user.password_hash }; - match sqlx::query_as!( - User, - "UPDATE users SET email = $2, password_hash = $3, is_admin = $4, updated_at = $5 WHERE id = $1 RETURNING id, email, password_hash, is_admin, created_at, updated_at", - id, - email, - password_hash, - is_admin, - now - ) - .fetch_one(&pool) - .await - { + match User::update(&pool, id, email, password_hash, is_admin).await { Ok(user) => Ok(ResponseJson(ApiResponse { success: true, data: Some(user.into()), @@ -245,12 +190,9 @@ pub async fn delete_user( return Err(StatusCode::FORBIDDEN); } - match sqlx::query!("DELETE FROM users WHERE id = $1", id) - .execute(&pool) - .await - { - Ok(result) => { - if result.rows_affected() == 0 { + match User::delete(&pool, id).await { + Ok(rows_affected) => { + if rows_affected == 0 { Err(StatusCode::NOT_FOUND) } else { Ok(ResponseJson(ApiResponse { @@ -271,14 +213,7 @@ pub async fn get_current_user( auth: AuthUser, Extension(pool): Extension ) -> Result>, StatusCode> { - match sqlx::query_as!( - User, - "SELECT id, email, password_hash, is_admin, created_at, updated_at FROM users WHERE id = $1", - auth.user_id - ) - .fetch_optional(&pool) - .await - { + match User::find_by_id(&pool, auth.user_id).await { Ok(Some(user)) => Ok(ResponseJson(ApiResponse { success: true, data: Some(user.into()),