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