diff --git a/backend/.gitignore b/backend/.gitignore index ddd1a4e..eb88735 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,8 +1,5 @@ target coverage -reports - -.sqlx *.db *.db-shm diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 45153c3..207f7de 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -3500,11 +3500,9 @@ 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 fbe2257..c8df765 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", "trace"] } +tower-http = { version = "0.6.0", features = ["cors"] } tracing = "0.1.41" tracing-subscriber = "0.3.19" uuid = { version = "1.18.0", features = ["serde", "v4"] } diff --git a/backend/migrations/20251023043152_create_projects.down.sql b/backend/migrations/20251023043152_create_projects.down.sql deleted file mode 100644 index 3b74f50..0000000 --- a/backend/migrations/20251023043152_create_projects.down.sql +++ /dev/null @@ -1,17 +0,0 @@ -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; diff --git a/backend/migrations/20251023043152_create_projects.up.sql b/backend/migrations/20251023043152_create_projects.up.sql deleted file mode 100644 index 81c8aa1..0000000 --- a/backend/migrations/20251023043152_create_projects.up.sql +++ /dev/null @@ -1,39 +0,0 @@ -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; diff --git a/backend/src/main.rs b/backend/src/main.rs index df54c7e..7088520 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -32,7 +32,6 @@ 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 66c1e21..e05a0c1 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,5 +1,3 @@ -mod project; mod task; -pub use project::*; pub use task::*; diff --git a/backend/src/models/project.rs b/backend/src/models/project.rs deleted file mode 100644 index 48fe035..0000000 --- a/backend/src/models/project.rs +++ /dev/null @@ -1,122 +0,0 @@ -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 7025d40..7c095e5 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,4 +1,3 @@ -mod projects; mod tasks; use axum::{ @@ -27,14 +26,11 @@ pub struct ErrorJson { impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { - Self::InternalError(anyhow_error) => { - error!(err = ?anyhow_error, "Internal Server Error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Something went wrong {}", anyhow_error), - ) - .into_response() - } + Self::InternalError(anyhow_error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong {}", anyhow_error), + ) + .into_response(), Self::JsonExtractError(rej) => ( StatusCode::UNPROCESSABLE_ENTITY, Json(ErrorJson { @@ -89,7 +85,5 @@ 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 deleted file mode 100644 index 568704c..0000000 --- a/backend/src/services/projects.rs +++ /dev/null @@ -1,106 +0,0 @@ -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 1c4debb..df2913d 100644 --- a/backend/tests/api.rs +++ b/backend/tests/api.rs @@ -1,15 +1,9 @@ -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; @@ -30,8 +24,6 @@ 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) } @@ -57,14 +49,11 @@ 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(); - 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); + assert!( + result.success, + "Hurl test failed for {}: {:?}", + hurl_file_path, result + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -86,8 +75,3 @@ 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; -} diff --git a/backend/tests/api/projects.hurl b/backend/tests/api/projects.hurl deleted file mode 100644 index d2d8676..0000000 --- a/backend/tests/api/projects.hurl +++ /dev/null @@ -1,387 +0,0 @@ -# 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}}" diff --git a/plan/02_PROJECTS/plan.md b/plan/02_PROJECTS/plan.md deleted file mode 100644 index df9d265..0000000 --- a/plan/02_PROJECTS/plan.md +++ /dev/null @@ -1,199 +0,0 @@ -# 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