Skip to main content

max / goingson

Add group admin commands + project share/unshare (Groups p4 M4 backend) The backend for shared workspaces: - group_create / group_list / my_group_pubkey / group_add_member / group_remove_member: thin Tauri commands over the SyncKit client's group admin surface (create a group, show your public key to be added, add a member by pasted email+pubkey, remove a member). - share_project / unshare_project: stamp group_id across a project's whole subtree (the nine group-scoped tables) in one transaction, so the engine routes those rows to the group's shared changelog on the next sync; unshare clears it back to personal. Predicates key on project_id/task_id (never changed), so statement order is irrelevant. Unit tests cover the cascade: share stamps exactly the project subtree (an unrelated project stays personal), and unshare clears it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 22:29 UTC
Commit: 3424e6066807899f815d4e439287a4fd0edb827e
Parent: ea65be9
3 files changed, +318 insertions, -0 deletions
@@ -0,0 +1,308 @@
1 + //! Group (shared-workspace) commands: SyncKit group admin management, plus the
2 + //! project-scope write path that shares a whole project into a group.
3 + //!
4 + //! Groups p4 / GO -> SyncStore M4. Sharing is whole-project: `share_project`
5 + //! stamps the group id across a project's entire subtree (the nine group-scoped
6 + //! tables), so the engine routes those rows to the group's shared changelog on
7 + //! the next sync; `unshare_project` clears it back to personal (`NULL`). The admin
8 + //! commands are thin wrappers over the SyncKit client (which owns the crypto and
9 + //! the server round-trips).
10 +
11 + use std::sync::Arc;
12 +
13 + use serde::Serialize;
14 + use sqlx::SqlitePool;
15 + use synckit_client::{GroupId, SyncKitClient, UserId};
16 + use tauri::State;
17 + use tracing::instrument;
18 + use uuid::Uuid;
19 +
20 + use super::{ApiError, ResultApiError};
21 + use crate::state::AppState;
22 +
23 + /// A group as surfaced to the frontend. `SyncGroup` is deserialize-only and
24 + /// `#[non_exhaustive]`, so map to this stable DTO for the Tauri boundary.
25 + #[derive(Debug, Serialize)]
26 + #[serde(rename_all = "camelCase")]
27 + pub struct GroupDto {
28 + pub id: String,
29 + pub name: String,
30 + pub gck_version: i32,
31 + }
32 +
33 + fn require_client(state: &AppState) -> Result<Arc<SyncKitClient>, ApiError> {
34 + state
35 + .read_recovering()
36 + .ok_or_else(|| ApiError::bad_request("Sync is not configured"))
37 + }
38 +
39 + fn parse_uuid(kind: &str, value: &str) -> Result<Uuid, ApiError> {
40 + Uuid::parse_str(value).map_err(|_| ApiError::bad_request(format!("Invalid {kind}")))
41 + }
42 +
43 + // ============ Admin management ============
44 +
45 + /// Create a group with this user as admin. Returns the new group.
46 + #[tauri::command]
47 + #[instrument(skip_all)]
48 + pub async fn group_create(
49 + state: State<'_, Arc<AppState>>,
50 + name: String,
51 + ) -> Result<GroupDto, ApiError> {
52 + let client = require_client(&state)?;
53 + let group = client
54 + .create_group(&name)
55 + .await
56 + .map_api_err("Failed to create group", ApiError::external_service)?;
57 + Ok(GroupDto {
58 + id: group.id.to_string(),
59 + name: group.name,
60 + gck_version: group.gck_version,
61 + })
62 + }
63 +
64 + /// List the groups this user belongs to.
65 + #[tauri::command]
66 + #[instrument(skip_all)]
67 + pub async fn group_list(state: State<'_, Arc<AppState>>) -> Result<Vec<GroupDto>, ApiError> {
68 + let client = require_client(&state)?;
69 + let groups = client
70 + .list_groups()
71 + .await
72 + .map_api_err("Failed to list groups", ApiError::external_service)?;
73 + Ok(groups
74 + .into_iter()
75 + .map(|g| GroupDto {
76 + id: g.id.to_string(),
77 + name: g.name,
78 + gck_version: g.gck_version,
79 + })
80 + .collect())
81 + }
82 +
83 + /// This user's identity public key (base64), to share with a group admin so they
84 + /// can add you (the paste-a-public-key model).
85 + #[tauri::command]
86 + #[instrument(skip_all)]
87 + pub async fn my_group_pubkey(state: State<'_, Arc<AppState>>) -> Result<String, ApiError> {
88 + let client = require_client(&state)?;
89 + client
90 + .my_identity_public_key()
91 + .map_api_err("Encryption not set up", ApiError::bad_request)
92 + }
93 +
94 + /// Add a member to a group by email, sealing the group key to their pasted public
95 + /// key. Admin only.
96 + #[tauri::command]
97 + #[instrument(skip_all)]
98 + pub async fn group_add_member(
99 + state: State<'_, Arc<AppState>>,
100 + group_id: String,
101 + email: String,
102 + pubkey: String,
103 + ) -> Result<bool, ApiError> {
104 + let client = require_client(&state)?;
105 + let group = GroupId::new(parse_uuid("group id", &group_id)?);
106 + client
107 + .add_member(group, &email, &pubkey)
108 + .await
109 + .map_api_err("Failed to add member", ApiError::external_service)?;
110 + Ok(true)
111 + }
112 +
113 + /// Remove a member from a group by user id. Admin only.
114 + #[tauri::command]
115 + #[instrument(skip_all)]
116 + pub async fn group_remove_member(
117 + state: State<'_, Arc<AppState>>,
118 + group_id: String,
119 + user_id: String,
120 + ) -> Result<bool, ApiError> {
121 + let client = require_client(&state)?;
122 + let group = GroupId::new(parse_uuid("group id", &group_id)?);
123 + let member = UserId::new(parse_uuid("user id", &user_id)?);
124 + client
125 + .remove_member(group, member)
126 + .await
127 + .map_api_err("Failed to remove member", ApiError::external_service)?;
128 + Ok(true)
129 + }
130 +
131 + // ============ Project scope write path ============
132 +
133 + /// Share a whole project into a group: stamp `group_id` across its entire subtree.
134 + #[tauri::command]
135 + #[instrument(skip_all)]
136 + pub async fn share_project(
137 + state: State<'_, Arc<AppState>>,
138 + project_id: String,
139 + group_id: String,
140 + ) -> Result<bool, ApiError> {
141 + // Validate the id shape even though the column is plain TEXT.
142 + parse_uuid("group id", &group_id)?;
143 + set_project_scope(&state.pool, &project_id, Some(&group_id))
144 + .await
145 + .map_api_err("Failed to share project", ApiError::internal)?;
146 + Ok(true)
147 + }
148 +
149 + /// Move a project (and its subtree) back to personal scope.
150 + #[tauri::command]
151 + #[instrument(skip_all)]
152 + pub async fn unshare_project(
153 + state: State<'_, Arc<AppState>>,
154 + project_id: String,
155 + ) -> Result<bool, ApiError> {
156 + set_project_scope(&state.pool, &project_id, None)
157 + .await
158 + .map_api_err("Failed to unshare project", ApiError::internal)?;
159 + Ok(true)
160 + }
161 +
162 + /// Set `group_id` (a group id, or `NULL` to unshare) on a project and every row
163 + /// in its subtree, in one transaction. The engine's UPDATE triggers capture each
164 + /// change with its new scope, re-routing the rows on the next sync.
165 + ///
166 + /// The subtree is the nine group-scoped tables: the project itself; its direct
167 + /// children (tasks, milestones, events); the task grandchildren (subtasks,
168 + /// annotations, task_status_tokens, time_sessions); and attachments (owned by the
169 + /// project or one of its tasks). Predicates key on `project_id`/`task_id`, which
170 + /// this never changes, so statement order is irrelevant.
171 + async fn set_project_scope(
172 + pool: &SqlitePool,
173 + project_id: &str,
174 + group_id: Option<&str>,
175 + ) -> Result<(), sqlx::Error> {
176 + // `WHERE task_id IN (tasks of this project)`.
177 + const BY_TASK: &str = "SELECT id FROM tasks WHERE project_id = ?";
178 +
179 + let mut tx = pool.begin().await?;
180 + for sql in [
181 + "UPDATE projects SET group_id = ? WHERE id = ?".to_string(),
182 + "UPDATE tasks SET group_id = ? WHERE project_id = ?".to_string(),
183 + "UPDATE milestones SET group_id = ? WHERE project_id = ?".to_string(),
184 + "UPDATE events SET group_id = ? WHERE project_id = ?".to_string(),
185 + format!("UPDATE subtasks SET group_id = ? WHERE task_id IN ({BY_TASK})"),
186 + format!("UPDATE annotations SET group_id = ? WHERE task_id IN ({BY_TASK})"),
187 + format!("UPDATE task_status_tokens SET group_id = ? WHERE task_id IN ({BY_TASK})"),
188 + format!("UPDATE time_sessions SET group_id = ? WHERE task_id IN ({BY_TASK})"),
189 + format!(
190 + "UPDATE attachments SET group_id = ? WHERE project_id = ? OR task_id IN ({BY_TASK})"
191 + ),
192 + // attachments binds project_id twice (direct owner + task subquery).
193 + ] {
194 + let attachments = sql.starts_with("UPDATE attachments");
195 + let mut q = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
196 + .bind(group_id)
197 + .bind(project_id);
198 + if attachments {
199 + q = q.bind(project_id);
200 + }
201 + q.execute(&mut *tx).await?;
202 + }
203 + tx.commit().await?;
204 + Ok(())
205 + }
206 +
207 + #[cfg(test)]
208 + mod tests {
209 + use super::set_project_scope;
210 + use sqlx::sqlite::SqlitePoolOptions;
211 +
212 + /// Build an in-memory DB with just the columns the cascade touches, seed a
213 + /// project subtree plus an unrelated project, and return the pool.
214 + async fn seed() -> sqlx::SqlitePool {
215 + let pool = SqlitePoolOptions::new()
216 + .connect("sqlite::memory:")
217 + .await
218 + .unwrap();
219 + sqlx::query(
220 + "CREATE TABLE projects (id TEXT PRIMARY KEY, group_id TEXT);
221 + CREATE TABLE tasks (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT);
222 + CREATE TABLE milestones (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT);
223 + CREATE TABLE events (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT);
224 + CREATE TABLE subtasks (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
225 + CREATE TABLE annotations (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
226 + CREATE TABLE task_status_tokens (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
227 + CREATE TABLE time_sessions (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
228 + CREATE TABLE attachments (id TEXT PRIMARY KEY, project_id TEXT, task_id TEXT, group_id TEXT);",
229 + )
230 + .execute(&pool)
231 + .await
232 + .unwrap();
233 +
234 + // Project P1 with a task T1 and children; an unrelated project P2/T2.
235 + sqlx::query(
236 + "INSERT INTO projects (id) VALUES ('P1'), ('P2');
237 + INSERT INTO tasks (id, project_id) VALUES ('T1', 'P1'), ('T2', 'P2');
238 + INSERT INTO milestones (id, project_id) VALUES ('M1', 'P1');
239 + INSERT INTO events (id, project_id) VALUES ('E1', 'P1');
240 + INSERT INTO subtasks (id, task_id) VALUES ('S1', 'T1'), ('S2', 'T2');
241 + INSERT INTO annotations (id, task_id) VALUES ('A1', 'T1');
242 + INSERT INTO task_status_tokens (id, task_id) VALUES ('K1', 'T1');
243 + INSERT INTO time_sessions (id, task_id) VALUES ('TS1', 'T1');
244 + INSERT INTO attachments (id, project_id, task_id) VALUES ('AT1', 'P1', NULL), ('AT2', NULL, 'T1'), ('AT3', 'P2', NULL);",
245 + )
246 + .execute(&pool)
247 + .await
248 + .unwrap();
249 + pool
250 + }
251 +
252 + async fn group_ids(pool: &sqlx::SqlitePool) -> Vec<(String, String, Option<String>)> {
253 + // (table, row id, group_id) across the whole subtree, ordered for stability.
254 + let tables = [
255 + ("projects", "id"),
256 + ("tasks", "id"),
257 + ("milestones", "id"),
258 + ("events", "id"),
259 + ("subtasks", "id"),
260 + ("annotations", "id"),
261 + ("task_status_tokens", "id"),
262 + ("time_sessions", "id"),
263 + ("attachments", "id"),
264 + ];
265 + let mut out = Vec::new();
266 + for (table, idcol) in tables {
267 + let sql = format!("SELECT {idcol}, group_id FROM {table} ORDER BY {idcol}");
268 + let rows: Vec<(String, Option<String>)> =
269 + sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
270 + .fetch_all(pool)
271 + .await
272 + .unwrap();
273 + for (id, gid) in rows {
274 + out.push((table.to_string(), id, gid));
275 + }
276 + }
277 + out
278 + }
279 +
280 + #[tokio::test]
281 + async fn share_stamps_the_whole_subtree_and_nothing_else() {
282 + let pool = seed().await;
283 + set_project_scope(&pool, "P1", Some("G")).await.unwrap();
284 +
285 + for (table, id, gid) in group_ids(&pool).await {
286 + // Everything under P1 (and P1's attachments AT1/AT2) is stamped; P2's
287 + // subtree (P2, T2, S2, AT3) stays personal.
288 + let personal = matches!(id.as_str(), "P2" | "T2" | "S2" | "AT3");
289 + let expected = if personal {
290 + None
291 + } else {
292 + Some("G".to_string())
293 + };
294 + assert_eq!(gid, expected, "{table}:{id}");
295 + }
296 + }
297 +
298 + #[tokio::test]
299 + async fn unshare_clears_the_whole_subtree() {
300 + let pool = seed().await;
301 + set_project_scope(&pool, "P1", Some("G")).await.unwrap();
302 + set_project_scope(&pool, "P1", None).await.unwrap();
303 +
304 + for (table, id, gid) in group_ids(&pool).await {
305 + assert_eq!(gid, None, "{table}:{id} should be personal after unshare");
306 + }
307 + }
308 + }
@@ -27,6 +27,7 @@ mod email_sync;
27 27 pub mod error;
28 28 mod event;
29 29 mod export;
30 + mod group;
30 31 mod import;
31 32 mod import_external;
32 33 mod milestone;
@@ -122,6 +123,7 @@ pub use email_account::*;
122 123 pub use email_sync::*;
123 124 pub use event::*;
124 125 pub use export::*;
126 + pub use group::*;
125 127 pub use import::*;
126 128 pub use import_external::*;
127 129 pub use milestone::*;
@@ -263,6 +263,14 @@ macro_rules! all_commands {
263 263 $crate::commands::sync_account_info,
264 264 $crate::commands::sync_subscription_status,
265 265 $crate::commands::sync_subscribe,
266 + // Groups (shared workspaces)
267 + $crate::commands::group_create,
268 + $crate::commands::group_list,
269 + $crate::commands::my_group_pubkey,
270 + $crate::commands::group_add_member,
271 + $crate::commands::group_remove_member,
272 + $crate::commands::share_project,
273 + $crate::commands::unshare_project,
266 274 // Themes
267 275 $crate::commands::list_themes,
268 276 $crate::commands::get_theme,