From 4f864a20a7cfede662091f3f71c8ba2aba71d295 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 1 Dec 2022 11:10:51 +0100 Subject: [PATCH] Implement invite codes using sea-orm --- crates/collab/src/db2.rs | 220 ++++++++++++++++++ crates/collab/src/db2/signup.rs | 33 +++ crates/collab/src/db2/tests.rs | 386 ++++++++++++++++---------------- 3 files changed, 446 insertions(+), 193 deletions(-) create mode 100644 crates/collab/src/db2/signup.rs diff --git a/crates/collab/src/db2.rs b/crates/collab/src/db2.rs index b69f7f32a4c3cb2b10bf5e29ef767002cde6860f..75329f926894d8df21fcd52888822b68638a2f56 100644 --- a/crates/collab/src/db2.rs +++ b/crates/collab/src/db2.rs @@ -4,6 +4,7 @@ mod project; mod project_collaborator; mod room; mod room_participant; +mod signup; #[cfg(test)] mod tests; mod user; @@ -14,6 +15,7 @@ use anyhow::anyhow; use collections::HashMap; use dashmap::DashMap; use futures::StreamExt; +use hyper::StatusCode; use rpc::{proto, ConnectionId}; use sea_orm::{ entity::prelude::*, ConnectOptions, DatabaseConnection, DatabaseTransaction, DbErr, @@ -34,6 +36,7 @@ use std::{future::Future, marker::PhantomData, rc::Rc, sync::Arc}; use tokio::sync::{Mutex, OwnedMutexGuard}; pub use contact::Contact; +pub use signup::Invite; pub use user::Model as User; pub struct Database { @@ -523,6 +526,222 @@ impl Database { .await } + // invite codes + + pub async fn create_invite_from_code( + &self, + code: &str, + email_address: &str, + device_id: Option<&str>, + ) -> Result { + self.transact(|tx| async move { + let existing_user = user::Entity::find() + .filter(user::Column::EmailAddress.eq(email_address)) + .one(&tx) + .await?; + + if existing_user.is_some() { + Err(anyhow!("email address is already in use"))?; + } + + let inviter = match user::Entity::find() + .filter(user::Column::InviteCode.eq(code)) + .one(&tx) + .await? + { + Some(inviter) => inviter, + None => { + return Err(Error::Http( + StatusCode::NOT_FOUND, + "invite code not found".to_string(), + ))? + } + }; + + if inviter.invite_count == 0 { + Err(Error::Http( + StatusCode::UNAUTHORIZED, + "no invites remaining".to_string(), + ))?; + } + + let signup = signup::Entity::insert(signup::ActiveModel { + email_address: ActiveValue::set(email_address.into()), + email_confirmation_code: ActiveValue::set(random_email_confirmation_code()), + email_confirmation_sent: ActiveValue::set(false), + inviting_user_id: ActiveValue::set(Some(inviter.id)), + platform_linux: ActiveValue::set(false), + platform_mac: ActiveValue::set(false), + platform_windows: ActiveValue::set(false), + platform_unknown: ActiveValue::set(true), + device_id: ActiveValue::set(device_id.map(|device_id| device_id.into())), + ..Default::default() + }) + .on_conflict( + OnConflict::column(signup::Column::EmailAddress) + .update_column(signup::Column::InvitingUserId) + .to_owned(), + ) + .exec_with_returning(&tx) + .await?; + tx.commit().await?; + + Ok(Invite { + email_address: signup.email_address, + email_confirmation_code: signup.email_confirmation_code, + }) + }) + .await + } + + pub async fn create_user_from_invite( + &self, + invite: &Invite, + user: NewUserParams, + ) -> Result> { + self.transact(|tx| async { + let tx = tx; + let signup = signup::Entity::find() + .filter( + signup::Column::EmailAddress + .eq(invite.email_address.as_str()) + .and( + signup::Column::EmailConfirmationCode + .eq(invite.email_confirmation_code.as_str()), + ), + ) + .one(&tx) + .await? + .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "no such invite".to_string()))?; + + if signup.user_id.is_some() { + return Ok(None); + } + + let user = user::Entity::insert(user::ActiveModel { + email_address: ActiveValue::set(Some(invite.email_address.clone())), + github_login: ActiveValue::set(user.github_login.clone()), + github_user_id: ActiveValue::set(Some(user.github_user_id)), + admin: ActiveValue::set(false), + invite_count: ActiveValue::set(user.invite_count), + invite_code: ActiveValue::set(Some(random_invite_code())), + metrics_id: ActiveValue::set(Uuid::new_v4()), + ..Default::default() + }) + .on_conflict( + OnConflict::column(user::Column::GithubLogin) + .update_columns([ + user::Column::EmailAddress, + user::Column::GithubUserId, + user::Column::Admin, + ]) + .to_owned(), + ) + .exec_with_returning(&tx) + .await?; + + let mut signup = signup.into_active_model(); + signup.user_id = ActiveValue::set(Some(user.id)); + let signup = signup.update(&tx).await?; + + if let Some(inviting_user_id) = signup.inviting_user_id { + let result = user::Entity::update_many() + .filter( + user::Column::Id + .eq(inviting_user_id) + .and(user::Column::InviteCount.gt(0)), + ) + .col_expr( + user::Column::InviteCount, + Expr::col(user::Column::InviteCount).sub(1), + ) + .exec(&tx) + .await?; + + if result.rows_affected == 0 { + Err(Error::Http( + StatusCode::UNAUTHORIZED, + "no invites remaining".to_string(), + ))?; + } + + contact::Entity::insert(contact::ActiveModel { + user_id_a: ActiveValue::set(inviting_user_id), + user_id_b: ActiveValue::set(user.id), + a_to_b: ActiveValue::set(true), + should_notify: ActiveValue::set(true), + accepted: ActiveValue::set(true), + ..Default::default() + }) + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .exec_without_returning(&tx) + .await?; + } + + tx.commit().await?; + Ok(Some(NewUserResult { + user_id: user.id, + metrics_id: user.metrics_id.to_string(), + inviting_user_id: signup.inviting_user_id, + signup_device_id: signup.device_id, + })) + }) + .await + } + + pub async fn set_invite_count_for_user(&self, id: UserId, count: u32) -> Result<()> { + self.transact(|tx| async move { + if count > 0 { + user::Entity::update_many() + .filter( + user::Column::Id + .eq(id) + .and(user::Column::InviteCode.is_null()), + ) + .col_expr(user::Column::InviteCode, random_invite_code().into()) + .exec(&tx) + .await?; + } + + user::Entity::update_many() + .filter(user::Column::Id.eq(id)) + .col_expr(user::Column::InviteCount, count.into()) + .exec(&tx) + .await?; + tx.commit().await?; + Ok(()) + }) + .await + } + + pub async fn get_invite_code_for_user(&self, id: UserId) -> Result> { + self.transact(|tx| async move { + match user::Entity::find_by_id(id).one(&tx).await? { + Some(user) if user.invite_code.is_some() => { + Ok(Some((user.invite_code.unwrap(), user.invite_count as u32))) + } + _ => Ok(None), + } + }) + .await + } + + pub async fn get_user_for_invite_code(&self, code: &str) -> Result { + self.transact(|tx| async move { + user::Entity::find() + .filter(user::Column::InviteCode.eq(code)) + .one(&tx) + .await? + .ok_or_else(|| { + Error::Http( + StatusCode::NOT_FOUND, + "that invite code does not exist".to_string(), + ) + }) + }) + .await + } + // projects pub async fn share_project( @@ -966,6 +1185,7 @@ id_type!(RoomId); id_type!(RoomParticipantId); id_type!(ProjectId); id_type!(ProjectCollaboratorId); +id_type!(SignupId); id_type!(WorktreeId); #[cfg(test)] diff --git a/crates/collab/src/db2/signup.rs b/crates/collab/src/db2/signup.rs new file mode 100644 index 0000000000000000000000000000000000000000..ad0aa5eb824b64bafc491a7b4125f333096f8210 --- /dev/null +++ b/crates/collab/src/db2/signup.rs @@ -0,0 +1,33 @@ +use super::{SignupId, UserId}; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "signups")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: SignupId, + pub email_address: String, + pub email_confirmation_code: String, + pub email_confirmation_sent: bool, + pub created_at: DateTime, + pub device_id: Option, + pub user_id: Option, + pub inviting_user_id: Option, + pub platform_mac: bool, + pub platform_linux: bool, + pub platform_windows: bool, + pub platform_unknown: bool, + pub editor_features: Option, + pub programming_languages: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Debug)] +pub struct Invite { + pub email_address: String, + pub email_confirmation_code: String, +} diff --git a/crates/collab/src/db2/tests.rs b/crates/collab/src/db2/tests.rs index 527f70adb8ce42bab6e88f81252812604713a7a6..468d0074d4fe28bd87883c263f503fee7f68fdd3 100644 --- a/crates/collab/src/db2/tests.rs +++ b/crates/collab/src/db2/tests.rs @@ -457,210 +457,210 @@ async fn test_fuzzy_search_users() { } } -// #[gpui::test] -// async fn test_invite_codes() { -// let test_db = PostgresTestDb::new(build_background_executor()); -// let db = test_db.db(); +#[gpui::test] +async fn test_invite_codes() { + let test_db = TestDb::postgres(build_background_executor()); + let db = test_db.db(); -// let NewUserResult { user_id: user1, .. } = db -// .create_user( -// "user1@example.com", -// false, -// NewUserParams { -// github_login: "user1".into(), -// github_user_id: 0, -// invite_count: 0, -// }, -// ) -// .await -// .unwrap(); + let NewUserResult { user_id: user1, .. } = db + .create_user( + "user1@example.com", + false, + NewUserParams { + github_login: "user1".into(), + github_user_id: 0, + invite_count: 0, + }, + ) + .await + .unwrap(); -// // Initially, user 1 has no invite code -// assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None); + // Initially, user 1 has no invite code + assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None); -// // Setting invite count to 0 when no code is assigned does not assign a new code -// db.set_invite_count_for_user(user1, 0).await.unwrap(); -// assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none()); + // Setting invite count to 0 when no code is assigned does not assign a new code + db.set_invite_count_for_user(user1, 0).await.unwrap(); + assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none()); -// // User 1 creates an invite code that can be used twice. -// db.set_invite_count_for_user(user1, 2).await.unwrap(); -// let (invite_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); -// assert_eq!(invite_count, 2); + // User 1 creates an invite code that can be used twice. + db.set_invite_count_for_user(user1, 2).await.unwrap(); + let (invite_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 2); -// // User 2 redeems the invite code and becomes a contact of user 1. -// let user2_invite = db -// .create_invite_from_code(&invite_code, "user2@example.com", Some("user-2-device-id")) -// .await -// .unwrap(); -// let NewUserResult { -// user_id: user2, -// inviting_user_id, -// signup_device_id, -// metrics_id, -// } = db -// .create_user_from_invite( -// &user2_invite, -// NewUserParams { -// github_login: "user2".into(), -// github_user_id: 2, -// invite_count: 7, -// }, -// ) -// .await -// .unwrap() -// .unwrap(); -// let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); -// assert_eq!(invite_count, 1); -// assert_eq!(inviting_user_id, Some(user1)); -// assert_eq!(signup_device_id.unwrap(), "user-2-device-id"); -// assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id); -// assert_eq!( -// db.get_contacts(user1).await.unwrap(), -// [Contact::Accepted { -// user_id: user2, -// should_notify: true, -// busy: false, -// }] -// ); -// assert_eq!( -// db.get_contacts(user2).await.unwrap(), -// [Contact::Accepted { -// user_id: user1, -// should_notify: false, -// busy: false, -// }] -// ); -// assert_eq!( -// db.get_invite_code_for_user(user2).await.unwrap().unwrap().1, -// 7 -// ); + // User 2 redeems the invite code and becomes a contact of user 1. + let user2_invite = db + .create_invite_from_code(&invite_code, "user2@example.com", Some("user-2-device-id")) + .await + .unwrap(); + let NewUserResult { + user_id: user2, + inviting_user_id, + signup_device_id, + metrics_id, + } = db + .create_user_from_invite( + &user2_invite, + NewUserParams { + github_login: "user2".into(), + github_user_id: 2, + invite_count: 7, + }, + ) + .await + .unwrap() + .unwrap(); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 1); + assert_eq!(inviting_user_id, Some(user1)); + assert_eq!(signup_device_id.unwrap(), "user-2-device-id"); + assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id); + assert_eq!( + db.get_contacts(user1).await.unwrap(), + [Contact::Accepted { + user_id: user2, + should_notify: true, + busy: false, + }] + ); + assert_eq!( + db.get_contacts(user2).await.unwrap(), + [Contact::Accepted { + user_id: user1, + should_notify: false, + busy: false, + }] + ); + assert_eq!( + db.get_invite_code_for_user(user2).await.unwrap().unwrap().1, + 7 + ); -// // User 3 redeems the invite code and becomes a contact of user 1. -// let user3_invite = db -// .create_invite_from_code(&invite_code, "user3@example.com", None) -// .await -// .unwrap(); -// let NewUserResult { -// user_id: user3, -// inviting_user_id, -// signup_device_id, -// .. -// } = db -// .create_user_from_invite( -// &user3_invite, -// NewUserParams { -// github_login: "user-3".into(), -// github_user_id: 3, -// invite_count: 3, -// }, -// ) -// .await -// .unwrap() -// .unwrap(); -// let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); -// assert_eq!(invite_count, 0); -// assert_eq!(inviting_user_id, Some(user1)); -// assert!(signup_device_id.is_none()); -// assert_eq!( -// db.get_contacts(user1).await.unwrap(), -// [ -// Contact::Accepted { -// user_id: user2, -// should_notify: true, -// busy: false, -// }, -// Contact::Accepted { -// user_id: user3, -// should_notify: true, -// busy: false, -// } -// ] -// ); -// assert_eq!( -// db.get_contacts(user3).await.unwrap(), -// [Contact::Accepted { -// user_id: user1, -// should_notify: false, -// busy: false, -// }] -// ); -// assert_eq!( -// db.get_invite_code_for_user(user3).await.unwrap().unwrap().1, -// 3 -// ); + // User 3 redeems the invite code and becomes a contact of user 1. + let user3_invite = db + .create_invite_from_code(&invite_code, "user3@example.com", None) + .await + .unwrap(); + let NewUserResult { + user_id: user3, + inviting_user_id, + signup_device_id, + .. + } = db + .create_user_from_invite( + &user3_invite, + NewUserParams { + github_login: "user-3".into(), + github_user_id: 3, + invite_count: 3, + }, + ) + .await + .unwrap() + .unwrap(); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 0); + assert_eq!(inviting_user_id, Some(user1)); + assert!(signup_device_id.is_none()); + assert_eq!( + db.get_contacts(user1).await.unwrap(), + [ + Contact::Accepted { + user_id: user2, + should_notify: true, + busy: false, + }, + Contact::Accepted { + user_id: user3, + should_notify: true, + busy: false, + } + ] + ); + assert_eq!( + db.get_contacts(user3).await.unwrap(), + [Contact::Accepted { + user_id: user1, + should_notify: false, + busy: false, + }] + ); + assert_eq!( + db.get_invite_code_for_user(user3).await.unwrap().unwrap().1, + 3 + ); -// // Trying to reedem the code for the third time results in an error. -// db.create_invite_from_code(&invite_code, "user4@example.com", Some("user-4-device-id")) -// .await -// .unwrap_err(); + // Trying to reedem the code for the third time results in an error. + db.create_invite_from_code(&invite_code, "user4@example.com", Some("user-4-device-id")) + .await + .unwrap_err(); -// // Invite count can be updated after the code has been created. -// db.set_invite_count_for_user(user1, 2).await.unwrap(); -// let (latest_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); -// assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0 -// assert_eq!(invite_count, 2); + // Invite count can be updated after the code has been created. + db.set_invite_count_for_user(user1, 2).await.unwrap(); + let (latest_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0 + assert_eq!(invite_count, 2); -// // User 4 can now redeem the invite code and becomes a contact of user 1. -// let user4_invite = db -// .create_invite_from_code(&invite_code, "user4@example.com", Some("user-4-device-id")) -// .await -// .unwrap(); -// let user4 = db -// .create_user_from_invite( -// &user4_invite, -// NewUserParams { -// github_login: "user-4".into(), -// github_user_id: 4, -// invite_count: 5, -// }, -// ) -// .await -// .unwrap() -// .unwrap() -// .user_id; + // User 4 can now redeem the invite code and becomes a contact of user 1. + let user4_invite = db + .create_invite_from_code(&invite_code, "user4@example.com", Some("user-4-device-id")) + .await + .unwrap(); + let user4 = db + .create_user_from_invite( + &user4_invite, + NewUserParams { + github_login: "user-4".into(), + github_user_id: 4, + invite_count: 5, + }, + ) + .await + .unwrap() + .unwrap() + .user_id; -// let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); -// assert_eq!(invite_count, 1); -// assert_eq!( -// db.get_contacts(user1).await.unwrap(), -// [ -// Contact::Accepted { -// user_id: user2, -// should_notify: true, -// busy: false, -// }, -// Contact::Accepted { -// user_id: user3, -// should_notify: true, -// busy: false, -// }, -// Contact::Accepted { -// user_id: user4, -// should_notify: true, -// busy: false, -// } -// ] -// ); -// assert_eq!( -// db.get_contacts(user4).await.unwrap(), -// [Contact::Accepted { -// user_id: user1, -// should_notify: false, -// busy: false, -// }] -// ); -// assert_eq!( -// db.get_invite_code_for_user(user4).await.unwrap().unwrap().1, -// 5 -// ); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 1); + assert_eq!( + db.get_contacts(user1).await.unwrap(), + [ + Contact::Accepted { + user_id: user2, + should_notify: true, + busy: false, + }, + Contact::Accepted { + user_id: user3, + should_notify: true, + busy: false, + }, + Contact::Accepted { + user_id: user4, + should_notify: true, + busy: false, + } + ] + ); + assert_eq!( + db.get_contacts(user4).await.unwrap(), + [Contact::Accepted { + user_id: user1, + should_notify: false, + busy: false, + }] + ); + assert_eq!( + db.get_invite_code_for_user(user4).await.unwrap().unwrap().1, + 5 + ); -// // An existing user cannot redeem invite codes. -// db.create_invite_from_code(&invite_code, "user2@example.com", Some("user-2-device-id")) -// .await -// .unwrap_err(); -// let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); -// assert_eq!(invite_count, 1); -// } + // An existing user cannot redeem invite codes. + db.create_invite_from_code(&invite_code, "user2@example.com", Some("user-2-device-id")) + .await + .unwrap_err(); + let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap(); + assert_eq!(invite_count, 1); +} // #[gpui::test] // async fn test_signups() {