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, | |
| 186 | surface: Surface, | |
| 187 | ) -> Result<Outcome<Invite>> { | |
| 188 | let person = self | |
| 189 | .db | |
| 190 | .prepare( | |
| 191 | "SELECT id, username, email, email_verified_at IS NOT NULL AS verified | |
| 192 | FROM users WHERE username = ? AND deleted_at IS NULL", | |
| 193 | ) | |
| 194 | .bind(&[username.into()])? | |
| 195 | .first::<Person>(None) | |
| 196 | .await?; | |
| 197 | let Some(person) = person.filter(|person| !crate::paid::is_g1t(&person.username)) else { | |
| 198 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no account with that username.")); | |
| 199 | }; | |
| 200 | if person.id == actor.id { | |
| 201 | return Ok(Outcome::fail(FailureCode::Conflict, format!("You are already in {slug}."))); | |
| 202 | } | |
| 203 | let member = self | |
| 204 | .db | |
| 205 | .prepare("SELECT 1 AS n FROM workspace_members WHERE workspace_id = ? AND user_id = ?") | |
| 206 | .bind(&[workspace_id.into(), person.id.as_str().into()])? | |
| 207 | .first::<serde_json::Value>(None) | |
| 208 | .await?; | |
| 209 | if member.is_some() { | |
| 210 | return Ok(Outcome::fail(FailureCode::Conflict, format!("@{} is already in {slug}.", person.username))); | |
| 211 | } | |
| 212 | if !self.hit(&format!("invite.create:{}", actor.id), CREATES_PER_HOUR).await? { | |
| 213 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 214 | } | |
| 215 | let pending = self | |
| 216 | .rows( | |
| 217 | &format!( | |
| 218 | "WHERE i.workspace_id = ? AND i.invitee_id = ? AND i.accepted_at IS NULL AND i.declined_at IS NULL | |
| 219 | AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}" | |
| 220 | ), | |
| 221 | &[workspace_id.into(), person.id.as_str().into()], | |
| 222 | 1, | |
| 223 | ) | |
| 224 | .await?; | |
| 225 | if !pending.is_empty() { | |
| 226 | return Ok(Outcome::fail( | |
| 227 | FailureCode::Conflict, | |
| 228 | format!("@{} already has a pending invitation to {slug}. Revoke it to send a new one.", person.username), | |
| 229 | )); | |
| 230 | } | |
| 231 | let draft = Draft { | |
| 232 | email: None, | |
| 233 | kind: "workspace", | |
| 234 | workspace_id: Some(workspace_id), | |
| 235 | inviter: Some(actor), | |
| 236 | staff: None, | |
| 237 | // Costs nothing: the person is on g1t already. | |
| 238 | charged_to: "none", | |
| 239 | charged_workspace_id: None, | |
| 240 | limit: None, | |
| 241 | invitee_id: Some(&person.id), | |
| 242 | role: Some(if role == Role::Owner { "owner" } else { "member" }), | |
| 243 | }; | |
| 244 | let Some(invite) = self.insert_invite(draft).await? else { | |
| 245 | return Ok(Outcome::fail(FailureCode::Conflict, "The invitation could not be made. Try again.")); | |
| 246 | }; | |
| 247 | if let (Some(email), true, Some(code)) = (&person.email, person.verified != 0, &invite.code) { | |
| 248 | let from = self.display_name(actor).await; | |
| 249 | let workspace = self.workspace_name(workspace_id, slug).await; | |
| 250 | self.send_invite_email(email, Some(&from), Some(&workspace), true, code, &invite.id, None).await; | |
| 251 | } | |
| 252 | if let Some(row) = self.invite_by_id(&invite.id).await? { | |
| 253 | self.invitation_sent(&row, &person.username).await; | |
| 254 | } | |
| 255 | self.audit_invites( | |
| 256 | actor, | |
| 257 | "invite.created", | |
| 258 | vec![slug.to_owned()], | |
| 259 | surface, | |
| 260 | format!("Invited @{} to {slug} as {}", person.username, as_role(role)), | |
| 261 | ) | |
| 262 | .await; | |
| 263 | Ok(Outcome::Ok(invite)) | |
| 264 | } | |
| 265 | ||
| 266 | /// Tells `username` in their inbox that they are invited to the | |
| 267 | /// invite's workspace. | |
| 268 | pub(crate) async fn invitation_sent(&self, row: &InviteRow, username: &str) { | |
| 269 | let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) else { | |
| 270 | return; | |
| 271 | }; | |
| 272 | let workspace = self.workspace_name(workspace_id, slug).await; | |
| 273 | let from = match &row.inviter { | |
| 274 | Some(inviter) => format!("@{inviter}"), | |
| 275 | None => "The g1t team".to_owned(), | |
| 276 | }; | |
| 277 | self.invitation_notice( | |
| 278 | "workspace_invitation.created", | |
| 279 | row.inviter_id.as_deref(), | |
| 280 | row, | |
| 281 | vec![username.to_owned()], | |
| 282 | format!("{from} invited you to join {workspace}"), | |
| 283 | format!("Join as {}, or decline. The invitation works until {}.", as_role(row.joins_as()), &row.expires_at[..10.min(row.expires_at.len())]), | |
| 284 | "/invitations".to_owned(), | |
| 285 | ) | |
| 286 | .await; | |
| 287 | } | |
| 288 | ||
| 289 | #[allow(clippy::too_many_arguments)] | |
| 290 | async fn invitation_notice( | |
| 291 | &self, | |
| 292 | kind: &'static str, | |
| 293 | actor: Option<&str>, | |
| 294 | row: &InviteRow, | |
| 295 | notify: Vec<String>, | |
| 296 | title: String, | |
| 297 | body: String, | |
| 298 | link: String, | |
| 299 | ) { | |
| 300 | let slug = row.workspace.as_deref().unwrap_or_default(); | |
| 301 | self.announce( | |
| 302 | kind, | |
| 303 | actor, | |
| 304 | InvitationNotice { | |
| 305 | workspace: slug, | |
| 306 | invitation_id: &row.id, | |
| 307 | thread: format!("invitation:{}", row.id), | |
| 308 | notify, | |
| 309 | title, | |
| 310 | body, | |
| 311 | link, | |
| 312 | }, | |
| 313 | ) | |
| 314 | .await; | |
| 315 | } | |
| 316 | ||
| 317 | /// `list_invitations`: the workspace invitations waiting for `user`'s | |
| 318 | /// answer, newest first. | |
| 319 | pub async fn list_invitations(&self, a: UserArgs) -> Result<Vec<WorkspaceInvitation>> { | |
| 320 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { | |
| 321 | return Ok(Vec::new()); | |
| 322 | } | |
| 323 | let rows = self | |
| 324 | .db | |
| 325 | .prepare(format!( | |
| 326 | "SELECT i.id, w.slug, w.name, w.avatar, i.role, i.created_at, i.expires_at, | |
| 327 | iu.username AS inviter, iu.display_name AS inviter_name, iu.avatar AS inviter_avatar | |
| 328 | FROM invites i | |
| 329 | JOIN workspaces w ON w.id = i.workspace_id AND w.deleted_at IS NULL | |
| 330 | LEFT JOIN users iu ON iu.id = i.inviter_id | |
| 331 | WHERE i.invitee_id = ?1 AND i.accepted_at IS NULL AND i.declined_at IS NULL | |
| 332 | AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW} | |
| 333 | AND ((i.kind = 'workspace' AND i.redeemed_at IS NULL) | |
| 334 | OR (i.kind = 'account' AND i.redeemed_at IS NOT NULL AND i.applied_at IS NOT NULL)) | |
| 335 | AND NOT EXISTS (SELECT 1 FROM workspace_members m WHERE m.workspace_id = i.workspace_id AND m.user_id = ?1) | |
| 336 | ORDER BY i.created_at DESC, i.id DESC LIMIT {MAX_INVITATIONS}" | |
| 337 | )) | |
| 338 | .bind(&[a.user.id.as_str().into()])? | |
| 339 | .all() | |
| 340 | .await? | |
| 341 | .results::<Pending>()?; | |
| 342 | Ok(rows | |
| 343 | .into_iter() | |
| 344 | .map(|row| WorkspaceInvitation { | |
| 345 | id: row.id, | |
| 346 | workspace: ProfileWorkspace { slug: row.slug, name: row.name, avatar: row.avatar }, | |
| 347 | role: if row.role.as_deref() == Some("owner") { Role::Owner } else { Role::Member }, | |
| 348 | invited_by: row.inviter.map(|username| InviteFrom { | |
| 349 | username, | |
| 350 | name: row.inviter_name, | |
| 351 | avatar: row.inviter_avatar, | |
| 352 | }), | |
| 353 | created_at: row.created_at, | |
| 354 | expires_at: row.expires_at, | |
| 355 | }) | |
| 356 | .collect()) | |
| 357 | } | |
| 358 | ||
| 359 | /// The invitation `id` if it is `user`'s and can be answered. | |
| 360 | async fn open_invitation(&self, user: &User, id: &str) -> Result<std::result::Result<InviteRow, &'static str>> { | |
| 361 | let row = self | |
| 362 | .invite_by_id(id) | |
| 363 | .await? | |
| 364 | .filter(|row| row.invitee_id.as_deref() == Some(user.id.as_str())); | |
| 365 | let Some(row) = row else { | |
| 366 | return Ok(Err(NOT_OPEN)); | |
| 367 | }; | |
| 368 | Ok(answerable(&row, &rfc3339(now_ms())).map(|()| row)) | |
| 369 | } | |
| 370 | ||
| 371 | /// `accept_invitation`: joins the invitation's workspace with its role. | |
| 372 | pub async fn accept_invitation(&self, a: InvitationArgs) -> Result<Outcome<String>> { | |
| 373 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { | |
| 374 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invitation.")); | |
| 375 | } | |
| 376 | if !a.user.verified { | |
| 377 | return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then accept the invitation.")); | |
| 378 | } | |
| 379 | let row = match self.open_invitation(&a.user, &a.id).await? { | |
| 380 | Ok(row) => row, | |
| 381 | Err(why) => return Ok(Outcome::fail(FailureCode::NotFound, why)), | |
| 382 | }; | |
| 383 | let (Some(workspace_id), Some(slug)) = (row.workspace_id.clone(), row.workspace.clone()) else { | |
| 384 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_OPEN)); | |
| 385 | }; | |
| 386 | // What the workspace asks of its members (security.rs). | |
| 387 | if let Some(why) = self.policy_refusal(&a.user.id, &slug).await? { | |
| 388 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); | |
| 389 | } | |
| 390 | // A free workspace adds no one until it starts the plan (paid.rs); | |
| 391 | // the invitation stays open until then. | |
| 392 | if let Some(refused) = self.free_workspace_refusal(&slug).await? { | |
| 393 | return Ok(refused); | |
| 394 | } | |
| 395 | let role = row.joins_as(); | |
| 396 | let now = rfc3339(now_ms()); | |
| 397 | let user = JsValue::from(a.user.id.as_str()); | |
| 398 | let id = JsValue::from(row.id.as_str()); | |
| 399 | let at = JsValue::from(now.as_str()); | |
| 400 | self.db | |
| 401 | .batch(vec![ | |
| 402 | self.db | |
| 403 | .prepare( | |
| 404 | "UPDATE invites SET accepted_at = ?3, redeemed_by = COALESCE(redeemed_by, ?2), | |
| 405 | redeemed_at = COALESCE(redeemed_at, ?3), applied_at = COALESCE(applied_at, ?3), sealed_code = NULL | |
| 406 | WHERE id = ?1 AND invitee_id = ?2 AND accepted_at IS NULL AND declined_at IS NULL | |
| 407 | AND revoked_at IS NULL AND expires_at > ?3", | |
| 408 | ) | |
| 409 | .bind(&[id.clone(), user.clone(), at.clone()])?, | |
| 410 | self.db | |
| 411 | .prepare( | |
| 412 | "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at) | |
| 413 | SELECT ?4, ?2, ?5, ?3 | |
| 414 | WHERE EXISTS (SELECT 1 FROM invites WHERE id = ?1 AND invitee_id = ?2 AND accepted_at = ?3) | |
| 415 | AND EXISTS (SELECT 1 FROM workspaces WHERE id = ?4 AND deleted_at IS NULL)", | |
| 416 | ) | |
| 417 | .bind(&[ | |
| 418 | id.clone(), | |
| 419 | user, | |
| 420 | at, | |
| 421 | workspace_id.as_str().into(), | |
| 422 | if role == Role::Owner { "owner" } else { "member" }.into(), | |
| 423 | ])?, | |
| 424 | ]) | |
| 425 | .await?; | |
| 426 | #[derive(Deserialize)] | |
| 427 | struct Accepted { | |
| 428 | accepted_at: Option<String>, | |
| 429 | } | |
| 430 | let accepted = self | |
| 431 | .db | |
| 432 | .prepare("SELECT accepted_at FROM invites WHERE id = ?") | |
| 433 | .bind(&[id])? | |
| 434 | .first::<Accepted>(None) | |
| 435 | .await? | |
| 436 | .and_then(|row| row.accepted_at); | |
| 437 | if accepted.as_deref() != Some(now.as_str()) { | |
| 438 | return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN)); | |
| 439 | } | |
| 440 | if row.kind == "workspace" { | |
| 441 | // An existing account's invitation: using it is this. | |
| 442 | self.settled(&row, &a.user, false, Some(slug.clone())).await; | |
| 443 | } else { | |
| 444 | let surface = a.surface.unwrap_or(Surface::Web); | |
| 445 | self.audit_invites(&a.user, "invite.accepted", vec![slug.clone()], surface, "Accepted the invitation".to_owned()).await; | |
| 446 | self.audit_invites( | |
| 447 | &a.user, | |
| 448 | "member.added", | |
| 449 | vec![slug.clone()], | |
| 450 | surface, | |
| 451 | format!("{} joined as {}", a.user.username, as_role(role)), | |
| 452 | ) | |
| 453 | .await; | |
| 454 | } | |
| 455 | let workspace = self.workspace_name(&workspace_id, &slug).await; | |
| 456 | self.invitation_notice( | |
| 457 | "workspace_invitation.accepted", | |
| 458 | Some(&a.user.id), | |
| 459 | &row, | |
| 460 | row.inviter.iter().cloned().collect(), | |
| 461 | format!("@{} accepted your invitation to {workspace}", a.user.username), | |
| 462 | format!("They joined as {}.", as_role(role)), | |
| 463 | format!("/{slug}/-/people"), | |
| 464 | ) | |
| 465 | .await; | |
| 466 | Ok(Outcome::Ok(slug)) | |
| 467 | } | |
| 468 | ||
| 469 | /// `decline_invitation`: says no, and tells whoever sent it. | |
| 470 | pub async fn decline_invitation(&self, a: InvitationArgs) -> Result<Outcome<bool>> { | |
| 471 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { | |
| 472 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can decline an invitation.")); | |
| 473 | } | |
| 474 | let row = match self.open_invitation(&a.user, &a.id).await? { | |
| 475 | Ok(row) => row, | |
| 476 | Err(why) => return Ok(Outcome::fail(FailureCode::NotFound, why)), | |
| 477 | }; | |
| 478 | let declined = self | |
| 479 | .db | |
| 480 | .prepare(format!( | |
| 481 | "UPDATE invites SET declined_at = {SQL_NOW}, sealed_code = NULL | |
| 482 | WHERE id = ?1 AND invitee_id = ?2 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL | |
| 483 | RETURNING id" | |
| 484 | )) | |
| 485 | .bind(&[row.id.as_str().into(), a.user.id.as_str().into()])? | |
| 486 | .first::<serde_json::Value>(None) | |
| 487 | .await?; | |
| 488 | if declined.is_none() { | |
| 489 | return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN)); | |
| 490 | } | |
| 491 | let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) else { | |
| 492 | return Ok(Outcome::Ok(true)); | |
| 493 | }; | |
| 494 | self.audit_invites( | |
| 495 | &a.user, | |
| 496 | "invite.declined", | |
| 497 | vec![slug.clone()], | |
| 498 | a.surface.unwrap_or(Surface::Web), | |
| 499 | format!("{} declined the invitation", a.user.username), | |
| 500 | ) | |
| 501 | .await; | |
| 502 | let workspace = self.workspace_name(workspace_id, slug).await; | |
| 503 | self.invitation_notice( | |
| 504 | "workspace_invitation.declined", | |
| 505 | Some(&a.user.id), | |
| 506 | &row, | |
| 507 | row.inviter.iter().cloned().collect(), | |
| 508 | format!("@{} declined your invitation to {workspace}", a.user.username), | |
| 509 | "Nothing changed in the workspace.".to_owned(), | |
| 510 | format!("/{slug}/-/people"), | |
| 511 | ) | |
| 512 | .await; | |
| 513 | Ok(Outcome::Ok(true)) | |
| 514 | } | |
| 515 | ||
| 516 | /// Closes the invitee's inbox item for an invitation revoked from People. | |
| 517 | pub(crate) async fn invitation_revoked(&self, actor: &User, row: &InviteRow) { | |
| 518 | if row.workspace_id.is_none() || row.invitee_id.is_none() { | |
| 519 | return; | |
| 520 | } | |
| 521 | self.invitation_notice( | |
| 522 | "workspace_invitation.revoked", | |
| 523 | Some(&actor.id), | |
| 524 | row, | |
| 525 | Vec::new(), | |
| 526 | String::new(), | |
| 527 | String::new(), | |
| 528 | String::new(), | |
| 529 | ) | |
| 530 | .await; | |
| 531 | } | |
| 532 | ||
| 533 | /// `find_people`: accounts to invite, by username or name. | |
| 534 | pub async fn find_people(&self, a: FindPeopleArgs) -> Result<Vec<InviteFrom>> { | |
| 535 | let Some((username, name)) = people_patterns(&a.query) else { | |
| 536 | return Ok(Vec::new()); | |
| 537 | }; | |
| 538 | let exact = a.query.trim().trim_start_matches('@').to_lowercase(); | |
| 539 | let limit = a.limit.unwrap_or(8).clamp(1, MAX_PEOPLE); | |
| 540 | #[derive(Deserialize)] | |
| 541 | struct Row { | |
| 542 | username: String, | |
| 543 | name: Option<String>, | |
| 544 | avatar: Option<String>, | |
| 545 | } | |
| 546 | let rows = self | |
| 547 | .db | |
| 548 | .prepare(format!( | |
| 549 | "SELECT username, display_name AS name, avatar FROM users | |
| 550 | WHERE deleted_at IS NULL AND email_verified_at IS NOT NULL AND username <> ?4 | |
| 551 | AND (username LIKE ?1 ESCAPE '\\' OR lower(display_name) LIKE ?2 ESCAPE '\\') | |
| 552 | ORDER BY username = ?3 DESC, username LIKE ?1 ESCAPE '\\' DESC, length(username), username | |
| 553 | LIMIT {limit}" | |
| 554 | )) | |
| 555 | .bind(&[ | |
| 556 | username.into(), | |
| 557 | name.into(), | |
| 558 | exact.into(), | |
| 559 | g1t_contracts::identity::AGENT_NAME.into(), | |
| 560 | ])? | |
| 561 | .all() | |
| 562 | .await? | |
| 563 | .results::<Row>()?; | |
| 564 | Ok(rows | |
| 565 | .into_iter() | |
| 566 | .map(|row| InviteFrom { username: row.username, name: row.name, avatar: row.avatar }) | |
| 567 | .collect()) | |
| 568 | } | |
| 569 | } | |
| 570 | ||
| 571 | #[cfg(test)] | |
| 572 | mod tests { | |
| 573 | use super::*; | |
| 574 | ||
| 575 | const NOW: &str = "2026-10-08T12:00:00.000Z"; | |
| 576 | const EARLIER: &str = "2026-10-01T12:00:00.000Z"; | |
| 577 | const LATER: &str = "2026-11-07T12:00:00.000Z"; | |
| 578 | ||
| 579 | fn invitation(kind: &str) -> InviteRow { | |
| 580 | InviteRow { | |
| 581 | id: "inv_1".into(), | |
| 582 | hint: "g1t-k7m2".into(), | |
| 583 | sealed_code: None, | |
| 584 | email: None, | |
| 585 | kind: kind.into(), | |
| 586 | workspace_id: Some("wsp_1".into()), | |
| 587 | workspace: Some("acme".into()), | |
| 588 | inviter_id: Some("usr_owner".into()), | |
| 589 | inviter: Some("syntaqx".into()), | |
| 590 | staff: None, | |
| 591 | charged_to: "none".into(), | |
| 592 | created_at: EARLIER.into(), | |
| 593 | expires_at: LATER.into(), | |
| 594 | revoked_at: None, | |
| 595 | redeemer: None, | |
| 596 | redeemed_at: None, | |
| 597 | applied_at: None, | |
| 598 | invitee_id: Some("usr_ada".into()), | |
| 599 | invitee: Some("ada".into()), | |
| 600 | role: None, | |
| 601 | accepted_at: None, | |
| 602 | declined_at: None, | |
| 603 | } | |
| 604 | } | |
| 605 | ||
| 606 | #[test] | |
| 607 | fn an_existing_accounts_invitation_is_open_until_answered_revoked_or_expired() { | |
| 608 | let open = invitation("workspace"); | |
| 609 | assert_eq!(answerable(&open, NOW), Ok(())); | |
| 610 | for closed in [ | |
| 611 | InviteRow { accepted_at: Some(NOW.into()), ..invitation("workspace") }, | |
| 612 | InviteRow { declined_at: Some(NOW.into()), ..invitation("workspace") }, | |
| 613 | InviteRow { revoked_at: Some(NOW.into()), ..invitation("workspace") }, | |
| 614 | InviteRow { expires_at: EARLIER.into(), ..invitation("workspace") }, | |
| 615 | // Its workspace was deleted. | |
| 616 | InviteRow { workspace: None, ..invitation("workspace") }, | |
| 617 | // Used through its link already. | |
| 618 | InviteRow { redeemed_at: Some(EARLIER.into()), ..invitation("workspace") }, | |
| 619 | ] { | |
| 620 | assert_eq!(answerable(&closed, NOW), Err(NOT_OPEN)); | |
| 621 | } | |
| 622 | } | |
| 623 | ||
| 624 | #[test] | |
| 625 | fn a_new_accounts_invitation_opens_once_the_account_is_confirmed() { | |
| 626 | // The invite made the account, which has not confirmed its address. | |
| 627 | let waiting = InviteRow { redeemed_at: Some(EARLIER.into()), ..invitation("account") }; | |
| 628 | assert_eq!(answerable(&waiting, NOW), Err(NOT_OPEN)); | |
| 629 | // Confirmed: the invitation waits for its answer, and nothing was joined. | |
| 630 | let confirmed = InviteRow { applied_at: Some(NOW.into()), ..waiting }; | |
| 631 | assert_eq!(answerable(&confirmed, NOW), Ok(())); | |
| 632 | assert_eq!(confirmed.status(NOW), InviteStatus::AwaitingAnswer); | |
| 633 | // Accepting joins with the role it names; member when none. | |
| 634 | assert_eq!(confirmed.joins_as(), Role::Member); | |
| 635 | assert_eq!(InviteRow { role: Some("owner".into()), ..invitation("workspace") }.joins_as(), Role::Owner); | |
| 636 | // An unused code is not an invitation yet. | |
| 637 | assert_eq!(answerable(&invitation("account"), NOW), Err(NOT_OPEN)); | |
| 638 | } | |
| 639 | ||
| 640 | #[test] | |
| 641 | fn an_answered_invitation_says_how_it_was_answered() { | |
| 642 | let accepted = InviteRow { | |
| 643 | redeemed_at: Some(NOW.into()), | |
| 644 | applied_at: Some(NOW.into()), | |
| 645 | accepted_at: Some(NOW.into()), | |
| 646 | ..invitation("account") | |
| 647 | }; | |
| 648 | assert_eq!(accepted.status(NOW), InviteStatus::Redeemed); | |
| 649 | let declined = InviteRow { declined_at: Some(NOW.into()), ..invitation("workspace") }; | |
| 650 | assert_eq!(declined.status(NOW), InviteStatus::Declined); | |
| 651 | // Not answered in time: expired, though the account it made stays. | |
| 652 | let late = InviteRow { | |
| 653 | redeemed_at: Some(EARLIER.into()), | |
| 654 | applied_at: Some(EARLIER.into()), | |
| 655 | expires_at: EARLIER.into(), | |
| 656 | ..invitation("account") | |
| 657 | }; | |
| 658 | assert_eq!(late.status(NOW), InviteStatus::Expired); | |
| 659 | // An existing account's open invitation is pending. | |
| 660 | assert_eq!(invitation("workspace").status(NOW), InviteStatus::Pending); | |
| 661 | } | |
| 662 | ||
| 663 | #[test] | |
| 664 | fn a_new_account_gets_its_own_workspace_unless_its_invite_brings_it_into_one() { | |
| 665 | // No invite, a shared link, or an invite that names no workspace. | |
| 666 | assert!(makes_own_workspace(None, false)); | |
| 667 | // Brought into a workspace: none of its own, so never two. | |
| 668 | assert!(!makes_own_workspace(Some("acme"), false)); | |
| 669 | // Into one on the free plan, which cannot take it: its own as well. | |
| 670 | assert!(makes_own_workspace(Some("acme"), true)); | |
| 671 | } | |
| 672 | ||
| 673 | #[test] | |
| 674 | fn people_are_found_by_username_prefix_or_name_with_wildcards_taken_literally() { | |
| 675 | assert_eq!(people_patterns(" @Ada "), Some(("ada%".to_owned(), "%ada%".to_owned()))); | |
| 676 | assert_eq!(people_patterns("a_b%"), Some(("a\\_b\\%%".to_owned(), "%a\\_b\\%%".to_owned()))); | |
| 677 | assert_eq!(people_patterns(" @ "), None); | |
| 678 | assert_eq!(people_patterns(""), None); | |
| 679 | } | |
| 680 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.