Skip to main content

max / goingson

12.3 KB · 344 lines History Blame Raw
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 /// One group member, for the admin panel.
84 #[derive(Debug, Serialize)]
85 #[serde(rename_all = "camelCase")]
86 pub struct GroupMemberDto {
87 pub user_id: String,
88 pub email: String,
89 pub role: String,
90 pub added_at: String,
91 }
92
93 /// List a group's members (admin only). The admin panel uses this to show who
94 /// can be removed.
95 #[tauri::command]
96 #[instrument(skip_all)]
97 pub async fn group_list_members(
98 state: State<'_, Arc<AppState>>,
99 group_id: String,
100 ) -> Result<Vec<GroupMemberDto>, ApiError> {
101 let client = require_client(&state)?;
102 let group = GroupId::new(parse_uuid("group id", &group_id)?);
103 let members = client
104 .list_members(group)
105 .await
106 .map_api_err("Failed to list members", ApiError::external_service)?;
107 Ok(members
108 .into_iter()
109 .map(|m| GroupMemberDto {
110 user_id: m.user_id.to_string(),
111 email: m.email,
112 role: m.role,
113 added_at: m.added_at.to_rfc3339(),
114 })
115 .collect())
116 }
117
118 /// This user's identity public key (base64), to share with a group admin so they
119 /// can add you (the paste-a-public-key model).
120 #[tauri::command]
121 #[instrument(skip_all)]
122 pub async fn my_group_pubkey(state: State<'_, Arc<AppState>>) -> Result<String, ApiError> {
123 let client = require_client(&state)?;
124 client
125 .my_identity_public_key()
126 .map_api_err("Encryption not set up", ApiError::bad_request)
127 }
128
129 /// Add a member to a group by email, sealing the group key to their pasted public
130 /// key. Admin only.
131 #[tauri::command]
132 #[instrument(skip_all)]
133 pub async fn group_add_member(
134 state: State<'_, Arc<AppState>>,
135 group_id: String,
136 email: String,
137 pubkey: String,
138 ) -> Result<bool, ApiError> {
139 let client = require_client(&state)?;
140 let group = GroupId::new(parse_uuid("group id", &group_id)?);
141 client
142 .add_member(group, &email, &pubkey)
143 .await
144 .map_api_err("Failed to add member", ApiError::external_service)?;
145 Ok(true)
146 }
147
148 /// Remove a member from a group by user id. Admin only.
149 #[tauri::command]
150 #[instrument(skip_all)]
151 pub async fn group_remove_member(
152 state: State<'_, Arc<AppState>>,
153 group_id: String,
154 user_id: String,
155 ) -> Result<bool, ApiError> {
156 let client = require_client(&state)?;
157 let group = GroupId::new(parse_uuid("group id", &group_id)?);
158 let member = UserId::new(parse_uuid("user id", &user_id)?);
159 client
160 .remove_member(group, member)
161 .await
162 .map_api_err("Failed to remove member", ApiError::external_service)?;
163 Ok(true)
164 }
165
166 // Project scope write path
167
168 /// Share a whole project into a group: stamp `group_id` across its entire subtree.
169 #[tauri::command]
170 #[instrument(skip_all)]
171 pub async fn share_project(
172 state: State<'_, Arc<AppState>>,
173 project_id: String,
174 group_id: String,
175 ) -> Result<bool, ApiError> {
176 // Validate the id shape even though the column is plain TEXT.
177 parse_uuid("group id", &group_id)?;
178 set_project_scope(&state.pool, &project_id, Some(&group_id))
179 .await
180 .map_api_err("Failed to share project", ApiError::internal)?;
181 Ok(true)
182 }
183
184 /// Move a project (and its subtree) back to personal scope.
185 #[tauri::command]
186 #[instrument(skip_all)]
187 pub async fn unshare_project(
188 state: State<'_, Arc<AppState>>,
189 project_id: String,
190 ) -> Result<bool, ApiError> {
191 set_project_scope(&state.pool, &project_id, None)
192 .await
193 .map_api_err("Failed to unshare project", ApiError::internal)?;
194 Ok(true)
195 }
196
197 /// Set `group_id` (a group id, or `NULL` to unshare) on a project and every row
198 /// in its subtree, in one transaction. The engine's UPDATE triggers capture each
199 /// change with its new scope, re-routing the rows on the next sync.
200 ///
201 /// The subtree is the nine group-scoped tables: the project itself; its direct
202 /// children (tasks, milestones, events); the task grandchildren (subtasks,
203 /// annotations, task_status_tokens, time_sessions); and attachments (owned by the
204 /// project or one of its tasks). Predicates key on `project_id`/`task_id`, which
205 /// this never changes, so statement order is irrelevant.
206 async fn set_project_scope(
207 pool: &SqlitePool,
208 project_id: &str,
209 group_id: Option<&str>,
210 ) -> Result<(), sqlx::Error> {
211 // `WHERE task_id IN (tasks of this project)`.
212 const BY_TASK: &str = "SELECT id FROM tasks WHERE project_id = ?";
213
214 let mut tx = pool.begin().await?;
215 for sql in [
216 "UPDATE projects SET group_id = ? WHERE id = ?".to_string(),
217 "UPDATE tasks SET group_id = ? WHERE project_id = ?".to_string(),
218 "UPDATE milestones SET group_id = ? WHERE project_id = ?".to_string(),
219 "UPDATE events SET group_id = ? WHERE project_id = ?".to_string(),
220 format!("UPDATE subtasks SET group_id = ? WHERE task_id IN ({BY_TASK})"),
221 format!("UPDATE annotations SET group_id = ? WHERE task_id IN ({BY_TASK})"),
222 format!("UPDATE task_status_tokens SET group_id = ? WHERE task_id IN ({BY_TASK})"),
223 format!("UPDATE time_sessions SET group_id = ? WHERE task_id IN ({BY_TASK})"),
224 format!(
225 "UPDATE attachments SET group_id = ? WHERE project_id = ? OR task_id IN ({BY_TASK})"
226 ),
227 // attachments binds project_id twice (direct owner + task subquery).
228 ] {
229 let attachments = sql.starts_with("UPDATE attachments");
230 let mut q = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
231 .bind(group_id)
232 .bind(project_id);
233 if attachments {
234 q = q.bind(project_id);
235 }
236 q.execute(&mut *tx).await?;
237 }
238 tx.commit().await?;
239 Ok(())
240 }
241
242 #[cfg(test)]
243 mod tests {
244 use super::set_project_scope;
245 use sqlx::sqlite::SqlitePoolOptions;
246
247 /// Build an in-memory DB with just the columns the cascade touches, seed a
248 /// project subtree plus an unrelated project, and return the pool.
249 async fn seed() -> sqlx::SqlitePool {
250 let pool = SqlitePoolOptions::new()
251 .connect("sqlite::memory:")
252 .await
253 .unwrap();
254 sqlx::query(
255 "CREATE TABLE projects (id TEXT PRIMARY KEY, group_id TEXT);
256 CREATE TABLE tasks (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT);
257 CREATE TABLE milestones (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT);
258 CREATE TABLE events (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT);
259 CREATE TABLE subtasks (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
260 CREATE TABLE annotations (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
261 CREATE TABLE task_status_tokens (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
262 CREATE TABLE time_sessions (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
263 CREATE TABLE attachments (id TEXT PRIMARY KEY, project_id TEXT, task_id TEXT, group_id TEXT);",
264 )
265 .execute(&pool)
266 .await
267 .unwrap();
268
269 // Project P1 with a task T1 and children; an unrelated project P2/T2.
270 sqlx::query(
271 "INSERT INTO projects (id) VALUES ('P1'), ('P2');
272 INSERT INTO tasks (id, project_id) VALUES ('T1', 'P1'), ('T2', 'P2');
273 INSERT INTO milestones (id, project_id) VALUES ('M1', 'P1');
274 INSERT INTO events (id, project_id) VALUES ('E1', 'P1');
275 INSERT INTO subtasks (id, task_id) VALUES ('S1', 'T1'), ('S2', 'T2');
276 INSERT INTO annotations (id, task_id) VALUES ('A1', 'T1');
277 INSERT INTO task_status_tokens (id, task_id) VALUES ('K1', 'T1');
278 INSERT INTO time_sessions (id, task_id) VALUES ('TS1', 'T1');
279 INSERT INTO attachments (id, project_id, task_id) VALUES ('AT1', 'P1', NULL), ('AT2', NULL, 'T1'), ('AT3', 'P2', NULL);",
280 )
281 .execute(&pool)
282 .await
283 .unwrap();
284 pool
285 }
286
287 async fn group_ids(pool: &sqlx::SqlitePool) -> Vec<(String, String, Option<String>)> {
288 // (table, row id, group_id) across the whole subtree, ordered for stability.
289 let tables = [
290 ("projects", "id"),
291 ("tasks", "id"),
292 ("milestones", "id"),
293 ("events", "id"),
294 ("subtasks", "id"),
295 ("annotations", "id"),
296 ("task_status_tokens", "id"),
297 ("time_sessions", "id"),
298 ("attachments", "id"),
299 ];
300 let mut out = Vec::new();
301 for (table, idcol) in tables {
302 let sql = format!("SELECT {idcol}, group_id FROM {table} ORDER BY {idcol}");
303 let rows: Vec<(String, Option<String>)> =
304 sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str()))
305 .fetch_all(pool)
306 .await
307 .unwrap();
308 for (id, gid) in rows {
309 out.push((table.to_string(), id, gid));
310 }
311 }
312 out
313 }
314
315 #[tokio::test]
316 async fn share_stamps_the_whole_subtree_and_nothing_else() {
317 let pool = seed().await;
318 set_project_scope(&pool, "P1", Some("G")).await.unwrap();
319
320 for (table, id, gid) in group_ids(&pool).await {
321 // Everything under P1 (and P1's attachments AT1/AT2) is stamped; P2's
322 // subtree (P2, T2, S2, AT3) stays personal.
323 let personal = matches!(id.as_str(), "P2" | "T2" | "S2" | "AT3");
324 let expected = if personal {
325 None
326 } else {
327 Some("G".to_string())
328 };
329 assert_eq!(gid, expected, "{table}:{id}");
330 }
331 }
332
333 #[tokio::test]
334 async fn unshare_clears_the_whole_subtree() {
335 let pool = seed().await;
336 set_project_scope(&pool, "P1", Some("G")).await.unwrap();
337 set_project_scope(&pool, "P1", None).await.unwrap();
338
339 for (table, id, gid) in group_ids(&pool).await {
340 assert_eq!(gid, None, "{table}:{id} should be personal after unshare");
341 }
342 }
343 }
344