From f529bf4552f19922dde08b861f901470317bcf7c Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Wed, 22 Oct 2025 22:20:14 -0700 Subject: [PATCH] Create Project APIs. --- .forgejo/workflows/ci.yml | 4 +- backend/.gitignore | 1 + ...078c9ae067aafcaa2e7ca29d597c0a74ac48d.json | 12 ++ ...a1635f191f0abd4d547835c0f05aa403f96e0.json | 20 +++ ...ba06cc3212ffffb8520fc7dbbcc8b60ada314.json | 12 ++ backend/Cargo.lock | 2 + backend/Cargo.toml | 2 +- backend/src/main.rs | 1 + backend/src/models/mod.rs | 2 + backend/src/models/project.rs | 122 ++++++++++++++++++ backend/src/services/mod.rs | 16 ++- backend/src/services/projects.rs | 106 +++++++++++++++ backend/tests/api.rs | 3 + 13 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 backend/.sqlx/query-3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d.json create mode 100644 backend/.sqlx/query-93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0.json create mode 100644 backend/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json create mode 100644 backend/src/models/project.rs create mode 100644 backend/src/services/projects.rs diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 27acecc..98d018b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -51,7 +51,9 @@ jobs: cargo test --locked -- --skip api working-directory: backend - name: "Integration Tests" - run: cargo test --locked --test api + run: | + mkdir -p reports/store + cargo test --locked --test api working-directory: backend check-frontend: diff --git a/backend/.gitignore b/backend/.gitignore index 34d5ca5..87fb6b3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,6 +2,7 @@ target coverage reports + *.db *.db-shm *.db-wal diff --git a/backend/.sqlx/query-3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d.json b/backend/.sqlx/query-3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d.json new file mode 100644 index 0000000..85072c7 --- /dev/null +++ b/backend/.sqlx/query-3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE projects \n SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7 \n WHERE id=$8", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d" +} diff --git a/backend/.sqlx/query-93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0.json b/backend/.sqlx/query-93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0.json new file mode 100644 index 0000000..c85c3b2 --- /dev/null +++ b/backend/.sqlx/query-93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT MAX(sort_order) as max_order FROM projects;", + "describe": { + "columns": [ + { + "name": "max_order", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true + ] + }, + "hash": "93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0" +} diff --git a/backend/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json b/backend/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json new file mode 100644 index 0000000..bac9de5 --- /dev/null +++ b/backend/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM projects WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 207f7de..45153c3 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -3500,9 +3500,11 @@ dependencies = [ "bitflags 2.9.3", "bytes", "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c8df765..fbe2257 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -12,7 +12,7 @@ chrono = { version = "0.4.41", features = ["serde"] } serde = "1.0.219" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] } tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] } -tower-http = { version = "0.6.0", features = ["cors"] } +tower-http = { version = "0.6.0", features = ["cors", "trace"] } tracing = "0.1.41" tracing-subscriber = "0.3.19" uuid = { version = "1.18.0", features = ["serde", "v4"] } diff --git a/backend/src/main.rs b/backend/src/main.rs index 7088520..df54c7e 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -32,6 +32,7 @@ async fn main() { let app = Router::new() .route("/health", get(health)) .nest("/api/tasks", services::create_task_router()) + .nest("/api/projects", services::create_project_router()) .layer(cors) .with_state(pool); diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index e05a0c1..66c1e21 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,3 +1,5 @@ +mod project; mod task; +pub use project::*; pub use task::*; diff --git a/backend/src/models/project.rs b/backend/src/models/project.rs new file mode 100644 index 0000000..48fe035 --- /dev/null +++ b/backend/src/models/project.rs @@ -0,0 +1,122 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Row, Sqlite}; +use uuid::Uuid; + +use crate::services::AppError; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)] +#[serde(rename_all = "snake_case")] +#[sqlx(rename_all = "lowercase")] +pub enum ProjectStatus { + Active, + Done, + Backlog, +} + +#[derive(sqlx::FromRow, Serialize)] +pub struct ProjectModel { + pub id: Uuid, + pub title: String, + pub color: String, + pub folder_id: Option, + pub sort_order: i64, + pub status: ProjectStatus, + pub created_at: DateTime, + pub updated_at: DateTime, + pub completed_at: Option>, +} + +impl ProjectModel { + async fn get_new_sort_order(pool: &Pool) -> anyhow::Result { + let result = sqlx::query!("SELECT MAX(sort_order) as max_order FROM projects;") + .fetch_one(pool) + .await?; + + match result.max_order { + Some(i) => Ok(i + 1000), + None => Ok(0), + } + } + pub async fn insert( + pool: &Pool, + title: String, + color: String, + ) -> anyhow::Result { + if title.is_empty() { + return Err(AppError::Unprocessable( + "Title must not be empty".to_string(), + )); + } + + if color.len() != 7 || color.chars().next() != Some('#') { + return Err(AppError::Unprocessable( + "Color is not a valid hex".to_string(), + )); + } + + let id = Uuid::new_v4(); + let sort_order = ProjectModel::get_new_sort_order(pool).await?; + + let result = sqlx::query_as( + "INSERT INTO projects(id, title, color, sort_order) VALUES($1, $2, $3, $4) RETURNING *", + ) + .bind(id) + .bind(title) + .bind(color) + .bind(sort_order) + .fetch_one(pool) + .await?; + Ok(result) + } + + pub async fn get_by_id( + pool: &Pool, + id: Uuid, + ) -> anyhow::Result { + sqlx::query_as("SELECT * FROM projects WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => AppError::NotFound, + e => e.into(), + }) + } + + pub async fn list(pool: &Pool) -> anyhow::Result> { + sqlx::query_as("SELECT * FROM projects;") + .fetch_all(pool) + .await + .map_err(|e| e.into()) + } + + pub async fn update(self, pool: &Pool) -> anyhow::Result { + let now: DateTime = Utc::now(); + + let _ = sqlx::query!( + "UPDATE projects + SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7 + WHERE id=$8", + self.title, + self.color, + self.folder_id, + self.status, + self.sort_order, + now, + self.completed_at, + self.id, + ) + .execute(pool) + .await?; + + ProjectModel::get_by_id(pool, self.id).await + } + + pub async fn delete(pool: &Pool, id: Uuid) -> anyhow::Result<()> { + sqlx::query!("DELETE FROM projects WHERE id = $1", id) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 7c095e5..7025d40 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,3 +1,4 @@ +mod projects; mod tasks; use axum::{ @@ -26,11 +27,14 @@ pub struct ErrorJson { impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { - Self::InternalError(anyhow_error) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Something went wrong {}", anyhow_error), - ) - .into_response(), + Self::InternalError(anyhow_error) => { + error!(err = ?anyhow_error, "Internal Server Error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong {}", anyhow_error), + ) + .into_response() + } Self::JsonExtractError(rej) => ( StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorJson { @@ -85,5 +89,7 @@ impl From for AppError { } } +pub use projects::*; use serde::Serialize; pub use tasks::*; +use tracing::error; diff --git a/backend/src/services/projects.rs b/backend/src/services/projects.rs new file mode 100644 index 0000000..568704c --- /dev/null +++ b/backend/src/services/projects.rs @@ -0,0 +1,106 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, +}; +use axum_extra::extract::WithRejection; +use serde::Deserialize; +use sqlx::{Pool, Sqlite}; +use uuid::Uuid; + +use crate::models::{ProjectModel, ProjectStatus}; + +use super::AppError; + +pub fn create_project_router() -> Router> { + Router::new() + .route("/", post(create_project).get(list_projects)) + .route( + "/{project_id}", + get(get_project).put(update_project).delete(delete_project), + ) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateProjectRequest { + title: String, + color: String, +} + +pub async fn create_project( + State(pool): State>, + WithRejection(Json(input), _): WithRejection, AppError>, +) -> Result<(StatusCode, Json), AppError> { + Ok(( + StatusCode::CREATED, + Json(ProjectModel::insert(&pool, input.title, input.color).await?), + )) +} + +pub async fn list_projects( + State(pool): State>, +) -> Result<(StatusCode, Json>), AppError> { + Ok((StatusCode::OK, Json(ProjectModel::list(&pool).await?))) +} + +pub async fn get_project( + State(pool): State>, + WithRejection(Path(project_id), _): WithRejection, AppError>, +) -> Result<(StatusCode, Json), AppError> { + Ok(( + StatusCode::OK, + Json(ProjectModel::get_by_id(&pool, project_id).await?), + )) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateProjectRequest { + title: Option, + color: Option, + status: Option, +} + +pub async fn update_project( + State(pool): State>, + WithRejection(Path(project_id), _): WithRejection, AppError>, + WithRejection(Json(input), _): WithRejection, AppError>, +) -> Result<(StatusCode, Json), AppError> { + let mut project = ProjectModel::get_by_id(&pool, project_id).await?; + + if let Some(new_title) = input.title { + if new_title.len() == 0 { + return Err(AppError::Unprocessable( + "Title must not be empty".to_string(), + )); + } + project.title = new_title; + } + + if let Some(color) = input.color { + if color.len() != 7 || color.chars().next() != Some('#') { + return Err(AppError::Unprocessable( + "Color is not a valid hex".to_string(), + )); + } + project.color = color; + } + + if let Some(status) = input.status { + project.status = status; + } + + Ok((StatusCode::OK, Json(project.update(&pool).await?))) +} + +pub async fn delete_project( + State(pool): State>, + WithRejection(Path(project_id), _): WithRejection, AppError>, +) -> Result { + ProjectModel::get_by_id(&pool, project_id).await?; + ProjectModel::delete(&pool, project_id).await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/tests/api.rs b/backend/tests/api.rs index 857457d..1c4debb 100644 --- a/backend/tests/api.rs +++ b/backend/tests/api.rs @@ -9,6 +9,7 @@ use hurl_core::input::Input; use tower_http::trace::TraceLayer; async fn create_app() -> Router { + tracing_subscriber::fmt::try_init(); use backend::database::{DatabaseConfig, create_pool}; use backend::services; @@ -29,6 +30,8 @@ async fn create_app() -> Router { Router::new() .route("/health", get(health)) .nest("/api/tasks", services::create_task_router()) + .nest("/api/projects", services::create_project_router()) + .layer(TraceLayer::new_for_http()) .with_state(pool) }