Skip to content
681 linesCodeBlameRaw

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
26use g1t_contracts::audit::Surface;
27use g1t_contracts::identity::*;
28use g1t_contracts::time::{SQL_NOW, rfc3339};
29use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User};
30use g1t_kit::now_ms;
31use serde::{Deserialize, Serialize};
32use worker::Result;
33use worker::wasm_bindgen::JsValue;
34
35use super::{CREATES_PER_HOUR, Draft, InviteRow, TOO_MANY};
36use crate::Identity;
37
38/// What an invitation that is not someone's, or no longer open, gets.
39const 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.
41const MAX_PEOPLE: u32 = 10;
42/// The most invitations one person's list shows.
43const 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.
49pub 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.
70pub 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.
77pub 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.
93fn 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")]
106struct 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)]
117struct Person {
118 id: String,
119 username: String,
120 email: Option<String>,
121 verified: u8,
122}
123
124#[derive(Deserialize)]
125struct 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
138impl 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)]
573mod 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.