Skip to content
350 linesCodeBlameRaw
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
6use std::collections::HashMap;
7
8use g1t_contracts::audit::Surface;
9use g1t_contracts::people::*;
10use g1t_contracts::teams::{TeamRef, TeamRole};
11use g1t_contracts::{FailureCode, Outcome, Role};
12use serde::Deserialize;
13use worker::Result;
14use worker::wasm_bindgen::JsValue;
15
16use crate::Identity;
17use crate::teams::{TeamRow, budget_of, channel_of, may_see, role_of};
18
19const MEMBERS_ONLY: &str = "Only members can see who is in a workspace.";
20const 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.
24pub 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)]
32struct 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
59impl 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.
82fn 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)]
96struct TeamPersonRow {
97 team_id: String,
98 username: String,
99 role: String,
100}
101
102#[derive(Deserialize)]
103struct TeamAgentRow {
104 team_id: String,
105 agent_id: String,
106}
107
108#[derive(Deserialize)]
109struct BaseRow {
110 #[serde(default)]
111 base_permission: Option<String>,
112}
113
114impl 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)]
341mod 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}