Skip to content
684 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 /// 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,
186 note: Option<&str>,
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;
251 self.send_invite_email(email, Some(&from), Some(&workspace), true, code, &invite.id, note).await;
252 }
253 if let Some(row) = self.invite_by_id(&invite.id).await? {
254 self.invitation_sent(&row, &person.username).await;
255 }
256 self.audit_invites(
257 actor,
258 "invite.created",
259 vec![slug.to_owned()],
260 surface,
261 format!("Invited @{} to {slug} as {}", person.username, as_role(role)),
262 )
263 .await;
264 Ok(Outcome::Ok(invite))
265 }
266
267 /// Tells `username` in their inbox that they are invited to the
268 /// invite's workspace.
269 pub(crate) async fn invitation_sent(&self, row: &InviteRow, username: &str) {
270 let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) else {
271 return;
272 };
273 let workspace = self.workspace_name(workspace_id, slug).await;
274 let from = match &row.inviter {
275 Some(inviter) => format!("@{inviter}"),
276 None => "The g1t team".to_owned(),
277 };
278 self.invitation_notice(
279 "workspace_invitation.created",
280 row.inviter_id.as_deref(),
281 row,
282 vec![username.to_owned()],
283 format!("{from} invited you to join {workspace}"),
284 format!("Join as {}, or decline. The invitation works until {}.", as_role(row.joins_as()), &row.expires_at[..10.min(row.expires_at.len())]),
285 "/invitations".to_owned(),
286 )
287 .await;
288 }
289
290 #[allow(clippy::too_many_arguments)]
291 async fn invitation_notice(
292 &self,
293 kind: &'static str,
294 actor: Option<&str>,
295 row: &InviteRow,
296 notify: Vec<String>,
297 title: String,
298 body: String,
299 link: String,
300 ) {
301 let slug = row.workspace.as_deref().unwrap_or_default();
302 self.announce(
303 kind,
304 actor,
305 InvitationNotice {
306 workspace: slug,
307 invitation_id: &row.id,
308 thread: format!("invitation:{}", row.id),
309 notify,
310 title,
311 body,
312 link,
313 },
314 )
315 .await;
316 }
317
318 /// `list_invitations`: the workspace invitations waiting for `user`'s
319 /// answer, newest first.
320 pub async fn list_invitations(&self, a: UserArgs) -> Result<Vec<WorkspaceInvitation>> {
321 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
322 return Ok(Vec::new());
323 }
324 let rows = self
325 .db
326 .prepare(format!(
327 "SELECT i.id, w.slug, w.name, w.avatar, i.role, i.created_at, i.expires_at,
328 iu.username AS inviter, iu.display_name AS inviter_name, iu.avatar AS inviter_avatar
329 FROM invites i
330 JOIN workspaces w ON w.id = i.workspace_id AND w.deleted_at IS NULL
331 LEFT JOIN users iu ON iu.id = i.inviter_id
332 WHERE i.invitee_id = ?1 AND i.accepted_at IS NULL AND i.declined_at IS NULL
333 AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}
334 AND ((i.kind = 'workspace' AND i.redeemed_at IS NULL)
335 OR (i.kind = 'account' AND i.redeemed_at IS NOT NULL AND i.applied_at IS NOT NULL))
336 AND NOT EXISTS (SELECT 1 FROM workspace_members m WHERE m.workspace_id = i.workspace_id AND m.user_id = ?1)
337 ORDER BY i.created_at DESC, i.id DESC LIMIT {MAX_INVITATIONS}"
338 ))
339 .bind(&[a.user.id.as_str().into()])?
340 .all()
341 .await?
342 .results::<Pending>()?;
343 Ok(rows
344 .into_iter()
345 .map(|row| WorkspaceInvitation {
346 id: row.id,
347 workspace: ProfileWorkspace { slug: row.slug, name: row.name, avatar: row.avatar },
348 role: if row.role.as_deref() == Some("owner") { Role::Owner } else { Role::Member },
349 invited_by: row.inviter.map(|username| InviteFrom {
350 username,
351 name: row.inviter_name,
352 avatar: row.inviter_avatar,
353 }),
354 created_at: row.created_at,
355 expires_at: row.expires_at,
356 })
357 .collect())
358 }
359
360 /// The invitation `id` if it is `user`'s and can be answered.
361 async fn open_invitation(&self, user: &User, id: &str) -> Result<std::result::Result<InviteRow, &'static str>> {
362 let row = self
363 .invite_by_id(id)
364 .await?
365 .filter(|row| row.invitee_id.as_deref() == Some(user.id.as_str()));
366 let Some(row) = row else {
367 return Ok(Err(NOT_OPEN));
368 };
369 Ok(answerable(&row, &rfc3339(now_ms())).map(|()| row))
370 }
371
372 /// `accept_invitation`: joins the invitation's workspace with its role.
373 pub async fn accept_invitation(&self, a: InvitationArgs) -> Result<Outcome<String>> {
374 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
375 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invitation."));
376 }
377 if !a.user.verified {
378 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then accept the invitation."));
379 }
380 let row = match self.open_invitation(&a.user, &a.id).await? {
381 Ok(row) => row,
382 Err(why) => return Ok(Outcome::fail(FailureCode::NotFound, why)),
383 };
384 let (Some(workspace_id), Some(slug)) = (row.workspace_id.clone(), row.workspace.clone()) else {
385 return Ok(Outcome::fail(FailureCode::NotFound, NOT_OPEN));
386 };
387 // What the workspace asks of its members (security.rs).
388 if let Some(why) = self.policy_refusal(&a.user.id, &slug).await? {
389 return Ok(Outcome::fail(FailureCode::Forbidden, why));
390 }
391 // A free workspace adds no one until it starts the plan (paid.rs);
392 // the invitation stays open until then.
393 if let Some(refused) = self.free_workspace_refusal(&slug).await? {
394 return Ok(refused);
395 }
396 let role = row.joins_as();
397 let now = rfc3339(now_ms());
398 let user = JsValue::from(a.user.id.as_str());
399 let id = JsValue::from(row.id.as_str());
400 let at = JsValue::from(now.as_str());
401 self.db
402 .batch(vec![
403 self.db
404 .prepare(
405 "UPDATE invites SET accepted_at = ?3, redeemed_by = COALESCE(redeemed_by, ?2),
406 redeemed_at = COALESCE(redeemed_at, ?3), applied_at = COALESCE(applied_at, ?3), sealed_code = NULL
407 WHERE id = ?1 AND invitee_id = ?2 AND accepted_at IS NULL AND declined_at IS NULL
408 AND revoked_at IS NULL AND expires_at > ?3",
409 )
410 .bind(&[id.clone(), user.clone(), at.clone()])?,
411 self.db
412 .prepare(
413 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
414 SELECT ?4, ?2, ?5, ?3
415 WHERE EXISTS (SELECT 1 FROM invites WHERE id = ?1 AND invitee_id = ?2 AND accepted_at = ?3)
416 AND EXISTS (SELECT 1 FROM workspaces WHERE id = ?4 AND deleted_at IS NULL)",
417 )
418 .bind(&[
419 id.clone(),
420 user,
421 at,
422 workspace_id.as_str().into(),
423 if role == Role::Owner { "owner" } else { "member" }.into(),
424 ])?,
425 ])
426 .await?;
427 #[derive(Deserialize)]
428 struct Accepted {
429 accepted_at: Option<String>,
430 }
431 let accepted = self
432 .db
433 .prepare("SELECT accepted_at FROM invites WHERE id = ?")
434 .bind(&[id])?
435 .first::<Accepted>(None)
436 .await?
437 .and_then(|row| row.accepted_at);
438 if accepted.as_deref() != Some(now.as_str()) {
439 return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN));
440 }
441 // 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;
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}