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

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