Merge pull request #1635 from zed-industries/new-signup-flow

Max Brunsfeld created

Implement APIs for new signup flow

Change summary

.github/workflows/ci.yml                                        |    1 
Cargo.lock                                                      |   21 
crates/client/Cargo.toml                                        |    4 
crates/client/src/channel.rs                                    |    2 
crates/client/src/client.rs                                     |   72 
crates/client/src/telemetry.rs                                  |  255 
crates/collab/migrations/20220913211150_create_signups.down.sql |    6 
crates/collab/migrations/20220913211150_create_signups.up.sql   |   27 
crates/collab/src/api.rs                                        |  217 
crates/collab/src/bin/seed.rs                                   |   25 
crates/collab/src/db.rs                                         | 1564 --
crates/collab/src/db_tests.rs                                   | 1289 ++
crates/collab/src/integration_tests.rs                          |   48 
crates/collab/src/main.rs                                       |    2 
crates/collab/src/rpc.rs                                        |   43 
crates/contacts_panel/src/contacts_panel.rs                     |    2 
crates/db/Cargo.toml                                            |   22 
crates/db/src/db.rs                                             |    0 
crates/editor/src/editor.rs                                     |   21 
crates/editor/src/items.rs                                      |    2 
crates/gpui/src/platform.rs                                     |    2 
crates/gpui/src/platform/mac/platform.rs                        |   21 
crates/gpui/src/platform/test.rs                                |   12 
crates/project/Cargo.toml                                       |    3 
crates/project/src/project.rs                                   |    3 
crates/project/src/worktree.rs                                  |   14 
crates/workspace/src/workspace.rs                               |    2 
crates/zed/build.rs                                             |    4 
crates/zed/src/main.rs                                          |   15 
crates/zed/src/menus.rs                                         |    5 
crates/zed/src/zed.rs                                           |   63 
31 files changed, 2,482 insertions(+), 1,285 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -56,6 +56,7 @@ jobs:
       MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
       APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
       APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
+      ZED_AMPLITUDE_API_KEY: ${{ secrets.ZED_AMPLITUDE_API_KEY }}
     steps:
       - name: Install Rust
         run: |

Cargo.lock 🔗

@@ -945,6 +945,7 @@ dependencies = [
  "async-recursion",
  "async-tungstenite",
  "collections",
+ "db",
  "futures",
  "gpui",
  "image",
@@ -955,13 +956,16 @@ dependencies = [
  "postage",
  "rand 0.8.5",
  "rpc",
+ "serde",
  "smol",
  "sum_tree",
+ "tempfile",
  "thiserror",
  "time 0.3.11",
  "tiny_http",
  "url",
  "util",
+ "uuid 1.1.2",
 ]
 
 [[package]]
@@ -1503,6 +1507,19 @@ dependencies = [
  "matches",
 ]
 
+[[package]]
+name = "db"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "collections",
+ "gpui",
+ "parking_lot 0.11.2",
+ "rocksdb",
+ "tempdir",
+]
+
 [[package]]
 name = "deflate"
 version = "0.8.6"
@@ -3949,6 +3966,7 @@ dependencies = [
  "client",
  "clock",
  "collections",
+ "db",
  "fsevent",
  "futures",
  "fuzzy",
@@ -6334,6 +6352,9 @@ name = "uuid"
 version = "1.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f"
+dependencies = [
+ "getrandom 0.2.7",
+]
 
 [[package]]
 name = "valuable"

crates/client/Cargo.toml 🔗

@@ -12,6 +12,7 @@ test-support = ["collections/test-support", "gpui/test-support", "rpc/test-suppo
 
 [dependencies]
 collections = { path = "../collections" }
+db = { path = "../db" }
 gpui = { path = "../gpui" }
 util = { path = "../util" }
 rpc = { path = "../rpc" }
@@ -31,7 +32,10 @@ smol = "1.2.5"
 thiserror = "1.0.29"
 time = { version = "0.3", features = ["serde", "serde-well-known"] }
 tiny_http = "0.8"
+uuid = { version = "1.1.2", features = ["v4"] }
 url = "2.2"
+serde = { version = "*", features = ["derive"] }
+tempfile = "3"
 
 [dev-dependencies]
 collections = { path = "../collections", features = ["test-support"] }

crates/client/src/channel.rs 🔗

@@ -601,7 +601,7 @@ mod tests {
 
         let user_id = 5;
         let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = cx.update(|cx| Client::new(http_client.clone(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
 
         Channel::init(&client);

crates/client/src/client.rs 🔗

@@ -3,6 +3,7 @@ pub mod test;
 
 pub mod channel;
 pub mod http;
+pub mod telemetry;
 pub mod user;
 
 use anyhow::{anyhow, Context, Result};
@@ -11,10 +12,14 @@ use async_tungstenite::tungstenite::{
     error::Error as WebsocketError,
     http::{Request, StatusCode},
 };
+use db::Db;
 use futures::{future::LocalBoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt};
 use gpui::{
-    actions, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AsyncAppContext,
-    Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext, ViewHandle,
+    actions,
+    serde_json::{json, Value},
+    AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AppContext,
+    AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext,
+    ViewHandle,
 };
 use http::HttpClient;
 use lazy_static::lazy_static;
@@ -28,9 +33,11 @@ use std::{
     convert::TryFrom,
     fmt::Write as _,
     future::Future,
+    path::PathBuf,
     sync::{Arc, Weak},
     time::{Duration, Instant},
 };
+use telemetry::Telemetry;
 use thiserror::Error;
 use url::Url;
 use util::{ResultExt, TryFutureExt};
@@ -49,13 +56,29 @@ lazy_static! {
 
 pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
 
-actions!(client, [Authenticate]);
+actions!(client, [Authenticate, TestTelemetry]);
 
-pub fn init(rpc: Arc<Client>, cx: &mut MutableAppContext) {
-    cx.add_global_action(move |_: &Authenticate, cx| {
-        let rpc = rpc.clone();
-        cx.spawn(|cx| async move { rpc.authenticate_and_connect(true, &cx).log_err().await })
+pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
+    cx.add_global_action({
+        let client = client.clone();
+        move |_: &Authenticate, cx| {
+            let client = client.clone();
+            cx.spawn(
+                |cx| async move { client.authenticate_and_connect(true, &cx).log_err().await },
+            )
             .detach();
+        }
+    });
+    cx.add_global_action({
+        let client = client.clone();
+        move |_: &TestTelemetry, _| {
+            client.report_event(
+                "test_telemetry",
+                json!({
+                    "test_property": "test_value"
+                }),
+            )
+        }
     });
 }
 
@@ -63,6 +86,7 @@ pub struct Client {
     id: usize,
     peer: Arc<Peer>,
     http: Arc<dyn HttpClient>,
+    telemetry: Arc<Telemetry>,
     state: RwLock<ClientState>,
 
     #[allow(clippy::type_complexity)]
@@ -232,10 +256,11 @@ impl Drop for Subscription {
 }
 
 impl Client {
-    pub fn new(http: Arc<dyn HttpClient>) -> Arc<Self> {
+    pub fn new(http: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
         Arc::new(Self {
             id: 0,
             peer: Peer::new(),
+            telemetry: Telemetry::new(http.clone(), cx),
             http,
             state: Default::default(),
 
@@ -308,9 +333,11 @@ impl Client {
         log::info!("set status on client {}: {:?}", self.id, status);
         let mut state = self.state.write();
         *state.status.0.borrow_mut() = status;
+        let user_id = state.credentials.as_ref().map(|c| c.user_id);
 
         match status {
             Status::Connected { .. } => {
+                self.telemetry.set_user_id(user_id);
                 state._reconnect_task = None;
             }
             Status::ConnectionLost => {
@@ -339,6 +366,7 @@ impl Client {
                 }));
             }
             Status::SignedOut | Status::UpgradeRequired => {
+                self.telemetry.set_user_id(user_id);
                 state._reconnect_task.take();
             }
             _ => {}
@@ -595,6 +623,9 @@ impl Client {
         if credentials.is_none() && try_keychain {
             credentials = read_credentials_from_keychain(cx);
             read_from_keychain = credentials.is_some();
+            if read_from_keychain {
+                self.report_event("read credentials from keychain", Default::default());
+            }
         }
         if credentials.is_none() {
             let mut status_rx = self.status();
@@ -878,6 +909,7 @@ impl Client {
     ) -> Task<Result<Credentials>> {
         let platform = cx.platform();
         let executor = cx.background();
+        let telemetry = self.telemetry.clone();
         executor.clone().spawn(async move {
             // Generate a pair of asymmetric encryption keys. The public key will be used by the
             // zed server to encrypt the user's access token, so that it can'be intercepted by
@@ -956,6 +988,8 @@ impl Client {
                 .context("failed to decrypt access token")?;
             platform.activate(true);
 
+            telemetry.report_event("authenticate with browser", Default::default());
+
             Ok(Credentials {
                 user_id: user_id.parse()?,
                 access_token,
@@ -1020,6 +1054,18 @@ impl Client {
         log::debug!("rpc respond. client_id:{}. name:{}", self.id, T::NAME);
         self.peer.respond_with_error(receipt, error)
     }
+
+    pub fn start_telemetry(&self, db: Arc<Db>) {
+        self.telemetry.start(db);
+    }
+
+    pub fn report_event(&self, kind: &str, properties: Value) {
+        self.telemetry.report_event(kind, properties)
+    }
+
+    pub fn telemetry_log_file_path(&self) -> Option<PathBuf> {
+        self.telemetry.log_file_path()
+    }
 }
 
 impl AnyWeakEntityHandle {
@@ -1085,7 +1131,7 @@ mod tests {
         cx.foreground().forbid_parking();
 
         let user_id = 5;
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
         let mut status = client.status();
         assert!(matches!(
@@ -1124,7 +1170,7 @@ mod tests {
 
         let auth_count = Arc::new(Mutex::new(0));
         let dropped_auth_count = Arc::new(Mutex::new(0));
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         client.override_authenticate({
             let auth_count = auth_count.clone();
             let dropped_auth_count = dropped_auth_count.clone();
@@ -1173,7 +1219,7 @@ mod tests {
         cx.foreground().forbid_parking();
 
         let user_id = 5;
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
 
         let (done_tx1, mut done_rx1) = smol::channel::unbounded();
@@ -1219,7 +1265,7 @@ mod tests {
         cx.foreground().forbid_parking();
 
         let user_id = 5;
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
 
         let model = cx.add_model(|_| Model::default());
@@ -1247,7 +1293,7 @@ mod tests {
         cx.foreground().forbid_parking();
 
         let user_id = 5;
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
 
         let model = cx.add_model(|_| Model::default());

crates/client/src/telemetry.rs 🔗

@@ -0,0 +1,255 @@
+use crate::http::HttpClient;
+use db::Db;
+use gpui::{
+    executor::Background,
+    serde_json::{self, value::Map, Value},
+    AppContext, Task,
+};
+use isahc::Request;
+use lazy_static::lazy_static;
+use parking_lot::Mutex;
+use serde::Serialize;
+use std::{
+    io::Write,
+    mem,
+    path::PathBuf,
+    sync::Arc,
+    time::{Duration, SystemTime, UNIX_EPOCH},
+};
+use tempfile::NamedTempFile;
+use util::{post_inc, ResultExt, TryFutureExt};
+use uuid::Uuid;
+
+pub struct Telemetry {
+    http_client: Arc<dyn HttpClient>,
+    executor: Arc<Background>,
+    session_id: u128,
+    state: Mutex<TelemetryState>,
+}
+
+#[derive(Default)]
+struct TelemetryState {
+    user_id: Option<Arc<str>>,
+    device_id: Option<Arc<str>>,
+    app_version: Option<Arc<str>>,
+    os_version: Option<Arc<str>>,
+    os_name: &'static str,
+    queue: Vec<AmplitudeEvent>,
+    next_event_id: usize,
+    flush_task: Option<Task<()>>,
+    log_file: Option<NamedTempFile>,
+}
+
+const AMPLITUDE_EVENTS_URL: &'static str = "https://api2.amplitude.com/batch";
+
+lazy_static! {
+    static ref AMPLITUDE_API_KEY: Option<String> = std::env::var("ZED_AMPLITUDE_API_KEY")
+        .ok()
+        .or_else(|| option_env!("ZED_AMPLITUDE_API_KEY").map(|key| key.to_string()));
+}
+
+#[derive(Serialize)]
+struct AmplitudeEventBatch {
+    api_key: &'static str,
+    events: Vec<AmplitudeEvent>,
+}
+
+#[derive(Serialize)]
+struct AmplitudeEvent {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    user_id: Option<Arc<str>>,
+    device_id: Option<Arc<str>>,
+    event_type: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    event_properties: Option<Map<String, Value>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    user_properties: Option<Map<String, Value>>,
+    os_name: &'static str,
+    os_version: Option<Arc<str>>,
+    app_version: Option<Arc<str>>,
+    event_id: usize,
+    session_id: u128,
+    time: u128,
+}
+
+#[cfg(debug_assertions)]
+const MAX_QUEUE_LEN: usize = 1;
+
+#[cfg(not(debug_assertions))]
+const MAX_QUEUE_LEN: usize = 10;
+
+#[cfg(debug_assertions)]
+const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(1);
+
+#[cfg(not(debug_assertions))]
+const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(30);
+
+impl Telemetry {
+    pub fn new(client: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
+        let platform = cx.platform();
+        let this = Arc::new(Self {
+            http_client: client,
+            executor: cx.background().clone(),
+            session_id: SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap()
+                .as_millis(),
+            state: Mutex::new(TelemetryState {
+                os_version: platform
+                    .os_version()
+                    .log_err()
+                    .map(|v| v.to_string().into()),
+                os_name: platform.os_name().into(),
+                app_version: platform
+                    .app_version()
+                    .log_err()
+                    .map(|v| v.to_string().into()),
+                device_id: None,
+                queue: Default::default(),
+                flush_task: Default::default(),
+                next_event_id: 0,
+                log_file: None,
+                user_id: None,
+            }),
+        });
+
+        if AMPLITUDE_API_KEY.is_some() {
+            this.executor
+                .spawn({
+                    let this = this.clone();
+                    async move {
+                        if let Some(tempfile) = NamedTempFile::new().log_err() {
+                            this.state.lock().log_file = Some(tempfile);
+                        }
+                    }
+                })
+                .detach();
+        }
+
+        this
+    }
+
+    pub fn log_file_path(&self) -> Option<PathBuf> {
+        Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
+    }
+
+    pub fn start(self: &Arc<Self>, db: Arc<Db>) {
+        let this = self.clone();
+        self.executor
+            .spawn(
+                async move {
+                    let device_id = if let Some(device_id) = db
+                        .read(["device_id"])?
+                        .into_iter()
+                        .flatten()
+                        .next()
+                        .and_then(|bytes| String::from_utf8(bytes).ok())
+                    {
+                        device_id
+                    } else {
+                        let device_id = Uuid::new_v4().to_string();
+                        db.write([("device_id", device_id.as_bytes())])?;
+                        device_id
+                    };
+
+                    let device_id = Some(Arc::from(device_id));
+                    let mut state = this.state.lock();
+                    state.device_id = device_id.clone();
+                    for event in &mut state.queue {
+                        event.device_id = device_id.clone();
+                    }
+                    if !state.queue.is_empty() {
+                        drop(state);
+                        this.flush();
+                    }
+
+                    anyhow::Ok(())
+                }
+                .log_err(),
+            )
+            .detach();
+    }
+
+    pub fn set_user_id(&self, user_id: Option<u64>) {
+        self.state.lock().user_id = user_id.map(|id| id.to_string().into());
+    }
+
+    pub fn report_event(self: &Arc<Self>, kind: &str, properties: Value) {
+        if AMPLITUDE_API_KEY.is_none() {
+            return;
+        }
+
+        let mut state = self.state.lock();
+        let event = AmplitudeEvent {
+            event_type: kind.to_string(),
+            time: SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap()
+                .as_millis(),
+            session_id: self.session_id,
+            event_properties: if let Value::Object(properties) = properties {
+                Some(properties)
+            } else {
+                None
+            },
+            user_properties: None,
+            user_id: state.user_id.clone(),
+            device_id: state.device_id.clone(),
+            os_name: state.os_name,
+            os_version: state.os_version.clone(),
+            app_version: state.app_version.clone(),
+            event_id: post_inc(&mut state.next_event_id),
+        };
+        state.queue.push(event);
+        if state.device_id.is_some() {
+            if state.queue.len() >= MAX_QUEUE_LEN {
+                drop(state);
+                self.flush();
+            } else {
+                let this = self.clone();
+                let executor = self.executor.clone();
+                state.flush_task = Some(self.executor.spawn(async move {
+                    executor.timer(DEBOUNCE_INTERVAL).await;
+                    this.flush();
+                }));
+            }
+        }
+    }
+
+    fn flush(self: &Arc<Self>) {
+        let mut state = self.state.lock();
+        let events = mem::take(&mut state.queue);
+        state.flush_task.take();
+        drop(state);
+
+        if let Some(api_key) = AMPLITUDE_API_KEY.as_ref() {
+            let this = self.clone();
+            self.executor
+                .spawn(
+                    async move {
+                        let mut json_bytes = Vec::new();
+
+                        if let Some(file) = &mut this.state.lock().log_file {
+                            let file = file.as_file_mut();
+                            for event in &events {
+                                json_bytes.clear();
+                                serde_json::to_writer(&mut json_bytes, event)?;
+                                file.write_all(&json_bytes)?;
+                                file.write(b"\n")?;
+                            }
+                        }
+
+                        let batch = AmplitudeEventBatch { api_key, events };
+                        json_bytes.clear();
+                        serde_json::to_writer(&mut json_bytes, &batch)?;
+                        let request =
+                            Request::post(AMPLITUDE_EVENTS_URL).body(json_bytes.into())?;
+                        this.http_client.send(request).await?;
+                        Ok(())
+                    }
+                    .log_err(),
+                )
+                .detach();
+        }
+    }
+}

crates/collab/migrations/20220913211150_create_signups.up.sql 🔗

@@ -0,0 +1,27 @@
+CREATE TABLE IF NOT EXISTS "signups" (
+    "id" SERIAL PRIMARY KEY,
+    "email_address" VARCHAR NOT NULL,
+    "email_confirmation_code" VARCHAR(64) NOT NULL,
+    "email_confirmation_sent" BOOLEAN NOT NULL,
+    "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "device_id" VARCHAR,
+    "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE,
+    "inviting_user_id" INTEGER REFERENCES users (id) ON DELETE SET NULL,
+
+    "platform_mac" BOOLEAN NOT NULL,
+    "platform_linux" BOOLEAN NOT NULL,
+    "platform_windows" BOOLEAN NOT NULL,
+    "platform_unknown" BOOLEAN NOT NULL,
+
+    "editor_features" VARCHAR[],
+    "programming_languages" VARCHAR[]
+);
+
+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 "github_user_id" INTEGER;
+
+CREATE INDEX "index_users_on_email_address" ON "users" ("email_address");
+CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id");

crates/collab/src/api.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     auth,
-    db::{ProjectId, User, UserId},
+    db::{Invite, NewUserParams, ProjectId, Signup, User, UserId, WaitlistSummary},
     rpc::{self, ResultExt},
     AppState, Error, Result,
 };
@@ -25,12 +25,8 @@ use tracing::instrument;
 pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
     Router::new()
         .route("/users", get(get_users).post(create_user))
-        .route(
-            "/users/:id",
-            put(update_user).delete(destroy_user).get(get_user),
-        )
+        .route("/users/:id", put(update_user).delete(destroy_user))
         .route("/users/:id/access_tokens", post(create_access_token))
-        .route("/bulk_users", post(create_users))
         .route("/users_with_no_invites", get(get_users_with_no_invites))
         .route("/invite_codes/:code", get(get_user_for_invite_code))
         .route("/panic", post(trace_panic))
@@ -45,6 +41,11 @@ 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("/signups_summary", get(get_waitlist_summary))
+        .route("/user_invites", post(create_invite_from_code))
+        .route("/unsent_invites", get(get_unsent_invites))
+        .route("/sent_invites", post(record_sent_invites))
         .layer(
             ServiceBuilder::new()
                 .layer(Extension(state))
@@ -86,6 +87,8 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
 
 #[derive(Debug, Deserialize)]
 struct GetUsersQueryParams {
+    github_user_id: Option<i32>,
+    github_login: Option<String>,
     query: Option<String>,
     page: Option<u32>,
     limit: Option<u32>,
@@ -95,6 +98,14 @@ async fn get_users(
     Query(params): Query<GetUsersQueryParams>,
     Extension(app): Extension<Arc<AppState>>,
 ) -> Result<Json<Vec<User>>> {
+    if let Some(github_login) = &params.github_login {
+        let user = app
+            .db
+            .get_user_by_github_account(github_login, params.github_user_id)
+            .await?;
+        return Ok(Json(Vec::from_iter(user)));
+    }
+
     let limit = params.limit.unwrap_or(100);
     let users = if let Some(query) = params.query {
         app.db.fuzzy_search_users(&query, limit).await?
@@ -108,40 +119,61 @@ async fn get_users(
 
 #[derive(Deserialize, Debug)]
 struct CreateUserParams {
+    github_user_id: i32,
     github_login: String,
-    invite_code: Option<String>,
-    email_address: Option<String>,
-    admin: bool,
+    email_address: String,
+    email_confirmation_code: Option<String>,
+    #[serde(default)]
+    invite_count: i32,
+}
+
+#[derive(Serialize, Debug)]
+struct CreateUserResponse {
+    user: User,
+    signup_device_id: Option<String>,
 }
 
 async fn create_user(
     Json(params): Json<CreateUserParams>,
     Extension(app): Extension<Arc<AppState>>,
     Extension(rpc_server): Extension<Arc<rpc::Server>>,
-) -> Result<Json<User>> {
-    let user_id = if let Some(invite_code) = params.invite_code {
-        let invitee_id = app
+) -> Result<Json<CreateUserResponse>> {
+    let user = NewUserParams {
+        github_login: params.github_login,
+        github_user_id: params.github_user_id,
+        invite_count: params.invite_count,
+    };
+    let user_id;
+    let signup_device_id;
+    // Creating a user via the normal signup process
+    if let Some(email_confirmation_code) = params.email_confirmation_code {
+        let result = app
             .db
-            .redeem_invite_code(
-                &invite_code,
-                &params.github_login,
-                params.email_address.as_deref(),
+            .create_user_from_invite(
+                &Invite {
+                    email_address: params.email_address,
+                    email_confirmation_code,
+                },
+                user,
             )
             .await?;
-        rpc_server
-            .invite_code_redeemed(&invite_code, invitee_id)
-            .await
-            .trace_err();
-        invitee_id
-    } else {
-        app.db
-            .create_user(
-                &params.github_login,
-                params.email_address.as_deref(),
-                params.admin,
-            )
-            .await?
-    };
+        user_id = result.user_id;
+        signup_device_id = result.signup_device_id;
+        if let Some(inviter_id) = result.inviting_user_id {
+            rpc_server
+                .invite_code_redeemed(inviter_id, user_id)
+                .await
+                .trace_err();
+        }
+    }
+    // Creating a user as an admin
+    else {
+        user_id = app
+            .db
+            .create_user(&params.email_address, false, user)
+            .await?;
+        signup_device_id = None;
+    }
 
     let user = app
         .db
@@ -149,7 +181,10 @@ async fn create_user(
         .await?
         .ok_or_else(|| anyhow!("couldn't find the user we just created"))?;
 
-    Ok(Json(user))
+    Ok(Json(CreateUserResponse {
+        user,
+        signup_device_id,
+    }))
 }
 
 #[derive(Deserialize)]
@@ -171,7 +206,9 @@ async fn update_user(
     }
 
     if let Some(invite_count) = params.invite_count {
-        app.db.set_invite_count(user_id, invite_count).await?;
+        app.db
+            .set_invite_count_for_user(user_id, invite_count)
+            .await?;
         rpc_server.invite_count_updated(user_id).await.trace_err();
     }
 
@@ -186,54 +223,6 @@ async fn destroy_user(
     Ok(())
 }
 
-async fn get_user(
-    Path(login): Path<String>,
-    Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<User>> {
-    let user = app
-        .db
-        .get_user_by_github_login(&login)
-        .await?
-        .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "User not found".to_string()))?;
-    Ok(Json(user))
-}
-
-#[derive(Deserialize)]
-struct CreateUsersParams {
-    users: Vec<CreateUsersEntry>,
-}
-
-#[derive(Deserialize)]
-struct CreateUsersEntry {
-    github_login: String,
-    email_address: String,
-    invite_count: usize,
-}
-
-async fn create_users(
-    Json(params): Json<CreateUsersParams>,
-    Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<Vec<User>>> {
-    let user_ids = app
-        .db
-        .create_users(
-            params
-                .users
-                .into_iter()
-                .map(|params| {
-                    (
-                        params.github_login,
-                        params.email_address,
-                        params.invite_count,
-                    )
-                })
-                .collect(),
-        )
-        .await?;
-    let users = app.db.get_users_by_ids(user_ids).await?;
-    Ok(Json(users))
-}
-
 #[derive(Debug, Deserialize)]
 struct GetUsersWithNoInvites {
     invited_by_another_user: bool,
@@ -368,22 +357,24 @@ struct CreateAccessTokenResponse {
 }
 
 async fn create_access_token(
-    Path(login): Path<String>,
+    Path(user_id): Path<UserId>,
     Query(params): Query<CreateAccessTokenQueryParams>,
     Extension(app): Extension<Arc<AppState>>,
 ) -> Result<Json<CreateAccessTokenResponse>> {
-    //     request.require_token().await?;
-
     let user = app
         .db
-        .get_user_by_github_login(&login)
+        .get_user_by_id(user_id)
         .await?
         .ok_or_else(|| anyhow!("user not found"))?;
 
     let mut user_id = user.id;
     if let Some(impersonate) = params.impersonate {
         if user.admin {
-            if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? {
+            if let Some(impersonated_user) = app
+                .db
+                .get_user_by_github_account(&impersonate, None)
+                .await?
+            {
                 user_id = impersonated_user.id;
             } else {
                 return Err(Error::Http(
@@ -415,3 +406,59 @@ 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 get_waitlist_summary(
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<WaitlistSummary>> {
+    Ok(Json(app.db.get_waitlist_summary().await?))
+}
+
+#[derive(Deserialize)]
+pub struct CreateInviteFromCodeParams {
+    invite_code: String,
+    email_address: String,
+    device_id: Option<String>,
+}
+
+async fn create_invite_from_code(
+    Json(params): Json<CreateInviteFromCodeParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<Invite>> {
+    Ok(Json(
+        app.db
+            .create_invite_from_code(
+                &params.invite_code,
+                &params.email_address,
+                params.device_id.as_deref(),
+            )
+            .await?,
+    ))
+}
+
+#[derive(Deserialize)]
+pub struct GetUnsentInvitesParams {
+    pub count: usize,
+}
+
+async fn get_unsent_invites(
+    Query(params): Query<GetUnsentInvitesParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<Vec<Invite>>> {
+    Ok(Json(app.db.get_unsent_invites(params.count).await?))
+}
+
+async fn record_sent_invites(
+    Json(params): Json<Vec<Invite>>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<()> {
+    app.db.record_sent_invites(&params).await?;
+    Ok(())
+}

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

@@ -11,7 +11,7 @@ mod db;
 
 #[derive(Debug, Deserialize)]
 struct GitHubUser {
-    id: usize,
+    id: i32,
     login: String,
     email: Option<String>,
 }
@@ -26,8 +26,11 @@ async fn main() {
     let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var");
     let client = reqwest::Client::new();
 
-    let current_user =
+    let mut current_user =
         fetch_github::<GitHubUser>(&client, &github_token, "https://api.github.com/user").await;
+    current_user
+        .email
+        .get_or_insert_with(|| "placeholder@example.com".to_string());
     let staff_users = fetch_github::<Vec<GitHubUser>>(
         &client,
         &github_token,
@@ -64,16 +67,24 @@ async fn main() {
     let mut zed_user_ids = Vec::<UserId>::new();
     for (github_user, admin) in zed_users {
         if let Some(user) = db
-            .get_user_by_github_login(&github_user.login)
+            .get_user_by_github_account(&github_user.login, Some(github_user.id))
             .await
             .expect("failed to fetch user")
         {
             zed_user_ids.push(user.id);
-        } else {
+        } else if let Some(email) = &github_user.email {
             zed_user_ids.push(
-                db.create_user(&github_user.login, github_user.email.as_deref(), admin)
-                    .await
-                    .expect("failed to insert user"),
+                db.create_user(
+                    email,
+                    admin,
+                    db::NewUserParams {
+                        github_login: github_user.login,
+                        github_user_id: github_user.id,
+                        invite_count: 5,
+                    },
+                )
+                .await
+                .expect("failed to insert user"),
             );
         }
     }

crates/collab/src/db.rs 🔗

@@ -1,5 +1,3 @@
-use std::{cmp, ops::Range, time::Duration};
-
 use crate::{Error, Result};
 use anyhow::{anyhow, Context};
 use async_trait::async_trait;
@@ -8,37 +6,51 @@ use collections::HashMap;
 use futures::StreamExt;
 use serde::{Deserialize, Serialize};
 pub use sqlx::postgres::PgPoolOptions as DbOptions;
-use sqlx::{types::Uuid, FromRow, QueryBuilder, Row};
+use sqlx::{types::Uuid, FromRow, QueryBuilder};
+use std::{cmp, ops::Range, time::Duration};
 use time::{OffsetDateTime, PrimitiveDateTime};
 
 #[async_trait]
 pub trait Db: Send + Sync {
     async fn create_user(
         &self,
-        github_login: &str,
-        email_address: Option<&str>,
+        email_address: &str,
         admin: bool,
+        params: NewUserParams,
     ) -> Result<UserId>;
     async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>>;
-    async fn create_users(&self, users: Vec<(String, String, usize)>) -> Result<Vec<UserId>>;
     async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
     async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>>;
     async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
     async fn get_users_with_no_invites(&self, invited_by_another_user: bool) -> Result<Vec<User>>;
-    async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
+    async fn get_user_by_github_account(
+        &self,
+        github_login: &str,
+        github_user_id: Option<i32>,
+    ) -> Result<Option<User>>;
     async fn set_user_is_admin(&self, id: UserId, is_admin: 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 set_invite_count(&self, id: UserId, count: u32) -> Result<()>;
+    async fn set_invite_count_for_user(&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>;
-    async fn redeem_invite_code(
+    async fn create_invite_from_code(
         &self,
         code: &str,
-        login: &str,
-        email_address: Option<&str>,
-    ) -> Result<UserId>;
+        email_address: &str,
+        device_id: Option<&str>,
+    ) -> Result<Invite>;
+
+    async fn create_signup(&self, signup: Signup) -> Result<()>;
+    async fn get_waitlist_summary(&self) -> Result<WaitlistSummary>;
+    async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>>;
+    async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()>;
+    async fn create_user_from_invite(
+        &self,
+        invite: &Invite,
+        user: NewUserParams,
+    ) -> Result<NewUserResult>;
 
     /// Registers a new project for the given user.
     async fn register_project(&self, host_user_id: UserId) -> Result<ProjectId>;
@@ -115,8 +127,8 @@ pub trait Db: Send + Sync {
         max_access_token_count: usize,
     ) -> Result<()>;
     async fn get_access_token_hashes(&self, user_id: UserId) -> Result<Vec<String>>;
-    #[cfg(any(test, feature = "seed-support"))]
 
+    #[cfg(any(test, feature = "seed-support"))]
     async fn find_org_by_slug(&self, slug: &str) -> Result<Option<Org>>;
     #[cfg(any(test, feature = "seed-support"))]
     async fn create_org(&self, name: &str, slug: &str) -> Result<OrgId>;
@@ -130,6 +142,7 @@ pub trait Db: Send + Sync {
     async fn get_accessible_channels(&self, user_id: UserId) -> Result<Vec<Channel>>;
     async fn can_user_access_channel(&self, user_id: UserId, channel_id: ChannelId)
         -> Result<bool>;
+
     #[cfg(any(test, feature = "seed-support"))]
     async fn add_channel_member(
         &self,
@@ -151,10 +164,12 @@ pub trait Db: Send + Sync {
         count: usize,
         before_id: Option<MessageId>,
     ) -> Result<Vec<ChannelMessage>>;
+
     #[cfg(test)]
     async fn teardown(&self, url: &str);
+
     #[cfg(test)]
-    fn as_fake(&self) -> Option<&tests::FakeDb>;
+    fn as_fake(&self) -> Option<&FakeDb>;
 }
 
 pub struct PostgresDb {
@@ -170,6 +185,18 @@ impl PostgresDb {
             .context("failed to connect to postgres database")?;
         Ok(Self { pool })
     }
+
+    pub fn fuzzy_like_string(string: &str) -> String {
+        let mut result = String::with_capacity(string.len() * 2 + 1);
+        for c in string.chars() {
+            if c.is_alphanumeric() {
+                result.push('%');
+                result.push(c);
+            }
+        }
+        result.push('%');
+        result
+    }
 }
 
 #[async_trait]
@@ -178,19 +205,20 @@ impl Db for PostgresDb {
 
     async fn create_user(
         &self,
-        github_login: &str,
-        email_address: Option<&str>,
+        email_address: &str,
         admin: bool,
+        params: NewUserParams,
     ) -> Result<UserId> {
         let query = "
-            INSERT INTO users (github_login, email_address, admin)
-            VALUES ($1, $2, $3)
+            INSERT INTO users (email_address, github_login, github_user_id, admin)
+            VALUES ($1, $2, $3, $4)
             ON CONFLICT (github_login) DO UPDATE SET github_login = excluded.github_login
             RETURNING id
         ";
         Ok(sqlx::query_scalar(query)
-            .bind(github_login)
             .bind(email_address)
+            .bind(params.github_login)
+            .bind(params.github_user_id)
             .bind(admin)
             .fetch_one(&self.pool)
             .await
@@ -206,43 +234,8 @@ impl Db for PostgresDb {
             .await?)
     }
 
-    async fn create_users(&self, users: Vec<(String, String, usize)>) -> Result<Vec<UserId>> {
-        let mut query = QueryBuilder::new(
-            "INSERT INTO users (github_login, email_address, admin, invite_code, invite_count)",
-        );
-        query.push_values(
-            users,
-            |mut query, (github_login, email_address, invite_count)| {
-                query
-                    .push_bind(github_login)
-                    .push_bind(email_address)
-                    .push_bind(false)
-                    .push_bind(random_invite_code())
-                    .push_bind(invite_count as i32);
-            },
-        );
-        query.push(
-            "
-            ON CONFLICT (github_login) DO UPDATE SET
-                github_login = excluded.github_login,
-                invite_count = excluded.invite_count,
-                invite_code = CASE WHEN users.invite_code IS NULL
-                                   THEN excluded.invite_code
-                                   ELSE users.invite_code
-                              END
-            RETURNING id
-            ",
-        );
-
-        let rows = query.build().fetch_all(&self.pool).await?;
-        Ok(rows
-            .into_iter()
-            .filter_map(|row| row.try_get::<UserId, _>(0).ok())
-            .collect())
-    }
-
     async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
-        let like_string = fuzzy_like_string(name_query);
+        let like_string = Self::fuzzy_like_string(name_query);
         let query = "
             SELECT users.*
             FROM users
@@ -290,12 +283,53 @@ impl Db for PostgresDb {
         Ok(sqlx::query_as(&query).fetch_all(&self.pool).await?)
     }
 
-    async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
-        let query = "SELECT * FROM users WHERE github_login = $1 LIMIT 1";
-        Ok(sqlx::query_as(query)
+    async fn get_user_by_github_account(
+        &self,
+        github_login: &str,
+        github_user_id: Option<i32>,
+    ) -> Result<Option<User>> {
+        if let Some(github_user_id) = github_user_id {
+            let mut user = sqlx::query_as::<_, User>(
+                "
+                UPDATE users
+                SET github_login = $1
+                WHERE github_user_id = $2
+                RETURNING *
+                ",
+            )
+            .bind(github_login)
+            .bind(github_user_id)
+            .fetch_optional(&self.pool)
+            .await?;
+
+            if user.is_none() {
+                user = sqlx::query_as::<_, User>(
+                    "
+                    UPDATE users
+                    SET github_user_id = $1
+                    WHERE github_login = $2
+                    RETURNING *
+                    ",
+                )
+                .bind(github_user_id)
+                .bind(github_login)
+                .fetch_optional(&self.pool)
+                .await?;
+            }
+
+            Ok(user)
+        } else {
+            Ok(sqlx::query_as(
+                "
+                SELECT * FROM users
+                WHERE github_login = $1
+                LIMIT 1
+                ",
+            )
             .bind(github_login)
             .fetch_optional(&self.pool)
             .await?)
+        }
     }
 
     async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()> {
@@ -333,9 +367,206 @@ 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,
+                device_id
+            )
+            VALUES
+                ($1, $2, 'f', $3, $4, $5, 'f', $6, $7, $8)
+            RETURNING id
+            ",
+        )
+        .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)
+        .bind(&signup.device_id)
+        .execute(&self.pool)
+        .await?;
+        Ok(())
+    }
+
+    async fn get_waitlist_summary(&self) -> Result<WaitlistSummary> {
+        Ok(sqlx::query_as(
+            "
+            SELECT
+                COUNT(*) as count,
+                COALESCE(SUM(CASE WHEN platform_linux THEN 1 ELSE 0 END), 0) as linux_count,
+                COALESCE(SUM(CASE WHEN platform_mac THEN 1 ELSE 0 END), 0) as mac_count,
+                COALESCE(SUM(CASE WHEN platform_windows THEN 1 ELSE 0 END), 0) as windows_count
+            FROM (
+                SELECT *
+                FROM signups
+                WHERE
+                    NOT email_confirmation_sent
+            ) AS unsent
+            ",
+        )
+        .fetch_one(&self.pool)
+        .await?)
+    }
+
+    async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>> {
+        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_sent_invites(&self, invites: &[Invite]) -> Result<()> {
+        sqlx::query(
+            "
+            UPDATE signups
+            SET email_confirmation_sent = 't'
+            WHERE email_address = ANY ($1)
+            ",
+        )
+        .bind(
+            &invites
+                .iter()
+                .map(|s| s.email_address.as_str())
+                .collect::<Vec<_>>(),
+        )
+        .execute(&self.pool)
+        .await?;
+        Ok(())
+    }
+
+    async fn create_user_from_invite(
+        &self,
+        invite: &Invite,
+        user: NewUserParams,
+    ) -> Result<NewUserResult> {
+        let mut tx = self.pool.begin().await?;
+
+        let (signup_id, existing_user_id, inviting_user_id, signup_device_id): (
+            i32,
+            Option<UserId>,
+            Option<UserId>,
+            Option<String>,
+        ) = sqlx::query_as(
+            "
+            SELECT id, user_id, inviting_user_id, device_id
+            FROM signups
+            WHERE
+                email_address = $1 AND
+                email_confirmation_code = $2
+            ",
+        )
+        .bind(&invite.email_address)
+        .bind(&invite.email_confirmation_code)
+        .fetch_optional(&mut tx)
+        .await?
+        .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "no such invite".to_string()))?;
+
+        if existing_user_id.is_some() {
+            Err(Error::Http(
+                StatusCode::UNPROCESSABLE_ENTITY,
+                "invitation already redeemed".to_string(),
+            ))?;
+        }
+
+        let user_id: UserId = sqlx::query_scalar(
+            "
+            INSERT INTO users
+            (email_address, github_login, github_user_id, admin, invite_count, invite_code)
+            VALUES
+            ($1, $2, $3, 'f', $4, $5)
+            RETURNING id
+            ",
+        )
+        .bind(&invite.email_address)
+        .bind(&user.github_login)
+        .bind(&user.github_user_id)
+        .bind(&user.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?;
+
+        if let Some(inviting_user_id) = inviting_user_id {
+            let id: Option<UserId> = sqlx::query_scalar(
+                "
+                UPDATE users
+                SET invite_count = invite_count - 1
+                WHERE id = $1 AND invite_count > 0
+                RETURNING id
+                ",
+            )
+            .bind(&inviting_user_id)
+            .fetch_optional(&mut tx)
+            .await?;
+
+            if id.is_none() {
+                Err(Error::Http(
+                    StatusCode::UNAUTHORIZED,
+                    "no invites remaining".to_string(),
+                ))?;
+            }
+
+            sqlx::query(
+                "
+                INSERT INTO contacts
+                    (user_id_a, user_id_b, a_to_b, should_notify, accepted)
+                VALUES
+                    ($1, $2, 't', 't', 't')
+                ",
+            )
+            .bind(inviting_user_id)
+            .bind(user_id)
+            .execute(&mut tx)
+            .await?;
+        }
+
+        tx.commit().await?;
+        Ok(NewUserResult {
+            user_id,
+            inviting_user_id,
+            signup_device_id,
+        })
+    }
+
     // invite codes
 
-    async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()> {
+    async fn set_invite_count_for_user(&self, id: UserId, count: u32) -> Result<()> {
         let mut tx = self.pool.begin().await?;
         if count > 0 {
             sqlx::query(
@@ -403,83 +634,89 @@ impl Db for PostgresDb {
         })
     }
 
-    async fn redeem_invite_code(
+    async fn create_invite_from_code(
         &self,
         code: &str,
-        login: &str,
-        email_address: Option<&str>,
-    ) -> Result<UserId> {
+        email_address: &str,
+        device_id: Option<&str>,
+    ) -> Result<Invite> {
         let mut tx = self.pool.begin().await?;
 
-        let inviter_id: Option<UserId> = sqlx::query_scalar(
+        let existing_user: Option<UserId> = sqlx::query_scalar(
             "
-                UPDATE users
-                SET invite_count = invite_count - 1
-                WHERE
-                    invite_code = $1 AND
-                    invite_count > 0
-                RETURNING id
+            SELECT id
+            FROM users
+            WHERE email_address = $1
             ",
         )
-        .bind(code)
+        .bind(email_address)
         .fetch_optional(&mut tx)
         .await?;
+        if existing_user.is_some() {
+            Err(anyhow!("email address is already in use"))?;
+        }
 
-        let inviter_id = match inviter_id {
-            Some(inviter_id) => inviter_id,
-            None => {
-                if sqlx::query_scalar::<_, i32>("SELECT 1 FROM users WHERE invite_code = $1")
-                    .bind(code)
-                    .fetch_optional(&mut tx)
-                    .await?
-                    .is_some()
-                {
-                    Err(Error::Http(
-                        StatusCode::UNAUTHORIZED,
-                        "no invites remaining".to_string(),
-                    ))?
-                } else {
-                    Err(Error::Http(
-                        StatusCode::NOT_FOUND,
-                        "invite code not found".to_string(),
-                    ))?
-                }
-            }
-        };
-
-        let invitee_id = sqlx::query_scalar(
+        let row: Option<(UserId, i32)> = sqlx::query_as(
             "
-                INSERT INTO users
-                    (github_login, email_address, admin, inviter_id, invite_code, invite_count)
-                VALUES
-                    ($1, $2, 'f', $3, $4, $5)
-                RETURNING id
+            SELECT id, invite_count
+            FROM users
+            WHERE invite_code = $1
             ",
         )
-        .bind(login)
-        .bind(email_address)
-        .bind(inviter_id)
-        .bind(random_invite_code())
-        .bind(5)
-        .fetch_one(&mut tx)
-        .await
-        .map(UserId)?;
+        .bind(code)
+        .fetch_optional(&mut tx)
+        .await?;
 
-        sqlx::query(
+        let (inviter_id, invite_count) = match row {
+            Some(row) => row,
+            None => Err(Error::Http(
+                StatusCode::NOT_FOUND,
+                "invite code not found".to_string(),
+            ))?,
+        };
+
+        if invite_count == 0 {
+            Err(Error::Http(
+                StatusCode::UNAUTHORIZED,
+                "no invites remaining".to_string(),
+            ))?;
+        }
+
+        let email_confirmation_code: String = sqlx::query_scalar(
             "
-                INSERT INTO contacts
-                    (user_id_a, user_id_b, a_to_b, should_notify, accepted)
-                VALUES
-                    ($1, $2, 't', 't', 't')
+            INSERT INTO signups
+            (
+                email_address,
+                email_confirmation_code,
+                email_confirmation_sent,
+                inviting_user_id,
+                platform_linux,
+                platform_mac,
+                platform_windows,
+                platform_unknown,
+                device_id
+            )
+            VALUES
+                ($1, $2, 'f', $3, 'f', 'f', 'f', 't', $4)
+            ON CONFLICT (email_address)
+            DO UPDATE SET
+                inviting_user_id = excluded.inviting_user_id
+            RETURNING email_confirmation_code
             ",
         )
-        .bind(inviter_id)
-        .bind(invitee_id)
-        .execute(&mut tx)
+        .bind(&email_address)
+        .bind(&random_email_confirmation_code())
+        .bind(&inviter_id)
+        .bind(&device_id)
+        .fetch_one(&mut tx)
         .await?;
 
         tx.commit().await?;
-        Ok(invitee_id)
+
+        Ok(Invite {
+            email_address: email_address.into(),
+            email_confirmation_code,
+        })
     }
 
     // projects
@@ -1294,7 +1531,7 @@ impl Db for PostgresDb {
     }
 
     #[cfg(test)]
-    fn as_fake(&self) -> Option<&tests::FakeDb> {
+    fn as_fake(&self) -> Option<&FakeDb> {
         None
     }
 }
@@ -1347,6 +1584,7 @@ id_type!(UserId);
 pub struct User {
     pub id: UserId,
     pub github_login: String,
+    pub github_user_id: Option<i32>,
     pub email_address: Option<String>,
     pub admin: bool,
     pub invite_code: Option<String>,
@@ -1371,19 +1609,19 @@ pub struct UserActivitySummary {
 
 #[derive(Clone, Debug, PartialEq, Serialize)]
 pub struct ProjectActivitySummary {
-    id: ProjectId,
-    duration: Duration,
-    max_collaborators: usize,
+    pub id: ProjectId,
+    pub duration: Duration,
+    pub max_collaborators: usize,
 }
 
 #[derive(Clone, Debug, PartialEq, Serialize)]
 pub struct UserActivityPeriod {
-    project_id: ProjectId,
+    pub project_id: ProjectId,
     #[serde(with = "time::serde::iso8601")]
-    start: OffsetDateTime,
+    pub start: OffsetDateTime,
     #[serde(with = "time::serde::iso8601")]
-    end: OffsetDateTime,
-    extensions: HashMap<String, usize>,
+    pub end: OffsetDateTime,
+    pub extensions: HashMap<String, usize>,
 }
 
 id_type!(OrgId);
@@ -1445,28 +1683,66 @@ pub struct IncomingContactRequest {
     pub should_notify: bool,
 }
 
-fn fuzzy_like_string(string: &str) -> String {
-    let mut result = String::with_capacity(string.len() * 2 + 1);
-    for c in string.chars() {
-        if c.is_alphanumeric() {
-            result.push('%');
-            result.push(c);
-        }
-    }
-    result.push('%');
-    result
+#[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>,
+    pub device_id: Option<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromRow)]
+pub struct WaitlistSummary {
+    #[sqlx(default)]
+    pub count: i64,
+    #[sqlx(default)]
+    pub linux_count: i64,
+    #[sqlx(default)]
+    pub mac_count: i64,
+    #[sqlx(default)]
+    pub windows_count: i64,
+}
+
+#[derive(FromRow, PartialEq, Debug, Serialize, Deserialize)]
+pub struct Invite {
+    pub email_address: String,
+    pub email_confirmation_code: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct NewUserParams {
+    pub github_login: String,
+    pub github_user_id: i32,
+    pub invite_count: i32,
+}
+
+#[derive(Debug)]
+pub struct NewUserResult {
+    pub user_id: UserId,
+    pub inviting_user_id: Option<UserId>,
+    pub signup_device_id: Option<String>,
 }
 
 fn random_invite_code() -> String {
     nanoid::nanoid!(16)
 }
 
+fn random_email_confirmation_code() -> String {
+    nanoid::nanoid!(64)
+}
+
+#[cfg(test)]
+pub use test::*;
+
 #[cfg(test)]
-pub mod tests {
+mod test {
     use super::*;
     use anyhow::anyhow;
     use collections::BTreeMap;
-    use gpui::executor::{Background, Deterministic};
+    use gpui::executor::Background;
     use lazy_static::lazy_static;
     use parking_lot::Mutex;
     use rand::prelude::*;
@@ -1477,1002 +1753,30 @@ pub mod tests {
     use std::{path::Path, sync::Arc};
     use util::post_inc;
 
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_get_users_by_ids() {
-        for test_db in [
-            TestDb::postgres().await,
-            TestDb::fake(build_background_executor()),
-        ] {
-            let db = test_db.db();
-
-            let user = db.create_user("user", None, false).await.unwrap();
-            let friend1 = db.create_user("friend-1", None, false).await.unwrap();
-            let friend2 = db.create_user("friend-2", None, false).await.unwrap();
-            let friend3 = db.create_user("friend-3", None, false).await.unwrap();
-
-            assert_eq!(
-                db.get_users_by_ids(vec![user, friend1, friend2, friend3])
-                    .await
-                    .unwrap(),
-                vec![
-                    User {
-                        id: user,
-                        github_login: "user".to_string(),
-                        admin: false,
-                        ..Default::default()
-                    },
-                    User {
-                        id: friend1,
-                        github_login: "friend-1".to_string(),
-                        admin: false,
-                        ..Default::default()
-                    },
-                    User {
-                        id: friend2,
-                        github_login: "friend-2".to_string(),
-                        admin: false,
-                        ..Default::default()
-                    },
-                    User {
-                        id: friend3,
-                        github_login: "friend-3".to_string(),
-                        admin: false,
-                        ..Default::default()
-                    }
-                ]
-            );
-        }
+    pub struct FakeDb {
+        background: Arc<Background>,
+        pub users: Mutex<BTreeMap<UserId, User>>,
+        pub projects: Mutex<BTreeMap<ProjectId, Project>>,
+        pub worktree_extensions: Mutex<BTreeMap<(ProjectId, u64, String), u32>>,
+        pub orgs: Mutex<BTreeMap<OrgId, Org>>,
+        pub org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
+        pub channels: Mutex<BTreeMap<ChannelId, Channel>>,
+        pub channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
+        pub channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
+        pub contacts: Mutex<Vec<FakeContact>>,
+        next_channel_message_id: Mutex<i32>,
+        next_user_id: Mutex<i32>,
+        next_org_id: Mutex<i32>,
+        next_channel_id: Mutex<i32>,
+        next_project_id: Mutex<i32>,
     }
 
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_create_users() {
-        let db = TestDb::postgres().await;
-        let db = db.db();
-
-        // Create the first batch of users, ensuring invite counts are assigned
-        // correctly and the respective invite codes are unique.
-        let user_ids_batch_1 = db
-            .create_users(vec![
-                ("user1".to_string(), "hi@user1.com".to_string(), 5),
-                ("user2".to_string(), "hi@user2.com".to_string(), 4),
-                ("user3".to_string(), "hi@user3.com".to_string(), 3),
-            ])
-            .await
-            .unwrap();
-        assert_eq!(user_ids_batch_1.len(), 3);
-
-        let users = db.get_users_by_ids(user_ids_batch_1.clone()).await.unwrap();
-        assert_eq!(users.len(), 3);
-        assert_eq!(users[0].github_login, "user1");
-        assert_eq!(users[0].email_address.as_deref(), Some("hi@user1.com"));
-        assert_eq!(users[0].invite_count, 5);
-        assert_eq!(users[1].github_login, "user2");
-        assert_eq!(users[1].email_address.as_deref(), Some("hi@user2.com"));
-        assert_eq!(users[1].invite_count, 4);
-        assert_eq!(users[2].github_login, "user3");
-        assert_eq!(users[2].email_address.as_deref(), Some("hi@user3.com"));
-        assert_eq!(users[2].invite_count, 3);
-
-        let invite_code_1 = users[0].invite_code.clone().unwrap();
-        let invite_code_2 = users[1].invite_code.clone().unwrap();
-        let invite_code_3 = users[2].invite_code.clone().unwrap();
-        assert_ne!(invite_code_1, invite_code_2);
-        assert_ne!(invite_code_1, invite_code_3);
-        assert_ne!(invite_code_2, invite_code_3);
-
-        // Create the second batch of users and include a user that is already in the database, ensuring
-        // the invite count for the existing user is updated without changing their invite code.
-        let user_ids_batch_2 = db
-            .create_users(vec![
-                ("user2".to_string(), "hi@user2.com".to_string(), 10),
-                ("user4".to_string(), "hi@user4.com".to_string(), 2),
-            ])
-            .await
-            .unwrap();
-        assert_eq!(user_ids_batch_2.len(), 2);
-        assert_eq!(user_ids_batch_2[0], user_ids_batch_1[1]);
-
-        let users = db.get_users_by_ids(user_ids_batch_2).await.unwrap();
-        assert_eq!(users.len(), 2);
-        assert_eq!(users[0].github_login, "user2");
-        assert_eq!(users[0].email_address.as_deref(), Some("hi@user2.com"));
-        assert_eq!(users[0].invite_count, 10);
-        assert_eq!(users[0].invite_code, Some(invite_code_2.clone()));
-        assert_eq!(users[1].github_login, "user4");
-        assert_eq!(users[1].email_address.as_deref(), Some("hi@user4.com"));
-        assert_eq!(users[1].invite_count, 2);
-
-        let invite_code_4 = users[1].invite_code.clone().unwrap();
-        assert_ne!(invite_code_4, invite_code_1);
-        assert_ne!(invite_code_4, invite_code_2);
-        assert_ne!(invite_code_4, invite_code_3);
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_worktree_extensions() {
-        let test_db = TestDb::postgres().await;
-        let db = test_db.db();
-
-        let user = db.create_user("user_1", None, false).await.unwrap();
-        let project = db.register_project(user).await.unwrap();
-
-        db.update_worktree_extensions(project, 100, Default::default())
-            .await
-            .unwrap();
-        db.update_worktree_extensions(
-            project,
-            100,
-            [("rs".to_string(), 5), ("md".to_string(), 3)]
-                .into_iter()
-                .collect(),
-        )
-        .await
-        .unwrap();
-        db.update_worktree_extensions(
-            project,
-            100,
-            [("rs".to_string(), 6), ("md".to_string(), 5)]
-                .into_iter()
-                .collect(),
-        )
-        .await
-        .unwrap();
-        db.update_worktree_extensions(
-            project,
-            101,
-            [("ts".to_string(), 2), ("md".to_string(), 1)]
-                .into_iter()
-                .collect(),
-        )
-        .await
-        .unwrap();
-
-        assert_eq!(
-            db.get_project_extensions(project).await.unwrap(),
-            [
-                (
-                    100,
-                    [("rs".into(), 6), ("md".into(), 5),]
-                        .into_iter()
-                        .collect::<HashMap<_, _>>()
-                ),
-                (
-                    101,
-                    [("ts".into(), 2), ("md".into(), 1),]
-                        .into_iter()
-                        .collect::<HashMap<_, _>>()
-                )
-            ]
-            .into_iter()
-            .collect()
-        );
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_user_activity() {
-        let test_db = TestDb::postgres().await;
-        let db = test_db.db();
-
-        let user_1 = db.create_user("user_1", None, false).await.unwrap();
-        let user_2 = db.create_user("user_2", None, false).await.unwrap();
-        let user_3 = db.create_user("user_3", None, false).await.unwrap();
-        let project_1 = db.register_project(user_1).await.unwrap();
-        db.update_worktree_extensions(
-            project_1,
-            1,
-            HashMap::from_iter([("rs".into(), 5), ("md".into(), 7)]),
-        )
-        .await
-        .unwrap();
-        let project_2 = db.register_project(user_2).await.unwrap();
-        let t0 = OffsetDateTime::now_utc() - Duration::from_secs(60 * 60);
-
-        // User 2 opens a project
-        let t1 = t0 + Duration::from_secs(10);
-        db.record_user_activity(t0..t1, &[(user_2, project_2)])
-            .await
-            .unwrap();
-
-        let t2 = t1 + Duration::from_secs(10);
-        db.record_user_activity(t1..t2, &[(user_2, project_2)])
-            .await
-            .unwrap();
-
-        // User 1 joins the project
-        let t3 = t2 + Duration::from_secs(10);
-        db.record_user_activity(t2..t3, &[(user_2, project_2), (user_1, project_2)])
-            .await
-            .unwrap();
-
-        // User 1 opens another project
-        let t4 = t3 + Duration::from_secs(10);
-        db.record_user_activity(
-            t3..t4,
-            &[
-                (user_2, project_2),
-                (user_1, project_2),
-                (user_1, project_1),
-            ],
-        )
-        .await
-        .unwrap();
-
-        // User 3 joins that project
-        let t5 = t4 + Duration::from_secs(10);
-        db.record_user_activity(
-            t4..t5,
-            &[
-                (user_2, project_2),
-                (user_1, project_2),
-                (user_1, project_1),
-                (user_3, project_1),
-            ],
-        )
-        .await
-        .unwrap();
-
-        // User 2 leaves
-        let t6 = t5 + Duration::from_secs(5);
-        db.record_user_activity(t5..t6, &[(user_1, project_1), (user_3, project_1)])
-            .await
-            .unwrap();
-
-        let t7 = t6 + Duration::from_secs(60);
-        let t8 = t7 + Duration::from_secs(10);
-        db.record_user_activity(t7..t8, &[(user_1, project_1)])
-            .await
-            .unwrap();
-
-        assert_eq!(
-            db.get_top_users_activity_summary(t0..t6, 10).await.unwrap(),
-            &[
-                UserActivitySummary {
-                    id: user_1,
-                    github_login: "user_1".to_string(),
-                    project_activity: vec![
-                        ProjectActivitySummary {
-                            id: project_1,
-                            duration: Duration::from_secs(25),
-                            max_collaborators: 2
-                        },
-                        ProjectActivitySummary {
-                            id: project_2,
-                            duration: Duration::from_secs(30),
-                            max_collaborators: 2
-                        }
-                    ]
-                },
-                UserActivitySummary {
-                    id: user_2,
-                    github_login: "user_2".to_string(),
-                    project_activity: vec![ProjectActivitySummary {
-                        id: project_2,
-                        duration: Duration::from_secs(50),
-                        max_collaborators: 2
-                    }]
-                },
-                UserActivitySummary {
-                    id: user_3,
-                    github_login: "user_3".to_string(),
-                    project_activity: vec![ProjectActivitySummary {
-                        id: project_1,
-                        duration: Duration::from_secs(15),
-                        max_collaborators: 2
-                    }]
-                },
-            ]
-        );
-
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(56), false)
-                .await
-                .unwrap(),
-            0
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(56), true)
-                .await
-                .unwrap(),
-            0
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(54), false)
-                .await
-                .unwrap(),
-            1
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(54), true)
-                .await
-                .unwrap(),
-            1
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(30), false)
-                .await
-                .unwrap(),
-            2
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(30), true)
-                .await
-                .unwrap(),
-            2
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(10), false)
-                .await
-                .unwrap(),
-            3
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(10), true)
-                .await
-                .unwrap(),
-            3
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t1, Duration::from_secs(5), false)
-                .await
-                .unwrap(),
-            1
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t1, Duration::from_secs(5), true)
-                .await
-                .unwrap(),
-            0
-        );
-
-        assert_eq!(
-            db.get_user_activity_timeline(t3..t6, user_1).await.unwrap(),
-            &[
-                UserActivityPeriod {
-                    project_id: project_1,
-                    start: t3,
-                    end: t6,
-                    extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
-                },
-                UserActivityPeriod {
-                    project_id: project_2,
-                    start: t3,
-                    end: t5,
-                    extensions: Default::default(),
-                },
-            ]
-        );
-        assert_eq!(
-            db.get_user_activity_timeline(t0..t8, user_1).await.unwrap(),
-            &[
-                UserActivityPeriod {
-                    project_id: project_2,
-                    start: t2,
-                    end: t5,
-                    extensions: Default::default(),
-                },
-                UserActivityPeriod {
-                    project_id: project_1,
-                    start: t3,
-                    end: t6,
-                    extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
-                },
-                UserActivityPeriod {
-                    project_id: project_1,
-                    start: t7,
-                    end: t8,
-                    extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
-                },
-            ]
-        );
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_recent_channel_messages() {
-        for test_db in [
-            TestDb::postgres().await,
-            TestDb::fake(build_background_executor()),
-        ] {
-            let db = test_db.db();
-            let user = db.create_user("user", None, false).await.unwrap();
-            let org = db.create_org("org", "org").await.unwrap();
-            let channel = db.create_org_channel(org, "channel").await.unwrap();
-            for i in 0..10 {
-                db.create_channel_message(
-                    channel,
-                    user,
-                    &i.to_string(),
-                    OffsetDateTime::now_utc(),
-                    i,
-                )
-                .await
-                .unwrap();
-            }
-
-            let messages = db.get_channel_messages(channel, 5, None).await.unwrap();
-            assert_eq!(
-                messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
-                ["5", "6", "7", "8", "9"]
-            );
-
-            let prev_messages = db
-                .get_channel_messages(channel, 4, Some(messages[0].id))
-                .await
-                .unwrap();
-            assert_eq!(
-                prev_messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
-                ["1", "2", "3", "4"]
-            );
-        }
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_channel_message_nonces() {
-        for test_db in [
-            TestDb::postgres().await,
-            TestDb::fake(build_background_executor()),
-        ] {
-            let db = test_db.db();
-            let user = db.create_user("user", None, false).await.unwrap();
-            let org = db.create_org("org", "org").await.unwrap();
-            let channel = db.create_org_channel(org, "channel").await.unwrap();
-
-            let msg1_id = db
-                .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1)
-                .await
-                .unwrap();
-            let msg2_id = db
-                .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2)
-                .await
-                .unwrap();
-            let msg3_id = db
-                .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1)
-                .await
-                .unwrap();
-            let msg4_id = db
-                .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2)
-                .await
-                .unwrap();
-
-            assert_ne!(msg1_id, msg2_id);
-            assert_eq!(msg1_id, msg3_id);
-            assert_eq!(msg2_id, msg4_id);
-        }
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_create_access_tokens() {
-        let test_db = TestDb::postgres().await;
-        let db = test_db.db();
-        let user = db.create_user("the-user", None, false).await.unwrap();
-
-        db.create_access_token_hash(user, "h1", 3).await.unwrap();
-        db.create_access_token_hash(user, "h2", 3).await.unwrap();
-        assert_eq!(
-            db.get_access_token_hashes(user).await.unwrap(),
-            &["h2".to_string(), "h1".to_string()]
-        );
-
-        db.create_access_token_hash(user, "h3", 3).await.unwrap();
-        assert_eq!(
-            db.get_access_token_hashes(user).await.unwrap(),
-            &["h3".to_string(), "h2".to_string(), "h1".to_string(),]
-        );
-
-        db.create_access_token_hash(user, "h4", 3).await.unwrap();
-        assert_eq!(
-            db.get_access_token_hashes(user).await.unwrap(),
-            &["h4".to_string(), "h3".to_string(), "h2".to_string(),]
-        );
-
-        db.create_access_token_hash(user, "h5", 3).await.unwrap();
-        assert_eq!(
-            db.get_access_token_hashes(user).await.unwrap(),
-            &["h5".to_string(), "h4".to_string(), "h3".to_string()]
-        );
-    }
-
-    #[test]
-    fn test_fuzzy_like_string() {
-        assert_eq!(fuzzy_like_string("abcd"), "%a%b%c%d%");
-        assert_eq!(fuzzy_like_string("x y"), "%x%y%");
-        assert_eq!(fuzzy_like_string(" z  "), "%z%");
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_fuzzy_search_users() {
-        let test_db = TestDb::postgres().await;
-        let db = test_db.db();
-        for github_login in [
-            "California",
-            "colorado",
-            "oregon",
-            "washington",
-            "florida",
-            "delaware",
-            "rhode-island",
-        ] {
-            db.create_user(github_login, None, false).await.unwrap();
-        }
-
-        assert_eq!(
-            fuzzy_search_user_names(db, "clr").await,
-            &["colorado", "California"]
-        );
-        assert_eq!(
-            fuzzy_search_user_names(db, "ro").await,
-            &["rhode-island", "colorado", "oregon"],
-        );
-
-        async fn fuzzy_search_user_names(db: &Arc<dyn Db>, query: &str) -> Vec<String> {
-            db.fuzzy_search_users(query, 10)
-                .await
-                .unwrap()
-                .into_iter()
-                .map(|user| user.github_login)
-                .collect::<Vec<_>>()
-        }
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_add_contacts() {
-        for test_db in [
-            TestDb::postgres().await,
-            TestDb::fake(build_background_executor()),
-        ] {
-            let db = test_db.db();
-
-            let user_1 = db.create_user("user1", None, false).await.unwrap();
-            let user_2 = db.create_user("user2", None, false).await.unwrap();
-            let user_3 = db.create_user("user3", None, false).await.unwrap();
-
-            // User starts with no contacts
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                vec![Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                }],
-            );
-
-            // User requests a contact. Both users see the pending request.
-            db.send_contact_request(user_1, user_2).await.unwrap();
-            assert!(!db.has_contact(user_1, user_2).await.unwrap());
-            assert!(!db.has_contact(user_2, user_1).await.unwrap());
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Outgoing { user_id: user_2 }
-                ],
-            );
-            assert_eq!(
-                db.get_contacts(user_2).await.unwrap(),
-                &[
-                    Contact::Incoming {
-                        user_id: user_1,
-                        should_notify: true
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false
-                    },
-                ]
-            );
-
-            // User 2 dismisses the contact request notification without accepting or rejecting.
-            // We shouldn't notify them again.
-            db.dismiss_contact_notification(user_1, user_2)
-                .await
-                .unwrap_err();
-            db.dismiss_contact_notification(user_2, user_1)
-                .await
-                .unwrap();
-            assert_eq!(
-                db.get_contacts(user_2).await.unwrap(),
-                &[
-                    Contact::Incoming {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false
-                    },
-                ]
-            );
-
-            // User can't accept their own contact request
-            db.respond_to_contact_request(user_1, user_2, true)
-                .await
-                .unwrap_err();
-
-            // User accepts a contact request. Both users see the contact.
-            db.respond_to_contact_request(user_2, user_1, true)
-                .await
-                .unwrap();
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: true
-                    }
-                ],
-            );
-            assert!(db.has_contact(user_1, user_2).await.unwrap());
-            assert!(db.has_contact(user_2, user_1).await.unwrap());
-            assert_eq!(
-                db.get_contacts(user_2).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false,
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false,
-                    },
-                ]
-            );
-
-            // Users cannot re-request existing contacts.
-            db.send_contact_request(user_1, user_2).await.unwrap_err();
-            db.send_contact_request(user_2, user_1).await.unwrap_err();
-
-            // Users can't dismiss notifications of them accepting other users' requests.
-            db.dismiss_contact_notification(user_2, user_1)
-                .await
-                .unwrap_err();
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: true,
-                    },
-                ]
-            );
-
-            // Users can dismiss notifications of other users accepting their requests.
-            db.dismiss_contact_notification(user_1, user_2)
-                .await
-                .unwrap();
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false,
-                    },
-                ]
-            );
-
-            // Users send each other concurrent contact requests and
-            // see that they are immediately accepted.
-            db.send_contact_request(user_1, user_3).await.unwrap();
-            db.send_contact_request(user_3, user_1).await.unwrap();
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false,
-                    },
-                    Contact::Accepted {
-                        user_id: user_3,
-                        should_notify: false
-                    },
-                ]
-            );
-            assert_eq!(
-                db.get_contacts(user_3).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_3,
-                        should_notify: false
-                    }
-                ],
-            );
-
-            // User declines a contact request. Both users see that it is gone.
-            db.send_contact_request(user_2, user_3).await.unwrap();
-            db.respond_to_contact_request(user_3, user_2, false)
-                .await
-                .unwrap();
-            assert!(!db.has_contact(user_2, user_3).await.unwrap());
-            assert!(!db.has_contact(user_3, user_2).await.unwrap());
-            assert_eq!(
-                db.get_contacts(user_2).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false
-                    }
-                ]
-            );
-            assert_eq!(
-                db.get_contacts(user_3).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_3,
-                        should_notify: false
-                    }
-                ],
-            );
-        }
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_invite_codes() {
-        let postgres = TestDb::postgres().await;
-        let db = postgres.db();
-        let user1 = db.create_user("user-1", None, false).await.unwrap();
-
-        // Initially, user 1 has no invite code
-        assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None);
-
-        // Setting invite count to 0 when no code is assigned does not assign a new code
-        db.set_invite_count(user1, 0).await.unwrap();
-        assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none());
-
-        // User 1 creates an invite code that can be used twice.
-        db.set_invite_count(user1, 2).await.unwrap();
-        let (invite_code, invite_count) =
-            db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 2);
-
-        // User 2 redeems the invite code and becomes a contact of user 1.
-        let user2 = db
-            .redeem_invite_code(&invite_code, "user-2", None)
-            .await
-            .unwrap();
-        let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 1);
-        assert_eq!(
-            db.get_contacts(user1).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user2,
-                    should_notify: true
-                }
-            ]
-        );
-        assert_eq!(
-            db.get_contacts(user2).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user2,
-                    should_notify: false
-                }
-            ]
-        );
-
-        // User 3 redeems the invite code and becomes a contact of user 1.
-        let user3 = db
-            .redeem_invite_code(&invite_code, "user-3", None)
-            .await
-            .unwrap();
-        let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 0);
-        assert_eq!(
-            db.get_contacts(user1).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user2,
-                    should_notify: true
-                },
-                Contact::Accepted {
-                    user_id: user3,
-                    should_notify: true
-                }
-            ]
-        );
-        assert_eq!(
-            db.get_contacts(user3).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user3,
-                    should_notify: false
-                },
-            ]
-        );
-
-        // Trying to reedem the code for the third time results in an error.
-        db.redeem_invite_code(&invite_code, "user-4", None)
-            .await
-            .unwrap_err();
-
-        // Invite count can be updated after the code has been created.
-        db.set_invite_count(user1, 2).await.unwrap();
-        let (latest_code, invite_count) =
-            db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0
-        assert_eq!(invite_count, 2);
-
-        // User 4 can now redeem the invite code and becomes a contact of user 1.
-        let user4 = db
-            .redeem_invite_code(&invite_code, "user-4", None)
-            .await
-            .unwrap();
-        let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 1);
-        assert_eq!(
-            db.get_contacts(user1).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user2,
-                    should_notify: true
-                },
-                Contact::Accepted {
-                    user_id: user3,
-                    should_notify: true
-                },
-                Contact::Accepted {
-                    user_id: user4,
-                    should_notify: true
-                }
-            ]
-        );
-        assert_eq!(
-            db.get_contacts(user4).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user4,
-                    should_notify: false
-                },
-            ]
-        );
-
-        // An existing user cannot redeem invite codes.
-        db.redeem_invite_code(&invite_code, "user-2", None)
-            .await
-            .unwrap_err();
-        let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 1);
-
-        // Ensure invited users get invite codes too.
-        assert_eq!(
-            db.get_invite_code_for_user(user2).await.unwrap().unwrap().1,
-            5
-        );
-        assert_eq!(
-            db.get_invite_code_for_user(user3).await.unwrap().unwrap().1,
-            5
-        );
-        assert_eq!(
-            db.get_invite_code_for_user(user4).await.unwrap().unwrap().1,
-            5
-        );
-    }
-
-    pub struct TestDb {
-        pub db: Option<Arc<dyn Db>>,
-        pub url: String,
-    }
-
-    impl TestDb {
-        #[allow(clippy::await_holding_lock)]
-        pub async fn postgres() -> Self {
-            lazy_static! {
-                static ref LOCK: Mutex<()> = Mutex::new(());
-            }
-
-            let _guard = LOCK.lock();
-            let mut rng = StdRng::from_entropy();
-            let name = format!("zed-test-{}", rng.gen::<u128>());
-            let url = format!("postgres://postgres@localhost/{}", name);
-            let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"));
-            Postgres::create_database(&url)
-                .await
-                .expect("failed to create test db");
-            let db = PostgresDb::new(&url, 5).await.unwrap();
-            let migrator = Migrator::new(migrations_path).await.unwrap();
-            migrator.run(&db.pool).await.unwrap();
-            Self {
-                db: Some(Arc::new(db)),
-                url,
-            }
-        }
-
-        pub fn fake(background: Arc<Background>) -> Self {
-            Self {
-                db: Some(Arc::new(FakeDb::new(background))),
-                url: Default::default(),
-            }
-        }
-
-        pub fn db(&self) -> &Arc<dyn Db> {
-            self.db.as_ref().unwrap()
-        }
-    }
-
-    impl Drop for TestDb {
-        fn drop(&mut self) {
-            if let Some(db) = self.db.take() {
-                futures::executor::block_on(db.teardown(&self.url));
-            }
-        }
-    }
-
-    pub struct FakeDb {
-        background: Arc<Background>,
-        pub users: Mutex<BTreeMap<UserId, User>>,
-        pub projects: Mutex<BTreeMap<ProjectId, Project>>,
-        pub worktree_extensions: Mutex<BTreeMap<(ProjectId, u64, String), u32>>,
-        pub orgs: Mutex<BTreeMap<OrgId, Org>>,
-        pub org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
-        pub channels: Mutex<BTreeMap<ChannelId, Channel>>,
-        pub channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
-        pub channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
-        pub contacts: Mutex<Vec<FakeContact>>,
-        next_channel_message_id: Mutex<i32>,
-        next_user_id: Mutex<i32>,
-        next_org_id: Mutex<i32>,
-        next_channel_id: Mutex<i32>,
-        next_project_id: Mutex<i32>,
-    }
-
-    #[derive(Debug)]
-    pub struct FakeContact {
-        pub requester_id: UserId,
-        pub responder_id: UserId,
-        pub accepted: bool,
-        pub should_notify: bool,
+    #[derive(Debug)]
+    pub struct FakeContact {
+        pub requester_id: UserId,
+        pub responder_id: UserId,
+        pub accepted: bool,
+        pub should_notify: bool,
     }
 
     impl FakeDb {

crates/collab/src/db_tests.rs 🔗

@@ -0,0 +1,1289 @@
+use super::db::*;
+use collections::HashMap;
+use gpui::executor::{Background, Deterministic};
+use std::{sync::Arc, time::Duration};
+use time::OffsetDateTime;
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_get_users_by_ids() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+
+        let user1 = db
+            .create_user(
+                "u1@example.com",
+                false,
+                NewUserParams {
+                    github_login: "u1".into(),
+                    github_user_id: 1,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+        let user2 = db
+            .create_user(
+                "u2@example.com",
+                false,
+                NewUserParams {
+                    github_login: "u2".into(),
+                    github_user_id: 2,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+        let user3 = db
+            .create_user(
+                "u3@example.com",
+                false,
+                NewUserParams {
+                    github_login: "u3".into(),
+                    github_user_id: 3,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+        let user4 = db
+            .create_user(
+                "u4@example.com",
+                false,
+                NewUserParams {
+                    github_login: "u4".into(),
+                    github_user_id: 4,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+
+        assert_eq!(
+            db.get_users_by_ids(vec![user1, user2, user3, user4])
+                .await
+                .unwrap(),
+            vec![
+                User {
+                    id: user1,
+                    github_login: "u1".to_string(),
+                    github_user_id: Some(1),
+                    email_address: Some("u1@example.com".to_string()),
+                    admin: false,
+                    ..Default::default()
+                },
+                User {
+                    id: user2,
+                    github_login: "u2".to_string(),
+                    github_user_id: Some(2),
+                    email_address: Some("u2@example.com".to_string()),
+                    admin: false,
+                    ..Default::default()
+                },
+                User {
+                    id: user3,
+                    github_login: "u3".to_string(),
+                    github_user_id: Some(3),
+                    email_address: Some("u3@example.com".to_string()),
+                    admin: false,
+                    ..Default::default()
+                },
+                User {
+                    id: user4,
+                    github_login: "u4".to_string(),
+                    github_user_id: Some(4),
+                    email_address: Some("u4@example.com".to_string()),
+                    admin: false,
+                    ..Default::default()
+                }
+            ]
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_get_user_by_github_account() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+        let user_id1 = db
+            .create_user(
+                "user1@example.com",
+                false,
+                NewUserParams {
+                    github_login: "login1".into(),
+                    github_user_id: 101,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+        let user_id2 = db
+            .create_user(
+                "user2@example.com",
+                false,
+                NewUserParams {
+                    github_login: "login2".into(),
+                    github_user_id: 102,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+
+        let user = db
+            .get_user_by_github_account("login1", 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_user_by_github_account("non-existent-login", None)
+            .await
+            .unwrap()
+            .is_none());
+
+        let user = db
+            .get_user_by_github_account("the-new-login2", Some(102))
+            .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));
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_worktree_extensions() {
+    let test_db = TestDb::postgres().await;
+    let db = test_db.db();
+
+    let user = db
+        .create_user(
+            "u1@example.com",
+            false,
+            NewUserParams {
+                github_login: "u1".into(),
+                github_user_id: 0,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+    let project = db.register_project(user).await.unwrap();
+
+    db.update_worktree_extensions(project, 100, Default::default())
+        .await
+        .unwrap();
+    db.update_worktree_extensions(
+        project,
+        100,
+        [("rs".to_string(), 5), ("md".to_string(), 3)]
+            .into_iter()
+            .collect(),
+    )
+    .await
+    .unwrap();
+    db.update_worktree_extensions(
+        project,
+        100,
+        [("rs".to_string(), 6), ("md".to_string(), 5)]
+            .into_iter()
+            .collect(),
+    )
+    .await
+    .unwrap();
+    db.update_worktree_extensions(
+        project,
+        101,
+        [("ts".to_string(), 2), ("md".to_string(), 1)]
+            .into_iter()
+            .collect(),
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(
+        db.get_project_extensions(project).await.unwrap(),
+        [
+            (
+                100,
+                [("rs".into(), 6), ("md".into(), 5),]
+                    .into_iter()
+                    .collect::<HashMap<_, _>>()
+            ),
+            (
+                101,
+                [("ts".into(), 2), ("md".into(), 1),]
+                    .into_iter()
+                    .collect::<HashMap<_, _>>()
+            )
+        ]
+        .into_iter()
+        .collect()
+    );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_user_activity() {
+    let test_db = TestDb::postgres().await;
+    let db = test_db.db();
+
+    let user_1 = db
+        .create_user(
+            "u1@example.com",
+            false,
+            NewUserParams {
+                github_login: "u1".into(),
+                github_user_id: 0,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+    let user_2 = db
+        .create_user(
+            "u2@example.com",
+            false,
+            NewUserParams {
+                github_login: "u2".into(),
+                github_user_id: 0,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+    let user_3 = db
+        .create_user(
+            "u3@example.com",
+            false,
+            NewUserParams {
+                github_login: "u3".into(),
+                github_user_id: 0,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+    let project_1 = db.register_project(user_1).await.unwrap();
+    db.update_worktree_extensions(
+        project_1,
+        1,
+        HashMap::from_iter([("rs".into(), 5), ("md".into(), 7)]),
+    )
+    .await
+    .unwrap();
+    let project_2 = db.register_project(user_2).await.unwrap();
+    let t0 = OffsetDateTime::now_utc() - Duration::from_secs(60 * 60);
+
+    // User 2 opens a project
+    let t1 = t0 + Duration::from_secs(10);
+    db.record_user_activity(t0..t1, &[(user_2, project_2)])
+        .await
+        .unwrap();
+
+    let t2 = t1 + Duration::from_secs(10);
+    db.record_user_activity(t1..t2, &[(user_2, project_2)])
+        .await
+        .unwrap();
+
+    // User 1 joins the project
+    let t3 = t2 + Duration::from_secs(10);
+    db.record_user_activity(t2..t3, &[(user_2, project_2), (user_1, project_2)])
+        .await
+        .unwrap();
+
+    // User 1 opens another project
+    let t4 = t3 + Duration::from_secs(10);
+    db.record_user_activity(
+        t3..t4,
+        &[
+            (user_2, project_2),
+            (user_1, project_2),
+            (user_1, project_1),
+        ],
+    )
+    .await
+    .unwrap();
+
+    // User 3 joins that project
+    let t5 = t4 + Duration::from_secs(10);
+    db.record_user_activity(
+        t4..t5,
+        &[
+            (user_2, project_2),
+            (user_1, project_2),
+            (user_1, project_1),
+            (user_3, project_1),
+        ],
+    )
+    .await
+    .unwrap();
+
+    // User 2 leaves
+    let t6 = t5 + Duration::from_secs(5);
+    db.record_user_activity(t5..t6, &[(user_1, project_1), (user_3, project_1)])
+        .await
+        .unwrap();
+
+    let t7 = t6 + Duration::from_secs(60);
+    let t8 = t7 + Duration::from_secs(10);
+    db.record_user_activity(t7..t8, &[(user_1, project_1)])
+        .await
+        .unwrap();
+
+    assert_eq!(
+        db.get_top_users_activity_summary(t0..t6, 10).await.unwrap(),
+        &[
+            UserActivitySummary {
+                id: user_1,
+                github_login: "u1".to_string(),
+                project_activity: vec![
+                    ProjectActivitySummary {
+                        id: project_1,
+                        duration: Duration::from_secs(25),
+                        max_collaborators: 2
+                    },
+                    ProjectActivitySummary {
+                        id: project_2,
+                        duration: Duration::from_secs(30),
+                        max_collaborators: 2
+                    }
+                ]
+            },
+            UserActivitySummary {
+                id: user_2,
+                github_login: "u2".to_string(),
+                project_activity: vec![ProjectActivitySummary {
+                    id: project_2,
+                    duration: Duration::from_secs(50),
+                    max_collaborators: 2
+                }]
+            },
+            UserActivitySummary {
+                id: user_3,
+                github_login: "u3".to_string(),
+                project_activity: vec![ProjectActivitySummary {
+                    id: project_1,
+                    duration: Duration::from_secs(15),
+                    max_collaborators: 2
+                }]
+            },
+        ]
+    );
+
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(56), false)
+            .await
+            .unwrap(),
+        0
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(56), true)
+            .await
+            .unwrap(),
+        0
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(54), false)
+            .await
+            .unwrap(),
+        1
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(54), true)
+            .await
+            .unwrap(),
+        1
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(30), false)
+            .await
+            .unwrap(),
+        2
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(30), true)
+            .await
+            .unwrap(),
+        2
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(10), false)
+            .await
+            .unwrap(),
+        3
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(10), true)
+            .await
+            .unwrap(),
+        3
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t1, Duration::from_secs(5), false)
+            .await
+            .unwrap(),
+        1
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t1, Duration::from_secs(5), true)
+            .await
+            .unwrap(),
+        0
+    );
+
+    assert_eq!(
+        db.get_user_activity_timeline(t3..t6, user_1).await.unwrap(),
+        &[
+            UserActivityPeriod {
+                project_id: project_1,
+                start: t3,
+                end: t6,
+                extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
+            },
+            UserActivityPeriod {
+                project_id: project_2,
+                start: t3,
+                end: t5,
+                extensions: Default::default(),
+            },
+        ]
+    );
+    assert_eq!(
+        db.get_user_activity_timeline(t0..t8, user_1).await.unwrap(),
+        &[
+            UserActivityPeriod {
+                project_id: project_2,
+                start: t2,
+                end: t5,
+                extensions: Default::default(),
+            },
+            UserActivityPeriod {
+                project_id: project_1,
+                start: t3,
+                end: t6,
+                extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
+            },
+            UserActivityPeriod {
+                project_id: project_1,
+                start: t7,
+                end: t8,
+                extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
+            },
+        ]
+    );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_recent_channel_messages() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+        let user = db
+            .create_user(
+                "u@example.com",
+                false,
+                NewUserParams {
+                    github_login: "u".into(),
+                    github_user_id: 1,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+        let org = db.create_org("org", "org").await.unwrap();
+        let channel = db.create_org_channel(org, "channel").await.unwrap();
+        for i in 0..10 {
+            db.create_channel_message(channel, user, &i.to_string(), OffsetDateTime::now_utc(), i)
+                .await
+                .unwrap();
+        }
+
+        let messages = db.get_channel_messages(channel, 5, None).await.unwrap();
+        assert_eq!(
+            messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
+            ["5", "6", "7", "8", "9"]
+        );
+
+        let prev_messages = db
+            .get_channel_messages(channel, 4, Some(messages[0].id))
+            .await
+            .unwrap();
+        assert_eq!(
+            prev_messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
+            ["1", "2", "3", "4"]
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_channel_message_nonces() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+        let user = db
+            .create_user(
+                "user@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user".into(),
+                    github_user_id: 1,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+        let org = db.create_org("org", "org").await.unwrap();
+        let channel = db.create_org_channel(org, "channel").await.unwrap();
+
+        let msg1_id = db
+            .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1)
+            .await
+            .unwrap();
+        let msg2_id = db
+            .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2)
+            .await
+            .unwrap();
+        let msg3_id = db
+            .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1)
+            .await
+            .unwrap();
+        let msg4_id = db
+            .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2)
+            .await
+            .unwrap();
+
+        assert_ne!(msg1_id, msg2_id);
+        assert_eq!(msg1_id, msg3_id);
+        assert_eq!(msg2_id, msg4_id);
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_create_access_tokens() {
+    let test_db = TestDb::postgres().await;
+    let db = test_db.db();
+    let user = db
+        .create_user(
+            "u1@example.com",
+            false,
+            NewUserParams {
+                github_login: "u1".into(),
+                github_user_id: 1,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+
+    db.create_access_token_hash(user, "h1", 3).await.unwrap();
+    db.create_access_token_hash(user, "h2", 3).await.unwrap();
+    assert_eq!(
+        db.get_access_token_hashes(user).await.unwrap(),
+        &["h2".to_string(), "h1".to_string()]
+    );
+
+    db.create_access_token_hash(user, "h3", 3).await.unwrap();
+    assert_eq!(
+        db.get_access_token_hashes(user).await.unwrap(),
+        &["h3".to_string(), "h2".to_string(), "h1".to_string(),]
+    );
+
+    db.create_access_token_hash(user, "h4", 3).await.unwrap();
+    assert_eq!(
+        db.get_access_token_hashes(user).await.unwrap(),
+        &["h4".to_string(), "h3".to_string(), "h2".to_string(),]
+    );
+
+    db.create_access_token_hash(user, "h5", 3).await.unwrap();
+    assert_eq!(
+        db.get_access_token_hashes(user).await.unwrap(),
+        &["h5".to_string(), "h4".to_string(), "h3".to_string()]
+    );
+}
+
+#[test]
+fn test_fuzzy_like_string() {
+    assert_eq!(PostgresDb::fuzzy_like_string("abcd"), "%a%b%c%d%");
+    assert_eq!(PostgresDb::fuzzy_like_string("x y"), "%x%y%");
+    assert_eq!(PostgresDb::fuzzy_like_string(" z  "), "%z%");
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_fuzzy_search_users() {
+    let test_db = TestDb::postgres().await;
+    let db = test_db.db();
+    for (i, github_login) in [
+        "California",
+        "colorado",
+        "oregon",
+        "washington",
+        "florida",
+        "delaware",
+        "rhode-island",
+    ]
+    .into_iter()
+    .enumerate()
+    {
+        db.create_user(
+            &format!("{github_login}@example.com"),
+            false,
+            NewUserParams {
+                github_login: github_login.into(),
+                github_user_id: i as i32,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+    }
+
+    assert_eq!(
+        fuzzy_search_user_names(db, "clr").await,
+        &["colorado", "California"]
+    );
+    assert_eq!(
+        fuzzy_search_user_names(db, "ro").await,
+        &["rhode-island", "colorado", "oregon"],
+    );
+
+    async fn fuzzy_search_user_names(db: &Arc<dyn Db>, query: &str) -> Vec<String> {
+        db.fuzzy_search_users(query, 10)
+            .await
+            .unwrap()
+            .into_iter()
+            .map(|user| user.github_login)
+            .collect::<Vec<_>>()
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_add_contacts() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+
+        let user_1 = db
+            .create_user(
+                "u1@example.com",
+                false,
+                NewUserParams {
+                    github_login: "u1".into(),
+                    github_user_id: 0,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+        let user_2 = db
+            .create_user(
+                "u2@example.com",
+                false,
+                NewUserParams {
+                    github_login: "u2".into(),
+                    github_user_id: 1,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+        let user_3 = db
+            .create_user(
+                "u3@example.com",
+                false,
+                NewUserParams {
+                    github_login: "u3".into(),
+                    github_user_id: 2,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
+
+        // User starts with no contacts
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            vec![Contact::Accepted {
+                user_id: user_1,
+                should_notify: false
+            }],
+        );
+
+        // User requests a contact. Both users see the pending request.
+        db.send_contact_request(user_1, user_2).await.unwrap();
+        assert!(!db.has_contact(user_1, user_2).await.unwrap());
+        assert!(!db.has_contact(user_2, user_1).await.unwrap());
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false
+                },
+                Contact::Outgoing { user_id: user_2 }
+            ],
+        );
+        assert_eq!(
+            db.get_contacts(user_2).await.unwrap(),
+            &[
+                Contact::Incoming {
+                    user_id: user_1,
+                    should_notify: true
+                },
+                Contact::Accepted {
+                    user_id: user_2,
+                    should_notify: false
+                },
+            ]
+        );
+
+        // User 2 dismisses the contact request notification without accepting or rejecting.
+        // We shouldn't notify them again.
+        db.dismiss_contact_notification(user_1, user_2)
+            .await
+            .unwrap_err();
+        db.dismiss_contact_notification(user_2, user_1)
+            .await
+            .unwrap();
+        assert_eq!(
+            db.get_contacts(user_2).await.unwrap(),
+            &[
+                Contact::Incoming {
+                    user_id: user_1,
+                    should_notify: false
+                },
+                Contact::Accepted {
+                    user_id: user_2,
+                    should_notify: false
+                },
+            ]
+        );
+
+        // User can't accept their own contact request
+        db.respond_to_contact_request(user_1, user_2, true)
+            .await
+            .unwrap_err();
+
+        // User accepts a contact request. Both users see the contact.
+        db.respond_to_contact_request(user_2, user_1, true)
+            .await
+            .unwrap();
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false
+                },
+                Contact::Accepted {
+                    user_id: user_2,
+                    should_notify: true
+                }
+            ],
+        );
+        assert!(db.has_contact(user_1, user_2).await.unwrap());
+        assert!(db.has_contact(user_2, user_1).await.unwrap());
+        assert_eq!(
+            db.get_contacts(user_2).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false,
+                },
+                Contact::Accepted {
+                    user_id: user_2,
+                    should_notify: false,
+                },
+            ]
+        );
+
+        // Users cannot re-request existing contacts.
+        db.send_contact_request(user_1, user_2).await.unwrap_err();
+        db.send_contact_request(user_2, user_1).await.unwrap_err();
+
+        // Users can't dismiss notifications of them accepting other users' requests.
+        db.dismiss_contact_notification(user_2, user_1)
+            .await
+            .unwrap_err();
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false
+                },
+                Contact::Accepted {
+                    user_id: user_2,
+                    should_notify: true,
+                },
+            ]
+        );
+
+        // Users can dismiss notifications of other users accepting their requests.
+        db.dismiss_contact_notification(user_1, user_2)
+            .await
+            .unwrap();
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false
+                },
+                Contact::Accepted {
+                    user_id: user_2,
+                    should_notify: false,
+                },
+            ]
+        );
+
+        // Users send each other concurrent contact requests and
+        // see that they are immediately accepted.
+        db.send_contact_request(user_1, user_3).await.unwrap();
+        db.send_contact_request(user_3, user_1).await.unwrap();
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false
+                },
+                Contact::Accepted {
+                    user_id: user_2,
+                    should_notify: false,
+                },
+                Contact::Accepted {
+                    user_id: user_3,
+                    should_notify: false
+                },
+            ]
+        );
+        assert_eq!(
+            db.get_contacts(user_3).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false
+                },
+                Contact::Accepted {
+                    user_id: user_3,
+                    should_notify: false
+                }
+            ],
+        );
+
+        // User declines a contact request. Both users see that it is gone.
+        db.send_contact_request(user_2, user_3).await.unwrap();
+        db.respond_to_contact_request(user_3, user_2, false)
+            .await
+            .unwrap();
+        assert!(!db.has_contact(user_2, user_3).await.unwrap());
+        assert!(!db.has_contact(user_3, user_2).await.unwrap());
+        assert_eq!(
+            db.get_contacts(user_2).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false
+                },
+                Contact::Accepted {
+                    user_id: user_2,
+                    should_notify: false
+                }
+            ]
+        );
+        assert_eq!(
+            db.get_contacts(user_3).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_1,
+                    should_notify: false
+                },
+                Contact::Accepted {
+                    user_id: user_3,
+                    should_notify: false
+                }
+            ],
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_invite_codes() {
+    let postgres = TestDb::postgres().await;
+    let db = postgres.db();
+    let user1 = db
+        .create_user(
+            "u1@example.com",
+            false,
+            NewUserParams {
+                github_login: "u1".into(),
+                github_user_id: 0,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+
+    // Initially, user 1 has no invite code
+    assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None);
+
+    // Setting invite count to 0 when no code is assigned does not assign a new code
+    db.set_invite_count_for_user(user1, 0).await.unwrap();
+    assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none());
+
+    // User 1 creates an invite code that can be used twice.
+    db.set_invite_count_for_user(user1, 2).await.unwrap();
+    let (invite_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 2);
+
+    // User 2 redeems the invite code and becomes a contact of user 1.
+    let user2_invite = db
+        .create_invite_from_code(&invite_code, "u2@example.com", Some("user-2-device-id"))
+        .await
+        .unwrap();
+    let NewUserResult {
+        user_id: user2,
+        inviting_user_id,
+        signup_device_id,
+    } = db
+        .create_user_from_invite(
+            &user2_invite,
+            NewUserParams {
+                github_login: "user2".into(),
+                github_user_id: 2,
+                invite_count: 7,
+            },
+        )
+        .await
+        .unwrap();
+    let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 1);
+    assert_eq!(inviting_user_id, Some(user1));
+    assert_eq!(signup_device_id.unwrap(), "user-2-device-id");
+    assert_eq!(
+        db.get_contacts(user1).await.unwrap(),
+        [
+            Contact::Accepted {
+                user_id: user1,
+                should_notify: false
+            },
+            Contact::Accepted {
+                user_id: user2,
+                should_notify: true
+            }
+        ]
+    );
+    assert_eq!(
+        db.get_contacts(user2).await.unwrap(),
+        [
+            Contact::Accepted {
+                user_id: user1,
+                should_notify: false
+            },
+            Contact::Accepted {
+                user_id: user2,
+                should_notify: false
+            }
+        ]
+    );
+    assert_eq!(
+        db.get_invite_code_for_user(user2).await.unwrap().unwrap().1,
+        7
+    );
+
+    // User 3 redeems the invite code and becomes a contact of user 1.
+    let user3_invite = db
+        .create_invite_from_code(&invite_code, "u3@example.com", None)
+        .await
+        .unwrap();
+    let NewUserResult {
+        user_id: user3,
+        inviting_user_id,
+        signup_device_id,
+    } = db
+        .create_user_from_invite(
+            &user3_invite,
+            NewUserParams {
+                github_login: "user-3".into(),
+                github_user_id: 3,
+                invite_count: 3,
+            },
+        )
+        .await
+        .unwrap();
+    let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 0);
+    assert_eq!(inviting_user_id, Some(user1));
+    assert!(signup_device_id.is_none());
+    assert_eq!(
+        db.get_contacts(user1).await.unwrap(),
+        [
+            Contact::Accepted {
+                user_id: user1,
+                should_notify: false
+            },
+            Contact::Accepted {
+                user_id: user2,
+                should_notify: true
+            },
+            Contact::Accepted {
+                user_id: user3,
+                should_notify: true
+            }
+        ]
+    );
+    assert_eq!(
+        db.get_contacts(user3).await.unwrap(),
+        [
+            Contact::Accepted {
+                user_id: user1,
+                should_notify: false
+            },
+            Contact::Accepted {
+                user_id: user3,
+                should_notify: false
+            },
+        ]
+    );
+    assert_eq!(
+        db.get_invite_code_for_user(user3).await.unwrap().unwrap().1,
+        3
+    );
+
+    // Trying to reedem the code for the third time results in an error.
+    db.create_invite_from_code(&invite_code, "u4@example.com", Some("user-4-device-id"))
+        .await
+        .unwrap_err();
+
+    // Invite count can be updated after the code has been created.
+    db.set_invite_count_for_user(user1, 2).await.unwrap();
+    let (latest_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0
+    assert_eq!(invite_count, 2);
+
+    // User 4 can now redeem the invite code and becomes a contact of user 1.
+    let user4_invite = db
+        .create_invite_from_code(&invite_code, "u4@example.com", Some("user-4-device-id"))
+        .await
+        .unwrap();
+    let user4 = db
+        .create_user_from_invite(
+            &user4_invite,
+            NewUserParams {
+                github_login: "user-4".into(),
+                github_user_id: 4,
+                invite_count: 5,
+            },
+        )
+        .await
+        .unwrap()
+        .user_id;
+
+    let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 1);
+    assert_eq!(
+        db.get_contacts(user1).await.unwrap(),
+        [
+            Contact::Accepted {
+                user_id: user1,
+                should_notify: false
+            },
+            Contact::Accepted {
+                user_id: user2,
+                should_notify: true
+            },
+            Contact::Accepted {
+                user_id: user3,
+                should_notify: true
+            },
+            Contact::Accepted {
+                user_id: user4,
+                should_notify: true
+            }
+        ]
+    );
+    assert_eq!(
+        db.get_contacts(user4).await.unwrap(),
+        [
+            Contact::Accepted {
+                user_id: user1,
+                should_notify: false
+            },
+            Contact::Accepted {
+                user_id: user4,
+                should_notify: false
+            },
+        ]
+    );
+    assert_eq!(
+        db.get_invite_code_for_user(user4).await.unwrap().unwrap().1,
+        5
+    );
+
+    // An existing user cannot redeem invite codes.
+    db.create_invite_from_code(&invite_code, "u2@example.com", Some("user-2-device-id"))
+        .await
+        .unwrap_err();
+    let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 1);
+}
+
+#[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: i % 2 == 0,
+            platform_windows: i % 4 == 0,
+            editor_features: vec!["speed".into()],
+            programming_languages: vec!["rust".into(), "c".into()],
+            device_id: Some(format!("device_id_{i}")),
+        })
+        .await
+        .unwrap();
+    }
+
+    assert_eq!(
+        db.get_waitlist_summary().await.unwrap(),
+        WaitlistSummary {
+            count: 8,
+            mac_count: 8,
+            linux_count: 4,
+            windows_count: 2,
+        }
+    );
+
+    // retrieve the next batch of signup emails to send
+    let signups_batch1 = db.get_unsent_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_unsent_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_sent_invites(&signups_batch1).await.unwrap();
+    let signups_batch2 = db.get_unsent_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"
+        ]
+    );
+
+    // the sent invites are excluded from the summary.
+    assert_eq!(
+        db.get_waitlist_summary().await.unwrap(),
+        WaitlistSummary {
+            count: 5,
+            mac_count: 5,
+            linux_count: 2,
+            windows_count: 1,
+        }
+    );
+
+    // user completes the signup process by providing their
+    // github account.
+    let NewUserResult {
+        user_id,
+        inviting_user_id,
+        signup_device_id,
+    } = db
+        .create_user_from_invite(
+            &Invite {
+                email_address: signups_batch1[0].email_address.clone(),
+                email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
+            },
+            NewUserParams {
+                github_login: "person-0".into(),
+                github_user_id: 0,
+                invite_count: 5,
+            },
+        )
+        .await
+        .unwrap();
+    let user = db.get_user_by_id(user_id).await.unwrap().unwrap();
+    assert!(inviting_user_id.is_none());
+    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);
+    assert_eq!(signup_device_id.unwrap(), "device_id_0");
+
+    // cannot redeem the same signup again.
+    db.create_user_from_invite(
+        &Invite {
+            email_address: signups_batch1[0].email_address.clone(),
+            email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
+        },
+        NewUserParams {
+            github_login: "some-other-github_account".into(),
+            github_user_id: 1,
+            invite_count: 5,
+        },
+    )
+    .await
+    .unwrap_err();
+
+    // cannot redeem a signup with the wrong confirmation code.
+    db.create_user_from_invite(
+        &Invite {
+            email_address: signups_batch1[1].email_address.clone(),
+            email_confirmation_code: "the-wrong-code".to_string(),
+        },
+        NewUserParams {
+            github_login: "person-1".into(),
+            github_user_id: 2,
+            invite_count: 5,
+        },
+    )
+    .await
+    .unwrap_err();
+}
+
+fn build_background_executor() -> Arc<Background> {
+    Deterministic::new(0).build_background()
+}

crates/collab/src/integration_tests.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{
-    db::{tests::TestDb, ProjectId, UserId},
+    db::{NewUserParams, ProjectId, TestDb, UserId},
     rpc::{Executor, Server, Store},
     AppState,
 };
@@ -4652,7 +4652,18 @@ async fn test_random_collaboration(
 
     let mut server = TestServer::start(cx.foreground(), cx.background()).await;
     let db = server.app_state.db.clone();
-    let host_user_id = db.create_user("host", None, false).await.unwrap();
+    let host_user_id = db
+        .create_user(
+            "host@example.com",
+            false,
+            NewUserParams {
+                github_login: "host".into(),
+                github_user_id: 0,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
     let mut available_guests = vec![
         "guest-1".to_string(),
         "guest-2".to_string(),
@@ -4660,8 +4671,19 @@ async fn test_random_collaboration(
         "guest-4".to_string(),
     ];
 
-    for username in &available_guests {
-        let guest_user_id = db.create_user(username, None, false).await.unwrap();
+    for (ix, username) in available_guests.iter().enumerate() {
+        let guest_user_id = db
+            .create_user(
+                &format!("{username}@example.com"),
+                false,
+                NewUserParams {
+                    github_login: username.into(),
+                    github_user_id: ix as i32,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap();
         assert_eq!(*username, format!("guest-{}", guest_user_id));
         server
             .app_state
@@ -5163,18 +5185,30 @@ impl TestServer {
         });
 
         let http = FakeHttpClient::with_404_response();
-        let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await
+        let user_id = if let Ok(Some(user)) = self
+            .app_state
+            .db
+            .get_user_by_github_account(name, None)
+            .await
         {
             user.id
         } else {
             self.app_state
                 .db
-                .create_user(name, None, false)
+                .create_user(
+                    &format!("{name}@example.com"),
+                    false,
+                    NewUserParams {
+                        github_login: name.into(),
+                        github_user_id: 0,
+                        invite_count: 0,
+                    },
+                )
                 .await
                 .unwrap()
         };
         let client_name = name.to_string();
-        let mut client = Client::new(http.clone());
+        let mut client = cx.read(|cx| Client::new(http.clone(), cx));
         let server = self.server.clone();
         let db = self.app_state.db.clone();
         let connection_killers = self.connection_killers.clone();

crates/collab/src/rpc.rs 🔗

@@ -541,27 +541,30 @@ impl Server {
 
     pub async fn invite_code_redeemed(
         self: &Arc<Self>,
-        code: &str,
+        inviter_id: UserId,
         invitee_id: UserId,
     ) -> Result<()> {
-        let user = self.app_state.db.get_user_for_invite_code(code).await?;
-        let store = self.store().await;
-        let invitee_contact = store.contact_for_user(invitee_id, true);
-        for connection_id in store.connection_ids_for_user(user.id) {
-            self.peer.send(
-                connection_id,
-                proto::UpdateContacts {
-                    contacts: vec![invitee_contact.clone()],
-                    ..Default::default()
-                },
-            )?;
-            self.peer.send(
-                connection_id,
-                proto::UpdateInviteInfo {
-                    url: format!("{}{}", self.app_state.invite_link_prefix, code),
-                    count: user.invite_count as u32,
-                },
-            )?;
+        if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? {
+            if let Some(code) = &user.invite_code {
+                let store = self.store().await;
+                let invitee_contact = store.contact_for_user(invitee_id, true);
+                for connection_id in store.connection_ids_for_user(inviter_id) {
+                    self.peer.send(
+                        connection_id,
+                        proto::UpdateContacts {
+                            contacts: vec![invitee_contact.clone()],
+                            ..Default::default()
+                        },
+                    )?;
+                    self.peer.send(
+                        connection_id,
+                        proto::UpdateInviteInfo {
+                            url: format!("{}{}", self.app_state.invite_link_prefix, &code),
+                            count: user.invite_count as u32,
+                        },
+                    )?;
+                }
+            }
         }
         Ok(())
     }
@@ -1401,7 +1404,7 @@ impl Server {
         let users = match query.len() {
             0 => vec![],
             1 | 2 => db
-                .get_user_by_github_login(&query)
+                .get_user_by_github_account(&query, None)
                 .await?
                 .into_iter()
                 .collect(),

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1216,7 +1216,7 @@ mod tests {
 
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = cx.read(|cx| Client::new(http_client.clone(), cx));
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
         let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
         let server = FakeServer::for_client(current_user_id, &client, cx).await;

crates/db/Cargo.toml 🔗

@@ -0,0 +1,22 @@
+[package]
+name = "db"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/db.rs"
+doctest = false
+
+[features]
+test-support = []
+
+[dependencies]
+collections = { path = "../collections" }
+anyhow = "1.0.57"
+async-trait = "0.1"
+parking_lot = "0.11.1"
+rocksdb = "0.18"
+
+[dev-dependencies]
+gpui = { path = "../gpui", features = ["test-support"] }
+tempdir = { version = "0.3.7" }

crates/editor/src/editor.rs 🔗

@@ -30,6 +30,7 @@ use gpui::{
     geometry::vector::{vec2f, Vector2F},
     impl_actions, impl_internal_actions,
     platform::CursorStyle,
+    serde_json::json,
     text_layout, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, ElementBox,
     Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription, Task, View,
     ViewContext, ViewHandle, WeakViewHandle,
@@ -1058,6 +1059,7 @@ impl Editor {
         let editor_created_event = EditorCreated(cx.handle());
         cx.emit_global(editor_created_event);
 
+        this.report_event("open editor", cx);
         this
     }
 
@@ -5983,6 +5985,25 @@ impl Editor {
             })
             .collect()
     }
+
+    fn report_event(&self, name: &str, cx: &AppContext) {
+        if let Some((project, file)) = self.project.as_ref().zip(
+            self.buffer
+                .read(cx)
+                .as_singleton()
+                .and_then(|b| b.read(cx).file()),
+        ) {
+            project.read(cx).client().report_event(
+                name,
+                json!({
+                    "file_extension": file
+                        .path()
+                        .extension()
+                        .and_then(|e| e.to_str())
+                }),
+            );
+        }
+    }
 }
 
 impl EditorSnapshot {

crates/editor/src/items.rs 🔗

@@ -404,6 +404,8 @@ impl Item for Editor {
         project: ModelHandle<Project>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
+        self.report_event("save editor", cx);
+
         let buffer = self.buffer().clone();
         let buffers = buffer.read(cx).all_buffers();
         let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse();

crates/gpui/src/platform.rs 🔗

@@ -69,6 +69,8 @@ pub trait Platform: Send + Sync {
     fn path_for_auxiliary_executable(&self, name: &str) -> Result<PathBuf>;
     fn app_path(&self) -> Result<PathBuf>;
     fn app_version(&self) -> Result<AppVersion>;
+    fn os_name(&self) -> &'static str;
+    fn os_version(&self) -> Result<AppVersion>;
 }
 
 pub(crate) trait ForegroundPlatform {

crates/gpui/src/platform/mac/platform.rs 🔗

@@ -4,7 +4,7 @@ use super::{
 use crate::{
     executor, keymap,
     platform::{self, CursorStyle},
-    Action, ClipboardItem, Event, Menu, MenuItem,
+    Action, AppVersion, ClipboardItem, Event, Menu, MenuItem,
 };
 use anyhow::{anyhow, Result};
 use block::ConcreteBlock;
@@ -16,7 +16,8 @@ use cocoa::{
     },
     base::{id, nil, selector, YES},
     foundation::{
-        NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSString, NSUInteger, NSURL,
+        NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSString,
+        NSUInteger, NSURL,
     },
 };
 use core_foundation::{
@@ -748,6 +749,22 @@ impl platform::Platform for MacPlatform {
             }
         }
     }
+
+    fn os_name(&self) -> &'static str {
+        "macOS"
+    }
+
+    fn os_version(&self) -> Result<crate::AppVersion> {
+        unsafe {
+            let process_info = NSProcessInfo::processInfo(nil);
+            let version = process_info.operatingSystemVersion();
+            Ok(AppVersion {
+                major: version.majorVersion as usize,
+                minor: version.minorVersion as usize,
+                patch: version.patchVersion as usize,
+            })
+        }
+    }
 }
 
 unsafe fn path_from_objc(path: id) -> PathBuf {

crates/gpui/src/platform/test.rs 🔗

@@ -196,6 +196,18 @@ impl super::Platform for Platform {
             patch: 0,
         })
     }
+
+    fn os_name(&self) -> &'static str {
+        "test"
+    }
+
+    fn os_version(&self) -> Result<AppVersion> {
+        Ok(AppVersion {
+            major: 1,
+            minor: 0,
+            patch: 0,
+        })
+    }
 }
 
 impl Window {

crates/project/Cargo.toml 🔗

@@ -10,6 +10,7 @@ doctest = false
 [features]
 test-support = [
     "client/test-support",
+    "db/test-support",
     "language/test-support",
     "settings/test-support",
     "text/test-support",
@@ -20,6 +21,7 @@ text = { path = "../text" }
 client = { path = "../client" }
 clock = { path = "../clock" }
 collections = { path = "../collections" }
+db = { path = "../db" }
 fsevent = { path = "../fsevent" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
@@ -54,6 +56,7 @@ rocksdb = "0.18"
 [dev-dependencies]
 client = { path = "../client", features = ["test-support"] }
 collections = { path = "../collections", features = ["test-support"] }
+db = { path = "../db", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }
 lsp = { path = "../lsp", features = ["test-support"] }

crates/project/src/project.rs 🔗

@@ -1,4 +1,3 @@
-mod db;
 pub mod fs;
 mod ignore;
 mod lsp_command;
@@ -666,7 +665,7 @@ impl Project {
 
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = client::test::FakeHttpClient::with_404_response();
-        let client = client::Client::new(http_client.clone());
+        let client = cx.update(|cx| client::Client::new(http_client.clone(), cx));
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
         let project_store = cx.add_model(|_| ProjectStore::new(Db::open_fake()));
         let project = cx.update(|cx| {

crates/project/src/worktree.rs 🔗

@@ -2804,7 +2804,7 @@ mod tests {
         .await;
 
         let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client);
+        let client = cx.read(|cx| Client::new(http_client, cx));
 
         let tree = Worktree::local(
             client,
@@ -2866,8 +2866,7 @@ mod tests {
         fs.insert_symlink("/root/lib/a/lib", "..".into()).await;
         fs.insert_symlink("/root/lib/b/lib", "..".into()).await;
 
-        let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client);
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let tree = Worktree::local(
             client,
             Arc::from(Path::new("/root")),
@@ -2945,8 +2944,7 @@ mod tests {
         }));
         let dir = parent_dir.path().join("tree");
 
-        let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
 
         let tree = Worktree::local(
             client,
@@ -3016,8 +3014,7 @@ mod tests {
             "ignored-dir": {}
         }));
 
-        let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
 
         let tree = Worktree::local(
             client,
@@ -3064,8 +3061,7 @@ mod tests {
 
     #[gpui::test(iterations = 30)]
     async fn test_create_directory(cx: &mut TestAppContext) {
-        let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
 
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(

crates/workspace/src/workspace.rs 🔗

@@ -856,7 +856,7 @@ impl AppState {
         let fs = project::FakeFs::new(cx.background().clone());
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = client::test::FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = Client::new(http_client.clone(), cx);
         let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
         let themes = ThemeRegistry::new((), cx.font_cache().clone());

crates/zed/build.rs 🔗

@@ -3,6 +3,10 @@ use std::process::Command;
 fn main() {
     println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.14");
 
+    if let Ok(api_key) = std::env::var("ZED_AMPLITUDE_API_KEY") {
+        println!("cargo:rustc-env=ZED_AMPLITUDE_API_KEY={api_key}");
+    }
+
     let output = Command::new("npm")
         .current_dir("../../styles")
         .args(["install", "--no-save"])

crates/zed/src/main.rs 🔗

@@ -20,7 +20,7 @@ use futures::{
     FutureExt, SinkExt, StreamExt,
 };
 use gpui::{executor::Background, App, AssetSource, AsyncAppContext, Task, ViewContext};
-use isahc::{config::Configurable, AsyncBody, Request};
+use isahc::{config::Configurable, Request};
 use language::LanguageRegistry;
 use log::LevelFilter;
 use parking_lot::Mutex;
@@ -88,7 +88,7 @@ fn main() {
     });
 
     app.run(move |cx| {
-        let client = client::Client::new(http.clone());
+        let client = client::Client::new(http.clone(), cx);
         let mut languages = LanguageRegistry::new(login_shell_env_loaded);
         languages.set_language_server_download_dir(zed::paths::LANGUAGES_DIR.clone());
         let languages = Arc::new(languages);
@@ -121,7 +121,6 @@ fn main() {
         vim::init(cx);
         terminal::init(cx);
 
-        let db = cx.background().block(db);
         cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx))
             .detach();
 
@@ -139,6 +138,10 @@ fn main() {
         })
         .detach();
 
+        let db = cx.background().block(db);
+        client.start_telemetry(db.clone());
+        client.report_event("start app", Default::default());
+
         let project_store = cx.add_model(|_| ProjectStore::new(db.clone()));
         let app_state = Arc::new(AppState {
             languages,
@@ -280,12 +283,10 @@ fn init_panic_hook(app_version: String, http: Arc<dyn HttpClient>, background: A
                         "token": ZED_SECRET_CLIENT_TOKEN,
                     }))
                     .unwrap();
-                    let request = Request::builder()
-                        .uri(&panic_report_url)
-                        .method(http::Method::POST)
+                    let request = Request::post(&panic_report_url)
                         .redirect_policy(isahc::config::RedirectPolicy::Follow)
                         .header("Content-Type", "application/json")
-                        .body(AsyncBody::from(body))?;
+                        .body(body.into())?;
                     let response = http.send(request).await.context("error sending panic")?;
                     if response.status().is_success() {
                         fs::remove_file(child_path)

crates/zed/src/menus.rs 🔗

@@ -332,6 +332,11 @@ pub fn menus() -> Vec<Menu<'static>> {
                     action: Box::new(command_palette::Toggle),
                 },
                 MenuItem::Separator,
+                MenuItem::Action {
+                    name: "View Telemetry Log",
+                    action: Box::new(crate::OpenTelemetryLog),
+                },
+                MenuItem::Separator,
                 MenuItem::Action {
                     name: "Documentation",
                     action: Box::new(crate::OpenBrowser {

crates/zed/src/zed.rs 🔗

@@ -56,6 +56,7 @@ actions!(
         DebugElements,
         OpenSettings,
         OpenLog,
+        OpenTelemetryLog,
         OpenKeymap,
         OpenDefaultSettings,
         OpenDefaultKeymap,
@@ -146,6 +147,12 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
             open_log_file(workspace, app_state.clone(), cx);
         }
     });
+    cx.add_action({
+        let app_state = app_state.clone();
+        move |workspace: &mut Workspace, _: &OpenTelemetryLog, cx: &mut ViewContext<Workspace>| {
+            open_telemetry_log_file(workspace, app_state.clone(), cx);
+        }
+    });
     cx.add_action({
         let app_state = app_state.clone();
         move |_: &mut Workspace, _: &OpenKeymap, cx: &mut ViewContext<Workspace>| {
@@ -504,6 +511,62 @@ fn open_log_file(
     });
 }
 
+fn open_telemetry_log_file(
+    workspace: &mut Workspace,
+    app_state: Arc<AppState>,
+    cx: &mut ViewContext<Workspace>,
+) {
+    workspace.with_local_workspace(cx, app_state.clone(), |_, cx| {
+        cx.spawn_weak(|workspace, mut cx| async move {
+            let workspace = workspace.upgrade(&cx)?;
+            let path = app_state.client.telemetry_log_file_path()?;
+            let log = app_state.fs.load(&path).await.log_err()?;
+
+            const MAX_TELEMETRY_LOG_LEN: usize = 5 * 1024 * 1024;
+            let mut start_offset = log.len().saturating_sub(MAX_TELEMETRY_LOG_LEN);
+            if let Some(newline_offset) = log[start_offset..].find('\n') {
+                start_offset += newline_offset + 1;
+            }
+            let log_suffix = &log[start_offset..];
+
+            workspace.update(&mut cx, |workspace, cx| {
+                let project = workspace.project().clone();
+                let buffer = project
+                    .update(cx, |project, cx| project.create_buffer("", None, cx))
+                    .expect("creating buffers on a local workspace always succeeds");
+                buffer.update(cx, |buffer, cx| {
+                    buffer.set_language(app_state.languages.get_language("JSON"), cx);
+                    buffer.edit(
+                        [(
+                            0..0,
+                            concat!(
+                                "// Zed collects anonymous usage data to help us understand how people are using the app.\n",
+                                "// After the beta release, we'll provide the ability to opt out of this telemetry.\n",
+                                "// Here is the data that has been reported for the current session:\n",
+                                "\n"
+                            ),
+                        )],
+                        None,
+                        cx,
+                    );
+                    buffer.edit([(buffer.len()..buffer.len(), log_suffix)], None, cx);
+                });
+
+                let buffer = cx.add_model(|cx| {
+                    MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into())
+                });
+                workspace.add_item(
+                    Box::new(cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx))),
+                    cx,
+                );
+            });
+
+            Some(())
+        })
+        .detach();
+    });
+}
+
 fn open_bundled_config_file(
     workspace: &mut Workspace,
     app_state: Arc<AppState>,