diff --git a/backend/.sqlx/query-290ce5c152be8d36e58ff42570f9157beb07ab9e77a03ec6fc30b4f56f9b8f6b.json b/backend/.sqlx/query-290ce5c152be8d36e58ff42570f9157beb07ab9e77a03ec6fc30b4f56f9b8f6b.json new file mode 100644 index 00000000..9b44073c --- /dev/null +++ b/backend/.sqlx/query-290ce5c152be8d36e58ff42570f9157beb07ab9e77a03ec6fc30b4f56f9b8f6b.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "UPDATE task_templates \n SET title = $2, description = $3, template_name = $4, updated_at = datetime('now', 'subsec')\n WHERE id = $1 \n RETURNING id as \"id!: Uuid\", project_id as \"project_id?: Uuid\", title, description, template_name, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id?: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "template_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + true, + true, + false, + true, + false, + false, + false + ] + }, + "hash": "290ce5c152be8d36e58ff42570f9157beb07ab9e77a03ec6fc30b4f56f9b8f6b" +} diff --git a/backend/.sqlx/query-36e4ba7bbd81b402d5a20b6005755eafbb174c8dda442081823406ac32809a94.json b/backend/.sqlx/query-36e4ba7bbd81b402d5a20b6005755eafbb174c8dda442081823406ac32809a94.json new file mode 100644 index 00000000..74797230 --- /dev/null +++ b/backend/.sqlx/query-36e4ba7bbd81b402d5a20b6005755eafbb174c8dda442081823406ac32809a94.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id?: Uuid\", title, description, template_name, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM task_templates \n WHERE id = $1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id?: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "template_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true, + false, + true, + false, + false, + false + ] + }, + "hash": "36e4ba7bbd81b402d5a20b6005755eafbb174c8dda442081823406ac32809a94" +} diff --git a/backend/.sqlx/query-3d6bd16fbce59efe30b7f67ea342e0e4ea6d1432389c02468ad79f1f742d4031.json b/backend/.sqlx/query-3d6bd16fbce59efe30b7f67ea342e0e4ea6d1432389c02468ad79f1f742d4031.json new file mode 100644 index 00000000..46cea41a --- /dev/null +++ b/backend/.sqlx/query-3d6bd16fbce59efe30b7f67ea342e0e4ea6d1432389c02468ad79f1f742d4031.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO task_templates (id, project_id, title, description, template_name) \n VALUES ($1, $2, $3, $4, $5) \n RETURNING id as \"id!: Uuid\", project_id as \"project_id?: Uuid\", title, description, template_name, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id?: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "template_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + true, + true, + false, + true, + false, + false, + false + ] + }, + "hash": "3d6bd16fbce59efe30b7f67ea342e0e4ea6d1432389c02468ad79f1f742d4031" +} diff --git a/backend/.sqlx/query-461cc1b0bb6fd909afc9dd2246e8526b3771cfbb0b22ae4b5d17b51af587b9e2.json b/backend/.sqlx/query-461cc1b0bb6fd909afc9dd2246e8526b3771cfbb0b22ae4b5d17b51af587b9e2.json new file mode 100644 index 00000000..64247c57 --- /dev/null +++ b/backend/.sqlx/query-461cc1b0bb6fd909afc9dd2246e8526b3771cfbb0b22ae4b5d17b51af587b9e2.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id?: Uuid\", title, description, template_name, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM task_templates \n WHERE project_id IS NULL\n ORDER BY template_name ASC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id?: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "template_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + true, + false, + true, + false, + false, + false + ] + }, + "hash": "461cc1b0bb6fd909afc9dd2246e8526b3771cfbb0b22ae4b5d17b51af587b9e2" +} diff --git a/backend/.sqlx/query-8f01ebd64bdcde6a090479f14810d73ba23020e76fd70854ac57f2da251702c3.json b/backend/.sqlx/query-8f01ebd64bdcde6a090479f14810d73ba23020e76fd70854ac57f2da251702c3.json new file mode 100644 index 00000000..7d25f577 --- /dev/null +++ b/backend/.sqlx/query-8f01ebd64bdcde6a090479f14810d73ba23020e76fd70854ac57f2da251702c3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM task_templates WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "8f01ebd64bdcde6a090479f14810d73ba23020e76fd70854ac57f2da251702c3" +} diff --git a/backend/.sqlx/query-96036c4f9e0f48bdc5a4a4588f0c5f288ac7aaa5425cac40fc33f337e1a351f2.json b/backend/.sqlx/query-96036c4f9e0f48bdc5a4a4588f0c5f288ac7aaa5425cac40fc33f337e1a351f2.json new file mode 100644 index 00000000..278c3500 --- /dev/null +++ b/backend/.sqlx/query-96036c4f9e0f48bdc5a4a4588f0c5f288ac7aaa5425cac40fc33f337e1a351f2.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id?: Uuid\", title, description, template_name, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM task_templates \n ORDER BY project_id IS NULL DESC, template_name ASC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id?: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "template_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + true, + false, + true, + false, + false, + false + ] + }, + "hash": "96036c4f9e0f48bdc5a4a4588f0c5f288ac7aaa5425cac40fc33f337e1a351f2" +} diff --git a/backend/.sqlx/query-fdb06a7d9050f98d73e743b6522c7443d603931c082bd38f13b8f1f127b88711.json b/backend/.sqlx/query-fdb06a7d9050f98d73e743b6522c7443d603931c082bd38f13b8f1f127b88711.json new file mode 100644 index 00000000..44b45c4d --- /dev/null +++ b/backend/.sqlx/query-fdb06a7d9050f98d73e743b6522c7443d603931c082bd38f13b8f1f127b88711.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id?: Uuid\", title, description, template_name, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM task_templates \n WHERE project_id = $1 OR project_id IS NULL\n ORDER BY project_id IS NULL, template_name ASC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id?: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "template_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true, + false, + true, + false, + false, + false + ] + }, + "hash": "fdb06a7d9050f98d73e743b6522c7443d603931c082bd38f13b8f1f127b88711" +} diff --git a/backend/migrations/20250715154859_add_task_templates.sql b/backend/migrations/20250715154859_add_task_templates.sql new file mode 100644 index 00000000..3657513c --- /dev/null +++ b/backend/migrations/20250715154859_add_task_templates.sql @@ -0,0 +1,25 @@ +-- Add task templates tables +CREATE TABLE task_templates ( + id BLOB PRIMARY KEY, + project_id BLOB, -- NULL for global templates + title TEXT NOT NULL, + description TEXT, + template_name TEXT NOT NULL, -- Display name for the template + created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +-- Add index for faster queries +CREATE INDEX idx_task_templates_project_id ON task_templates(project_id); + +-- Add unique constraints to prevent duplicate template names within same scope +-- For project-specific templates: unique within each project +CREATE UNIQUE INDEX idx_task_templates_unique_name_project +ON task_templates(project_id, template_name) +WHERE project_id IS NOT NULL; + +-- For global templates: unique across all global templates +CREATE UNIQUE INDEX idx_task_templates_unique_name_global +ON task_templates(template_name) +WHERE project_id IS NULL; \ No newline at end of file diff --git a/backend/migrations/20250716143725_add_default_templates.sql b/backend/migrations/20250716143725_add_default_templates.sql new file mode 100644 index 00000000..16732608 --- /dev/null +++ b/backend/migrations/20250716143725_add_default_templates.sql @@ -0,0 +1,174 @@ +-- Add default global templates + +-- 1. Bug Analysis template +INSERT INTO task_templates ( + id, + project_id, + title, + description, + template_name, + created_at, + updated_at +) VALUES ( + randomblob(16), + NULL, -- Global template + 'Analyze codebase for potential bugs and issues', + 'Perform a comprehensive analysis of the project codebase to identify potential bugs, code smells, and areas of improvement. + +## Analysis Checklist: + +### 1. Static Code Analysis +- [ ] Run linting tools to identify syntax and style issues +- [ ] Check for unused variables, imports, and dead code +- [ ] Identify potential type errors or mismatches +- [ ] Look for deprecated API usage + +### 2. Common Bug Patterns +- [ ] Check for null/undefined reference errors +- [ ] Identify potential race conditions +- [ ] Look for improper error handling +- [ ] Check for resource leaks (memory, file handles, connections) +- [ ] Identify potential security vulnerabilities (XSS, SQL injection, etc.) + +### 3. Code Quality Issues +- [ ] Identify overly complex functions (high cyclomatic complexity) +- [ ] Look for code duplication +- [ ] Check for missing or inadequate input validation +- [ ] Identify hardcoded values that should be configurable + +### 4. Testing Gaps +- [ ] Identify untested code paths +- [ ] Check for missing edge case tests +- [ ] Look for inadequate error scenario testing + +### 5. Performance Concerns +- [ ] Identify potential performance bottlenecks +- [ ] Check for inefficient algorithms or data structures +- [ ] Look for unnecessary database queries or API calls + +## Deliverables: +1. Prioritized list of identified issues +2. Recommendations for fixes +3. Estimated effort for addressing each issue', + 'Bug Analysis', + datetime('now', 'subsec'), + datetime('now', 'subsec') +); + +-- 2. Unit Test template +INSERT INTO task_templates ( + id, + project_id, + title, + description, + template_name, + created_at, + updated_at +) VALUES ( + randomblob(16), + NULL, -- Global template + 'Add unit tests for [component/function]', + 'Write unit tests to improve code coverage and ensure reliability. + +## Unit Testing Checklist + +### 1. Identify What to Test +- [ ] Run coverage report to find untested functions +- [ ] List the specific functions/methods to test +- [ ] Note current coverage percentage + +### 2. Write Tests +- [ ] Test the happy path (expected behavior) +- [ ] Test edge cases (empty inputs, boundaries) +- [ ] Test error cases (invalid inputs, exceptions) +- [ ] Mock external dependencies +- [ ] Use descriptive test names + +### 3. Test Quality +- [ ] Each test focuses on one behavior +- [ ] Tests can run independently +- [ ] No hardcoded values that might change +- [ ] Clear assertions that verify the behavior + +## Examples to Cover: +- Normal inputs → Expected outputs +- Empty/null inputs → Proper handling +- Invalid inputs → Error cases +- Boundary values → Edge case behavior + +## Goal +Achieve at least 80% coverage for the target component + +## Deliverables +1. New test file(s) with comprehensive unit tests +2. Updated coverage report +3. All tests passing', + 'Add Unit Tests', + datetime('now', 'subsec'), + datetime('now', 'subsec') +); + +-- 3. Code Refactoring template +INSERT INTO task_templates ( + id, + project_id, + title, + description, + template_name, + created_at, + updated_at +) VALUES ( + randomblob(16), + NULL, -- Global template + 'Refactor [component/module] for better maintainability', + 'Improve code structure and maintainability without changing functionality. + +## Refactoring Checklist + +### 1. Identify Refactoring Targets +- [ ] Run code analysis tools (linters, complexity analyzers) +- [ ] Identify code smells (long methods, duplicate code, large classes) +- [ ] Check for outdated patterns or deprecated approaches +- [ ] Review areas with frequent bugs or changes + +### 2. Plan the Refactoring +- [ ] Define clear goals (what to improve and why) +- [ ] Ensure tests exist for current functionality +- [ ] Create a backup branch +- [ ] Break down into small, safe steps + +### 3. Common Refactoring Actions +- [ ] Extract methods from long functions +- [ ] Remove duplicate code (DRY principle) +- [ ] Rename variables/functions for clarity +- [ ] Simplify complex conditionals +- [ ] Extract constants from magic numbers/strings +- [ ] Group related functionality into modules +- [ ] Remove dead code + +### 4. Maintain Functionality +- [ ] Run tests after each change +- [ ] Keep changes small and incremental +- [ ] Commit frequently with clear messages +- [ ] Verify no behavior has changed + +### 5. Code Quality Improvements +- [ ] Apply consistent formatting +- [ ] Update to modern syntax/features +- [ ] Improve error handling +- [ ] Add type annotations (if applicable) + +## Success Criteria +- All tests still pass +- Code is more readable and maintainable +- No new bugs introduced +- Performance not degraded + +## Deliverables +1. Refactored code with improved structure +2. All tests passing +3. Brief summary of changes made', + 'Code Refactoring', + datetime('now', 'subsec'), + datetime('now', 'subsec') +); \ No newline at end of file diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index 7efeeb5e..53e1ac1f 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -98,6 +98,9 @@ fn generate_types_content() -> String { vibe_kanban::models::task::Task::decl(), vibe_kanban::models::task::TaskWithAttemptStatus::decl(), vibe_kanban::models::task::UpdateTask::decl(), + vibe_kanban::models::task_template::TaskTemplate::decl(), + vibe_kanban::models::task_template::CreateTaskTemplate::decl(), + vibe_kanban::models::task_template::UpdateTaskTemplate::decl(), vibe_kanban::models::task_attempt::TaskAttemptStatus::decl(), vibe_kanban::models::task_attempt::TaskAttempt::decl(), vibe_kanban::models::task_attempt::CreateTaskAttempt::decl(), diff --git a/backend/src/main.rs b/backend/src/main.rs index f539fbd8..aad47b8c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -29,7 +29,9 @@ mod utils; use app_state::AppState; use execution_monitor::execution_monitor; use models::{ApiResponse, Config}; -use routes::{auth, config, filesystem, health, projects, stream, task_attempts, tasks}; +use routes::{ + auth, config, filesystem, health, projects, stream, task_attempts, task_templates, tasks, +}; use services::PrMonitorService; async fn echo_handler( @@ -199,6 +201,7 @@ fn main() -> anyhow::Result<()> { .merge(tasks::tasks_router()) .merge(task_attempts::task_attempts_router()) .merge(stream::stream_router()) + .merge(task_templates::templates_router()) .merge(filesystem::filesystem_router()) .merge(config::config_router()) .merge(auth::auth_router()) diff --git a/backend/src/models/api_response.rs b/backend/src/models/api_response.rs index aa89cf0d..c88abe40 100644 --- a/backend/src/models/api_response.rs +++ b/backend/src/models/api_response.rs @@ -8,3 +8,21 @@ pub struct ApiResponse { pub data: Option, pub message: Option, } + +impl ApiResponse { + pub fn success(data: T) -> Self { + Self { + success: true, + data: Some(data), + message: None, + } + } + + pub fn error(message: &str) -> Self { + Self { + success: false, + data: None, + message: Some(message.to_string()), + } + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index c27e934a..b7eb6a54 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -6,6 +6,7 @@ pub mod project; pub mod task; pub mod task_attempt; pub mod task_attempt_activity; +pub mod task_template; pub use api_response::ApiResponse; pub use config::Config; diff --git a/backend/src/models/task_template.rs b/backend/src/models/task_template.rs new file mode 100644 index 00000000..08c9b903 --- /dev/null +++ b/backend/src/models/task_template.rs @@ -0,0 +1,145 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct TaskTemplate { + pub id: Uuid, + pub project_id: Option, // None for global templates + pub title: String, + pub description: Option, + pub template_name: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateTaskTemplate { + pub project_id: Option, + pub title: String, + pub description: Option, + pub template_name: String, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct UpdateTaskTemplate { + pub title: Option, + pub description: Option, + pub template_name: Option, +} + +impl TaskTemplate { + pub async fn find_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + TaskTemplate, + r#"SELECT id as "id!: Uuid", project_id as "project_id?: Uuid", title, description, template_name, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + FROM task_templates + ORDER BY project_id IS NULL DESC, template_name ASC"# + ) + .fetch_all(pool) + .await + } + + pub async fn find_by_project_id( + pool: &SqlitePool, + project_id: Option, + ) -> Result, sqlx::Error> { + if let Some(pid) = project_id { + // Return only project-specific templates + sqlx::query_as::<_, TaskTemplate>( + r#"SELECT id, project_id, title, description, template_name, created_at, updated_at + FROM task_templates + WHERE project_id = ? + ORDER BY template_name ASC"#, + ) + .bind(pid) + .fetch_all(pool) + .await + } else { + // Return only global templates + sqlx::query_as!( + TaskTemplate, + r#"SELECT id as "id!: Uuid", project_id as "project_id?: Uuid", title, description, template_name, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + FROM task_templates + WHERE project_id IS NULL + ORDER BY template_name ASC"# + ) + .fetch_all(pool) + .await + } + } + + pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + TaskTemplate, + r#"SELECT id as "id!: Uuid", project_id as "project_id?: Uuid", title, description, template_name, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + FROM task_templates + WHERE id = $1"#, + id + ) + .fetch_optional(pool) + .await + } + + pub async fn create(pool: &SqlitePool, data: &CreateTaskTemplate) -> Result { + let id = Uuid::new_v4(); + sqlx::query_as!( + TaskTemplate, + r#"INSERT INTO task_templates (id, project_id, title, description, template_name) + VALUES ($1, $2, $3, $4, $5) + RETURNING id as "id!: Uuid", project_id as "project_id?: Uuid", title, description, template_name, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + id, + data.project_id, + data.title, + data.description, + data.template_name + ) + .fetch_one(pool) + .await + } + + pub async fn update( + pool: &SqlitePool, + id: Uuid, + data: &UpdateTaskTemplate, + ) -> Result { + // Get existing template first + let existing = Self::find_by_id(pool, id) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + // Use let bindings to create longer-lived values + let title = data.title.as_ref().unwrap_or(&existing.title); + let description = data.description.as_ref().or(existing.description.as_ref()); + let template_name = data + .template_name + .as_ref() + .unwrap_or(&existing.template_name); + + sqlx::query_as!( + TaskTemplate, + r#"UPDATE task_templates + SET title = $2, description = $3, template_name = $4, updated_at = datetime('now', 'subsec') + WHERE id = $1 + RETURNING id as "id!: Uuid", project_id as "project_id?: Uuid", title, description, template_name, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + id, + title, + description, + template_name + ) + .fetch_one(pool) + .await + } + + pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result { + let result = sqlx::query!("DELETE FROM task_templates WHERE id = $1", id) + .execute(pool) + .await?; + Ok(result.rows_affected()) + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 31290d3e..f282c90b 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -5,4 +5,5 @@ pub mod health; pub mod projects; pub mod stream; pub mod task_attempts; +pub mod task_templates; pub mod tasks; diff --git a/backend/src/routes/task_templates.rs b/backend/src/routes/task_templates.rs new file mode 100644 index 00000000..b14f49fd --- /dev/null +++ b/backend/src/routes/task_templates.rs @@ -0,0 +1,178 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + models::{ + api_response::ApiResponse, + task_template::{CreateTaskTemplate, TaskTemplate, UpdateTaskTemplate}, + }, +}; + +pub async fn list_templates( + State(state): State, +) -> Result>)> { + match TaskTemplate::find_all(&state.db_pool).await { + Ok(templates) => Ok(Json(ApiResponse::success(templates))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::error(&format!( + "Failed to fetch templates: {}", + e + ))), + )), + } +} + +pub async fn list_project_templates( + State(state): State, + Path(project_id): Path, +) -> Result>)> { + match TaskTemplate::find_by_project_id(&state.db_pool, Some(project_id)).await { + Ok(templates) => Ok(Json(ApiResponse::success(templates))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::error(&format!( + "Failed to fetch templates: {}", + e + ))), + )), + } +} + +pub async fn list_global_templates( + State(state): State, +) -> Result>)> { + match TaskTemplate::find_by_project_id(&state.db_pool, None).await { + Ok(templates) => Ok(Json(ApiResponse::success(templates))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::error(&format!( + "Failed to fetch global templates: {}", + e + ))), + )), + } +} + +pub async fn get_template( + State(state): State, + Path(template_id): Path, +) -> Result>)> { + match TaskTemplate::find_by_id(&state.db_pool, template_id).await { + Ok(Some(template)) => Ok(Json(ApiResponse::success(template))), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(ApiResponse::error("Template not found")), + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::error(&format!( + "Failed to fetch template: {}", + e + ))), + )), + } +} + +pub async fn create_template( + State(state): State, + Json(payload): Json, +) -> Result>)> { + match TaskTemplate::create(&state.db_pool, &payload).await { + Ok(template) => Ok((StatusCode::CREATED, Json(ApiResponse::success(template)))), + Err(e) => { + if e.to_string().contains("UNIQUE constraint failed") { + Err(( + StatusCode::CONFLICT, + Json(ApiResponse::error( + "A template with this name already exists in this scope", + )), + )) + } else { + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::error(&format!( + "Failed to create template: {}", + e + ))), + )) + } + } + } +} + +pub async fn update_template( + State(state): State, + Path(template_id): Path, + Json(payload): Json, +) -> Result>)> { + match TaskTemplate::update(&state.db_pool, template_id, &payload).await { + Ok(template) => Ok(Json(ApiResponse::success(template))), + Err(e) => { + if matches!(e, sqlx::Error::RowNotFound) { + Err(( + StatusCode::NOT_FOUND, + Json(ApiResponse::error("Template not found")), + )) + } else if e.to_string().contains("UNIQUE constraint failed") { + Err(( + StatusCode::CONFLICT, + Json(ApiResponse::error( + "A template with this name already exists in this scope", + )), + )) + } else { + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::error(&format!( + "Failed to update template: {}", + e + ))), + )) + } + } + } +} + +pub async fn delete_template( + State(state): State, + Path(template_id): Path, +) -> Result>)> { + match TaskTemplate::delete(&state.db_pool, template_id).await { + Ok(0) => Err(( + StatusCode::NOT_FOUND, + Json(ApiResponse::error("Template not found")), + )), + Ok(_) => Ok(Json(ApiResponse::success(()))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::error(&format!( + "Failed to delete template: {}", + e + ))), + )), + } +} + +pub fn templates_router() -> Router { + Router::new() + .route("/templates", get(list_templates).post(create_template)) + .route("/templates/global", get(list_global_templates)) + .route( + "/templates/:id", + get(get_template) + .put(update_template) + .delete(delete_template), + ) + .route( + "/projects/:project_id/templates", + get(list_project_templates), + ) +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 45ffc7f4..f6ded6b3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@sentry/react": "^9.34.0", "@sentry/vite-plugin": "^3.5.0", @@ -1698,6 +1699,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz", + "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 31b0baff..77e8d9b4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@sentry/react": "^9.34.0", "@sentry/vite-plugin": "^3.5.0", diff --git a/frontend/src/components/TaskTemplateManager.tsx b/frontend/src/components/TaskTemplateManager.tsx new file mode 100644 index 00000000..9d1d4601 --- /dev/null +++ b/frontend/src/components/TaskTemplateManager.tsx @@ -0,0 +1,336 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react'; +import { templatesApi } from '@/lib/api'; +import type { + TaskTemplate, + CreateTaskTemplate, + UpdateTaskTemplate, +} from 'shared/types'; + +interface TaskTemplateManagerProps { + projectId?: string; + isGlobal?: boolean; +} + +export function TaskTemplateManager({ + projectId, + isGlobal = false, +}: TaskTemplateManagerProps) { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [editingTemplate, setEditingTemplate] = useState( + null + ); + const [formData, setFormData] = useState({ + template_name: '', + title: '', + description: '', + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const fetchTemplates = useCallback(async () => { + setLoading(true); + try { + const data = isGlobal + ? await templatesApi.listGlobal() + : projectId + ? await templatesApi.listByProject(projectId) + : []; + + // Filter to show only templates for this specific scope + const filtered = data.filter((template) => + isGlobal + ? template.project_id === null + : template.project_id === projectId + ); + + setTemplates(filtered); + } catch (err) { + console.error('Failed to fetch templates:', err); + } finally { + setLoading(false); + } + }, [isGlobal, projectId]); + + useEffect(() => { + fetchTemplates(); + }, [fetchTemplates]); + + const handleOpenDialog = useCallback((template?: TaskTemplate) => { + if (template) { + setEditingTemplate(template); + setFormData({ + template_name: template.template_name, + title: template.title, + description: template.description || '', + }); + } else { + setEditingTemplate(null); + setFormData({ + template_name: '', + title: '', + description: '', + }); + } + setError(null); + setIsDialogOpen(true); + }, []); + + const handleCloseDialog = useCallback(() => { + setIsDialogOpen(false); + setEditingTemplate(null); + setFormData({ + template_name: '', + title: '', + description: '', + }); + setError(null); + }, []); + + const handleSave = useCallback(async () => { + if (!formData.template_name.trim() || !formData.title.trim()) { + setError('Template name and title are required'); + return; + } + + setSaving(true); + setError(null); + + try { + if (editingTemplate) { + const updateData: UpdateTaskTemplate = { + template_name: formData.template_name, + title: formData.title, + description: formData.description || null, + }; + await templatesApi.update(editingTemplate.id, updateData); + } else { + const createData: CreateTaskTemplate = { + project_id: isGlobal ? null : projectId || null, + template_name: formData.template_name, + title: formData.title, + description: formData.description || null, + }; + await templatesApi.create(createData); + } + await fetchTemplates(); + handleCloseDialog(); + } catch (err: any) { + setError(err.message || 'Failed to save template'); + } finally { + setSaving(false); + } + }, [ + formData, + editingTemplate, + isGlobal, + projectId, + fetchTemplates, + handleCloseDialog, + ]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Command/Ctrl + Enter to save template + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + if (isDialogOpen && !saving) { + event.preventDefault(); + handleSave(); + } + } + }; + + if (isDialogOpen) { + document.addEventListener('keydown', handleKeyDown, true); // Use capture phase for priority + return () => document.removeEventListener('keydown', handleKeyDown, true); + } + }, [isDialogOpen, saving, handleSave]); + + const handleDelete = useCallback( + async (template: TaskTemplate) => { + if ( + !confirm( + `Are you sure you want to delete the template "${template.template_name}"?` + ) + ) { + return; + } + + try { + await templatesApi.delete(template.id); + await fetchTemplates(); + } catch (err) { + console.error('Failed to delete template:', err); + } + }, + [fetchTemplates] + ); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ {isGlobal ? 'Global Task Templates' : 'Project Task Templates'} +

+ +
+ + {templates.length === 0 ? ( +
+ No templates yet. Create your first template to get started. +
+ ) : ( +
+
+ + + + + + + + + + + {templates.map((template) => ( + + + + + + + ))} + +
+ Template Name + Title + Description + + Actions +
+ {template.template_name} + {template.title} +
+ {template.description || ( + - + )} +
+
+
+ + +
+
+
+
+ )} + + + + + + {editingTemplate ? 'Edit Template' : 'Create Template'} + + +
+
+ + + setFormData({ ...formData, template_name: e.target.value }) + } + placeholder="e.g., Bug Fix, Feature Request" + /> +
+
+ + + setFormData({ ...formData, title: e.target.value }) + } + placeholder="e.g., Fix bug in..." + /> +
+
+ +