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.
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 1 | //! Workspace invitations: nobody joins a workspace without saying yes. |
| 2 | //! | |
| 3 | //! An invite that names a workspace (`invites.workspace_id`) is an | |
| 4 | //! invitation for one account (`invitee_id`), which accepts or declines it: | |
| 5 | //! | |
| 6 | //! - **Someone with an account**, invited from a workspace's People page by | |
| 7 | //! username or by address, gets the invitation at once: an item in their | |
| 8 | //! inbox and an email, both leading to `/invitations`. Accepting joins | |
| 9 | //! with the role chosen when they were invited; declining tells whoever | |
| 10 | //! invited them, in their inbox. | |
| 11 | //! - **Someone without one** gets an invite to make an account, which can | |
| 12 | //! name a workspace the inviter owns (Settings → Invites, "Bring them | |
| 13 | //! into"). Once the new account confirms its address, the invitation | |
| 14 | //! waits for its answer the same way, for the invite TTL from then. | |
| 15 | //! | |
| 16 | //! An invitation works for the invite TTL (`INVITE_TTL_DAYS`, 30 days), | |
| 17 | //! and the workspace's owners can revoke it from People → Pending | |
| 18 | //! invitations until it is answered. A workspace on the free plan adds no | |
| 19 | //! one (paid.rs): its invitations cannot be accepted until it starts the | |
| 20 | //! plan, so they cannot be made either. | |
| 21 | //! | |
| 22 | //! Every new account that its invite does not bring into a workspace gets | |
| 23 | //! a workspace of its own, named for its username, on the free plan | |
| 24 | //! (workspaces.rs, `create_own_workspace`), so nobody is left without one. | |
| 25 | ||
| 26 | use g1t_contracts::audit::Surface; | |
| 27 | use g1t_contracts::identity::*; | |
| 28 | use g1t_contracts::time::{SQL_NOW, rfc3339}; | |
| 29 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User}; | |
| 30 | use g1t_kit::now_ms; | |
| 31 | use serde::{Deserialize, Serialize}; | |
| 32 | use worker::Result; | |
| 33 | use worker::wasm_bindgen::JsValue; | |
| 34 | ||
| 35 | use super::{CREATES_PER_HOUR, Draft, InviteRow, TOO_MANY}; | |
| 36 | use crate::Identity; | |
| 37 | ||
| 38 | /// What an invitation that is not someone's, or no longer open, gets. | |
| 39 | const NOT_OPEN: &str = "That invitation is not open: it may have been answered, revoked or expired. Ask the workspace's owners to invite you again."; | |
| 40 | /// The most people `find_people` returns. | |
| 41 | const MAX_PEOPLE: u32 = 10; | |
| 42 | /// The most invitations one person's list shows. | |
| 43 | const MAX_INVITATIONS: u32 = 50; | |
| 44 | ||
| 45 | /// Whether an invitation can still be answered at `now`: it names a | |
| 46 | /// workspace that exists, is neither answered nor revoked, has not | |
| 47 | /// expired, and is ready for its person: an existing account's at once | |
| 48 | /// (unused), a new account's once that account confirmed its address. | |
| 49 | pub fn answerable(row: &InviteRow, now: &str) -> std::result::Result<(), &'static str> { | |
| 50 | let ready = match row.kind.as_str() { | |
| 51 | "workspace" => row.redeemed_at.is_none(), | |
| 52 | _ => row.redeemed_at.is_some() && row.applied_at.is_some(), | |
| 53 | }; | |
| 54 | if row.workspace_id.is_none() | |
| 55 | || row.workspace.is_none() | |
| 56 | || row.accepted_at.is_some() | |
| 57 | || row.declined_at.is_some() | |
| 58 | || row.revoked_at.is_some() | |
| 59 | || row.expires_at.as_str() <= now | |
| 60 | || !ready | |
| 61 | { | |
| 62 | return Err(NOT_OPEN); | |
| 63 | } | |
| 64 | Ok(()) | |
| 65 | } | |
| 66 | ||
| 67 | /// Whether a new account gets a workspace of its own: unless its invite | |
| 68 | /// brings it into one (`invited_to`, a workspace that still exists) that | |
| 69 | /// can take it, which a workspace on the free plan (`free`) cannot. | |
| 70 | pub fn makes_own_workspace(invited_to: Option<&str>, free: bool) -> bool { | |
| 71 | invited_to.is_none() || free | |
| 72 | } | |
| 73 | ||
| 74 | /// The LIKE patterns `find_people` matches: a username starting with the | |
| 75 | /// query, a name containing it. None for an empty query. `%`, `_` and `\` | |
| 76 | /// match only themselves. | |
| 77 | pub fn people_patterns(query: &str) -> Option<(String, String)> { | |
| 78 | let query = query.trim().trim_start_matches('@').to_lowercase(); | |
| 79 | if query.is_empty() { | |
| 80 | return None; | |
| 81 | } | |
| 82 | let escaped: String = query | |
| 83 | .chars() | |
| 84 | .flat_map(|c| match c { | |
| 85 | '%' | '_' | '\\' => vec!['\\', c], | |
| 86 | c => vec![c], | |
| 87 | }) | |
| 88 | .collect(); | |
| 89 | Some((format!("{escaped}%"), format!("%{escaped}%"))) | |
| 90 | } | |
| 91 | ||
| 92 | /// How an invitation's role reads in a sentence. | |
| 93 | fn as_role(role: Role) -> &'static str { | |
| 94 | match role { | |
| 95 | Role::Owner => "an owner", | |
| 96 | Role::Member => "a member", | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | /// `workspace_invitation.created`, `.accepted`, `.declined` and `.revoked`: | |
| 101 | /// told in the inbox of the people named in `notify`, with a link (events' | |
| 102 | /// inbox.rs). The inbox closes the invitee's item once it is answered or | |
| 103 | /// revoked. | |
| 104 | #[derive(Serialize)] | |
| 105 | #[serde(rename_all = "camelCase")] | |
| 106 | struct InvitationNotice<'a> { | |
| 107 | workspace: &'a str, | |
| 108 | invitation_id: &'a str, | |
| 109 | thread: String, | |
| 110 | notify: Vec<String>, | |
| 111 | title: String, | |
| 112 | body: String, | |
| 113 | link: String, | |
| 114 | } | |
| 115 | ||
| 116 | #[derive(Deserialize)] | |
| 117 | struct Person { | |
| 118 | id: String, | |
| 119 | username: String, | |
| 120 | email: Option<String>, | |
| 121 | verified: u8, | |
| 122 | } | |
| 123 | ||
| 124 | #[derive(Deserialize)] | |
| 125 | struct Pending { | |
| 126 | id: String, | |
| 127 | slug: String, | |
| 128 | name: String, | |
| 129 | avatar: Option<String>, | |
| 130 | role: Option<String>, | |
| 131 | created_at: String, | |
| 132 | expires_at: String, | |
| 133 | inviter: Option<String>, | |
| 134 | inviter_name: Option<String>, | |
| 135 | inviter_avatar: Option<String>, | |
| 136 | } | |
| 137 | ||
| 138 | impl Identity { | |
| 139 | /// The workspace an own invite brings its person into, from `join` (a | |
| 140 | /// slug): one `user` owns that can add members. None for none. | |
| 141 | pub(crate) async fn joinable_workspace(&self, user: &User, join: Option<&str>) -> Result<Outcome<Option<(String, String)>>> { | |
| 142 | let Some(slug) = join.map(str::trim).filter(|slug| !slug.is_empty()).map(str::to_lowercase) else { | |
| 143 | return Ok(Outcome::Ok(None)); | |
| 144 | }; | |
| 145 | if user.role_in(&slug) != Some(Role::Owner) { | |
| 146 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a workspace's owners can bring people into it.")); | |
| 147 | } | |
| 148 | let Some(id) = self.workspace_id(&slug).await? else { | |
| 149 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 150 | }; | |
| 151 | // A free workspace adds no one until it starts the plan (paid.rs). | |
| 152 | if let Some(refused) = self.free_workspace_refusal(&slug).await? { | |
| 153 | return Ok(refused); | |
| 154 | } | |
| 155 | Ok(Outcome::Ok(Some((id, slug)))) | |
| 156 | } | |
| 157 | ||
| 158 | /// Gives a new account a workspace of its own unless `invite` brings | |
| 159 | /// it into one. Never fails the sign-up: an account left without one | |
| 160 | /// is asked to make one by the site. | |
| 161 | pub(crate) async fn give_own_workspace(&self, user: &User, invite: Option<&InviteRow>) { | |
| 162 | let invited_to = invite.and_then(|row| row.workspace.as_deref()); | |
| 163 | let free = match invited_to { | |
| 164 | Some(slug) => self.is_free_workspace(slug).await, | |
| 165 | None => false, | |
| 166 | }; | |
| 167 | if !makes_own_workspace(invited_to, free) { | |
| 168 | return; | |
| 169 | } | |
| 170 | match self.create_own_workspace(user).await { | |
| 171 | Ok(Some(_)) => {} | |
| 172 | Ok(None) => worker::console_log!("no workspace of its own for {}: its name is taken", user.id), | |
| 173 | Err(error) => worker::console_error!("no workspace of its own for {}: {error}", user.id), | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | /// `invite_member` with a username: that account gets an invitation | |
| 178 | /// to `slug` (already checked: the actor owns it, it can add people). | |
| 179 | pub(crate) async fn invite_account( | |
| 180 | &self, | |
| 181 | actor: &User, | |
| 182 | slug: &str, | |
| 183 | workspace_id: &str, | |
| 184 | username: &str, | |
| 185 | role: Role, | |
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 186 | note: Option<&str>, |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 187 | surface: Surface, |
| 188 | ) -> Result<Outcome<Invite>> { | |
| 189 | let person = self | |
| 190 | .db | |
| 191 | .prepare( | |
| 192 | "SELECT id, username, email, email_verified_at IS NOT NULL AS verified | |
| 193 | FROM users WHERE username = ? AND deleted_at IS NULL", | |
| 194 | ) | |
| 195 | .bind(&[username.into()])? | |
| 196 | .first::<Person>(None) | |
| 197 | .await?; | |
| 198 | let Some(person) = person.filter(|person| !crate::paid::is_g1t(&person.username)) else { | |
| 199 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no account with that username.")); | |
| 200 | }; | |
| 201 | if person.id == actor.id { | |
| 202 | return Ok(Outcome::fail(FailureCode::Conflict, format!("You are already in {slug}."))); | |
| 203 | } | |
| 204 | let member = self | |
| 205 | .db | |
| 206 | .prepare("SELECT 1 AS n FROM workspace_members WHERE workspace_id = ? AND user_id = ?") | |
| 207 | .bind(&[workspace_id.into(), person.id.as_str().into()])? | |
| 208 | .first::<serde_json::Value>(None) | |
| 209 | .await?; | |
| 210 | if member.is_some() { | |
| 211 | return Ok(Outcome::fail(FailureCode::Conflict, format!("@{} is already in {slug}.", person.username))); | |
| 212 | } | |
| 213 | if !self.hit(&format!("invite.create:{}", actor.id), CREATES_PER_HOUR).await? { | |
| 214 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 215 | } | |
| 216 | let pending = self | |
| 217 | .rows( | |
| 218 | &format!( | |
| 219 | "WHERE i.workspace_id = ? AND i.invitee_id = ? AND i.accepted_at IS NULL AND i.declined_at IS NULL | |
| 220 | AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}" | |
| 221 | ), | |
| 222 | &[workspace_id.into(), person.id.as_str().into()], | |
| 223 | 1, | |
| 224 | ) | |
| 225 | .await?; | |
| 226 | if !pending.is_empty() { | |
| 227 | return Ok(Outcome::fail( | |
| 228 | FailureCode::Conflict, | |
| 229 | format!("@{} already has a pending invitation to {slug}. Revoke it to send a new one.", person.username), | |
| 230 | )); | |
| 231 | } | |
| 232 | let draft = Draft { | |
| 233 | email: None, | |
| 234 | kind: "workspace", | |
| 235 | workspace_id: Some(workspace_id), | |
| 236 | inviter: Some(actor), | |
| 237 | staff: None, | |
| 238 | // Costs nothing: the person is on g1t already. | |
| 239 | charged_to: "none", | |
| 240 | charged_workspace_id: None, | |
| 241 | limit: None, | |
| 242 | invitee_id: Some(&person.id), | |
| 243 | role: Some(if role == Role::Owner { "owner" } else { "member" }), | |
| 244 | }; | |
| 245 | let Some(invite) = self.insert_invite(draft).await? else { | |
| 246 | return Ok(Outcome::fail(FailureCode::Conflict, "The invitation could not be made. Try again.")); | |
| 247 | }; | |
| 248 | if let (Some(email), true, Some(code)) = (&person.email, person.verified != 0, &invite.code) { | |
| 249 | let from = self.display_name(actor).await; | |
| 250 | let workspace = self.workspace_name(workspace_id, slug).await; | |
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 251 | self.send_invite_email(email, Some(&from), Some(&workspace), true, code, &invite.id, note).await; |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 252 | } |
| 253 | if let Some(row) = self.invite_by_id(&invite.id).await? { | |
| 254 | self.invitation_sent(&row, &person.username).await; | |
| 255 | } | |
| 256 | self.audit_invites( | |
| 257 | actor, | |
| 258 | "invite.created", | |
| 259 | vec![slug.to_owned()], | |
| 260 | surface, | |
| 261 | format!("Invited @{} to {slug} as {}", person.username, as_role(role)), | |
| 262 | ) | |
| 263 | .await; | |
| 264 | Ok(Outcome::Ok(invite)) | |
| 265 | } | |
| 266 | ||
| 267 | /// Tells `username` in their inbox that they are invited to the | |
| 268 | /// invite's workspace. | |
| 269 | pub(crate) async fn invitation_sent(&self, row: &InviteRow, username: &str) { | |
| 270 | let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) else { | |
| 271 | return; | |
| 272 | }; | |
| 273 | let workspace = self.workspace_name(workspace_id, slug).await; | |
| 274 | let from = match &row.inviter { | |
| 275 | Some(inviter) => format!("@{inviter}"), | |
| 276 | None => "The g1t team".to_owned(), | |
| 277 | }; | |
| 278 | self.invitation_notice( | |
| 279 | "workspace_invitation.created", | |
| 280 | row.inviter_id.as_deref(), | |
| 281 | row, | |
| 282 | vec![username.to_owned()], | |
| 283 | format!("{from} invited you to join {workspace}"), | |
| 284 | format!("Join as {}, or decline. The invitation works until {}.", as_role(row.joins_as()), &row.expires_at[..10.min(row.expires_at.len())]), | |
| 285 | "/invitations".to_owned(), | |
| 286 | ) | |
| 287 | .await; | |
| 288 | } | |
| 289 | ||
| 290 | #[allow(clippy::too_many_arguments)] | |
| 291 | async fn invitation_notice( | |
| 292 | &self, | |
| 293 | kind: &'static str, | |
| 294 | actor: Option<&str>, | |
| 295 | row: &InviteRow, | |
| 296 | notify: Vec<String>, | |
| 297 | title: String, | |
| 298 | body: String, | |
| 299 | link: String, | |
| 300 | ) { | |
| 301 | let slug = row.workspace.as_deref().unwrap_or_default(); | |
| 302 | self.announce( | |
| 303 | kind, | |
| 304 | actor, | |
| 305 | InvitationNotice { | |
| 306 | workspace: slug, | |
| 307 | invitation_id: &row.id, | |
| 308 | thread: format!("invitation:{}", row.id), | |
| 309 | notify, | |
| 310 | title, | |
| 311 | body, | |
| 312 | link, | |
| 313 | }, | |
| 314 | ) | |
| 315 | .await; | |
| 316 | } | |
| 317 | ||
| 318 | /// `list_invitations`: the workspace invitations waiting for `user`'s | |
| 319 | /// answer, newest first. | |
| 320 | pub async fn list_invitations(&self, a: UserArgs) -> Result<Vec<WorkspaceInvitation>> { | |
| 321 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { | |
| 322 | return Ok(Vec::new()); | |
| 323 | } | |
| 324 | let rows = self | |
| 325 | .db | |
| 326 | .prepare(format!( | |
| 327 | "SELECT i.id, w.slug, w.name, w.avatar, i.role, i.created_at, i.expires_at, | |
| 328 | iu.username AS inviter, iu.display_name AS inviter_name, iu.avatar AS inviter_avatar | |
| 329 | FROM invites i | |
| 330 | JOIN workspaces w ON w.id = i.workspace_id AND w.deleted_at IS NULL | |
| 331 | LEFT JOIN users iu ON iu.id = i.inviter_id | |
| 332 | WHERE i.invitee_id = ?1 AND i.accepted_at IS NULL AND i.declined_at IS NULL | |
| 333 | AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW} | |
| 334 | AND ((i.kind = 'workspace' AND i.redeemed_at IS NULL) | |
| 335 | OR (i.kind = 'account' AND i.redeemed_at IS NOT NULL AND i.applied_at IS NOT NULL)) | |
| 336 | AND NOT EXISTS (SELECT 1 FROM workspace_members m WHERE m.workspace_id = i.workspace_id AND m.user_id = ?1) | |
| 337 | ORDER BY i.created_at DESC, i.id DESC LIMIT {MAX_INVITATIONS}" | |
| 338 | )) | |
| 339 | .bind(&[a.user.id.as_str().into()])? | |
| 340 | .all() | |
| 341 | .await? | |
| 342 | .results::<Pending>()?; | |
| 343 | Ok(rows | |
| 344 | .into_iter() | |
| 345 | .map(|row| WorkspaceInvitation { | |
| 346 | id: row.id, | |
| 347 | workspace: ProfileWorkspace { slug: row.slug, name: row.name, avatar: row.avatar }, | |
| 348 | role: if row.role.as_deref() == Some("owner") { Role::Owner } else { Role::Member }, | |
| 349 | invited_by: row.inviter.map(|username| InviteFrom { | |
| 350 | username, | |
| 351 | name: row.inviter_name, | |
| 352 | avatar: row.inviter_avatar, | |
| 353 | }), | |
| 354 | created_at: row.created_at, | |
| 355 | expires_at: row.expires_at, | |
| 356 | }) | |
| 357 | .collect()) | |
| 358 | } | |
| 359 | ||
| 360 | /// The invitation `id` if it is `user`'s and can be answered. | |
| 361 | async fn open_invitation(&self, user: &User, id: &str) -> Result<std::result::Result<InviteRow, &'static str>> { | |
| 362 | let row = self | |
| 363 | .invite_by_id(id) | |
| 364 | .await? | |
| 365 | .filter(|row| row.invitee_id.as_deref() == Some(user.id.as_str())); | |
| 366 | let Some(row) = row else { | |
| 367 | return Ok(Err(NOT_OPEN)); | |
| 368 | }; | |
| 369 | Ok(answerable(&row, &rfc3339(now_ms())).map(|()| row)) | |
| 370 | } | |
| 371 | ||
| 372 | /// `accept_invitation`: joins the invitation's workspace with its role. | |
| 373 | pub async fn accept_invitation(&self, a: InvitationArgs) -> Result<Outcome<String>> { | |
| 374 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { | |
| 375 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invitation.")); | |
| 376 | } | |
| 377 | if !a.user.verified { | |
| 378 | return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then accept the invitation.")); | |
| 379 | } | |
| 380 | let row = match self.open_invitation(&a.user, &a.id).await? { | |
| 381 | Ok(row) => row, | |
| 382 | Err(why) => return Ok(Outcome::fail(FailureCode::NotFound, why)), | |
| 383 | }; | |
| 384 | let (Some(workspace_id), Some(slug)) = (row.workspace_id.clone(), row.workspace.clone()) else { | |
| 385 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_OPEN)); | |
| 386 | }; | |
| 387 | // What the workspace asks of its members (security.rs). | |
| 388 | if let Some(why) = self.policy_refusal(&a.user.id, &slug).await? { | |
| 389 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); | |
| 390 | } | |
| 391 | // A free workspace adds no one until it starts the plan (paid.rs); | |
| 392 | // the invitation stays open until then. | |
| 393 | if let Some(refused) = self.free_workspace_refusal(&slug).await? { | |
| 394 | return Ok(refused); | |
| 395 | } | |
| 396 | let role = row.joins_as(); | |
| 397 | let now = rfc3339(now_ms()); | |
| 398 | let user = JsValue::from(a.user.id.as_str()); | |
| 399 | let id = JsValue::from(row.id.as_str()); | |
| 400 | let at = JsValue::from(now.as_str()); | |
| 401 | self.db | |
| 402 | .batch(vec![ | |
| 403 | self.db | |
| 404 | .prepare( | |
| 405 | "UPDATE invites SET accepted_at = ?3, redeemed_by = COALESCE(redeemed_by, ?2), | |
| 406 | redeemed_at = COALESCE(redeemed_at, ?3), applied_at = COALESCE(applied_at, ?3), sealed_code = NULL | |
| 407 | WHERE id = ?1 AND invitee_id = ?2 AND accepted_at IS NULL AND declined_at IS NULL | |
| 408 | AND revoked_at IS NULL AND expires_at > ?3", | |
| 409 | ) | |
| 410 | .bind(&[id.clone(), user.clone(), at.clone()])?, | |
| 411 | self.db | |
| 412 | .prepare( | |
| 413 | "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at) | |
| 414 | SELECT ?4, ?2, ?5, ?3 | |
| 415 | WHERE EXISTS (SELECT 1 FROM invites WHERE id = ?1 AND invitee_id = ?2 AND accepted_at = ?3) | |
| 416 | AND EXISTS (SELECT 1 FROM workspaces WHERE id = ?4 AND deleted_at IS NULL)", | |
| 417 | ) | |
| 418 | .bind(&[ | |
| 419 | id.clone(), | |
| 420 | user, | |
| 421 | at, | |
| 422 | workspace_id.as_str().into(), | |
| 423 | if role == Role::Owner { "owner" } else { "member" }.into(), | |
| 424 | ])?, | |
| 425 | ]) | |
| 426 | .await?; | |
| 427 | #[derive(Deserialize)] | |
| 428 | struct Accepted { | |
| 429 | accepted_at: Option<String>, | |
| 430 | } | |
| 431 | let accepted = self | |
| 432 | .db | |
| 433 | .prepare("SELECT accepted_at FROM invites WHERE id = ?") | |
| 434 | .bind(&[id])? | |
| 435 | .first::<Accepted>(None) | |
| 436 | .await? | |
| 437 | .and_then(|row| row.accepted_at); | |
| 438 | if accepted.as_deref() != Some(now.as_str()) { | |
| 439 | return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN)); | |
| 440 | } | |
| 441 | if row.kind == "workspace" { | |
| 442 | // An existing account's invitation: using it is this. | |
| 443 | self.settled(&row, &a.user, false, Some(slug.clone())).await; | |
| 444 | } else { | |
| 445 | let surface = a.surface.unwrap_or(Surface::Web); | |
| 446 | self.audit_invites(&a.user, "invite.accepted", vec![slug.clone()], surface, "Accepted the invitation".to_owned()).await; | |
| 447 | self.audit_invites( | |
| 448 | &a.user, | |
| 449 | "member.added", | |
| 450 | vec![slug.clone()], | |
| 451 | surface, | |
| 452 | format!("{} joined as {}", a.user.username, as_role(role)), | |
| 453 | ) | |
| 454 | .await; | |
| 455 | } | |
| 456 | let workspace = self.workspace_name(&workspace_id, &slug).await; | |
| 457 | self.invitation_notice( | |
| 458 | "workspace_invitation.accepted", | |
| 459 | Some(&a.user.id), | |
| 460 | &row, | |
| 461 | row.inviter.iter().cloned().collect(), | |
| 462 | format!("@{} accepted your invitation to {workspace}", a.user.username), | |
| 463 | format!("They joined as {}.", as_role(role)), | |
| 464 | format!("/{slug}/-/people"), | |
| 465 | ) | |
| 466 | .await; | |
| 467 | Ok(Outcome::Ok(slug)) | |
| 468 | } | |
| 469 | ||
| 470 | /// `decline_invitation`: says no, and tells whoever sent it. | |
| 471 | pub async fn decline_invitation(&self, a: InvitationArgs) -> Result<Outcome<bool>> { | |
| 472 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { | |
| 473 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can decline an invitation.")); | |
| 474 | } | |
| 475 | let row = match self.open_invitation(&a.user, &a.id).await? { | |
| 476 | Ok(row) => row, | |
| 477 | Err(why) => return Ok(Outcome::fail(FailureCode::NotFound, why)), | |
| 478 | }; | |
| 479 | let declined = self | |
| 480 | .db | |
| 481 | .prepare(format!( | |
| 482 | "UPDATE invites SET declined_at = {SQL_NOW}, sealed_code = NULL | |
| 483 | WHERE id = ?1 AND invitee_id = ?2 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL | |
| 484 | RETURNING id" | |
| 485 | )) | |
| 486 | .bind(&[row.id.as_str().into(), a.user.id.as_str().into()])? | |
| 487 | .first::<serde_json::Value>(None) | |
| 488 | .await?; | |
| 489 | if declined.is_none() { | |
| 490 | return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN)); | |
| 491 | } | |
| 492 | let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) else { | |
| 493 | return Ok(Outcome::Ok(true)); | |
| 494 | }; | |
| 495 | self.audit_invites( | |
| 496 | &a.user, | |
| 497 | "invite.declined", | |
| 498 | vec![slug.clone()], | |
| 499 | a.surface.unwrap_or(Surface::Web), | |
| 500 | format!("{} declined the invitation", a.user.username), | |
| 501 | ) | |
| 502 | .await; | |
| 503 | let workspace = self.workspace_name(workspace_id, slug).await; | |
| 504 | self.invitation_notice( | |
| 505 | "workspace_invitation.declined", | |
| 506 | Some(&a.user.id), | |
| 507 | &row, | |
| 508 | row.inviter.iter().cloned().collect(), | |
| 509 | format!("@{} declined your invitation to {workspace}", a.user.username), | |
| 510 | "Nothing changed in the workspace.".to_owned(), | |
| 511 | format!("/{slug}/-/people"), | |
| 512 | ) | |
| 513 | .await; | |
| 514 | Ok(Outcome::Ok(true)) | |
| 515 | } | |
| 516 | ||
| 517 | /// Closes the invitee's inbox item for an invitation revoked from People. | |
| 518 | pub(crate) async fn invitation_revoked(&self, actor: &User, row: &InviteRow) { | |
| 519 | if row.workspace_id.is_none() || row.invitee_id.is_none() { | |
| 520 | return; | |
| 521 | } | |
| 522 | self.invitation_notice( | |
| 523 | "workspace_invitation.revoked", | |
| 524 | Some(&actor.id), | |
| 525 | row, | |
| 526 | Vec::new(), | |
| 527 | String::new(), | |
| 528 | String::new(), | |
| 529 | String::new(), | |
| 530 | ) | |
| 531 | .await; | |
| 532 | } | |
| 533 | ||
| 534 | /// `find_people`: accounts to invite, by username or name. | |
| 535 | pub async fn find_people(&self, a: FindPeopleArgs) -> Result<Vec<InviteFrom>> { | |
| 536 | let Some((username, name)) = people_patterns(&a.query) else { | |
| 537 | return Ok(Vec::new()); | |
| 538 | }; | |
| 539 | let exact = a.query.trim().trim_start_matches('@').to_lowercase(); | |
| 540 | let limit = a.limit.unwrap_or(8).clamp(1, MAX_PEOPLE); | |
| 541 | #[derive(Deserialize)] | |
| 542 | struct Row { | |
| 543 | username: String, | |
| 544 | name: Option<String>, | |
| 545 | avatar: Option<String>, | |
| 546 | } | |
| 547 | let rows = self | |
| 548 | .db | |
| 549 | .prepare(format!( | |
| 550 | "SELECT username, display_name AS name, avatar FROM users | |
| 551 | WHERE deleted_at IS NULL AND email_verified_at IS NOT NULL AND username <> ?4 | |
| 552 | AND (username LIKE ?1 ESCAPE '\\' OR lower(display_name) LIKE ?2 ESCAPE '\\') | |
| 553 | ORDER BY username = ?3 DESC, username LIKE ?1 ESCAPE '\\' DESC, length(username), username | |
| 554 | LIMIT {limit}" | |
| 555 | )) | |
| 556 | .bind(&[ | |
| 557 | username.into(), | |
| 558 | name.into(), | |
| 559 | exact.into(), | |
| 560 | g1t_contracts::identity::AGENT_NAME.into(), | |
| 561 | ])? | |
| 562 | .all() | |
| 563 | .await? | |
| 564 | .results::<Row>()?; | |
| 565 | Ok(rows | |
| 566 | .into_iter() | |
| 567 | .map(|row| InviteFrom { username: row.username, name: row.name, avatar: row.avatar }) | |
| 568 | .collect()) | |
| 569 | } | |
| 570 | } | |
| 571 | ||
| 572 | #[cfg(test)] | |
| 573 | mod tests { | |
| 574 | use super::*; | |
| 575 | ||
| 576 | const NOW: &str = "2026-10-08T12:00:00.000Z"; | |
| 577 | const EARLIER: &str = "2026-10-01T12:00:00.000Z"; | |
| 578 | const LATER: &str = "2026-11-07T12:00:00.000Z"; | |
| 579 | ||
| 580 | fn invitation(kind: &str) -> InviteRow { | |
| 581 | InviteRow { | |
| 582 | id: "inv_1".into(), | |
| 583 | hint: "g1t-k7m2".into(), | |
| 584 | sealed_code: None, | |
| 585 | email: None, | |
| 586 | kind: kind.into(), | |
| 587 | workspace_id: Some("wsp_1".into()), | |
| 588 | workspace: Some("acme".into()), | |
| 589 | inviter_id: Some("usr_owner".into()), | |
| 590 | inviter: Some("syntaqx".into()), | |
| 591 | staff: None, | |
| 592 | charged_to: "none".into(), | |
| 593 | created_at: EARLIER.into(), | |
| 594 | expires_at: LATER.into(), | |
| 595 | revoked_at: None, | |
| 596 | redeemer: None, | |
| 597 | redeemed_at: None, | |
| 598 | applied_at: None, | |
| 599 | invitee_id: Some("usr_ada".into()), | |
| 600 | invitee: Some("ada".into()), | |
| 601 | role: None, | |
| 602 | accepted_at: None, | |
| 603 | declined_at: None, | |
| 604 | } | |
| 605 | } | |
| 606 | ||
| 607 | #[test] | |
| 608 | fn an_existing_accounts_invitation_is_open_until_answered_revoked_or_expired() { | |
| 609 | let open = invitation("workspace"); | |
| 610 | assert_eq!(answerable(&open, NOW), Ok(())); | |
| 611 | for closed in [ | |
| 612 | InviteRow { accepted_at: Some(NOW.into()), ..invitation("workspace") }, | |
| 613 | InviteRow { declined_at: Some(NOW.into()), ..invitation("workspace") }, | |
| 614 | InviteRow { revoked_at: Some(NOW.into()), ..invitation("workspace") }, | |
| 615 | InviteRow { expires_at: EARLIER.into(), ..invitation("workspace") }, | |
| 616 | // Its workspace was deleted. | |
| 617 | InviteRow { workspace: None, ..invitation("workspace") }, | |
| 618 | // Used through its link already. | |
| 619 | InviteRow { redeemed_at: Some(EARLIER.into()), ..invitation("workspace") }, | |
| 620 | ] { | |
| 621 | assert_eq!(answerable(&closed, NOW), Err(NOT_OPEN)); | |
| 622 | } | |
| 623 | } | |
| 624 | ||
| 625 | #[test] | |
| 626 | fn a_new_accounts_invitation_opens_once_the_account_is_confirmed() { | |
| 627 | // The invite made the account, which has not confirmed its address. | |
| 628 | let waiting = InviteRow { redeemed_at: Some(EARLIER.into()), ..invitation("account") }; | |
| 629 | assert_eq!(answerable(&waiting, NOW), Err(NOT_OPEN)); | |
| 630 | // Confirmed: the invitation waits for its answer, and nothing was joined. | |
| 631 | let confirmed = InviteRow { applied_at: Some(NOW.into()), ..waiting }; | |
| 632 | assert_eq!(answerable(&confirmed, NOW), Ok(())); | |
| 633 | assert_eq!(confirmed.status(NOW), InviteStatus::AwaitingAnswer); | |
| 634 | // Accepting joins with the role it names; member when none. | |
| 635 | assert_eq!(confirmed.joins_as(), Role::Member); | |
| 636 | assert_eq!(InviteRow { role: Some("owner".into()), ..invitation("workspace") }.joins_as(), Role::Owner); | |
| 637 | // An unused code is not an invitation yet. | |
| 638 | assert_eq!(answerable(&invitation("account"), NOW), Err(NOT_OPEN)); | |
| 639 | } | |
| 640 | ||
| 641 | #[test] | |
| 642 | fn an_answered_invitation_says_how_it_was_answered() { | |
| 643 | let accepted = InviteRow { | |
| 644 | redeemed_at: Some(NOW.into()), | |
| 645 | applied_at: Some(NOW.into()), | |
| 646 | accepted_at: Some(NOW.into()), | |
| 647 | ..invitation("account") | |
| 648 | }; | |
| 649 | assert_eq!(accepted.status(NOW), InviteStatus::Redeemed); | |
| 650 | let declined = InviteRow { declined_at: Some(NOW.into()), ..invitation("workspace") }; | |
| 651 | assert_eq!(declined.status(NOW), InviteStatus::Declined); | |
| 652 | // Not answered in time: expired, though the account it made stays. | |
| 653 | let late = InviteRow { | |
| 654 | redeemed_at: Some(EARLIER.into()), | |
| 655 | applied_at: Some(EARLIER.into()), | |
| 656 | expires_at: EARLIER.into(), | |
| 657 | ..invitation("account") | |
| 658 | }; | |
| 659 | assert_eq!(late.status(NOW), InviteStatus::Expired); | |
| 660 | // An existing account's open invitation is pending. | |
| 661 | assert_eq!(invitation("workspace").status(NOW), InviteStatus::Pending); | |
| 662 | } | |
| 663 | ||
| 664 | #[test] | |
| 665 | fn a_new_account_gets_its_own_workspace_unless_its_invite_brings_it_into_one() { | |
| 666 | // No invite, a shared link, or an invite that names no workspace. | |
| 667 | assert!(makes_own_workspace(None, false)); | |
| 668 | // Brought into a workspace: none of its own, so never two. | |
| 669 | assert!(!makes_own_workspace(Some("acme"), false)); | |
| 670 | // Into one on the free plan, which cannot take it: its own as well. | |
| 671 | assert!(makes_own_workspace(Some("acme"), true)); | |
| 672 | } | |
| 673 | ||
| 674 | #[test] | |
| 675 | fn people_are_found_by_username_prefix_or_name_with_wildcards_taken_literally() { | |
| 676 | assert_eq!(people_patterns(" @Ada "), Some(("ada%".to_owned(), "%ada%".to_owned()))); | |
| 677 | assert_eq!(people_patterns("a_b%"), Some(("a\\_b\\%%".to_owned(), "%a\\_b\\%%".to_owned()))); | |
| 678 | assert_eq!(people_patterns(" @ "), None); | |
| 679 | assert_eq!(people_patterns(""), None); | |
| 680 | } | |
| 681 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.