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
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,
167 note: Option<&str>,
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;
228 self.send_invite_email(email, Some(&from), Some(&workspace), true, code, &invite.id, note).await;
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 }
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;
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}