Create a plan for implementing projects and also create the backend API. #19
17 changed files with 957 additions and 12 deletions
|
|
@ -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:
|
||||
|
|
|
|||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
|||
target
|
||||
coverage
|
||||
reports
|
||||
|
||||
|
||||
*.db
|
||||
*.db-shm
|
||||
|
|
|
|||
12
backend/.sqlx/query-3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d.json
generated
Normal file
12
backend/.sqlx/query-3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
20
backend/.sqlx/query-93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0.json
generated
Normal file
20
backend/.sqlx/query-93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
12
backend/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json
generated
Normal file
12
backend/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM projects WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314"
|
||||
}
|
||||
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
17
backend/migrations/20251023043152_create_projects.down.sql
Normal file
17
backend/migrations/20251023043152_create_projects.down.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
ALTER TABLE tasks RENAME TO tasks_old;
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
title VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT CHECK(status IN ('todo', 'done', 'backlog')) DEFAULT 'todo',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO tasks SELECT id, title, description, status, created_at, updated_at, completed_at FROM tasks_old;
|
||||
|
||||
DROP TABLE tasks_old;
|
||||
DROP TABLE projects;
|
||||
DROP TABLE folders;
|
||||
39
backend/migrations/20251023043152_create_projects.up.sql
Normal file
39
backend/migrations/20251023043152_create_projects.up.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
CREATE TABLE folders (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
title VARCHAR NOT NULL,
|
||||
sort_order INTEGER NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
title VARCHAR NOT NULL,
|
||||
color VARCHAR(7) NOT NULL, -- hex code
|
||||
folder_id UUID,
|
||||
sort_order INTEGER NOT NULL UNIQUE,
|
||||
status TEXT CHECK(status IN ('active', 'done', 'backlog')) DEFAULT 'active',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
FOREIGN KEY(folder_id) REFERENCES folders(id)
|
||||
);
|
||||
|
||||
ALTER TABLE tasks RENAME TO tasks_old;
|
||||
|
||||
-- Add up migration script here
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
title VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT CHECK(status IN ('todo', 'done', 'backlog')) DEFAULT 'todo',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
project_id UUID,
|
||||
FOREIGN KEY(project_id) REFERENCES projects(id)
|
||||
);
|
||||
|
||||
INSERT INTO tasks SELECT *, NULL as project_id FROM tasks_old;
|
||||
|
||||
DROP TABLE tasks_old;
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
mod project;
|
||||
mod task;
|
||||
|
||||
pub use project::*;
|
||||
pub use task::*;
|
||||
|
|
|
|||
122
backend/src/models/project.rs
Normal file
122
backend/src/models/project.rs
Normal file
|
|
@ -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<Uuid>,
|
||||
pub sort_order: i64,
|
||||
pub status: ProjectStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl ProjectModel {
|
||||
async fn get_new_sort_order(pool: &Pool<Sqlite>) -> anyhow::Result<i64> {
|
||||
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<Sqlite>,
|
||||
title: String,
|
||||
color: String,
|
||||
) -> anyhow::Result<ProjectModel, AppError> {
|
||||
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<Sqlite>,
|
||||
id: Uuid,
|
||||
) -> anyhow::Result<ProjectModel, AppError> {
|
||||
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<Sqlite>) -> anyhow::Result<Vec<ProjectModel>> {
|
||||
sqlx::query_as("SELECT * FROM projects;")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub async fn update(self, pool: &Pool<Sqlite>) -> anyhow::Result<ProjectModel, AppError> {
|
||||
let now: DateTime<Utc> = 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<Sqlite>, id: Uuid) -> anyhow::Result<()> {
|
||||
sqlx::query!("DELETE FROM projects WHERE id = $1", id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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<sqlx::Error> for AppError {
|
|||
}
|
||||
}
|
||||
|
||||
pub use projects::*;
|
||||
use serde::Serialize;
|
||||
pub use tasks::*;
|
||||
use tracing::error;
|
||||
|
|
|
|||
106
backend/src/services/projects.rs
Normal file
106
backend/src/services/projects.rs
Normal file
|
|
@ -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<Pool<Sqlite>> {
|
||||
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<Pool<Sqlite>>,
|
||||
WithRejection(Json(input), _): WithRejection<Json<CreateProjectRequest>, AppError>,
|
||||
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ProjectModel::insert(&pool, input.title, input.color).await?),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_projects(
|
||||
State(pool): State<Pool<Sqlite>>,
|
||||
) -> Result<(StatusCode, Json<Vec<ProjectModel>>), AppError> {
|
||||
Ok((StatusCode::OK, Json(ProjectModel::list(&pool).await?)))
|
||||
}
|
||||
|
||||
pub async fn get_project(
|
||||
State(pool): State<Pool<Sqlite>>,
|
||||
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
|
||||
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ProjectModel::get_by_id(&pool, project_id).await?),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateProjectRequest {
|
||||
title: Option<String>,
|
||||
color: Option<String>,
|
||||
status: Option<ProjectStatus>,
|
||||
}
|
||||
|
||||
pub async fn update_project(
|
||||
State(pool): State<Pool<Sqlite>>,
|
||||
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
|
||||
WithRejection(Json(input), _): WithRejection<Json<UpdateProjectRequest>, AppError>,
|
||||
) -> Result<(StatusCode, Json<ProjectModel>), 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<Pool<Sqlite>>,
|
||||
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
ProjectModel::get_by_id(&pool, project_id).await?;
|
||||
ProjectModel::delete(&pool, project_id).await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
use std::path::Path;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use axum_test::{TestServer, TestServerConfig, Transport};
|
||||
use hurl::report::html::{Testcase, write_report};
|
||||
use hurl::runner::{self, RunnerOptions, Value, VariableSet};
|
||||
use hurl::util::logger::LoggerOptionsBuilder;
|
||||
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;
|
||||
|
||||
|
|
@ -24,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)
|
||||
}
|
||||
|
||||
|
|
@ -49,11 +57,14 @@ async fn run_hurl_test(hurl_file_path: &str) {
|
|||
let logger_opts = LoggerOptionsBuilder::new().build();
|
||||
let result = runner::run(&content, None, &runner_opts, &variables, &logger_opts).unwrap();
|
||||
|
||||
assert!(
|
||||
result.success,
|
||||
"Hurl test failed for {}: {:?}",
|
||||
hurl_file_path, result
|
||||
);
|
||||
let input = Input::new(hurl_file_path);
|
||||
let test_case = Testcase::from(&result, &input);
|
||||
test_case
|
||||
.write_html(&content, &result.entries, Path::new("reports/store"), &[])
|
||||
.expect("Failed to write html files");
|
||||
write_report(Path::new("reports/"), &vec![test_case]).expect("Failed to write report");
|
||||
|
||||
assert!(result.success, "Hurl test failed for {}", hurl_file_path);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
|
@ -75,3 +86,8 @@ async fn test_update_tasks_api() {
|
|||
async fn test_delete_tasks_api() {
|
||||
run_hurl_test("./tests/api/delete_tasks.hurl").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_projects_api() {
|
||||
run_hurl_test("./tests/api/projects.hurl").await;
|
||||
}
|
||||
|
|
|
|||
387
backend/tests/api/projects.hurl
Normal file
387
backend/tests/api/projects.hurl
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
# Project API Tests
|
||||
|
||||
# Test: Create a new project with all fields (POST /api/projects)
|
||||
POST {{host}}/api/projects
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "Test Project Alpha",
|
||||
"color": "#1976d2"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
project_id: jsonpath "$.id"
|
||||
[Asserts]
|
||||
jsonpath "$.title" == "Test Project Alpha"
|
||||
jsonpath "$.color" == "#1976d2"
|
||||
jsonpath "$.status" == "active"
|
||||
jsonpath "$.folder_id" == null
|
||||
jsonpath "$.sort_order" == 0
|
||||
jsonpath "$.id" exists
|
||||
jsonpath "$.created_at" exists
|
||||
jsonpath "$.updated_at" exists
|
||||
|
||||
# Test: Create another new project
|
||||
POST {{host}}/api/projects
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "Test Project Beta",
|
||||
"color": "#ffffff"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
project_id_beta: jsonpath "$.id"
|
||||
[Asserts]
|
||||
jsonpath "$.title" == "Test Project Beta"
|
||||
jsonpath "$.color" == "#ffffff"
|
||||
jsonpath "$.status" == "active"
|
||||
jsonpath "$.folder_id" == null
|
||||
jsonpath "$.sort_order" == 1000
|
||||
jsonpath "$.id" exists
|
||||
jsonpath "$.created_at" exists
|
||||
jsonpath "$.updated_at" exists
|
||||
|
||||
|
||||
POST {{host}}/api/projects
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "Missing Color"
|
||||
}
|
||||
|
||||
HTTP 422
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
|
||||
# Test: Create project with invalid data (missing title)
|
||||
POST {{host}}/api/projects
|
||||
Content-Type: application/json
|
||||
{
|
||||
"color": "#ffffff"
|
||||
}
|
||||
|
||||
HTTP 422
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: Create project with invalid data (invalid color)
|
||||
POST {{host}}/api/projects
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "Invalid Color",
|
||||
"color": "#ffffffasdf"
|
||||
}
|
||||
|
||||
HTTP 422
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
|
||||
# Test: Get a specific project by ID (GET /api/projects/{id})
|
||||
GET {{host}}/api/projects/{{project_id}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.id" == "{{project_id}}"
|
||||
jsonpath "$.title" == "Test Project Alpha"
|
||||
jsonpath "$.color" == "#1976d2"
|
||||
jsonpath "$.status" == "active"
|
||||
jsonpath "$.folder_id" == null
|
||||
jsonpath "$.created_at" exists
|
||||
jsonpath "$.updated_at" exists
|
||||
|
||||
# Test: Get a minimal project by ID
|
||||
GET {{host}}/api/projects/{{project_id_beta}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.id" == "{{project_id_beta}}"
|
||||
jsonpath "$.title" == "Test Project Beta"
|
||||
jsonpath "$.color" == "#ffffff"
|
||||
jsonpath "$.status" == "active"
|
||||
|
||||
# Test: Get non-existent project
|
||||
GET {{host}}/api/projects/00000000-0000-0000-0000-000000000000
|
||||
|
||||
HTTP 404
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: Get project with invalid UUID format
|
||||
GET {{host}}/api/projects/invalid-uuid
|
||||
|
||||
HTTP 400
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: List all projects (GET /api/projects)
|
||||
GET {{host}}/api/projects
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
initial_count: jsonpath "$" count
|
||||
[Asserts]
|
||||
jsonpath "$" isCollection
|
||||
jsonpath "$[*].id" contains "{{project_id}}"
|
||||
jsonpath "$[*].id" contains "{{project_id_beta}}"
|
||||
jsonpath "$[*].title" contains "Test Project Alpha"
|
||||
jsonpath "$[*].title" contains "Test Project Beta"
|
||||
|
||||
# Test: Verify all projects have required fields
|
||||
GET {{host}}/api/projects
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.id=='{{project_id}}')].title" exists
|
||||
jsonpath "$[?(@.id=='{{project_id}}')].status" exists
|
||||
jsonpath "$[?(@.id=='{{project_id}}')].created_at" exists
|
||||
jsonpath "$[?(@.id=='{{project_id}}')].updated_at" exists
|
||||
jsonpath "$[?(@.id=='{{project_id}}')].sort_order" exists
|
||||
|
||||
# Test: Update project title only (PUT /api/projects/{id})
|
||||
PUT {{host}}/api/projects/{{project_id}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "Updated Project Alpha"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
first_updated_at: jsonpath "$.updated_at"
|
||||
[Asserts]
|
||||
jsonpath "$.id" == "{{project_id}}"
|
||||
jsonpath "$.title" == "Updated Project Alpha"
|
||||
jsonpath "$.color" == "#1976d2"
|
||||
jsonpath "$.status" == "active"
|
||||
|
||||
|
||||
# Test: Update status to done
|
||||
PUT {{host}}/api/projects/{{project_id}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"status": "done"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.id" == "{{project_id}}"
|
||||
jsonpath "$.status" == "done"
|
||||
|
||||
# Test: Update status to backlog
|
||||
PUT {{host}}/api/projects/{{project_id}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"status": "backlog"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.id" == "{{project_id}}"
|
||||
jsonpath "$.status" == "backlog"
|
||||
|
||||
# Test: Update color
|
||||
PUT {{host}}/api/projects/{{project_id}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"color": "#388e3c"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.id" == "{{project_id}}"
|
||||
jsonpath "$.color" == "#388e3c"
|
||||
|
||||
|
||||
# Test: Update multiple fields together
|
||||
PUT {{host}}/api/projects/{{project_id}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "Completely Updated Project",
|
||||
"color": "#d32f2f",
|
||||
"status": "active"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.id" == "{{project_id}}"
|
||||
jsonpath "$.title" == "Completely Updated Project"
|
||||
jsonpath "$.color" == "#d32f2f"
|
||||
jsonpath "$.status" == "active"
|
||||
|
||||
|
||||
# Test: Update non-existent project
|
||||
PUT {{host}}/api/projects/00000000-0000-0000-0000-000000000000
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "This should fail"
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: Update with invalid UUID format
|
||||
PUT {{host}}/api/projects/invalid-uuid-format
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "This should also fail"
|
||||
}
|
||||
|
||||
HTTP 400
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: Update with empty title
|
||||
PUT {{host}}/api/projects/{{project_id_beta}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": ""
|
||||
}
|
||||
|
||||
HTTP 422
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: Update with invalid status
|
||||
PUT {{host}}/api/projects/{{project_id_beta}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"status": "invalid_status"
|
||||
}
|
||||
|
||||
HTTP 422
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: Update with invalid color format
|
||||
PUT {{host}}/api/projects/{{project_id_beta}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"color": "invalid-color"
|
||||
}
|
||||
|
||||
HTTP 422
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Setup: Create a project to delete
|
||||
POST {{host}}/api/projects
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "Project to Delete",
|
||||
"color": "#222222"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
delete_project_id: jsonpath "$.id"
|
||||
|
||||
# Test: Delete project successfully (DELETE /api/projects/{id})
|
||||
DELETE {{host}}/api/projects/{{delete_project_id}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
# Test: Verify project is deleted (should return 404)
|
||||
GET {{host}}/api/projects/{{delete_project_id}}
|
||||
|
||||
HTTP 404
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Setup: Create another project for additional delete tests
|
||||
POST {{host}}/api/projects
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "Another Project to Delete",
|
||||
"color": "#333333"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
another_delete_project_id: jsonpath "$.id"
|
||||
|
||||
# Test: Verify project exists before deletion
|
||||
GET {{host}}/api/projects/{{another_delete_project_id}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.title" == "Another Project to Delete"
|
||||
|
||||
# Test: Delete the project
|
||||
DELETE {{host}}/api/projects/{{another_delete_project_id}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
# Test: Confirm project no longer exists
|
||||
GET {{host}}/api/projects/{{another_delete_project_id}}
|
||||
|
||||
HTTP 404
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: Delete non-existent project
|
||||
DELETE {{host}}/api/projects/00000000-0000-0000-0000-000000000000
|
||||
|
||||
HTTP 404
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: Delete with invalid UUID format
|
||||
DELETE {{host}}/api/projects/invalid-uuid-format
|
||||
|
||||
HTTP 400
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: Multiple deletions of same project (idempotency test)
|
||||
POST {{host}}/api/projects
|
||||
Content-Type: application/json
|
||||
{
|
||||
"title": "Project for Idempotency Test",
|
||||
"color": "#234567"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
idempotent_project_id: jsonpath "$.id"
|
||||
|
||||
# First deletion should succeed
|
||||
DELETE {{host}}/api/projects/{{idempotent_project_id}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
# Second deletion should return 404 (project already gone)
|
||||
DELETE {{host}}/api/projects/{{idempotent_project_id}}
|
||||
|
||||
HTTP 404
|
||||
[Asserts]
|
||||
jsonpath "$.error" exists
|
||||
|
||||
# Test: List shows remaining projects after deletions
|
||||
GET {{host}}/api/projects
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].id" contains "{{project_id}}"
|
||||
jsonpath "$[*].id" contains "{{project_id_beta}}"
|
||||
jsonpath "$[*].id" not contains "{{delete_project_id}}"
|
||||
jsonpath "$[*].id" not contains "{{another_delete_project_id}}"
|
||||
jsonpath "$[*].id" not contains "{{idempotent_project_id}}"
|
||||
|
||||
# Cleanup: Delete remaining test projects
|
||||
DELETE {{host}}/api/projects/{{project_id}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{host}}/api/projects/{{project_id_beta}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
# Test: Verify all test projects are cleaned up
|
||||
GET {{host}}/api/projects
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].id" not contains "{{project_id}}"
|
||||
jsonpath "$[*].id" not contains "{{project_id_beta}}"
|
||||
199
plan/02_PROJECTS/plan.md
Normal file
199
plan/02_PROJECTS/plan.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# Project-Based Organization Implementation Plan
|
||||
|
||||
## Phase Overview
|
||||
This plan implements project-based organization with a clean folder/project hierarchy:
|
||||
- **Folders**: Organizational containers for projects (can be reordered)
|
||||
- **Projects**: Work containers that hold tasks (can belong to folders or exist at root)
|
||||
- **Tasks**: Belong to zero or one project (never directly to folders)
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### 1. Database Schema Changes
|
||||
- **New Tables:**
|
||||
- `folders` table: id, name, color, sort_order, created_at, updated_at
|
||||
- `projects` table: id, name, description, color, due_date, status, folder_id (nullable), sort_order, created_at, updated_at
|
||||
- **Schema Updates:**
|
||||
- Add `project_id` field to existing `tasks` table (nullable foreign key)
|
||||
- Database indexes for efficient querying
|
||||
|
||||
### 2. Backend Models & Services
|
||||
- **FolderModel** (backend/src/models/folder.rs):
|
||||
- CRUD operations for folders
|
||||
- Reordering methods for sort_order management
|
||||
- **ProjectModel** (backend/src/models/project.rs):
|
||||
- CRUD operations with folder relationship
|
||||
- Methods for reordering within folders and at root level
|
||||
- Query methods for folder contents
|
||||
- **Updated TaskModel** (backend/src/models/task.rs):
|
||||
- Add project_id field and foreign key constraint
|
||||
- Project relationship methods
|
||||
- **API Services:**
|
||||
- folders.rs - Folder CRUD and reordering endpoints
|
||||
- projects.rs - Project CRUD, folder assignment, reordering
|
||||
- Updated tasks.rs - Project filtering and assignment
|
||||
|
||||
### 3. Database Migrations
|
||||
- Create folders table
|
||||
- Create projects table with folder foreign key
|
||||
- Add project_id to tasks table
|
||||
- Set up proper indexes and constraints
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### 1. Data Models & Types
|
||||
- **Folder Types** (frontend/app/types/folder.ts):
|
||||
- Folder interface, CreateFolderRequest, UpdateFolderRequest
|
||||
- **Project Types** (frontend/app/types/project.ts):
|
||||
- Project interface with folder_id field
|
||||
- ProjectStatus enum, CRUD request types
|
||||
- **Updated Task Types** (frontend/app/types/task.ts):
|
||||
- Add project_id field to Task interface
|
||||
|
||||
### 2. Sidebar & Navigation
|
||||
- **ProjectSidebar** (frontend/app/components/ProjectSidebar.tsx):
|
||||
- Hierarchical display: Folders → Projects → (Root Projects)
|
||||
- Drag-and-drop for both folder and project reordering
|
||||
- Inline creation forms for folders and projects
|
||||
- Collapsible folder sections
|
||||
- **FolderSection** (frontend/app/components/FolderSection.tsx):
|
||||
- Individual folder with contained projects
|
||||
- Folder editing and project management
|
||||
|
||||
### 3. Project Management
|
||||
- **ProjectForm** (frontend/app/components/ProjectForm.tsx):
|
||||
- Create/edit projects with folder assignment
|
||||
- 16-color palette, due dates, status selection
|
||||
- **FolderForm** (frontend/app/components/FolderForm.tsx):
|
||||
- Simple folder creation/editing (name, color)
|
||||
|
||||
### 4. Updated Task Components
|
||||
- **Enhanced TaskList** with Project column
|
||||
- **Updated TaskForm** with project selection dropdown
|
||||
- Project filtering throughout the UI
|
||||
|
||||
### 5. Routes & State Management
|
||||
- **New Routes:**
|
||||
- `/projects` - Projects overview
|
||||
- `/projects/{id}` - Project detail page
|
||||
- `/folders/{id}` - Folder contents view
|
||||
- **State Hooks:**
|
||||
- `useFolders.ts`, `useProjects.ts`, updated `useTasks.ts`
|
||||
- **API Integration:**
|
||||
- Folder and project API clients
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Backend Foundation
|
||||
1. Create folders and projects tables (migrations)
|
||||
2. Implement FolderModel and ProjectModel
|
||||
3. Add project_id to TaskModel
|
||||
4. Create API endpoints for folders and projects
|
||||
5. Unit tests for new functionality
|
||||
|
||||
### Phase 2: Frontend Core
|
||||
1. Implement folder and project types
|
||||
2. Create basic ProjectSidebar (no drag-drop yet)
|
||||
3. Add project column to TaskList
|
||||
4. Update TaskForm with project selection
|
||||
5. Basic project/folder CRUD forms
|
||||
|
||||
### Phase 3: Advanced Features
|
||||
1. Drag-and-drop reordering in sidebar
|
||||
2. Project and folder detail pages
|
||||
3. Hierarchical project creation flows
|
||||
4. Project filtering and search
|
||||
5. Mobile responsive sidebar
|
||||
|
||||
### Phase 4: Polish & Integration
|
||||
1. Project color theming throughout UI
|
||||
2. Performance optimization
|
||||
3. Enhanced UX for project management
|
||||
4. Integration testing
|
||||
5. Migration tools for existing data
|
||||
|
||||
## Data Relationships
|
||||
```
|
||||
folders (1) → (many) projects
|
||||
projects (1) → (many) tasks
|
||||
folders (no direct relationship) tasks
|
||||
```
|
||||
|
||||
This clean separation ensures tasks only belong to projects, while folders serve purely as organizational containers for projects.
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Database Schema
|
||||
|
||||
#### Folders Table
|
||||
```sql
|
||||
CREATE TABLE folders (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
#### Projects Table
|
||||
```sql
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7), -- hex color code
|
||||
due_date DATE,
|
||||
status TEXT CHECK(status IN ('active', 'done', 'backlog')) DEFAULT 'active',
|
||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
#### Tasks Table Update
|
||||
```sql
|
||||
ALTER TABLE tasks ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
|
||||
```
|
||||
|
||||
### Color Palette
|
||||
16-color palette for folders and projects:
|
||||
- Primary Blues: #1976d2, #0288d1, #0097a7
|
||||
- Greens: #388e3c, #689f38, #7cb342
|
||||
- Oranges/Reds: #f57c00, #ff5722, #d32f2f
|
||||
- Purples: #7b1fa2, #512da8, #303f9f
|
||||
- Neutrals: #455a64, #616161, #757575, #424242
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Folders
|
||||
- `GET /api/folders` - List all folders with projects
|
||||
- `POST /api/folders` - Create folder
|
||||
- `PUT /api/folders/{id}` - Update folder
|
||||
- `DELETE /api/folders/{id}` - Delete folder
|
||||
- `PUT /api/folders/reorder` - Reorder folders
|
||||
|
||||
#### Projects
|
||||
- `GET /api/projects` - List all projects
|
||||
- `GET /api/projects?folder_id={id}` - List projects in folder
|
||||
- `POST /api/projects` - Create project
|
||||
- `PUT /api/projects/{id}` - Update project
|
||||
- `DELETE /api/projects/{id}` - Delete project
|
||||
- `PUT /api/projects/reorder` - Reorder projects within folder/root
|
||||
|
||||
#### Tasks (Updated)
|
||||
- `GET /api/tasks?project_id={id}` - List tasks in project
|
||||
- Task CRUD operations include project_id field
|
||||
|
||||
## Migration Strategy
|
||||
1. Existing tasks remain unassigned (project_id = NULL)
|
||||
2. Users can create folders and projects
|
||||
3. Users can assign existing tasks to projects
|
||||
4. No data loss during migration
|
||||
5. Backwards compatibility maintained
|
||||
|
||||
## Testing Strategy
|
||||
- Unit tests for all model methods
|
||||
- API integration tests with Hurl
|
||||
- Frontend component tests
|
||||
- End-to-end project workflow tests
|
||||
Loading…
Add table
Add a link
Reference in a new issue