Add REST APIs for signing the CLA, retrieving users who've signed the CLA (#4202)

Max Brunsfeld created

Change summary

Cargo.lock                                                     |  1 
Cargo.toml                                                     |  1 
crates/assistant/Cargo.toml                                    |  2 
crates/collab/Cargo.toml                                       |  1 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql |  6 
crates/collab/migrations/20240122174606_add_contributors.sql   |  5 
crates/collab/src/api.rs                                       | 69 ++
crates/collab/src/bin/seed.rs                                  |  2 
crates/collab/src/db.rs                                        |  1 
crates/collab/src/db/queries.rs                                |  1 
crates/collab/src/db/queries/contributors.rs                   | 89 +++
crates/collab/src/db/queries/users.rs                          | 90 ++-
crates/collab/src/db/tables.rs                                 |  1 
crates/collab/src/db/tables/contributor.rs                     | 30 +
crates/collab/src/db/tables/user.rs                            |  2 
crates/collab/src/db/tests.rs                                  |  1 
crates/collab/src/db/tests/contributor_tests.rs                | 37 +
crates/collab/src/db/tests/db_tests.rs                         | 44 -
18 files changed, 305 insertions(+), 78 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1463,6 +1463,7 @@ dependencies = [
  "base64 0.13.1",
  "call",
  "channel",
+ "chrono",
  "clap 3.2.25",
  "client",
  "clock",

Cargo.toml 🔗

@@ -93,6 +93,7 @@ resolver = "2"
 anyhow = { version = "1.0.57" }
 async-trait = { version = "0.1" }
 async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
+chrono = { version = "0.4", features = ["serde"] }
 ctor = "0.2.6"
 derive_more = { version = "0.99.17" }
 env_logger = { version = "0.9" }

crates/assistant/Cargo.toml 🔗

@@ -30,7 +30,7 @@ workspace = { path = "../workspace" }
 uuid.workspace = true
 log.workspace = true
 anyhow.workspace = true
-chrono = { version = "0.4", features = ["serde"] }
+chrono.workspace = true
 futures.workspace = true
 indoc.workspace = true
 isahc.workspace = true

crates/collab/Cargo.toml 🔗

@@ -27,6 +27,7 @@ axum = { version = "0.5", features = ["json", "headers", "ws"] }
 axum-extra = { version = "0.3", features = ["erased-json"] }
 base64 = "0.13"
 clap = { version = "3.1", features = ["derive"], optional = true }
+chrono.workspace = true
 dashmap = "5.4"
 envy = "0.4.2"
 futures.workspace = true

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -344,3 +344,9 @@ CREATE INDEX
     "index_notifications_on_recipient_id_is_read_kind_entity_id"
     ON "notifications"
     ("recipient_id", "is_read", "kind", "entity_id");
+
+CREATE TABLE contributors (
+    user_id INTEGER REFERENCES users(id),
+    signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (user_id)
+);

crates/collab/src/api.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     auth,
-    db::{User, UserId},
+    db::{ContributorSelector, User, UserId},
     rpc, AppState, Error, Result,
 };
 use anyhow::anyhow;
@@ -14,6 +14,7 @@ use axum::{
     Extension, Json, Router,
 };
 use axum_extra::response::ErasedJson;
+use chrono::SecondsFormat;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use tower::ServiceBuilder;
@@ -25,6 +26,8 @@ pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body
         .route("/users/:id/access_tokens", post(create_access_token))
         .route("/panic", post(trace_panic))
         .route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
+        .route("/contributors", get(get_contributors).post(add_contributor))
+        .route("/contributor", get(check_is_contributor))
         .layer(
             ServiceBuilder::new()
                 .layer(Extension(state))
@@ -66,7 +69,7 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
 
 #[derive(Debug, Deserialize)]
 struct AuthenticatedUserParams {
-    github_user_id: Option<i32>,
+    github_user_id: i32,
     github_login: String,
     github_email: Option<String>,
 }
@@ -88,8 +91,7 @@ async fn get_authenticated_user(
             params.github_user_id,
             params.github_email.as_deref(),
         )
-        .await?
-        .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "user not found".into()))?;
+        .await?;
     let metrics_id = app.db.get_user_metrics_id(user.id).await?;
     return Ok(Json(AuthenticatedUserResponse { user, metrics_id }));
 }
@@ -133,6 +135,65 @@ async fn get_rpc_server_snapshot(
     Ok(ErasedJson::pretty(rpc_server.snapshot().await))
 }
 
+async fn get_contributors(Extension(app): Extension<Arc<AppState>>) -> Result<Json<Vec<String>>> {
+    Ok(Json(app.db.get_contributors().await?))
+}
+
+#[derive(Debug, Deserialize)]
+struct CheckIsContributorParams {
+    github_user_id: Option<i32>,
+    github_login: Option<String>,
+}
+
+impl CheckIsContributorParams {
+    fn as_contributor_selector(self) -> Result<ContributorSelector> {
+        if let Some(github_user_id) = self.github_user_id {
+            return Ok(ContributorSelector::GitHubUserId { github_user_id });
+        }
+
+        if let Some(github_login) = self.github_login {
+            return Ok(ContributorSelector::GitHubLogin { github_login });
+        }
+
+        Err(anyhow!(
+            "must be one of `github_user_id` or `github_login`."
+        ))?
+    }
+}
+
+#[derive(Debug, Serialize)]
+struct CheckIsContributorResponse {
+    signed_at: Option<String>,
+}
+
+async fn check_is_contributor(
+    Extension(app): Extension<Arc<AppState>>,
+    Query(params): Query<CheckIsContributorParams>,
+) -> Result<Json<CheckIsContributorResponse>> {
+    let params = params.as_contributor_selector()?;
+    Ok(Json(CheckIsContributorResponse {
+        signed_at: app
+            .db
+            .get_contributor_sign_timestamp(&params)
+            .await?
+            .map(|ts| ts.and_utc().to_rfc3339_opts(SecondsFormat::Millis, true)),
+    }))
+}
+
+async fn add_contributor(
+    Json(params): Json<AuthenticatedUserParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<()> {
+    Ok(app
+        .db
+        .add_contributor(
+            &params.github_login,
+            params.github_user_id,
+            params.github_email.as_deref(),
+        )
+        .await?)
+}
+
 #[derive(Deserialize)]
 struct CreateAccessTokenQueryParams {
     public_key: String,

crates/collab/src/bin/seed.rs 🔗

@@ -68,7 +68,7 @@ async fn main() {
             user_count += 1;
             db.get_or_create_user_by_github_account(
                 &github_user.login,
-                Some(github_user.id),
+                github_user.id,
                 github_user.email.as_deref(),
             )
             .await

crates/collab/src/db.rs 🔗

@@ -44,6 +44,7 @@ use tables::*;
 use tokio::sync::{Mutex, OwnedMutexGuard};
 
 pub use ids::*;
+pub use queries::contributors::ContributorSelector;
 pub use sea_orm::ConnectOptions;
 pub use tables::user::Model as User;
 

crates/collab/src/db/queries.rs 🔗

@@ -4,6 +4,7 @@ pub mod access_tokens;
 pub mod buffers;
 pub mod channels;
 pub mod contacts;
+pub mod contributors;
 pub mod messages;
 pub mod notifications;
 pub mod projects;

crates/collab/src/db/queries/contributors.rs 🔗

@@ -0,0 +1,89 @@
+use super::*;
+
+#[derive(Debug)]
+pub enum ContributorSelector {
+    GitHubUserId { github_user_id: i32 },
+    GitHubLogin { github_login: String },
+}
+
+impl Database {
+    /// Retrieves the GitHub logins of all users who have signed the CLA.
+    pub async fn get_contributors(&self) -> Result<Vec<String>> {
+        self.transaction(|tx| async move {
+            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+            enum QueryGithubLogin {
+                GithubLogin,
+            }
+
+            Ok(contributor::Entity::find()
+                .inner_join(user::Entity)
+                .order_by_asc(contributor::Column::SignedAt)
+                .select_only()
+                .column(user::Column::GithubLogin)
+                .into_values::<_, QueryGithubLogin>()
+                .all(&*tx)
+                .await?)
+        })
+        .await
+    }
+
+    /// Records that a given user has signed the CLA.
+    pub async fn get_contributor_sign_timestamp(
+        &self,
+        selector: &ContributorSelector,
+    ) -> Result<Option<DateTime>> {
+        self.transaction(|tx| async move {
+            let condition = match selector {
+                ContributorSelector::GitHubUserId { github_user_id } => {
+                    user::Column::GithubUserId.eq(*github_user_id)
+                }
+                ContributorSelector::GitHubLogin { github_login } => {
+                    user::Column::GithubLogin.eq(github_login)
+                }
+            };
+
+            let Some(user) = user::Entity::find().filter(condition).one(&*tx).await? else {
+                return Ok(None);
+            };
+            let Some(contributor) = contributor::Entity::find_by_id(user.id).one(&*tx).await?
+            else {
+                return Ok(None);
+            };
+            Ok(Some(contributor.signed_at))
+        })
+        .await
+    }
+
+    /// Records that a given user has signed the CLA.
+    pub async fn add_contributor(
+        &self,
+        github_login: &str,
+        github_user_id: i32,
+        github_email: Option<&str>,
+    ) -> Result<()> {
+        self.transaction(|tx| async move {
+            let user = self
+                .get_or_create_user_by_github_account_tx(
+                    github_login,
+                    github_user_id,
+                    github_email,
+                    &*tx,
+                )
+                .await?;
+
+            contributor::Entity::insert(contributor::ActiveModel {
+                user_id: ActiveValue::Set(user.id),
+                signed_at: ActiveValue::NotSet,
+            })
+            .on_conflict(
+                OnConflict::column(contributor::Column::UserId)
+                    .do_nothing()
+                    .to_owned(),
+            )
+            .exec_without_returning(&*tx)
+            .await?;
+            Ok(())
+        })
+        .await
+    }
+}

crates/collab/src/db/queries/users.rs 🔗

@@ -72,53 +72,61 @@ impl Database {
     pub async fn get_or_create_user_by_github_account(
         &self,
         github_login: &str,
-        github_user_id: Option<i32>,
+        github_user_id: i32,
         github_email: Option<&str>,
-    ) -> Result<Option<User>> {
+    ) -> Result<User> {
         self.transaction(|tx| async move {
-            let tx = &*tx;
-            if let Some(github_user_id) = github_user_id {
-                if let Some(user_by_github_user_id) = user::Entity::find()
-                    .filter(user::Column::GithubUserId.eq(github_user_id))
-                    .one(tx)
-                    .await?
-                {
-                    let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
-                    user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
-                    Ok(Some(user_by_github_user_id.update(tx).await?))
-                } else if let Some(user_by_github_login) = user::Entity::find()
-                    .filter(user::Column::GithubLogin.eq(github_login))
-                    .one(tx)
-                    .await?
-                {
-                    let mut user_by_github_login = user_by_github_login.into_active_model();
-                    user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
-                    Ok(Some(user_by_github_login.update(tx).await?))
-                } else {
-                    let user = user::Entity::insert(user::ActiveModel {
-                        email_address: ActiveValue::set(github_email.map(|email| email.into())),
-                        github_login: ActiveValue::set(github_login.into()),
-                        github_user_id: ActiveValue::set(Some(github_user_id)),
-                        admin: ActiveValue::set(false),
-                        invite_count: ActiveValue::set(0),
-                        invite_code: ActiveValue::set(None),
-                        metrics_id: ActiveValue::set(Uuid::new_v4()),
-                        ..Default::default()
-                    })
-                    .exec_with_returning(&*tx)
-                    .await?;
-                    Ok(Some(user))
-                }
-            } else {
-                Ok(user::Entity::find()
-                    .filter(user::Column::GithubLogin.eq(github_login))
-                    .one(tx)
-                    .await?)
-            }
+            self.get_or_create_user_by_github_account_tx(
+                github_login,
+                github_user_id,
+                github_email,
+                &*tx,
+            )
+            .await
         })
         .await
     }
 
+    pub async fn get_or_create_user_by_github_account_tx(
+        &self,
+        github_login: &str,
+        github_user_id: i32,
+        github_email: Option<&str>,
+        tx: &DatabaseTransaction,
+    ) -> Result<User> {
+        if let Some(user_by_github_user_id) = user::Entity::find()
+            .filter(user::Column::GithubUserId.eq(github_user_id))
+            .one(tx)
+            .await?
+        {
+            let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
+            user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
+            Ok(user_by_github_user_id.update(tx).await?)
+        } else if let Some(user_by_github_login) = user::Entity::find()
+            .filter(user::Column::GithubLogin.eq(github_login))
+            .one(tx)
+            .await?
+        {
+            let mut user_by_github_login = user_by_github_login.into_active_model();
+            user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
+            Ok(user_by_github_login.update(tx).await?)
+        } else {
+            let user = user::Entity::insert(user::ActiveModel {
+                email_address: ActiveValue::set(github_email.map(|email| email.into())),
+                github_login: ActiveValue::set(github_login.into()),
+                github_user_id: ActiveValue::set(Some(github_user_id)),
+                admin: ActiveValue::set(false),
+                invite_count: ActiveValue::set(0),
+                invite_code: ActiveValue::set(None),
+                metrics_id: ActiveValue::set(Uuid::new_v4()),
+                ..Default::default()
+            })
+            .exec_with_returning(&*tx)
+            .await?;
+            Ok(user)
+        }
+    }
+
     /// get_all_users returns the next page of users. To get more call again with
     /// the same limit and the page incremented by 1.
     pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>> {

crates/collab/src/db/tables.rs 🔗

@@ -9,6 +9,7 @@ pub mod channel_member;
 pub mod channel_message;
 pub mod channel_message_mention;
 pub mod contact;
+pub mod contributor;
 pub mod feature_flag;
 pub mod follower;
 pub mod language_server;

crates/collab/src/db/tables/contributor.rs 🔗

@@ -0,0 +1,30 @@
+use crate::db::UserId;
+use sea_orm::entity::prelude::*;
+use serde::Serialize;
+
+/// A user who has signed the CLA.
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
+#[sea_orm(table_name = "contributors")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub user_id: UserId,
+    pub signed_at: DateTime,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+    #[sea_orm(
+        belongs_to = "super::user::Entity",
+        from = "Column::UserId",
+        to = "super::user::Column::Id"
+    )]
+    User,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+impl Related<super::user::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::User.def()
+    }
+}

crates/collab/src/db/tables/user.rs 🔗

@@ -31,6 +31,8 @@ pub enum Relation {
     ChannelMemberships,
     #[sea_orm(has_many = "super::user_feature::Entity")]
     UserFeatures,
+    #[sea_orm(has_one = "super::contributor::Entity")]
+    Contributor,
 }
 
 impl Related<super::access_token::Entity> for Entity {

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

@@ -1,5 +1,6 @@
 mod buffer_tests;
 mod channel_tests;
+mod contributor_tests;
 mod db_tests;
 mod feature_flag_tests;
 mod message_tests;

crates/collab/src/db/tests/contributor_tests.rs 🔗

@@ -0,0 +1,37 @@
+use super::Database;
+use crate::{db::NewUserParams, test_both_dbs};
+use std::sync::Arc;
+
+test_both_dbs!(
+    test_contributors,
+    test_contributors_postgres,
+    test_contributors_sqlite
+);
+
+async fn test_contributors(db: &Arc<Database>) {
+    db.create_user(
+        &format!("user1@example.com"),
+        false,
+        NewUserParams {
+            github_login: format!("user1"),
+            github_user_id: 1,
+        },
+    )
+    .await
+    .unwrap()
+    .user_id;
+
+    assert_eq!(db.get_contributors().await.unwrap(), Vec::<String>::new());
+
+    db.add_contributor("user1", 1, None).await.unwrap();
+    assert_eq!(
+        db.get_contributors().await.unwrap(),
+        vec!["user1".to_string()]
+    );
+
+    db.add_contributor("user2", 2, None).await.unwrap();
+    assert_eq!(
+        db.get_contributors().await.unwrap(),
+        vec!["user1".to_string(), "user2".to_string()]
+    );
+}

crates/collab/src/db/tests/db_tests.rs 🔗

@@ -80,18 +80,17 @@ test_both_dbs!(
 );
 
 async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
-    let user_id1 = db
-        .create_user(
-            "user1@example.com",
-            false,
-            NewUserParams {
-                github_login: "login1".into(),
-                github_user_id: 101,
-            },
-        )
-        .await
-        .unwrap()
-        .user_id;
+    db.create_user(
+        "user1@example.com",
+        false,
+        NewUserParams {
+            github_login: "login1".into(),
+            github_user_id: 101,
+        },
+    )
+    .await
+    .unwrap()
+    .user_id;
     let user_id2 = db
         .create_user(
             "user2@example.com",
@@ -106,33 +105,16 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
         .user_id;
 
     let user = db
-        .get_or_create_user_by_github_account("login1", None, None)
+        .get_or_create_user_by_github_account("the-new-login2", 102, None)
         .await
-        .unwrap()
-        .unwrap();
-    assert_eq!(user.id, user_id1);
-    assert_eq!(&user.github_login, "login1");
-    assert_eq!(user.github_user_id, Some(101));
-
-    assert!(db
-        .get_or_create_user_by_github_account("non-existent-login", None, None)
-        .await
-        .unwrap()
-        .is_none());
-
-    let user = db
-        .get_or_create_user_by_github_account("the-new-login2", Some(102), None)
-        .await
-        .unwrap()
         .unwrap();
     assert_eq!(user.id, user_id2);
     assert_eq!(&user.github_login, "the-new-login2");
     assert_eq!(user.github_user_id, Some(102));
 
     let user = db
-        .get_or_create_user_by_github_account("login3", Some(103), Some("user3@example.com"))
+        .get_or_create_user_by_github_account("login3", 103, Some("user3@example.com"))
         .await
-        .unwrap()
         .unwrap();
     assert_eq!(&user.github_login, "login3");
     assert_eq!(user.github_user_id, Some(103));