Implement signups using sea-orm

Antonio Scandurra created

Change summary

crates/collab/Cargo.toml        |   2 
crates/collab/src/db2.rs        | 102 ++++++++++++
crates/collab/src/db2/signup.rs |  29 +++
crates/collab/src/db2/tests.rs  | 290 +++++++++++++++++-----------------
4 files changed, 271 insertions(+), 152 deletions(-)

Detailed changes

crates/collab/Cargo.toml 🔗

@@ -36,7 +36,7 @@ prometheus = "0.13"
 rand = "0.8"
 reqwest = { version = "0.11", features = ["json"], optional = true }
 scrypt = "0.7"
-sea-orm = { version = "0.10", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
+sea-orm = { version = "0.10", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls"] }
 sea-query = { version = "0.27", features = ["derive"] }
 sea-query-binder = { version = "0.2", features = ["sqlx-postgres"] }
 serde = { version = "1.0", features = ["derive", "rc"] }

crates/collab/src/db2.rs 🔗

@@ -36,7 +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 signup::{Invite, NewSignup, WaitlistSummary};
 pub use user::Model as User;
 
 pub struct Database {
@@ -140,6 +140,11 @@ impl Database {
         .await
     }
 
+    pub async fn get_user_by_id(&self, id: UserId) -> Result<Option<user::Model>> {
+        self.transact(|tx| async move { Ok(user::Entity::find_by_id(id).one(&tx).await?) })
+            .await
+    }
+
     pub async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<user::Model>> {
         self.transact(|tx| async {
             let tx = tx;
@@ -322,7 +327,7 @@ impl Database {
     }
 
     pub async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> {
-        self.transact(|mut tx| async move {
+        self.transact(|tx| async move {
             let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
                 (sender_id, receiver_id, true)
             } else {
@@ -526,6 +531,99 @@ impl Database {
         .await
     }
 
+    // signups
+
+    pub async fn create_signup(&self, signup: NewSignup) -> Result<()> {
+        self.transact(|tx| async {
+            signup::ActiveModel {
+                email_address: ActiveValue::set(signup.email_address.clone()),
+                email_confirmation_code: ActiveValue::set(random_email_confirmation_code()),
+                email_confirmation_sent: ActiveValue::set(false),
+                platform_mac: ActiveValue::set(signup.platform_mac),
+                platform_windows: ActiveValue::set(signup.platform_windows),
+                platform_linux: ActiveValue::set(signup.platform_linux),
+                platform_unknown: ActiveValue::set(false),
+                editor_features: ActiveValue::set(Some(signup.editor_features.clone())),
+                programming_languages: ActiveValue::set(Some(signup.programming_languages.clone())),
+                device_id: ActiveValue::set(signup.device_id.clone()),
+                ..Default::default()
+            }
+            .insert(&tx)
+            .await?;
+            tx.commit().await?;
+            Ok(())
+        })
+        .await
+    }
+
+    pub async fn get_waitlist_summary(&self) -> Result<WaitlistSummary> {
+        self.transact(|tx| async move {
+            let query = "
+                SELECT
+                    COUNT(*) as count,
+                    COALESCE(SUM(CASE WHEN platform_linux THEN 1 ELSE 0 END), 0) as linux_count,
+                    COALESCE(SUM(CASE WHEN platform_mac THEN 1 ELSE 0 END), 0) as mac_count,
+                    COALESCE(SUM(CASE WHEN platform_windows THEN 1 ELSE 0 END), 0) as windows_count,
+                    COALESCE(SUM(CASE WHEN platform_unknown THEN 1 ELSE 0 END), 0) as unknown_count
+                FROM (
+                    SELECT *
+                    FROM signups
+                    WHERE
+                        NOT email_confirmation_sent
+                ) AS unsent
+            ";
+            Ok(
+                WaitlistSummary::find_by_statement(Statement::from_sql_and_values(
+                    self.pool.get_database_backend(),
+                    query.into(),
+                    vec![],
+                ))
+                .one(&tx)
+                .await?
+                .ok_or_else(|| anyhow!("invalid result"))?,
+            )
+        })
+        .await
+    }
+
+    pub async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> {
+        let emails = invites
+            .iter()
+            .map(|s| s.email_address.as_str())
+            .collect::<Vec<_>>();
+        self.transact(|tx| async {
+            signup::Entity::update_many()
+                .filter(signup::Column::EmailAddress.is_in(emails.iter().copied()))
+                .col_expr(signup::Column::EmailConfirmationSent, true.into())
+                .exec(&tx)
+                .await?;
+            tx.commit().await?;
+            Ok(())
+        })
+        .await
+    }
+
+    pub async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>> {
+        self.transact(|tx| async move {
+            Ok(signup::Entity::find()
+                .select_only()
+                .column(signup::Column::EmailAddress)
+                .column(signup::Column::EmailConfirmationCode)
+                .filter(
+                    signup::Column::EmailConfirmationSent.eq(false).and(
+                        signup::Column::PlatformMac
+                            .eq(true)
+                            .or(signup::Column::PlatformUnknown.eq(true)),
+                    ),
+                )
+                .limit(count as u64)
+                .into_model()
+                .all(&tx)
+                .await?)
+        })
+        .await
+    }
+
     // invite codes
 
     pub async fn create_invite_from_code(

crates/collab/src/db2/signup.rs 🔗

@@ -1,5 +1,6 @@
 use super::{SignupId, UserId};
-use sea_orm::entity::prelude::*;
+use sea_orm::{entity::prelude::*, FromQueryResult};
+use serde::{Deserialize, Serialize};
 
 #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
 #[sea_orm(table_name = "signups")]
@@ -17,8 +18,8 @@ pub struct Model {
     pub platform_linux: bool,
     pub platform_windows: bool,
     pub platform_unknown: bool,
-    pub editor_features: Option<String>,
-    pub programming_languages: Option<String>,
+    pub editor_features: Option<Vec<String>>,
+    pub programming_languages: Option<Vec<String>>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -26,8 +27,28 @@ pub enum Relation {}
 
 impl ActiveModelBehavior for ActiveModel {}
 
-#[derive(Debug)]
+#[derive(Debug, PartialEq, Eq, FromQueryResult)]
 pub struct Invite {
     pub email_address: String,
     pub email_confirmation_code: String,
 }
+
+#[derive(Clone, Deserialize)]
+pub struct NewSignup {
+    pub email_address: String,
+    pub platform_mac: bool,
+    pub platform_windows: bool,
+    pub platform_linux: bool,
+    pub editor_features: Vec<String>,
+    pub programming_languages: Vec<String>,
+    pub device_id: Option<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromQueryResult)]
+pub struct WaitlistSummary {
+    pub count: i64,
+    pub linux_count: i64,
+    pub mac_count: i64,
+    pub windows_count: i64,
+    pub unknown_count: i64,
+}

crates/collab/src/db2/tests.rs 🔗

@@ -662,151 +662,151 @@ async fn test_invite_codes() {
     assert_eq!(invite_count, 1);
 }
 
-// #[gpui::test]
-// async fn test_signups() {
-//     let test_db = PostgresTestDb::new(build_background_executor());
-//     let db = test_db.db();
-
-//     // people sign up on the waitlist
-//     for i in 0..8 {
-//         db.create_signup(Signup {
-//             email_address: format!("person-{i}@example.com"),
-//             platform_mac: true,
-//             platform_linux: i % 2 == 0,
-//             platform_windows: i % 4 == 0,
-//             editor_features: vec!["speed".into()],
-//             programming_languages: vec!["rust".into(), "c".into()],
-//             device_id: Some(format!("device_id_{i}")),
-//         })
-//         .await
-//         .unwrap();
-//     }
-
-//     assert_eq!(
-//         db.get_waitlist_summary().await.unwrap(),
-//         WaitlistSummary {
-//             count: 8,
-//             mac_count: 8,
-//             linux_count: 4,
-//             windows_count: 2,
-//             unknown_count: 0,
-//         }
-//     );
-
-//     // retrieve the next batch of signup emails to send
-//     let signups_batch1 = db.get_unsent_invites(3).await.unwrap();
-//     let addresses = signups_batch1
-//         .iter()
-//         .map(|s| &s.email_address)
-//         .collect::<Vec<_>>();
-//     assert_eq!(
-//         addresses,
-//         &[
-//             "person-0@example.com",
-//             "person-1@example.com",
-//             "person-2@example.com"
-//         ]
-//     );
-//     assert_ne!(
-//         signups_batch1[0].email_confirmation_code,
-//         signups_batch1[1].email_confirmation_code
-//     );
-
-//     // the waitlist isn't updated until we record that the emails
-//     // were successfully sent.
-//     let signups_batch = db.get_unsent_invites(3).await.unwrap();
-//     assert_eq!(signups_batch, signups_batch1);
-
-//     // once the emails go out, we can retrieve the next batch
-//     // of signups.
-//     db.record_sent_invites(&signups_batch1).await.unwrap();
-//     let signups_batch2 = db.get_unsent_invites(3).await.unwrap();
-//     let addresses = signups_batch2
-//         .iter()
-//         .map(|s| &s.email_address)
-//         .collect::<Vec<_>>();
-//     assert_eq!(
-//         addresses,
-//         &[
-//             "person-3@example.com",
-//             "person-4@example.com",
-//             "person-5@example.com"
-//         ]
-//     );
-
-//     // the sent invites are excluded from the summary.
-//     assert_eq!(
-//         db.get_waitlist_summary().await.unwrap(),
-//         WaitlistSummary {
-//             count: 5,
-//             mac_count: 5,
-//             linux_count: 2,
-//             windows_count: 1,
-//             unknown_count: 0,
-//         }
-//     );
-
-//     // user completes the signup process by providing their
-//     // github account.
-//     let NewUserResult {
-//         user_id,
-//         inviting_user_id,
-//         signup_device_id,
-//         ..
-//     } = db
-//         .create_user_from_invite(
-//             &Invite {
-//                 email_address: signups_batch1[0].email_address.clone(),
-//                 email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
-//             },
-//             NewUserParams {
-//                 github_login: "person-0".into(),
-//                 github_user_id: 0,
-//                 invite_count: 5,
-//             },
-//         )
-//         .await
-//         .unwrap()
-//         .unwrap();
-//     let user = db.get_user_by_id(user_id).await.unwrap().unwrap();
-//     assert!(inviting_user_id.is_none());
-//     assert_eq!(user.github_login, "person-0");
-//     assert_eq!(user.email_address.as_deref(), Some("person-0@example.com"));
-//     assert_eq!(user.invite_count, 5);
-//     assert_eq!(signup_device_id.unwrap(), "device_id_0");
-
-//     // cannot redeem the same signup again.
-//     assert!(db
-//         .create_user_from_invite(
-//             &Invite {
-//                 email_address: signups_batch1[0].email_address.clone(),
-//                 email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
-//             },
-//             NewUserParams {
-//                 github_login: "some-other-github_account".into(),
-//                 github_user_id: 1,
-//                 invite_count: 5,
-//             },
-//         )
-//         .await
-//         .unwrap()
-//         .is_none());
-
-//     // cannot redeem a signup with the wrong confirmation code.
-//     db.create_user_from_invite(
-//         &Invite {
-//             email_address: signups_batch1[1].email_address.clone(),
-//             email_confirmation_code: "the-wrong-code".to_string(),
-//         },
-//         NewUserParams {
-//             github_login: "person-1".into(),
-//             github_user_id: 2,
-//             invite_count: 5,
-//         },
-//     )
-//     .await
-//     .unwrap_err();
-// }
+#[gpui::test]
+async fn test_signups() {
+    let test_db = TestDb::postgres(build_background_executor());
+    let db = test_db.db();
+
+    // people sign up on the waitlist
+    for i in 0..8 {
+        db.create_signup(NewSignup {
+            email_address: format!("person-{i}@example.com"),
+            platform_mac: true,
+            platform_linux: i % 2 == 0,
+            platform_windows: i % 4 == 0,
+            editor_features: vec!["speed".into()],
+            programming_languages: vec!["rust".into(), "c".into()],
+            device_id: Some(format!("device_id_{i}")),
+        })
+        .await
+        .unwrap();
+    }
+
+    assert_eq!(
+        db.get_waitlist_summary().await.unwrap(),
+        WaitlistSummary {
+            count: 8,
+            mac_count: 8,
+            linux_count: 4,
+            windows_count: 2,
+            unknown_count: 0,
+        }
+    );
+
+    // retrieve the next batch of signup emails to send
+    let signups_batch1 = db.get_unsent_invites(3).await.unwrap();
+    let addresses = signups_batch1
+        .iter()
+        .map(|s| &s.email_address)
+        .collect::<Vec<_>>();
+    assert_eq!(
+        addresses,
+        &[
+            "person-0@example.com",
+            "person-1@example.com",
+            "person-2@example.com"
+        ]
+    );
+    assert_ne!(
+        signups_batch1[0].email_confirmation_code,
+        signups_batch1[1].email_confirmation_code
+    );
+
+    // the waitlist isn't updated until we record that the emails
+    // were successfully sent.
+    let signups_batch = db.get_unsent_invites(3).await.unwrap();
+    assert_eq!(signups_batch, signups_batch1);
+
+    // once the emails go out, we can retrieve the next batch
+    // of signups.
+    db.record_sent_invites(&signups_batch1).await.unwrap();
+    let signups_batch2 = db.get_unsent_invites(3).await.unwrap();
+    let addresses = signups_batch2
+        .iter()
+        .map(|s| &s.email_address)
+        .collect::<Vec<_>>();
+    assert_eq!(
+        addresses,
+        &[
+            "person-3@example.com",
+            "person-4@example.com",
+            "person-5@example.com"
+        ]
+    );
+
+    // the sent invites are excluded from the summary.
+    assert_eq!(
+        db.get_waitlist_summary().await.unwrap(),
+        WaitlistSummary {
+            count: 5,
+            mac_count: 5,
+            linux_count: 2,
+            windows_count: 1,
+            unknown_count: 0,
+        }
+    );
+
+    // user completes the signup process by providing their
+    // github account.
+    let NewUserResult {
+        user_id,
+        inviting_user_id,
+        signup_device_id,
+        ..
+    } = db
+        .create_user_from_invite(
+            &Invite {
+                email_address: signups_batch1[0].email_address.clone(),
+                email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
+            },
+            NewUserParams {
+                github_login: "person-0".into(),
+                github_user_id: 0,
+                invite_count: 5,
+            },
+        )
+        .await
+        .unwrap()
+        .unwrap();
+    let user = db.get_user_by_id(user_id).await.unwrap().unwrap();
+    assert!(inviting_user_id.is_none());
+    assert_eq!(user.github_login, "person-0");
+    assert_eq!(user.email_address.as_deref(), Some("person-0@example.com"));
+    assert_eq!(user.invite_count, 5);
+    assert_eq!(signup_device_id.unwrap(), "device_id_0");
+
+    // cannot redeem the same signup again.
+    assert!(db
+        .create_user_from_invite(
+            &Invite {
+                email_address: signups_batch1[0].email_address.clone(),
+                email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
+            },
+            NewUserParams {
+                github_login: "some-other-github_account".into(),
+                github_user_id: 1,
+                invite_count: 5,
+            },
+        )
+        .await
+        .unwrap()
+        .is_none());
+
+    // cannot redeem a signup with the wrong confirmation code.
+    db.create_user_from_invite(
+        &Invite {
+            email_address: signups_batch1[1].email_address.clone(),
+            email_confirmation_code: "the-wrong-code".to_string(),
+        },
+        NewUserParams {
+            github_login: "person-1".into(),
+            github_user_id: 2,
+            invite_count: 5,
+        },
+    )
+    .await
+    .unwrap_err();
+}
 
 fn build_background_executor() -> Arc<Background> {
     Deterministic::new(0).build_background()