Compare commits

...

5 commits

Author SHA1 Message Date
844f5f7ce4 Allow updating task status. 2025-10-26 15:55:36 -07:00
8b32739e51 Create Project APIs.
Some checks failed
Check / Backend (pull_request) Failing after 3m14s
Check / Frontend (pull_request) Successful in 1m59s
2025-10-26 15:55:36 -07:00
d8094c4812 Hurl tests for project api. 2025-10-26 15:30:21 -07:00
4895f7c4b7 Database migration for projects. 2025-10-22 22:12:31 -07:00
dfcf56aa4d Plan for implementing projects. 2025-10-22 22:12:31 -07:00
19 changed files with 1129 additions and 19 deletions

View file

@ -51,7 +51,10 @@ jobs:
cargo test --locked -- --skip api cargo test --locked -- --skip api
working-directory: backend working-directory: backend
- name: "Integration Tests" - name: "Integration Tests"
run: cargo test --locked --test api run: |
mkdir backend/reports/
mkdir backend/reports/store
cargo test --locked --test api
working-directory: backend working-directory: backend
check-frontend: check-frontend:

2
backend/.gitignore vendored
View file

@ -1,5 +1,7 @@
target target
coverage coverage
reports
*.db *.db
*.db-shm *.db-shm

View 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"
}

View 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"
}

View 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
View file

@ -3500,9 +3500,11 @@ dependencies = [
"bitflags 2.9.3", "bitflags 2.9.3",
"bytes", "bytes",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1",
"pin-project-lite", "pin-project-lite",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]

View file

@ -12,7 +12,7 @@ chrono = { version = "0.4.41", features = ["serde"] }
serde = "1.0.219" serde = "1.0.219"
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] } sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] } 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 = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
uuid = { version = "1.18.0", features = ["serde", "v4"] } uuid = { version = "1.18.0", features = ["serde", "v4"] }

View 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;

View 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;

View file

@ -32,6 +32,7 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/health", get(health)) .route("/health", get(health))
.nest("/api/tasks", services::create_task_router()) .nest("/api/tasks", services::create_task_router())
.nest("/api/projects", services::create_project_router())
.layer(cors) .layer(cors)
.with_state(pool); .with_state(pool);

View file

@ -1,3 +1,5 @@
mod project;
mod task; mod task;
pub use project::*;
pub use task::*; pub use task::*;

View 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(())
}
}

View file

@ -1,3 +1,4 @@
mod projects;
mod tasks; mod tasks;
use axum::{ use axum::{
@ -26,11 +27,14 @@ pub struct ErrorJson {
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
match self { match self {
Self::InternalError(anyhow_error) => ( Self::InternalError(anyhow_error) => {
error!(err = ?anyhow_error, "Internal Server Error");
(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong {}", anyhow_error), format!("Something went wrong {}", anyhow_error),
) )
.into_response(), .into_response()
}
Self::JsonExtractError(rej) => ( Self::JsonExtractError(rej) => (
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
Json(ErrorJson { Json(ErrorJson {
@ -85,5 +89,7 @@ impl From<sqlx::Error> for AppError {
} }
} }
pub use projects::*;
use serde::Serialize; use serde::Serialize;
pub use tasks::*; pub use tasks::*;
use tracing::error;

View 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)
}

View file

@ -1,9 +1,15 @@
use std::path::Path;
use axum::{Router, routing::get}; use axum::{Router, routing::get};
use axum_test::{TestServer, TestServerConfig, Transport}; use axum_test::{TestServer, TestServerConfig, Transport};
use hurl::report::html::{Testcase, write_report};
use hurl::runner::{self, RunnerOptions, Value, VariableSet}; use hurl::runner::{self, RunnerOptions, Value, VariableSet};
use hurl::util::logger::LoggerOptionsBuilder; use hurl::util::logger::LoggerOptionsBuilder;
use hurl_core::input::Input;
use tower_http::trace::TraceLayer;
async fn create_app() -> Router { async fn create_app() -> Router {
tracing_subscriber::fmt::try_init();
use backend::database::{DatabaseConfig, create_pool}; use backend::database::{DatabaseConfig, create_pool};
use backend::services; use backend::services;
@ -24,6 +30,8 @@ async fn create_app() -> Router {
Router::new() Router::new()
.route("/health", get(health)) .route("/health", get(health))
.nest("/api/tasks", services::create_task_router()) .nest("/api/tasks", services::create_task_router())
.nest("/api/projects", services::create_project_router())
.layer(TraceLayer::new_for_http())
.with_state(pool) .with_state(pool)
} }
@ -49,11 +57,14 @@ async fn run_hurl_test(hurl_file_path: &str) {
let logger_opts = LoggerOptionsBuilder::new().build(); let logger_opts = LoggerOptionsBuilder::new().build();
let result = runner::run(&content, None, &runner_opts, &variables, &logger_opts).unwrap(); let result = runner::run(&content, None, &runner_opts, &variables, &logger_opts).unwrap();
assert!( let input = Input::new(hurl_file_path);
result.success, let test_case = Testcase::from(&result, &input);
"Hurl test failed for {}: {:?}", test_case
hurl_file_path, result .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)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@ -75,3 +86,8 @@ async fn test_update_tasks_api() {
async fn test_delete_tasks_api() { async fn test_delete_tasks_api() {
run_hurl_test("./tests/api/delete_tasks.hurl").await; 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;
}

View 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}}"

View file

@ -6,7 +6,6 @@ import {
InputLabel, InputLabel,
Select, Select,
MenuItem, MenuItem,
Chip,
Stack, Stack,
Paper, Paper,
Table, Table,
@ -46,7 +45,7 @@ interface TaskListProps {
} }
export function TaskList({ className, initialTasks }: TaskListProps) { export function TaskList({ className, initialTasks }: TaskListProps) {
const { tasks, loading, error, deleteTask } = useTasks({ const { tasks, loading, error, updateTask, deleteTask } = useTasks({
autoFetch: !initialTasks, autoFetch: !initialTasks,
initialData: initialTasks, initialData: initialTasks,
}) })
@ -217,6 +216,13 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
} }
} }
const handleStatusChange = async (taskId: string, newStatus: TaskStatus) => {
const result = await updateTask(taskId, { status: newStatus })
if (!result) {
console.error('Failed to update task status')
}
}
const isAllSelected = const isAllSelected =
filteredAndSortedTasks.length > 0 && filteredAndSortedTasks.length > 0 &&
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id)) filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
@ -447,12 +453,139 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
</TableCell> </TableCell>
)} )}
<TableCell> <TableCell>
<Chip <Select
label={task.status} value={task.status}
color={getStatusColor(task.status)} onChange={e =>
handleStatusChange(
task.id,
e.target.value as TaskStatus
)
}
size="small" size="small"
variant="outlined" variant="outlined"
/> MenuProps={{
PaperProps: {
sx: {
padding: '4px',
minWidth: '100px',
'& .MuiList-root': {
padding: 0,
},
},
},
}}
sx={{
minWidth: 90,
height: '24px',
borderRadius: '16px',
backgroundColor:
getStatusColor(task.status) === 'primary'
? 'primary.main'
: getStatusColor(task.status) === 'success'
? 'success.main'
: 'action.disabled',
color:
getStatusColor(task.status) === 'default'
? 'text.primary'
: 'white',
fontSize: '0.75rem',
'& .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
'& .MuiSelect-select': {
padding: '2px 12px 2px 12px',
paddingRight: '24px !important',
display: 'flex',
alignItems: 'center',
height: '20px',
lineHeight: '20px',
},
'& .MuiSelect-icon': {
right: '4px',
color:
getStatusColor(task.status) === 'default'
? 'text.primary'
: 'white',
fontSize: '1rem',
},
}}
>
<MenuItem
value={TaskStatus.Todo}
sx={{
backgroundColor: 'primary.main',
color: 'white',
margin: '2px 4px',
borderRadius: '12px',
fontSize: '0.75rem',
minHeight: '24px',
padding: '2px 8px',
'&:hover': {
backgroundColor: 'primary.dark',
},
'&.Mui-selected': {
backgroundColor: 'primary.main',
'&:hover': {
backgroundColor: 'primary.dark',
},
},
}}
>
Todo
</MenuItem>
<MenuItem
value={TaskStatus.Done}
sx={{
backgroundColor: 'success.main',
color: 'white',
margin: '2px 4px',
borderRadius: '12px',
fontSize: '0.75rem',
minHeight: '24px',
padding: '2px 8px',
'&:hover': {
backgroundColor: 'success.dark',
},
'&.Mui-selected': {
backgroundColor: 'success.main',
'&:hover': {
backgroundColor: 'success.dark',
},
},
}}
>
Done
</MenuItem>
<MenuItem
value={TaskStatus.Backlog}
sx={{
backgroundColor: 'action.disabled',
color: 'text.primary',
margin: '2px 4px',
borderRadius: '12px',
fontSize: '0.75rem',
minHeight: '24px',
padding: '2px 8px',
'&:hover': {
backgroundColor: 'action.hover',
},
'&.Mui-selected': {
backgroundColor: 'action.disabled',
'&:hover': {
backgroundColor: 'action.hover',
},
},
}}
>
Backlog
</MenuItem>
</Select>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Tooltip <Tooltip

View file

@ -1,5 +1,10 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from 'react'
import type { Task, CreateTaskRequest, ApiError } from '~/types/task' import type {
Task,
CreateTaskRequest,
UpdateTaskRequest,
ApiError,
} from '~/types/task'
import { TaskStatus } from '~/types/task' import { TaskStatus } from '~/types/task'
import { apiClient } from '~/services/api' import { apiClient } from '~/services/api'
@ -13,6 +18,7 @@ interface UseTasksState {
interface UseTasksActions { interface UseTasksActions {
fetchTasks: () => Promise<void> fetchTasks: () => Promise<void>
createTask: (data: CreateTaskRequest) => Promise<Task | null> createTask: (data: CreateTaskRequest) => Promise<Task | null>
updateTask: (id: string, data: UpdateTaskRequest) => Promise<Task | null>
deleteTask: (id: string) => Promise<boolean> deleteTask: (id: string) => Promise<boolean>
refreshTasks: () => Promise<void> refreshTasks: () => Promise<void>
clearError: () => void clearError: () => void
@ -91,6 +97,30 @@ export function useTasks(
[] []
) )
const updateTask = useCallback(
async (id: string, data: UpdateTaskRequest): Promise<Task | null> => {
try {
const updatedTask = await apiClient.updateTask(id, data)
// Update the task in the local state immediately
setState(prev => ({
...prev,
tasks: prev.tasks.map(task => (task.id === id ? updatedTask : task)),
}))
return updatedTask
} catch (error) {
const apiError = error as ApiError
setState(prev => ({
...prev,
error: apiError.message,
}))
return null
}
},
[]
)
const deleteTask = useCallback(async (id: string): Promise<boolean> => { const deleteTask = useCallback(async (id: string): Promise<boolean> => {
try { try {
await apiClient.deleteTask(id) await apiClient.deleteTask(id)
@ -176,6 +206,7 @@ export function useTasks(
...state, ...state,
fetchTasks, fetchTasks,
createTask, createTask,
updateTask,
deleteTask, deleteTask,
refreshTasks, refreshTasks,
clearError, clearError,

199
plan/02_PROJECTS/plan.md Normal file
View 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