Skip to main content

max / goingson

Inherit group scope when creating items in a shared project (Groups p4 M4) A row created inside a shared project must join the group atomically, so the INSERT sets group_id from its parent via a (SELECT group_id FROM <parent> WHERE id = ?) subquery rather than a post-insert UPDATE (which would double-write the changelog — a personal INSERT then a group UPDATE — and mis-route the row). Covers every insert into the nine group-scoped tables: tasks and events and milestones inherit from their project; subtasks, annotations, task_status_tokens, and time_sessions from their task; attachments from the owning task (preferred) or project via COALESCE. A row with no parent, or a parent that is personal, gets NULL (personal) as before. An integration test confirms a task created in a shared project inherits its group scope, and a project-less task stays personal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 22:47 UTC
Commit: 205d846402a65a46eec797107b1bfecf9b3c7191
Parent: 003e4cc
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;