Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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