diff --git a/Cargo.lock b/Cargo.lock index df785345e5bca37a8951e5b3c234840f18869baa..516a21308f8eb18e7358eeb60ce7843331fd0f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1463,6 +1463,7 @@ dependencies = [ "base64 0.13.1", "call", "channel", + "chrono", "clap 3.2.25", "client", "clock", diff --git a/Cargo.toml b/Cargo.toml index 79d28821d4b040f35fc1ab1dd391cddeee93bc47..eea2b4fb47711f1783a3dc4fed972846ce1b0f69 100644 --- a/Cargo.toml +++ b/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" } diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 9588932c250edf6b7bd4194d2c4cc17ec0108bf5..5380b91a9cf7b6746c1059d948c4e334275029f7 100644 --- a/crates/assistant/Cargo.toml +++ b/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 diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index e30622adc3d360f40bb3c832cca063f6d4f6a84e..16ec9dbefd52d2b20353590ee58962e462fae131 100644 --- a/crates/collab/Cargo.toml +++ b/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 diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 8d8f523c94c2caa2e70212f3e08ae6f4f493599a..14657fe682eb5741a5e86b345e87987be86fe3a8 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/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) +); diff --git a/crates/collab/migrations/20240122174606_add_contributors.sql b/crates/collab/migrations/20240122174606_add_contributors.sql new file mode 100644 index 0000000000000000000000000000000000000000..16bec82d4f2bd0a1b3f4221366cd822ebcd70bb1 --- /dev/null +++ b/crates/collab/migrations/20240122174606_add_contributors.sql @@ -0,0 +1,5 @@ +CREATE TABLE contributors ( + user_id INTEGER REFERENCES users(id), + signed_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id) +); diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 6bdbd7357fb857c4db90bfc0f5583023d3b76daf..d0cac55df1bd7aaf57c5cae0b880fdf7b877023a 100644 --- a/crates/collab/src/api.rs +++ b/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, state: Arc) -> Router(req: Request, next: Next) -> impl IntoR #[derive(Debug, Deserialize)] struct AuthenticatedUserParams { - github_user_id: Option, + github_user_id: i32, github_login: String, github_email: Option, } @@ -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>) -> Result>> { + Ok(Json(app.db.get_contributors().await?)) +} + +#[derive(Debug, Deserialize)] +struct CheckIsContributorParams { + github_user_id: Option, + github_login: Option, +} + +impl CheckIsContributorParams { + fn as_contributor_selector(self) -> Result { + 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, +} + +async fn check_is_contributor( + Extension(app): Extension>, + Query(params): Query, +) -> Result> { + let params = params.as_contributor_selector()?; + Ok(Json(CheckIsContributorResponse { + signed_at: app + .db + .get_contributor_sign_timestamp(¶ms) + .await? + .map(|ts| ts.and_utc().to_rfc3339_opts(SecondsFormat::Millis, true)), + })) +} + +async fn add_contributor( + Json(params): Json, + Extension(app): Extension>, +) -> Result<()> { + Ok(app + .db + .add_contributor( + ¶ms.github_login, + params.github_user_id, + params.github_email.as_deref(), + ) + .await?) +} + #[derive(Deserialize)] struct CreateAccessTokenQueryParams { public_key: String, diff --git a/crates/collab/src/bin/seed.rs b/crates/collab/src/bin/seed.rs index ed24ccef75dce446eb54a431d1371139d67b7140..bca2a7899a6bc2f49f760ca78ff78fb58ea4994b 100644 --- a/crates/collab/src/bin/seed.rs +++ b/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 diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 480dcf6f8595143659069739104608dac4c15fd8..f3eeb68afc8db31633f549fbd40161bcf5242530 100644 --- a/crates/collab/src/db.rs +++ b/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; diff --git a/crates/collab/src/db/queries.rs b/crates/collab/src/db/queries.rs index 629e26f1a9e2ac1479f80984d2f9ae3efe7e9ab7..f6bba13ede5fee59b313a602fbf25d3a3d9b3ace 100644 --- a/crates/collab/src/db/queries.rs +++ b/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; diff --git a/crates/collab/src/db/queries/contributors.rs b/crates/collab/src/db/queries/contributors.rs new file mode 100644 index 0000000000000000000000000000000000000000..30d89cab7ba4d588bc81fe461c246aa5134f3a21 --- /dev/null +++ b/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> { + 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> { + 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 + } +} diff --git a/crates/collab/src/db/queries/users.rs b/crates/collab/src/db/queries/users.rs index d6dfe480427fc8ed6dce8f460b5b307e7735317e..4249f06617709668c54e9a4b0b870f5b3f302af0 100644 --- a/crates/collab/src/db/queries/users.rs +++ b/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, + github_user_id: i32, github_email: Option<&str>, - ) -> Result> { + ) -> Result { 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 { + 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> { diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index 4f28ce4fbd4f6a5c3214270001efb11a6885c293..646447c91f6e3c56016786a5d39f81aa8f5e8eef 100644 --- a/crates/collab/src/db/tables.rs +++ b/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; diff --git a/crates/collab/src/db/tables/contributor.rs b/crates/collab/src/db/tables/contributor.rs new file mode 100644 index 0000000000000000000000000000000000000000..3ae96a62d910e38823828a7eb502c0d9840111c2 --- /dev/null +++ b/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 for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} diff --git a/crates/collab/src/db/tables/user.rs b/crates/collab/src/db/tables/user.rs index 53866b5c54f96a2e3b42c06515acba4a341bead3..5ab7f17a01ebee9073bfb66bf124936adcc0e9f5 100644 --- a/crates/collab/src/db/tables/user.rs +++ b/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 for Entity { diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 56e37abc1d9248a885724a50dcd0a8ca25ec93dc..4a9c98f02240964fc27b462cb6764fdbedc9c567 100644 --- a/crates/collab/src/db/tests.rs +++ b/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; diff --git a/crates/collab/src/db/tests/contributor_tests.rs b/crates/collab/src/db/tests/contributor_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1985229f2f35a120a9db38ddf7f79bc74fad64b8 --- /dev/null +++ b/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) { + 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::::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()] + ); +} diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index 3e1bdede71e3691dc1da0e0df0fb59c6c92ac83b..7ae1a8a1a45b6c0ffe2b99d8106d4b6eb66aea93 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/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) { - 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) { .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));