//! Group (shared-workspace) commands: SyncKit group admin management, plus the //! project-scope write path that shares a whole project into a group. //! //! Groups p4 / GO -> SyncStore M4. Sharing is whole-project: `share_project` //! stamps the group id across a project's entire subtree (the nine group-scoped //! tables), so the engine routes those rows to the group's shared changelog on //! the next sync; `unshare_project` clears it back to personal (`NULL`). The admin //! commands are thin wrappers over the SyncKit client (which owns the crypto and //! the server round-trips). use std::sync::Arc; use serde::Serialize; use sqlx::SqlitePool; use synckit_client::{GroupId, SyncKitClient, UserId}; use tauri::State; use tracing::instrument; use uuid::Uuid; use super::{ApiError, ResultApiError}; use crate::state::AppState; /// A group as surfaced to the frontend. `SyncGroup` is deserialize-only and /// `#[non_exhaustive]`, so map to this stable DTO for the Tauri boundary. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GroupDto { pub id: String, pub name: String, pub gck_version: i32, } fn require_client(state: &AppState) -> Result, ApiError> { state .read_recovering() .ok_or_else(|| ApiError::bad_request("Sync is not configured")) } fn parse_uuid(kind: &str, value: &str) -> Result { Uuid::parse_str(value).map_err(|_| ApiError::bad_request(format!("Invalid {kind}"))) } // Admin management /// Create a group with this user as admin. Returns the new group. #[tauri::command] #[instrument(skip_all)] pub async fn group_create( state: State<'_, Arc>, name: String, ) -> Result { let client = require_client(&state)?; let group = client .create_group(&name) .await .map_api_err("Failed to create group", ApiError::external_service)?; Ok(GroupDto { id: group.id.to_string(), name: group.name, gck_version: group.gck_version, }) } /// List the groups this user belongs to. #[tauri::command] #[instrument(skip_all)] pub async fn group_list(state: State<'_, Arc>) -> Result, ApiError> { let client = require_client(&state)?; let groups = client .list_groups() .await .map_api_err("Failed to list groups", ApiError::external_service)?; Ok(groups .into_iter() .map(|g| GroupDto { id: g.id.to_string(), name: g.name, gck_version: g.gck_version, }) .collect()) } /// One group member, for the admin panel. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GroupMemberDto { pub user_id: String, pub email: String, pub role: String, pub added_at: String, } /// List a group's members (admin only). The admin panel uses this to show who /// can be removed. #[tauri::command] #[instrument(skip_all)] pub async fn group_list_members( state: State<'_, Arc>, group_id: String, ) -> Result, ApiError> { let client = require_client(&state)?; let group = GroupId::new(parse_uuid("group id", &group_id)?); let members = client .list_members(group) .await .map_api_err("Failed to list members", ApiError::external_service)?; Ok(members .into_iter() .map(|m| GroupMemberDto { user_id: m.user_id.to_string(), email: m.email, role: m.role, added_at: m.added_at.to_rfc3339(), }) .collect()) } /// This user's identity public key (base64), to share with a group admin so they /// can add you (the paste-a-public-key model). #[tauri::command] #[instrument(skip_all)] pub async fn my_group_pubkey(state: State<'_, Arc>) -> Result { let client = require_client(&state)?; client .my_identity_public_key() .map_api_err("Encryption not set up", ApiError::bad_request) } /// Add a member to a group by email, sealing the group key to their pasted public /// key. Admin only. #[tauri::command] #[instrument(skip_all)] pub async fn group_add_member( state: State<'_, Arc>, group_id: String, email: String, pubkey: String, ) -> Result { let client = require_client(&state)?; let group = GroupId::new(parse_uuid("group id", &group_id)?); client .add_member(group, &email, &pubkey) .await .map_api_err("Failed to add member", ApiError::external_service)?; Ok(true) } /// Remove a member from a group by user id. Admin only. #[tauri::command] #[instrument(skip_all)] pub async fn group_remove_member( state: State<'_, Arc>, group_id: String, user_id: String, ) -> Result { let client = require_client(&state)?; let group = GroupId::new(parse_uuid("group id", &group_id)?); let member = UserId::new(parse_uuid("user id", &user_id)?); client .remove_member(group, member) .await .map_api_err("Failed to remove member", ApiError::external_service)?; Ok(true) } // Project scope write path /// Share a whole project into a group: stamp `group_id` across its entire subtree. #[tauri::command] #[instrument(skip_all)] pub async fn share_project( state: State<'_, Arc>, project_id: String, group_id: String, ) -> Result { // Validate the id shape even though the column is plain TEXT. parse_uuid("group id", &group_id)?; set_project_scope(&state.pool, &project_id, Some(&group_id)) .await .map_api_err("Failed to share project", ApiError::internal)?; Ok(true) } /// Move a project (and its subtree) back to personal scope. #[tauri::command] #[instrument(skip_all)] pub async fn unshare_project( state: State<'_, Arc>, project_id: String, ) -> Result { set_project_scope(&state.pool, &project_id, None) .await .map_api_err("Failed to unshare project", ApiError::internal)?; Ok(true) } /// Set `group_id` (a group id, or `NULL` to unshare) on a project and every row /// in its subtree, in one transaction. The engine's UPDATE triggers capture each /// change with its new scope, re-routing the rows on the next sync. /// /// The subtree is the nine group-scoped tables: the project itself; its direct /// children (tasks, milestones, events); the task grandchildren (subtasks, /// annotations, task_status_tokens, time_sessions); and attachments (owned by the /// project or one of its tasks). Predicates key on `project_id`/`task_id`, which /// this never changes, so statement order is irrelevant. async fn set_project_scope( pool: &SqlitePool, project_id: &str, group_id: Option<&str>, ) -> Result<(), sqlx::Error> { // `WHERE task_id IN (tasks of this project)`. const BY_TASK: &str = "SELECT id FROM tasks WHERE project_id = ?"; let mut tx = pool.begin().await?; for sql in [ "UPDATE projects SET group_id = ? WHERE id = ?".to_string(), "UPDATE tasks SET group_id = ? WHERE project_id = ?".to_string(), "UPDATE milestones SET group_id = ? WHERE project_id = ?".to_string(), "UPDATE events SET group_id = ? WHERE project_id = ?".to_string(), format!("UPDATE subtasks SET group_id = ? WHERE task_id IN ({BY_TASK})"), format!("UPDATE annotations SET group_id = ? WHERE task_id IN ({BY_TASK})"), format!("UPDATE task_status_tokens SET group_id = ? WHERE task_id IN ({BY_TASK})"), format!("UPDATE time_sessions SET group_id = ? WHERE task_id IN ({BY_TASK})"), format!( "UPDATE attachments SET group_id = ? WHERE project_id = ? OR task_id IN ({BY_TASK})" ), // attachments binds project_id twice (direct owner + task subquery). ] { let attachments = sql.starts_with("UPDATE attachments"); let mut q = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())) .bind(group_id) .bind(project_id); if attachments { q = q.bind(project_id); } q.execute(&mut *tx).await?; } tx.commit().await?; Ok(()) } #[cfg(test)] mod tests { use super::set_project_scope; use sqlx::sqlite::SqlitePoolOptions; /// Build an in-memory DB with just the columns the cascade touches, seed a /// project subtree plus an unrelated project, and return the pool. async fn seed() -> sqlx::SqlitePool { let pool = SqlitePoolOptions::new() .connect("sqlite::memory:") .await .unwrap(); sqlx::query( "CREATE TABLE projects (id TEXT PRIMARY KEY, group_id TEXT); CREATE TABLE tasks (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT); CREATE TABLE milestones (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT); CREATE TABLE events (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT); CREATE TABLE subtasks (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT); CREATE TABLE annotations (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT); CREATE TABLE task_status_tokens (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT); CREATE TABLE time_sessions (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT); CREATE TABLE attachments (id TEXT PRIMARY KEY, project_id TEXT, task_id TEXT, group_id TEXT);", ) .execute(&pool) .await .unwrap(); // Project P1 with a task T1 and children; an unrelated project P2/T2. sqlx::query( "INSERT INTO projects (id) VALUES ('P1'), ('P2'); INSERT INTO tasks (id, project_id) VALUES ('T1', 'P1'), ('T2', 'P2'); INSERT INTO milestones (id, project_id) VALUES ('M1', 'P1'); INSERT INTO events (id, project_id) VALUES ('E1', 'P1'); INSERT INTO subtasks (id, task_id) VALUES ('S1', 'T1'), ('S2', 'T2'); INSERT INTO annotations (id, task_id) VALUES ('A1', 'T1'); INSERT INTO task_status_tokens (id, task_id) VALUES ('K1', 'T1'); INSERT INTO time_sessions (id, task_id) VALUES ('TS1', 'T1'); INSERT INTO attachments (id, project_id, task_id) VALUES ('AT1', 'P1', NULL), ('AT2', NULL, 'T1'), ('AT3', 'P2', NULL);", ) .execute(&pool) .await .unwrap(); pool } async fn group_ids(pool: &sqlx::SqlitePool) -> Vec<(String, String, Option)> { // (table, row id, group_id) across the whole subtree, ordered for stability. let tables = [ ("projects", "id"), ("tasks", "id"), ("milestones", "id"), ("events", "id"), ("subtasks", "id"), ("annotations", "id"), ("task_status_tokens", "id"), ("time_sessions", "id"), ("attachments", "id"), ]; let mut out = Vec::new(); for (table, idcol) in tables { let sql = format!("SELECT {idcol}, group_id FROM {table} ORDER BY {idcol}"); let rows: Vec<(String, Option)> = sqlx::query_as(sqlx::AssertSqlSafe(sql.as_str())) .fetch_all(pool) .await .unwrap(); for (id, gid) in rows { out.push((table.to_string(), id, gid)); } } out } #[tokio::test] async fn share_stamps_the_whole_subtree_and_nothing_else() { let pool = seed().await; set_project_scope(&pool, "P1", Some("G")).await.unwrap(); for (table, id, gid) in group_ids(&pool).await { // Everything under P1 (and P1's attachments AT1/AT2) is stamped; P2's // subtree (P2, T2, S2, AT3) stays personal. let personal = matches!(id.as_str(), "P2" | "T2" | "S2" | "AT3"); let expected = if personal { None } else { Some("G".to_string()) }; assert_eq!(gid, expected, "{table}:{id}"); } } #[tokio::test] async fn unshare_clears_the_whole_subtree() { let pool = seed().await; set_project_scope(&pool, "P1", Some("G")).await.unwrap(); set_project_scope(&pool, "P1", None).await.unwrap(); for (table, id, gid) in group_ids(&pool).await { assert_eq!(gid, None, "{table}:{id} should be personal after unshare"); } } }