Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 1 | //! A workspace's people: the directory of its members and teams, and each |
| 2 | //! member's place in it (a title, who they report to, what they own). See | |
| 3 | //! `g1t_contracts::people`. Agents live in the agents service; teams name | |
| 4 | //! them by id. | |
| 5 | ||
| 6 | use std::collections::HashMap; | |
| 7 | ||
| 8 | use g1t_contracts::audit::Surface; | |
| 9 | use g1t_contracts::people::*; | |
| 10 | use g1t_contracts::teams::{TeamRef, TeamRole}; | |
| 11 | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 12 | use serde::Deserialize; | |
| 13 | use worker::Result; | |
| 14 | use worker::wasm_bindgen::JsValue; | |
| 15 | ||
| 16 | use crate::Identity; | |
| 17 | use crate::teams::{TeamRow, budget_of, channel_of, may_see, role_of}; | |
| 18 | ||
| 19 | const MEMBERS_ONLY: &str = "Only members can see who is in a workspace."; | |
| 20 | const NO_SUCH_MEMBER: &str = "There is no member with that username."; | |
| 21 | ||
| 22 | /// What someone owns, from its stored JSON; nothing when it is missing or | |
| 23 | /// not a list of words. | |
| 24 | pub fn owns_from(json: Option<&str>) -> Vec<String> { | |
| 25 | json.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok()) | |
| 26 | .map(|owns| clean_owns(&owns)) | |
| 27 | .unwrap_or_default() | |
| 28 | } | |
| 29 | ||
| 30 | /// One member's row, as the directory reads it. | |
| 31 | #[derive(Deserialize)] | |
| 32 | struct PersonRow { | |
| 33 | user_id: String, | |
| 34 | username: String, | |
| 35 | #[serde(default)] | |
| 36 | display_username: Option<String>, | |
| 37 | #[serde(default)] | |
| 38 | name: Option<String>, | |
| 39 | #[serde(default)] | |
| 40 | avatar: Option<String>, | |
| 41 | #[serde(default)] | |
| 42 | bio: Option<String>, | |
| 43 | #[serde(default)] | |
| 44 | location: Option<String>, | |
| 45 | #[serde(default)] | |
| 46 | pronouns: Option<String>, | |
| 47 | #[serde(default)] | |
| 48 | timezone: Option<String>, | |
| 49 | role: String, | |
| 50 | #[serde(default)] | |
| 51 | title: Option<String>, | |
| 52 | #[serde(default)] | |
| 53 | owns: Option<String>, | |
| 54 | #[serde(default)] | |
| 55 | manager: Option<String>, | |
| 56 | joined_at: String, | |
| 57 | } | |
| 58 | ||
| 59 | impl PersonRow { | |
| 60 | fn person(self) -> DirectoryPerson { | |
| 61 | let blank = |value: Option<String>| value.filter(|value| !value.trim().is_empty()); | |
| 62 | DirectoryPerson { | |
| 63 | user_id: self.user_id, | |
| 64 | display_username: self.display_username.filter(|shown| *shown != self.username), | |
| 65 | username: self.username, | |
| 66 | name: blank(self.name), | |
| 67 | avatar: self.avatar, | |
| 68 | bio: blank(self.bio), | |
| 69 | location: blank(self.location), | |
| 70 | pronouns: blank(self.pronouns), | |
| 71 | timezone: blank(self.timezone), | |
| 72 | role: if self.role == "owner" { Role::Owner } else { Role::Member }, | |
| 73 | title: self.title.as_deref().and_then(clean_title), | |
| 74 | manager: self.manager, | |
| 75 | owns: owns_from(self.owns.as_deref()), | |
| 76 | joined_at: self.joined_at, | |
| 77 | } | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | /// Every member of a workspace (by slug, `?1`), with `filter` added. | |
| 82 | fn people_sql(filter: &str) -> String { | |
| 83 | format!( | |
| 84 | "SELECT u.id AS user_id, u.username, u.display_username, u.display_name AS name, u.avatar, u.bio, u.location, | |
| 85 | u.pronouns, u.timezone, wm.role, wm.title, wm.owns, mu.username AS manager, wm.created_at AS joined_at | |
| 86 | FROM workspace_members wm | |
| 87 | JOIN users u ON u.id = wm.user_id AND u.deleted_at IS NULL | |
| 88 | JOIN workspaces w ON w.id = wm.workspace_id AND w.deleted_at IS NULL | |
| 89 | LEFT JOIN users mu ON mu.id = wm.manager_id AND mu.deleted_at IS NULL | |
| 90 | WHERE w.slug = ?1 {filter} | |
| 91 | ORDER BY lower(coalesce(u.display_name, u.username)) LIMIT 5000" | |
| 92 | ) | |
| 93 | } | |
| 94 | ||
| 95 | #[derive(Deserialize)] | |
| 96 | struct TeamPersonRow { | |
| 97 | team_id: String, | |
| 98 | username: String, | |
| 99 | role: String, | |
| 100 | } | |
| 101 | ||
| 102 | #[derive(Deserialize)] | |
| 103 | struct TeamAgentRow { | |
| 104 | team_id: String, | |
| 105 | agent_id: String, | |
| 106 | } | |
| 107 | ||
| 108 | #[derive(Deserialize)] | |
| 109 | struct BaseRow { | |
| 110 | #[serde(default)] | |
| 111 | base_permission: Option<String>, | |
| 112 | } | |
| 113 | ||
| 114 | impl Identity { | |
| 115 | pub async fn people_directory(&self, a: PeopleArgs) -> Result<Outcome<PeopleDirectory>> { | |
| 116 | let workspace = a.workspace.trim().to_lowercase(); | |
| 117 | let Some(viewer) = a.viewer.as_ref() else { | |
| 118 | return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY)); | |
| 119 | }; | |
| 120 | let Some(role) = role_of(viewer, &workspace) else { | |
| 121 | return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY)); | |
| 122 | }; | |
| 123 | let owner = role == Role::Owner; | |
| 124 | let slug = JsValue::from(workspace.as_str()); | |
| 125 | let mut found = self | |
| 126 | .db | |
| 127 | .batch(vec![ | |
| 128 | self.db.prepare(people_sql("")).bind(std::slice::from_ref(&slug))?, | |
| 129 | self.db | |
| 130 | .prepare( | |
| 131 | "SELECT tm.team_id, u.username, tm.role FROM team_members tm | |
| 132 | JOIN teams t ON t.id = tm.team_id | |
| 133 | JOIN workspaces w ON w.id = t.workspace_id | |
| 134 | JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL | |
| 135 | WHERE w.slug = ?1 ORDER BY tm.role DESC, u.username LIMIT 20000", | |
| 136 | ) | |
| 137 | .bind(std::slice::from_ref(&slug))?, | |
| 138 | self.db | |
| 139 | .prepare( | |
| 140 | "SELECT ta.team_id, ta.agent_id FROM team_agents ta | |
| 141 | JOIN teams t ON t.id = ta.team_id | |
| 142 | JOIN workspaces w ON w.id = t.workspace_id | |
| 143 | WHERE w.slug = ?1 ORDER BY ta.created_at LIMIT 20000", | |
| 144 | ) | |
| 145 | .bind(std::slice::from_ref(&slug))?, | |
| 146 | self.db | |
| 147 | .prepare("SELECT base_permission FROM workspaces WHERE slug = ?1 AND deleted_at IS NULL") | |
| 148 | .bind(std::slice::from_ref(&slug))?, | |
| 149 | ]) | |
| 150 | .await? | |
| 151 | .into_iter(); | |
| 152 | let mut next = || found.next().ok_or_else(|| worker::Error::RustError("a statement of the batch did not answer".into())); | |
| 153 | let people = next()?.results::<PersonRow>()?; | |
| 154 | let team_people = next()?.results::<TeamPersonRow>()?; | |
| 155 | let team_agents = next()?.results::<TeamAgentRow>()?; | |
| 156 | let base = next()?.results::<BaseRow>()?; | |
| 157 | let rows = self.workspace_team_rows(&workspace, &viewer.id).await?; | |
| 158 | let teams = rows | |
| 159 | .iter() | |
| 160 | .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some())) | |
| 161 | .map(|row| DirectoryTeam { | |
| 162 | slug: row.slug.clone(), | |
| 163 | name: row.name.clone(), | |
| 164 | description: row.description.clone(), | |
| 165 | visibility: row.visibility(), | |
| 166 | parent: match (&row.parent_slug, &row.parent_name) { | |
| 167 | (Some(slug), Some(name)) => Some(TeamRef { | |
| 168 | slug: slug.clone(), | |
| 169 | name: name.clone(), | |
| 170 | }), | |
| 171 | _ => None, | |
| 172 | }, | |
| 173 | lead: row.lead(), | |
| 174 | channel: channel_of(row.channel_id.as_deref(), row.channel_name.as_deref()), | |
| 175 | budget_micros: budget_of(row.budget_micros), | |
| 176 | people: team_people | |
| 177 | .iter() | |
| 178 | .filter(|person| person.team_id == row.id) | |
| 179 | .map(|person| TeamPersonRef { | |
| 180 | username: person.username.clone(), | |
| 181 | role: TeamRole::parse(&person.role).unwrap_or(TeamRole::Member), | |
| 182 | }) | |
| 183 | .collect(), | |
| 184 | agent_ids: team_agents.iter().filter(|agent| agent.team_id == row.id).map(|agent| agent.agent_id.clone()).collect(), | |
| 185 | repos_count: row.repos_count, | |
| 186 | }) | |
| 187 | .collect(); | |
| 188 | Ok(Outcome::Ok(PeopleDirectory { | |
| 189 | people: people.into_iter().map(PersonRow::person).collect(), | |
| 190 | teams, | |
| 191 | base_permission: base | |
| 192 | .into_iter() | |
| 193 | .next() | |
| 194 | .and_then(|row| row.base_permission) | |
| 195 | .filter(|base| !base.trim().is_empty()) | |
| 196 | .unwrap_or_else(|| "write".to_owned()), | |
| 197 | can_manage: owner, | |
| 198 | })) | |
| 199 | } | |
| 200 | ||
| 201 | pub async fn set_member_profile(&self, a: SetMemberProfileArgs) -> Result<Outcome<DirectoryPerson>> { | |
| 202 | let workspace = a.workspace.trim().to_lowercase(); | |
| 203 | if !crate::security::is_person(&a.actor) { | |
| 204 | return Ok(Outcome::fail( | |
| 205 | FailureCode::Forbidden, | |
| 206 | "Only a person can change a profile, signed in as themselves.", | |
| 207 | )); | |
| 208 | } | |
| 209 | let Some(role) = role_of(&a.actor, &workspace) else { | |
| 210 | return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY)); | |
| 211 | }; | |
| 212 | if !a.actor.verified { | |
| 213 | return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first.")); | |
| 214 | } | |
| 215 | let owner = role == Role::Owner; | |
| 216 | let Some(workspace_id) = self.workspace_id_of(&workspace).await? else { | |
| 217 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 218 | }; | |
| 219 | let Some((user_id, username)) = self.person_by_username(&a.username).await? else { | |
| 220 | return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER)); | |
| 221 | }; | |
| 222 | if !self.is_member_of(&workspace_id, &user_id).await? { | |
| 223 | return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER)); | |
| 224 | } | |
| 225 | let is_self = a.actor.id == user_id; | |
| 226 | let mut sets: Vec<String> = Vec::new(); | |
| 227 | let mut binds: Vec<JsValue> = Vec::new(); | |
| 228 | let mut changed: Vec<&str> = Vec::new(); | |
| 229 | let refused = |what: &str| { | |
| 230 | Ok(Outcome::fail( | |
| 231 | FailureCode::Forbidden, | |
| 232 | format!("Only owners of {workspace} can change {what} for someone else."), | |
| 233 | )) | |
| 234 | }; | |
| 235 | if let Some(title) = &a.title { | |
| 236 | if !may_edit_profile(ProfileField::Title, is_self, owner) { | |
| 237 | return refused("a title"); | |
| 238 | } | |
| 239 | binds.push(clean_title(title).map_or(JsValue::NULL, JsValue::from)); | |
| 240 | sets.push(format!("title = ?{}", binds.len())); | |
| 241 | changed.push("title"); | |
| 242 | } | |
| 243 | if let Some(owns) = &a.owns { | |
| 244 | if !may_edit_profile(ProfileField::Owns, is_self, owner) { | |
| 245 | return refused("what someone owns"); | |
| 246 | } | |
| 247 | let owns = clean_owns(owns); | |
| 248 | binds.push(if owns.is_empty() { JsValue::NULL } else { serde_json::to_string(&owns)?.into() }); | |
| 249 | sets.push(format!("owns = ?{}", binds.len())); | |
| 250 | changed.push("what they own"); | |
| 251 | } | |
| 252 | if let Some(manager) = a.manager.as_deref().map(str::trim) { | |
| 253 | if !may_edit_profile(ProfileField::Manager, is_self, owner) { | |
| 254 | return Ok(Outcome::fail(FailureCode::Forbidden, format!("Only owners of {workspace} set who someone reports to."))); | |
| 255 | } | |
| 256 | if manager.is_empty() { | |
| 257 | binds.push(JsValue::NULL); | |
| 258 | } else { | |
| 259 | let Some((manager_id, manager_name)) = self.person_by_username(manager).await? else { | |
| 260 | return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER)); | |
| 261 | }; | |
| 262 | if !self.is_member_of(&workspace_id, &manager_id).await? { | |
| 263 | return Ok(Outcome::fail( | |
| 264 | FailureCode::Invalid, | |
| 265 | format!("{manager_name} is not a member of {workspace}."), | |
| 266 | )); | |
| 267 | } | |
| 268 | let lines = self.reporting_lines(&workspace_id).await?; | |
| 269 | if makes_loop(&user_id, &manager_id, |id| lines.get(id).cloned()) { | |
| 270 | return Ok(Outcome::fail( | |
| 271 | FailureCode::Invalid, | |
| 272 | format!("{username} can't report to {manager_name}: {manager_name} already reports to {username}, directly or through others."), | |
| 273 | )); | |
| 274 | } | |
| 275 | binds.push(manager_id.into()); | |
| 276 | } | |
| 277 | sets.push(format!("manager_id = ?{}", binds.len())); | |
| 278 | changed.push("manager"); | |
| 279 | } | |
| 280 | if !sets.is_empty() { | |
| 281 | binds.push(JsValue::from(workspace_id.clone())); | |
| 282 | binds.push(JsValue::from(user_id.clone())); | |
| 283 | self.db | |
| 284 | .prepare(format!( | |
| 285 | "UPDATE workspace_members SET {} WHERE workspace_id = ?{} AND user_id = ?{}", | |
| 286 | sets.join(", "), | |
| 287 | binds.len() - 1, | |
| 288 | binds.len() | |
| 289 | )) | |
| 290 | .bind(&binds)? | |
| 291 | .run() | |
| 292 | .await?; | |
| 293 | self.audit_workspace( | |
| 294 | &a.actor, | |
| 295 | "member.profile_edited", | |
| 296 | &workspace, | |
| 297 | a.surface.unwrap_or(Surface::Web), | |
| 298 | format!("Changed {} for {username}", changed.join(", ")), | |
| 299 | ) | |
| 300 | .await; | |
| 301 | } | |
| 302 | let person = self | |
| 303 | .db | |
| 304 | .prepare(people_sql("AND u.id = ?2")) | |
| 305 | .bind(&[workspace.as_str().into(), user_id.as_str().into()])? | |
| 306 | .first::<PersonRow>(None) | |
| 307 | .await?; | |
| 308 | Ok(match person { | |
| 309 | Some(person) => Outcome::Ok(person.person()), | |
| 310 | None => Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER), | |
| 311 | }) | |
| 312 | } | |
| 313 | ||
| 314 | /// Each member's manager in a workspace, by user id. | |
| 315 | async fn reporting_lines(&self, workspace_id: &str) -> Result<HashMap<String, String>> { | |
| 316 | #[derive(Deserialize)] | |
| 317 | struct Line { | |
| 318 | user_id: String, | |
| 319 | manager_id: String, | |
| 320 | } | |
| 321 | Ok(self | |
| 322 | .db | |
| 323 | .prepare("SELECT user_id, manager_id FROM workspace_members WHERE workspace_id = ? AND manager_id IS NOT NULL") | |
| 324 | .bind(&[workspace_id.into()])? | |
| 325 | .all() | |
| 326 | .await? | |
| 327 | .results::<Line>()? | |
| 328 | .into_iter() | |
| 329 | .map(|line| (line.user_id, line.manager_id)) | |
| 330 | .collect()) | |
| 331 | } | |
| 332 | ||
| 333 | /// Every team of a workspace, as `viewer_id` stands in each. | |
| 334 | async fn workspace_team_rows(&self, workspace: &str, viewer_id: &str) -> Result<Vec<TeamRow>> { | |
| 335 | self.team_rows("WHERE w.slug = ?2 ORDER BY lower(t.name)", &[viewer_id.into(), workspace.into()]) | |
| 336 | .await | |
| 337 | } | |
| 338 | } | |
| 339 | ||
| 340 | #[cfg(test)] | |
| 341 | mod tests { | |
| 342 | use super::*; | |
| 343 | ||
| 344 | #[test] | |
| 345 | fn what_someone_owns_reads_back_from_storage() { | |
| 346 | assert_eq!(owns_from(Some(r#"["storefront"," Storefront ","releases"]"#)), vec!["storefront", "releases"]); | |
| 347 | assert!(owns_from(Some("not json")).is_empty()); | |
| 348 | assert!(owns_from(None).is_empty()); | |
| 349 | } | |
| 350 | } |