Detailed changes
@@ -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: |
@@ -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"
@@ -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"] }
@@ -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);
@@ -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());
@@ -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();
+ }
+ }
+}
@@ -0,0 +1,6 @@
+DROP TABLE signups;
+
+ALTER TABLE users
+ DROP COLUMN github_user_id;
+
+DROP INDEX index_users_on_email_address;
@@ -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");
@@ -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) = ¶ms.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,
- ¶ms.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(
- ¶ms.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(¶ms.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(
+ ¶ms.invite_code,
+ ¶ms.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(¶ms).await?;
+ Ok(())
+}
@@ -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"),
);
}
}
@@ -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 {
@@ -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()
+}
@@ -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();
@@ -4,6 +4,8 @@ mod db;
mod env;
mod rpc;
+#[cfg(test)]
+mod db_tests;
#[cfg(test)]
mod integration_tests;
@@ -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(),
@@ -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;
@@ -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" }
@@ -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 {
@@ -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();
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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"] }
@@ -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| {
@@ -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(
@@ -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());
@@ -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"])
@@ -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)
@@ -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 {
@@ -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>,