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

This file's history is long; its oldest lines are credited to the oldest commit read.