Add collab APIs for new signup flow

Co-authored-by: Nathan Sobo <nathan@zed.dev>
This commit is contained in:
Max Brunsfeld 2022-09-13 18:24:40 -07:00
parent f081dbced5
commit d85ecc8302
3 changed files with 338 additions and 1 deletions

View 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');

View file

@ -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(&params).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?))
}

View file

@ -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<()> {