|
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 |
+ |
}
|