mirror of
https://github.com/zed-industries/zed.git
synced 2024-12-26 10:40:54 +00:00
Add collab APIs for new signup flow
Co-authored-by: Nathan Sobo <nathan@zed.dev>
This commit is contained in:
parent
f081dbced5
commit
d85ecc8302
3 changed files with 338 additions and 1 deletions
25
crates/collab/migrations/20220913211150_create_signups.sql
Normal file
25
crates/collab/migrations/20220913211150_create_signups.sql
Normal file
|
@ -0,0 +1,25 @@
|
|||
CREATE SEQUENCE metrics_id_seq;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "signups" (
|
||||
"id" SERIAL PRIMARY KEY NOT NULL,
|
||||
"email_address" VARCHAR NOT NULL,
|
||||
"email_confirmation_code" VARCHAR(64) NOT NULL,
|
||||
"email_confirmation_sent" BOOLEAN NOT NULL,
|
||||
"metrics_id" INTEGER NOT NULL DEFAULT nextval('metrics_id_seq'),
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"user_id" INTEGER REFERENCES users (id),
|
||||
|
||||
"platform_mac" BOOLEAN NOT NULL,
|
||||
"platform_linux" BOOLEAN NOT NULL,
|
||||
"platform_windows" BOOLEAN NOT NULL,
|
||||
"platform_unknown" BOOLEAN NOT NULL,
|
||||
|
||||
"editor_features" VARCHAR[] NOT NULL,
|
||||
"programming_languages" VARCHAR[] NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_address");
|
||||
CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent");
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD "metrics_id" INTEGER DEFAULT nextval('metrics_id_seq');
|
|
@ -1,6 +1,6 @@
|
|||
use crate::{
|
||||
auth,
|
||||
db::{ProjectId, User, UserId},
|
||||
db::{ProjectId, Signup, SignupInvite, SignupRedemption, User, UserId},
|
||||
rpc::{self, ResultExt},
|
||||
AppState, Error, Result,
|
||||
};
|
||||
|
@ -45,6 +45,10 @@ pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Bod
|
|||
)
|
||||
.route("/user_activity/counts", get(get_active_user_counts))
|
||||
.route("/project_metadata", get(get_project_metadata))
|
||||
.route("/signups", post(create_signup))
|
||||
.route("/signup/redeem", post(redeem_signup))
|
||||
.route("/signups_invites", get(get_signup_invites))
|
||||
.route("/signups_invites_sent", post(record_signup_invites_sent))
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(Extension(state))
|
||||
|
@ -415,3 +419,39 @@ async fn get_user_for_invite_code(
|
|||
) -> Result<Json<User>> {
|
||||
Ok(Json(app.db.get_user_for_invite_code(&code).await?))
|
||||
}
|
||||
|
||||
async fn create_signup(
|
||||
Json(params): Json<Signup>,
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
) -> Result<()> {
|
||||
app.db.create_signup(params).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn redeem_signup(
|
||||
Json(redemption): Json<SignupRedemption>,
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
) -> Result<()> {
|
||||
app.db.redeem_signup(redemption).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_signup_invites_sent(
|
||||
Json(params): Json<Vec<SignupInvite>>,
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
) -> Result<()> {
|
||||
app.db.record_signup_invites_sent(¶ms).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetSignupInvitesParams {
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
async fn get_signup_invites(
|
||||
Query(params): Query<GetSignupInvitesParams>,
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<SignupInvite>>> {
|
||||
Ok(Json(app.db.get_signup_invites(params.count).await?))
|
||||
}
|
||||
|
|
|
@ -30,6 +30,11 @@ pub trait Db: Send + Sync {
|
|||
async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()>;
|
||||
async fn destroy_user(&self, id: UserId) -> Result<()>;
|
||||
|
||||
async fn create_signup(&self, signup: Signup) -> Result<()>;
|
||||
async fn get_signup_invites(&self, count: usize) -> Result<Vec<SignupInvite>>;
|
||||
async fn record_signup_invites_sent(&self, signups: &[SignupInvite]) -> Result<()>;
|
||||
async fn redeem_signup(&self, redemption: SignupRedemption) -> Result<UserId>;
|
||||
|
||||
async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()>;
|
||||
async fn get_invite_code_for_user(&self, id: UserId) -> Result<Option<(String, u32)>>;
|
||||
async fn get_user_for_invite_code(&self, code: &str) -> Result<User>;
|
||||
|
@ -333,6 +338,125 @@ impl Db for PostgresDb {
|
|||
.map(drop)?)
|
||||
}
|
||||
|
||||
// signups
|
||||
|
||||
async fn create_signup(&self, signup: Signup) -> Result<()> {
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO signups
|
||||
(
|
||||
email_address,
|
||||
email_confirmation_code,
|
||||
email_confirmation_sent,
|
||||
platform_linux,
|
||||
platform_mac,
|
||||
platform_windows,
|
||||
platform_unknown,
|
||||
editor_features,
|
||||
programming_languages
|
||||
)
|
||||
VALUES
|
||||
($1, $2, 'f', $3, $4, $5, 'f', $6, $7)
|
||||
",
|
||||
)
|
||||
.bind(&signup.email_address)
|
||||
.bind(&random_email_confirmation_code())
|
||||
.bind(&signup.platform_linux)
|
||||
.bind(&signup.platform_mac)
|
||||
.bind(&signup.platform_windows)
|
||||
.bind(&signup.editor_features)
|
||||
.bind(&signup.programming_languages)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_signup_invites(&self, count: usize) -> Result<Vec<SignupInvite>> {
|
||||
Ok(sqlx::query_as(
|
||||
"
|
||||
SELECT
|
||||
email_address, email_confirmation_code
|
||||
FROM signups
|
||||
WHERE
|
||||
NOT email_confirmation_sent AND
|
||||
platform_mac
|
||||
LIMIT $1
|
||||
",
|
||||
)
|
||||
.bind(count as i32)
|
||||
.fetch_all(&self.pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn record_signup_invites_sent(&self, signups: &[SignupInvite]) -> Result<()> {
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE signups
|
||||
SET email_confirmation_sent = 't'
|
||||
WHERE email_address = ANY ($1)
|
||||
",
|
||||
)
|
||||
.bind(
|
||||
&signups
|
||||
.iter()
|
||||
.map(|s| s.email_address.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn redeem_signup(&self, redemption: SignupRedemption) -> Result<UserId> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let signup_id: i32 = sqlx::query_scalar(
|
||||
"
|
||||
SELECT id
|
||||
FROM signups
|
||||
WHERE
|
||||
email_address = $1 AND
|
||||
email_confirmation_code = $2 AND
|
||||
email_confirmation_sent AND
|
||||
user_id is NULL
|
||||
",
|
||||
)
|
||||
.bind(&redemption.email_address)
|
||||
.bind(&redemption.email_confirmation_code)
|
||||
.fetch_one(&mut tx)
|
||||
.await?;
|
||||
|
||||
let user_id: i32 = sqlx::query_scalar(
|
||||
"
|
||||
INSERT INTO users
|
||||
(email_address, github_login, admin, invite_count, invite_code)
|
||||
VALUES
|
||||
($1, $2, 'f', $3, $4)
|
||||
RETURNING id
|
||||
",
|
||||
)
|
||||
.bind(&redemption.email_address)
|
||||
.bind(&redemption.github_login)
|
||||
.bind(&redemption.invite_count)
|
||||
.bind(random_invite_code())
|
||||
.fetch_one(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE signups
|
||||
SET user_id = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&signup_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(UserId(user_id))
|
||||
}
|
||||
|
||||
// invite codes
|
||||
|
||||
async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()> {
|
||||
|
@ -1445,6 +1569,30 @@ pub struct IncomingContactRequest {
|
|||
pub should_notify: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Signup {
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, PartialEq, Debug, Serialize, Deserialize)]
|
||||
pub struct SignupInvite {
|
||||
pub email_address: String,
|
||||
pub email_confirmation_code: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SignupRedemption {
|
||||
pub email_address: String,
|
||||
pub email_confirmation_code: String,
|
||||
pub github_login: String,
|
||||
pub invite_count: i32,
|
||||
}
|
||||
|
||||
fn fuzzy_like_string(string: &str) -> String {
|
||||
let mut result = String::with_capacity(string.len() * 2 + 1);
|
||||
for c in string.chars() {
|
||||
|
@ -1461,6 +1609,10 @@ fn random_invite_code() -> String {
|
|||
nanoid::nanoid!(16)
|
||||
}
|
||||
|
||||
fn random_email_confirmation_code() -> String {
|
||||
nanoid::nanoid!(64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
|
@ -2400,6 +2552,105 @@ pub mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_signups() {
|
||||
let postgres = TestDb::postgres().await;
|
||||
let db = postgres.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: true,
|
||||
platform_windows: false,
|
||||
editor_features: vec!["speed".into()],
|
||||
programming_languages: vec!["rust".into(), "c".into()],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// retrieve the next batch of signup emails to send
|
||||
let signups_batch1 = db.get_signup_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_signup_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_signup_invites_sent(&signups_batch1)
|
||||
.await
|
||||
.unwrap();
|
||||
let signups_batch2 = db.get_signup_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"
|
||||
]
|
||||
);
|
||||
|
||||
// user completes the signup process by providing their
|
||||
// github account.
|
||||
let user_id = db
|
||||
.redeem_signup(SignupRedemption {
|
||||
email_address: signups_batch1[0].email_address.clone(),
|
||||
email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
|
||||
github_login: "person-0".into(),
|
||||
invite_count: 5,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let user = db.get_user_by_id(user_id).await.unwrap().unwrap();
|
||||
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);
|
||||
|
||||
// cannot redeem the same signup again.
|
||||
db.redeem_signup(SignupRedemption {
|
||||
email_address: signups_batch1[0].email_address.clone(),
|
||||
email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
|
||||
github_login: "some-other-github_account".into(),
|
||||
invite_count: 5,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
// cannot redeem a signup with the wrong confirmation code.
|
||||
db.redeem_signup(SignupRedemption {
|
||||
email_address: signups_batch1[1].email_address.clone(),
|
||||
email_confirmation_code: "the-wrong-code".to_string(),
|
||||
github_login: "person-1".into(),
|
||||
invite_count: 5,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
|
||||
pub struct TestDb {
|
||||
pub db: Option<Arc<dyn Db>>,
|
||||
pub url: String,
|
||||
|
@ -2586,6 +2837,27 @@ pub mod tests {
|
|||
unimplemented!()
|
||||
}
|
||||
|
||||
// signups
|
||||
|
||||
async fn create_signup(&self, _signup: Signup) -> Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_signup_invites(&self, _count: usize) -> Result<Vec<SignupInvite>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn record_signup_invites_sent(&self, _signups: &[SignupInvite]) -> Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn redeem_signup(
|
||||
&self,
|
||||
_redemption: SignupRedemption,
|
||||
) -> Result<UserId> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
// invite codes
|
||||
|
||||
async fn set_invite_count(&self, _id: UserId, _count: u32) -> Result<()> {
|
||||
|
|
Loading…
Reference in a new issue