max / goingson
9 files changed,
+103 insertions,
-22 deletions
| @@ -113,14 +113,16 @@ pub(crate) async fn add_annotation( | |||
| 113 | 113 | ||
| 114 | 114 | sqlx::query( | |
| 115 | 115 | r#" | |
| 116 | - | INSERT INTO annotations (id, task_id, timestamp, note) | |
| 117 | - | VALUES (?, ?, ?, ?) | |
| 116 | + | INSERT INTO annotations (id, task_id, timestamp, note, group_id) | |
| 117 | + | VALUES (?, ?, ?, ?, (SELECT group_id FROM tasks WHERE id = ?)) | |
| 118 | 118 | "#, | |
| 119 | 119 | ) | |
| 120 | 120 | .bind(id.to_string()) | |
| 121 | 121 | .bind(task_id.to_string()) | |
| 122 | 122 | .bind(&now) | |
| 123 | 123 | .bind(note) | |
| 124 | + | // Inherit the parent task's group scope. | |
| 125 | + | .bind(task_id.to_string()) | |
| 124 | 126 | .execute(pool) | |
| 125 | 127 | .await | |
| 126 | 128 | .map_err(CoreError::database)?; |
| @@ -66,8 +66,8 @@ impl AttachmentRepository for SqliteAttachmentRepository { | |||
| 66 | 66 | ||
| 67 | 67 | sqlx::query( | |
| 68 | 68 | r#" | |
| 69 | - | INSERT INTO attachments (id, user_id, task_id, project_id, filename, file_size, mime_type, blob_hash, source_email_id, created_at) | |
| 70 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 69 | + | INSERT INTO attachments (id, user_id, task_id, project_id, filename, file_size, mime_type, blob_hash, source_email_id, created_at, group_id) | |
| 70 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT group_id FROM tasks WHERE id = ?), (SELECT group_id FROM projects WHERE id = ?))) | |
| 71 | 71 | "#, | |
| 72 | 72 | ) | |
| 73 | 73 | .bind(id.to_string()) | |
| @@ -80,6 +80,9 @@ impl AttachmentRepository for SqliteAttachmentRepository { | |||
| 80 | 80 | .bind(&attachment.blob_hash) | |
| 81 | 81 | .bind(attachment.source_email_id.map(|e| e.to_string())) | |
| 82 | 82 | .bind(&now) | |
| 83 | + | // Inherit the group scope of the owning task (preferred) or project. | |
| 84 | + | .bind(attachment.task_id.map(|t| t.to_string())) | |
| 85 | + | .bind(attachment.project_id.map(|p| p.to_string())) | |
| 83 | 86 | .execute(&self.pool) | |
| 84 | 87 | .await | |
| 85 | 88 | .map_err(CoreError::database)?; |
| @@ -197,7 +197,7 @@ impl EventRepository for SqliteEventRepository { | |||
| 197 | 197 | }; | |
| 198 | 198 | ||
| 199 | 199 | sqlx::query( | |
| 200 | - | "INSERT INTO events (id, user_id, project_id, title, description, start_time, end_time, location, linked_task_id, recurrence, recurrence_rule, contact_id, block_type, reminder_offsets_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 200 | + | "INSERT INTO events (id, user_id, project_id, title, description, start_time, end_time, location, linked_task_id, recurrence, recurrence_rule, contact_id, block_type, reminder_offsets_seconds, group_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM projects WHERE id = ?))", | |
| 201 | 201 | ) | |
| 202 | 202 | .bind(id.to_string()) | |
| 203 | 203 | .bind(user_id.to_string()) | |
| @@ -213,6 +213,8 @@ impl EventRepository for SqliteEventRepository { | |||
| 213 | 213 | .bind(event.contact_id.map(|c| c.to_string())) | |
| 214 | 214 | .bind(event.block_type.as_ref().map(|b| b.db_value())) | |
| 215 | 215 | .bind(&reminder_offsets_json) | |
| 216 | + | // Inherit the project's group scope (personal if no project). | |
| 217 | + | .bind(event.project_id.map(|p| p.to_string())) | |
| 216 | 218 | .execute(&self.pool) | |
| 217 | 219 | .await | |
| 218 | 220 | .map_err(CoreError::database)?; |
| @@ -139,8 +139,8 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 139 | 139 | ||
| 140 | 140 | sqlx::query( | |
| 141 | 141 | r#" | |
| 142 | - | INSERT INTO milestones (id, user_id, project_id, name, description, position, target_date, status, created_at) | |
| 143 | - | VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?) | |
| 142 | + | INSERT INTO milestones (id, user_id, project_id, name, description, position, target_date, status, created_at, group_id) | |
| 143 | + | VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, (SELECT group_id FROM projects WHERE id = ?)) | |
| 144 | 144 | "#, | |
| 145 | 145 | ) | |
| 146 | 146 | .bind(id.to_string()) | |
| @@ -151,6 +151,8 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 151 | 151 | .bind(milestone.position) | |
| 152 | 152 | .bind(&target_date_str) | |
| 153 | 153 | .bind(&now) | |
| 154 | + | // Inherit the project's group scope. | |
| 155 | + | .bind(milestone.project_id.to_string()) | |
| 154 | 156 | .execute(&self.pool) | |
| 155 | 157 | .await | |
| 156 | 158 | .map_err(CoreError::database)?; |
| @@ -149,8 +149,8 @@ pub(crate) async fn record_token( | |||
| 149 | 149 | // so a concurrent record can't race the MAX (SQLite serializes writers). | |
| 150 | 150 | let (position,): (i32,) = sqlx::query_as( | |
| 151 | 151 | r#" | |
| 152 | - | INSERT INTO task_status_tokens (id, task_id, kind, reference, state, is_primary, position) | |
| 153 | - | SELECT ?, ?, ?, ?, ?, ?, COALESCE(MAX(position), -1) + 1 FROM task_status_tokens WHERE task_id = ? | |
| 152 | + | INSERT INTO task_status_tokens (id, task_id, kind, reference, state, is_primary, position, group_id) | |
| 153 | + | SELECT ?, ?, ?, ?, ?, ?, COALESCE(MAX(position), -1) + 1, (SELECT group_id FROM tasks WHERE id = ?) FROM task_status_tokens WHERE task_id = ? | |
| 154 | 154 | ON CONFLICT(id) DO UPDATE SET state = excluded.state, is_primary = excluded.is_primary | |
| 155 | 155 | RETURNING position | |
| 156 | 156 | "#, | |
| @@ -161,6 +161,8 @@ pub(crate) async fn record_token( | |||
| 161 | 161 | .bind(reference) | |
| 162 | 162 | .bind(state.db_value()) | |
| 163 | 163 | .bind(primary_int) | |
| 164 | + | // Inherit the parent task's group scope. | |
| 165 | + | .bind(task_id.to_string()) | |
| 164 | 166 | .bind(task_id.to_string()) | |
| 165 | 167 | .fetch_one(&mut *tx) | |
| 166 | 168 | .await |
| @@ -118,14 +118,16 @@ pub(crate) async fn add_subtask( | |||
| 118 | 118 | // committed sibling). RETURNING gives back the position actually stored. | |
| 119 | 119 | let (position,): (i32,) = sqlx::query_as( | |
| 120 | 120 | r#" | |
| 121 | - | INSERT INTO subtasks (id, task_id, text, position) | |
| 122 | - | SELECT ?, ?, ?, COALESCE(MAX(position), -1) + 1 FROM subtasks WHERE task_id = ? | |
| 121 | + | INSERT INTO subtasks (id, task_id, text, position, group_id) | |
| 122 | + | SELECT ?, ?, ?, COALESCE(MAX(position), -1) + 1, (SELECT group_id FROM tasks WHERE id = ?) FROM subtasks WHERE task_id = ? | |
| 123 | 123 | RETURNING position | |
| 124 | 124 | "#, | |
| 125 | 125 | ) | |
| 126 | 126 | .bind(id.to_string()) | |
| 127 | 127 | .bind(task_id.to_string()) | |
| 128 | 128 | .bind(text) | |
| 129 | + | // Inherit the parent task's group scope. | |
| 130 | + | .bind(task_id.to_string()) | |
| 129 | 131 | .bind(task_id.to_string()) | |
| 130 | 132 | .fetch_one(pool) | |
| 131 | 133 | .await | |
| @@ -267,8 +269,8 @@ pub(crate) async fn add_subtask_link( | |||
| 267 | 269 | // Atomic position assignment + RETURNING (see add_subtask) — no MAX+1 race. | |
| 268 | 270 | let (position,): (i32,) = sqlx::query_as( | |
| 269 | 271 | r#" | |
| 270 | - | INSERT INTO subtasks (id, task_id, text, linked_task_id, is_completed, position) | |
| 271 | - | SELECT ?, ?, ?, ?, ?, COALESCE(MAX(position), -1) + 1 FROM subtasks WHERE task_id = ? | |
| 272 | + | INSERT INTO subtasks (id, task_id, text, linked_task_id, is_completed, position, group_id) | |
| 273 | + | SELECT ?, ?, ?, ?, ?, COALESCE(MAX(position), -1) + 1, (SELECT group_id FROM tasks WHERE id = ?) FROM subtasks WHERE task_id = ? | |
| 272 | 274 | RETURNING position | |
| 273 | 275 | "#, | |
| 274 | 276 | ) | |
| @@ -277,6 +279,8 @@ pub(crate) async fn add_subtask_link( | |||
| 277 | 279 | .bind(linked_task_description) | |
| 278 | 280 | .bind(linked_task_id.to_string()) | |
| 279 | 281 | .bind(is_completed as i32) | |
| 282 | + | // Inherit the parent task's group scope. | |
| 283 | + | .bind(task_id.to_string()) | |
| 280 | 284 | .bind(task_id.to_string()) | |
| 281 | 285 | .fetch_one(pool) | |
| 282 | 286 | .await |
| @@ -577,8 +577,8 @@ impl TaskCrud for SqliteTaskRepository { | |||
| 577 | 577 | ||
| 578 | 578 | sqlx::query( | |
| 579 | 579 | r#" | |
| 580 | - | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, recurrence_rule, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, created_at) | |
| 581 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 580 | + | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, recurrence_rule, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, created_at, group_id) | |
| 581 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM projects WHERE id = ?)) | |
| 582 | 582 | "#, | |
| 583 | 583 | ) | |
| 584 | 584 | .bind(id.to_string()) | |
| @@ -598,6 +598,10 @@ impl TaskCrud for SqliteTaskRepository { | |||
| 598 | 598 | .bind(task.scheduled_duration) | |
| 599 | 599 | .bind(task.estimated_minutes) | |
| 600 | 600 | .bind(&now) | |
| 601 | + | // Inherit the project's group scope so a task created in a shared project | |
| 602 | + | // joins the group atomically (a post-insert UPDATE would double-write the | |
| 603 | + | // changelog and mis-route the row). | |
| 604 | + | .bind(task.project_id.map(|p| p.to_string())) | |
| 601 | 605 | .execute(&self.pool) | |
| 602 | 606 | .await | |
| 603 | 607 | .map_err(CoreError::database)?; | |
| @@ -907,8 +911,8 @@ impl TaskCrud for SqliteTaskRepository { | |||
| 907 | 911 | ||
| 908 | 912 | sqlx::query( | |
| 909 | 913 | r#" | |
| 910 | - | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, recurrence_rule, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, recurrence_parent_id, created_at) | |
| 911 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 914 | + | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, recurrence_rule, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, recurrence_parent_id, created_at, group_id) | |
| 915 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM projects WHERE id = ?)) | |
| 912 | 916 | "#, | |
| 913 | 917 | ) | |
| 914 | 918 | .bind(nid.to_string()) | |
| @@ -929,6 +933,8 @@ impl TaskCrud for SqliteTaskRepository { | |||
| 929 | 933 | .bind(new_task.estimated_minutes) | |
| 930 | 934 | .bind(new_task.recurrence_parent_id.map(|p| p.to_string())) | |
| 931 | 935 | .bind(&now) | |
| 936 | + | // Inherit the project's group scope (see the create() insert). | |
| 937 | + | .bind(new_task.project_id.map(|p| p.to_string())) | |
| 932 | 938 | .execute(&mut *tx) | |
| 933 | 939 | .await | |
| 934 | 940 | .map_err(CoreError::database)?; |
| @@ -108,13 +108,15 @@ pub(crate) async fn start_timer( | |||
| 108 | 108 | let now = format_datetime_now(); | |
| 109 | 109 | ||
| 110 | 110 | sqlx::query( | |
| 111 | - | "INSERT INTO time_sessions (id, task_id, user_id, started_at, created_at) VALUES (?, ?, ?, ?, ?)" | |
| 111 | + | "INSERT INTO time_sessions (id, task_id, user_id, started_at, created_at, group_id) VALUES (?, ?, ?, ?, ?, (SELECT group_id FROM tasks WHERE id = ?))" | |
| 112 | 112 | ) | |
| 113 | 113 | .bind(id.to_string()) | |
| 114 | 114 | .bind(task_id.to_string()) | |
| 115 | 115 | .bind(user_id.to_string()) | |
| 116 | 116 | .bind(&now) | |
| 117 | 117 | .bind(&now) | |
| 118 | + | // Inherit the parent task's group scope. | |
| 119 | + | .bind(task_id.to_string()) | |
| 118 | 120 | .execute(&mut *tx) | |
| 119 | 121 | .await | |
| 120 | 122 | .map_err(|e| { | |
| @@ -414,8 +416,8 @@ pub(crate) async fn log_manual_time( | |||
| 414 | 416 | } | |
| 415 | 417 | ||
| 416 | 418 | sqlx::query( | |
| 417 | - | "INSERT INTO time_sessions (id, task_id, user_id, started_at, ended_at, duration_minutes, created_at) | |
| 418 | - | VALUES (?, ?, ?, ?, ?, ?, ?)" | |
| 419 | + | "INSERT INTO time_sessions (id, task_id, user_id, started_at, ended_at, duration_minutes, created_at, group_id) | |
| 420 | + | VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT group_id FROM tasks WHERE id = ?))" | |
| 419 | 421 | ) | |
| 420 | 422 | .bind(id.to_string()) | |
| 421 | 423 | .bind(task_id.to_string()) | |
| @@ -424,6 +426,8 @@ pub(crate) async fn log_manual_time( | |||
| 424 | 426 | .bind(&ended_str) | |
| 425 | 427 | .bind(minutes) | |
| 426 | 428 | .bind(&created_str) | |
| 429 | + | // Inherit the parent task's group scope. | |
| 430 | + | .bind(task_id.to_string()) | |
| 427 | 431 | .execute(&mut *tx) | |
| 428 | 432 | .await | |
| 429 | 433 | .map_err(CoreError::database)?; |
| @@ -4,8 +4,8 @@ mod common; | |||
| 4 | 4 | ||
| 5 | 5 | use chrono::{Duration, Utc}; | |
| 6 | 6 | use goingson_core::{ | |
| 7 | - | NewProject, NewTask, Priority, ProjectRepository, Recurrence, RecurrenceRule, TaskCrud, | |
| 8 | - | TaskFilterQuery, TaskScheduling, TaskStatus, UpdateTask, | |
| 7 | + | NewProject, NewTask, Priority, ProjectRepository, ProjectStatus, ProjectType, Recurrence, | |
| 8 | + | RecurrenceRule, TaskCrud, TaskFilterQuery, TaskScheduling, TaskStatus, UpdateTask, | |
| 9 | 9 | }; | |
| 10 | 10 | use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository}; | |
| 11 | 11 | ||
| @@ -38,6 +38,62 @@ async fn test_create_and_get_task() { | |||
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | 40 | #[tokio::test] | |
| 41 | + | async fn task_created_in_a_shared_project_inherits_its_group_scope() { | |
| 42 | + | let pool = common::setup_test_db().await; | |
| 43 | + | let user_id = common::create_test_user(&pool).await; | |
| 44 | + | ||
| 45 | + | // A project shared into a group "grp-1". | |
| 46 | + | let projects = SqliteProjectRepository::new(pool.clone()); | |
| 47 | + | let project = projects | |
| 48 | + | .create( | |
| 49 | + | user_id, | |
| 50 | + | NewProject { | |
| 51 | + | name: "Shared".into(), | |
| 52 | + | description: String::new(), | |
| 53 | + | project_type: ProjectType::SideProject, | |
| 54 | + | status: ProjectStatus::Active, | |
| 55 | + | }, | |
| 56 | + | ) | |
| 57 | + | .await | |
| 58 | + | .expect("create project"); | |
| 59 | + | sqlx::query("UPDATE projects SET group_id = 'grp-1' WHERE id = ?") | |
| 60 | + | .bind(project.id.to_string()) | |
| 61 | + | .execute(&pool) | |
| 62 | + | .await | |
| 63 | + | .expect("mark project shared"); | |
| 64 | + | ||
| 65 | + | // A task created in it inherits the group scope atomically at insert. | |
| 66 | + | let tasks = SqliteTaskRepository::new(pool.clone()); | |
| 67 | + | let task = tasks | |
| 68 | + | .create( | |
| 69 | + | user_id, | |
| 70 | + | NewTask::builder("in shared project") | |
| 71 | + | .project_id(project.id) | |
| 72 | + | .build(), | |
| 73 | + | ) | |
| 74 | + | .await | |
| 75 | + | .expect("create task"); | |
| 76 | + | let (gid,): (Option<String>,) = sqlx::query_as("SELECT group_id FROM tasks WHERE id = ?") | |
| 77 | + | .bind(task.id.to_string()) | |
| 78 | + | .fetch_one(&pool) | |
| 79 | + | .await | |
| 80 | + | .unwrap(); | |
| 81 | + | assert_eq!(gid.as_deref(), Some("grp-1"), "task should join the group"); | |
| 82 | + | ||
| 83 | + | // A task with no project stays personal (NULL). | |
| 84 | + | let personal = tasks | |
| 85 | + | .create(user_id, NewTask::builder("no project").build()) | |
| 86 | + | .await | |
| 87 | + | .expect("create personal task"); | |
| 88 | + | let (gid,): (Option<String>,) = sqlx::query_as("SELECT group_id FROM tasks WHERE id = ?") | |
| 89 | + | .bind(personal.id.to_string()) | |
| 90 | + | .fetch_one(&pool) | |
| 91 | + | .await | |
| 92 | + | .unwrap(); | |
| 93 | + | assert_eq!(gid, None, "a project-less task stays personal"); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | #[tokio::test] | |
| 41 | 97 | async fn test_task_status_transitions() { | |
| 42 | 98 | let pool = common::setup_test_db().await; | |
| 43 | 99 | let user_id = common::create_test_user(&pool).await; |