Skip to content
684 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 }
@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.441 // They are a member now: the workspace's @g1t welcomes them (agents service).
442 self.announce_member_joined(&workspace_id, &slug, &a.user, role, g1t_contracts::events::JoinedHow::Invitation)
443 .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)444 if row.kind == "workspace" {
445 // An existing account's invitation: using it is this.
446 self.settled(&row, &a.user, false, Some(slug.clone())).await;
447 } else {
448 let surface = a.surface.unwrap_or(Surface::Web);
449 self.audit_invites(&a.user, "invite.accepted", vec![slug.clone()], surface, "Accepted the invitation".to_owned()).await;
450 self.audit_invites(
451 &a.user,
452 "member.added",
453 vec![slug.clone()],
454 surface,
455 format!("{} joined as {}", a.user.username, as_role(role)),
456 )
457 .await;
458 }
459 let workspace = self.workspace_name(&workspace_id, &slug).await;
460 self.invitation_notice(
461 "workspace_invitation.accepted",
462 Some(&a.user.id),
463 &row,
464 row.inviter.iter().cloned().collect(),
465 format!("@{} accepted your invitation to {workspace}", a.user.username),
466 format!("They joined as {}.", as_role(role)),
467 format!("/{slug}/-/people"),
468 )
469 .await;
470 Ok(Outcome::Ok(slug))
471 }
472
473 /// `decline_invitation`: says no, and tells whoever sent it.
474 pub async fn decline_invitation(&self, a: InvitationArgs) -> Result<Outcome<bool>> {
475 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
476 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can decline an invitation."));
477 }
478 let row = match self.open_invitation(&a.user, &a.id).await? {
479 Ok(row) => row,
480 Err(why) => return Ok(Outcome::fail(FailureCode::NotFound, why)),
481 };
482 let declined = self
483 .db
484 .prepare(format!(
485 "UPDATE invites SET declined_at = {SQL_NOW}, sealed_code = NULL
486 WHERE id = ?1 AND invitee_id = ?2 AND accepted_at IS NULL AND declined_at IS NULL AND revoked_at IS NULL
487 RETURNING id"
488 ))
489 .bind(&[row.id.as_str().into(), a.user.id.as_str().into()])?
490 .first::<serde_json::Value>(None)
491 .await?;
492 if declined.is_none() {
493 return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN));
494 }
495 let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) else {
496 return Ok(Outcome::Ok(true));
497 };
498 self.audit_invites(
499 &a.user,
500 "invite.declined",
501 vec![slug.clone()],
502 a.surface.unwrap_or(Surface::Web),
503 format!("{} declined the invitation", a.user.username),
504 )
505 .await;
506 let workspace = self.workspace_name(workspace_id, slug).await;
507 self.invitation_notice(
508 "workspace_invitation.declined",
509 Some(&a.user.id),
510 &row,
511 row.inviter.iter().cloned().collect(),
512 format!("@{} declined your invitation to {workspace}", a.user.username),
513 "Nothing changed in the workspace.".to_owned(),
514 format!("/{slug}/-/people"),
515 )
516 .await;
517 Ok(Outcome::Ok(true))
518 }
519
520 /// Closes the invitee's inbox item for an invitation revoked from People.
521 pub(crate) async fn invitation_revoked(&self, actor: &User, row: &InviteRow) {
522 if row.workspace_id.is_none() || row.invitee_id.is_none() {
523 return;
524 }
525 self.invitation_notice(
526 "workspace_invitation.revoked",
527 Some(&actor.id),
528 row,
529 Vec::new(),
530 String::new(),
531 String::new(),
532 String::new(),
533 )
534 .await;
535 }
536
537 /// `find_people`: accounts to invite, by username or name.
538 pub async fn find_people(&self, a: FindPeopleArgs) -> Result<Vec<InviteFrom>> {
539 let Some((username, name)) = people_patterns(&a.query) else {
540 return Ok(Vec::new());
541 };
542 let exact = a.query.trim().trim_start_matches('@').to_lowercase();
543 let limit = a.limit.unwrap_or(8).clamp(1, MAX_PEOPLE);
544 #[derive(Deserialize)]
545 struct Row {
546 username: String,
547 name: Option<String>,
548 avatar: Option<String>,
549 }
550 let rows = self
551 .db
552 .prepare(format!(
553 "SELECT username, display_name AS name, avatar FROM users
554 WHERE deleted_at IS NULL AND email_verified_at IS NOT NULL AND username <> ?4
555 AND (username LIKE ?1 ESCAPE '\\' OR lower(display_name) LIKE ?2 ESCAPE '\\')
556 ORDER BY username = ?3 DESC, username LIKE ?1 ESCAPE '\\' DESC, length(username), username
557 LIMIT {limit}"
558 ))
559 .bind(&[
560 username.into(),
561 name.into(),
562 exact.into(),
563 g1t_contracts::identity::AGENT_NAME.into(),
564 ])?
565 .all()
566 .await?
567 .results::<Row>()?;
568 Ok(rows
569 .into_iter()
570 .map(|row| InviteFrom { username: row.username, name: row.name, avatar: row.avatar })
571 .collect())
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 const NOW: &str = "2026-10-08T12:00:00.000Z";
580 const EARLIER: &str = "2026-10-01T12:00:00.000Z";
581 const LATER: &str = "2026-11-07T12:00:00.000Z";
582
583 fn invitation(kind: &str) -> InviteRow {
584 InviteRow {
585 id: "inv_1".into(),
586 hint: "g1t-k7m2".into(),
587 sealed_code: None,
588 email: None,
589 kind: kind.into(),
590 workspace_id: Some("wsp_1".into()),
591 workspace: Some("acme".into()),
592 inviter_id: Some("usr_owner".into()),
593 inviter: Some("syntaqx".into()),
594 staff: None,
595 charged_to: "none".into(),
596 created_at: EARLIER.into(),
597 expires_at: LATER.into(),
598 revoked_at: None,
599 redeemer: None,
600 redeemed_at: None,
601 applied_at: None,
602 invitee_id: Some("usr_ada".into()),
603 invitee: Some("ada".into()),
604 role: None,
605 accepted_at: None,
606 declined_at: None,
607 }
608 }
609
610 #[test]
611 fn an_existing_accounts_invitation_is_open_until_answered_revoked_or_expired() {
612 let open = invitation("workspace");
613 assert_eq!(answerable(&open, NOW), Ok(()));
614 for closed in [
615 InviteRow { accepted_at: Some(NOW.into()), ..invitation("workspace") },
616 InviteRow { declined_at: Some(NOW.into()), ..invitation("workspace") },
617 InviteRow { revoked_at: Some(NOW.into()), ..invitation("workspace") },
618 InviteRow { expires_at: EARLIER.into(), ..invitation("workspace") },
619 // Its workspace was deleted.
620 InviteRow { workspace: None, ..invitation("workspace") },
621 // Used through its link already.
622 InviteRow { redeemed_at: Some(EARLIER.into()), ..invitation("workspace") },
623 ] {
624 assert_eq!(answerable(&closed, NOW), Err(NOT_OPEN));
625 }
626 }
627
628 #[test]
629 fn a_new_accounts_invitation_opens_once_the_account_is_confirmed() {
630 // The invite made the account, which has not confirmed its address.
631 let waiting = InviteRow { redeemed_at: Some(EARLIER.into()), ..invitation("account") };
632 assert_eq!(answerable(&waiting, NOW), Err(NOT_OPEN));
633 // Confirmed: the invitation waits for its answer, and nothing was joined.
634 let confirmed = InviteRow { applied_at: Some(NOW.into()), ..waiting };
635 assert_eq!(answerable(&confirmed, NOW), Ok(()));
636 assert_eq!(confirmed.status(NOW), InviteStatus::AwaitingAnswer);
637 // Accepting joins with the role it names; member when none.
638 assert_eq!(confirmed.joins_as(), Role::Member);
639 assert_eq!(InviteRow { role: Some("owner".into()), ..invitation("workspace") }.joins_as(), Role::Owner);
640 // An unused code is not an invitation yet.
641 assert_eq!(answerable(&invitation("account"), NOW), Err(NOT_OPEN));
642 }
643
644 #[test]
645 fn an_answered_invitation_says_how_it_was_answered() {
646 let accepted = InviteRow {
647 redeemed_at: Some(NOW.into()),
648 applied_at: Some(NOW.into()),
649 accepted_at: Some(NOW.into()),
650 ..invitation("account")
651 };
652 assert_eq!(accepted.status(NOW), InviteStatus::Redeemed);
653 let declined = InviteRow { declined_at: Some(NOW.into()), ..invitation("workspace") };
654 assert_eq!(declined.status(NOW), InviteStatus::Declined);
655 // Not answered in time: expired, though the account it made stays.
656 let late = InviteRow {
657 redeemed_at: Some(EARLIER.into()),
658 applied_at: Some(EARLIER.into()),
659 expires_at: EARLIER.into(),
660 ..invitation("account")
661 };
662 assert_eq!(late.status(NOW), InviteStatus::Expired);
663 // An existing account's open invitation is pending.
664 assert_eq!(invitation("workspace").status(NOW), InviteStatus::Pending);
665 }
666
667 #[test]
668 fn a_new_account_gets_its_own_workspace_unless_its_invite_brings_it_into_one() {
669 // No invite, a shared link, or an invite that names no workspace.
670 assert!(makes_own_workspace(None, false));
671 // Brought into a workspace: none of its own, so never two.
672 assert!(!makes_own_workspace(Some("acme"), false));
673 // Into one on the free plan, which cannot take it: its own as well.
674 assert!(makes_own_workspace(Some("acme"), true));
675 }
676
677 #[test]
678 fn people_are_found_by_username_prefix_or_name_with_wildcards_taken_literally() {
679 assert_eq!(people_patterns(" @Ada "), Some(("ada%".to_owned(), "%ada%".to_owned())));
680 assert_eq!(people_patterns("a_b%"), Some(("a\\_b\\%%".to_owned(), "%a\\_b\\%%".to_owned())));
681 assert_eq!(people_patterns(" @ "), None);
682 assert_eq!(people_patterns(""), None);
683 }
684}

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