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::{
|
use crate::{
|
||||||
auth,
|
auth,
|
||||||
db::{ProjectId, User, UserId},
|
db::{ProjectId, Signup, SignupInvite, SignupRedemption, User, UserId},
|
||||||
rpc::{self, ResultExt},
|
rpc::{self, ResultExt},
|
||||||
AppState, Error, Result,
|
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("/user_activity/counts", get(get_active_user_counts))
|
||||||
.route("/project_metadata", get(get_project_metadata))
|
.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(
|
.layer(
|
||||||
ServiceBuilder::new()
|
ServiceBuilder::new()
|
||||||
.layer(Extension(state))
|
.layer(Extension(state))
|
||||||
|
@ -415,3 +419,39 @@ async fn get_user_for_invite_code(
|
||||||
) -> Result<Json<User>> {
|
) -> Result<Json<User>> {
|
||||||
Ok(Json(app.db.get_user_for_invite_code(&code).await?))
|
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 set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()>;
|
||||||
async fn destroy_user(&self, id: UserId) -> 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 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_invite_code_for_user(&self, id: UserId) -> Result<Option<(String, u32)>>;
|
||||||
async fn get_user_for_invite_code(&self, code: &str) -> Result<User>;
|
async fn get_user_for_invite_code(&self, code: &str) -> Result<User>;
|
||||||
|
@ -333,6 +338,125 @@ impl Db for PostgresDb {
|
||||||
.map(drop)?)
|
.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
|
// invite codes
|
||||||
|
|
||||||
async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()> {
|
async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()> {
|
||||||
|
@ -1445,6 +1569,30 @@ pub struct IncomingContactRequest {
|
||||||
pub should_notify: bool,
|
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 {
|
fn fuzzy_like_string(string: &str) -> String {
|
||||||
let mut result = String::with_capacity(string.len() * 2 + 1);
|
let mut result = String::with_capacity(string.len() * 2 + 1);
|
||||||
for c in string.chars() {
|
for c in string.chars() {
|
||||||
|
@ -1461,6 +1609,10 @@ fn random_invite_code() -> String {
|
||||||
nanoid::nanoid!(16)
|
nanoid::nanoid!(16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn random_email_confirmation_code() -> String {
|
||||||
|
nanoid::nanoid!(64)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod tests {
|
pub mod tests {
|
||||||
use super::*;
|
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 struct TestDb {
|
||||||
pub db: Option<Arc<dyn Db>>,
|
pub db: Option<Arc<dyn Db>>,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
@ -2586,6 +2837,27 @@ pub mod tests {
|
||||||
unimplemented!()
|
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
|
// invite codes
|
||||||
|
|
||||||
async fn set_invite_count(&self, _id: UserId, _count: u32) -> Result<()> {
|
async fn set_invite_count(&self, _id: UserId, _count: u32) -> Result<()> {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue