From c3d44040c66f5e1ad59ad18ce2d6b5d703f4577d Mon Sep 17 00:00:00 2001 From: Tyr Chen Date: Mon, 29 Apr 2024 00:07:23 -0700 Subject: [PATCH] feature: support get users API --- chat_server/src/handlers/auth.rs | 6 +- chat_server/src/handlers/mod.rs | 2 + chat_server/src/handlers/workspace.rs | 11 ++ chat_server/src/lib.rs | 1 + chat_server/src/models/mod.rs | 17 +++ chat_server/src/models/user.rs | 52 +++++--- chat_server/src/models/workspace.rs | 156 ++++++++++++++++++++++++ migrations/20240429055434_workspace.sql | 30 +++++ test.rest | 10 +- 9 files changed, 266 insertions(+), 19 deletions(-) create mode 100644 chat_server/src/handlers/workspace.rs create mode 100644 chat_server/src/models/workspace.rs create mode 100644 migrations/20240429055434_workspace.sql diff --git a/chat_server/src/handlers/auth.rs b/chat_server/src/handlers/auth.rs index 6cf98f1..2255aac 100644 --- a/chat_server/src/handlers/auth.rs +++ b/chat_server/src/handlers/auth.rs @@ -49,7 +49,7 @@ mod tests { async fn signup_should_work() -> Result<()> { let config = AppConfig::load()?; let (_tdb, state) = AppState::new_for_test(config).await?; - let input = CreateUser::new("Tyr Chen", "tchen@acme.org", "Hunter42"); + let input = CreateUser::new("none", "Tyr Chen", "tchen@acme.org", "Hunter42"); let ret = signup_handler(State(state), Json(input)) .await? .into_response(); @@ -64,7 +64,7 @@ mod tests { async fn signup_duplicate_user_should_409() -> Result<()> { let config = AppConfig::load()?; let (_tdb, state) = AppState::new_for_test(config).await?; - let input = CreateUser::new("Tyr Chen", "tchen@acme.org", "Hunter42"); + let input = CreateUser::new("none", "Tyr Chen", "tchen@acme.org", "Hunter42"); signup_handler(State(state.clone()), Json(input.clone())).await?; let ret = signup_handler(State(state.clone()), Json(input.clone())) .await @@ -84,7 +84,7 @@ mod tests { let name = "Alice"; let email = "alice@acme.org"; let password = "Hunter42"; - let user = CreateUser::new(name, email, password); + let user = CreateUser::new("none", name, email, password); User::create(&user, &state.pool).await?; let input = SigninUser::new(email, password); let ret = signin_handler(State(state), Json(input)) diff --git a/chat_server/src/handlers/mod.rs b/chat_server/src/handlers/mod.rs index 7a22e18..e01b05a 100644 --- a/chat_server/src/handlers/mod.rs +++ b/chat_server/src/handlers/mod.rs @@ -1,12 +1,14 @@ mod auth; mod chat; mod messages; +mod workspace; use axum::response::IntoResponse; pub(crate) use auth::*; pub(crate) use chat::*; pub(crate) use messages::*; +pub(crate) use workspace::*; pub(crate) async fn index_handler() -> impl IntoResponse { "index" diff --git a/chat_server/src/handlers/workspace.rs b/chat_server/src/handlers/workspace.rs new file mode 100644 index 0000000..5a4d2fd --- /dev/null +++ b/chat_server/src/handlers/workspace.rs @@ -0,0 +1,11 @@ +use axum::{extract::State, response::IntoResponse, Extension, Json}; + +use crate::{models::Workspace, AppError, AppState, User}; + +pub(crate) async fn list_chat_users_handler( + Extension(user): Extension, + State(state): State, +) -> Result { + let users = Workspace::fetch_all_chat_users(user.ws_id as _, &state.pool).await?; + Ok(Json(users)) +} diff --git a/chat_server/src/lib.rs b/chat_server/src/lib.rs index f071929..5c971fc 100644 --- a/chat_server/src/lib.rs +++ b/chat_server/src/lib.rs @@ -40,6 +40,7 @@ pub async fn get_router(config: AppConfig) -> Result { let state = AppState::try_new(config).await?; let api = Router::new() + .route("/users", get(list_chat_users_handler)) .route("/chat", get(list_chat_handler).post(create_chat_handler)) .route( "/chat/:id", diff --git a/chat_server/src/models/mod.rs b/chat_server/src/models/mod.rs index 7b79635..d41625c 100644 --- a/chat_server/src/models/mod.rs +++ b/chat_server/src/models/mod.rs @@ -1,4 +1,5 @@ mod user; +mod workspace; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -9,6 +10,7 @@ pub use user::{CreateUser, SigninUser}; #[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq)] pub struct User { pub id: i64, + pub ws_id: i64, pub fullname: String, pub email: String, #[sqlx(default)] @@ -16,3 +18,18 @@ pub struct User { pub password_hash: Option, pub created_at: DateTime, } + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq)] +pub struct Workspace { + pub id: i64, + pub name: String, + pub owner_id: i64, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq)] +pub struct ChatUser { + pub id: i64, + pub fullname: String, + pub email: String, +} diff --git a/chat_server/src/models/user.rs b/chat_server/src/models/user.rs index 6c71803..5cbd5d9 100644 --- a/chat_server/src/models/user.rs +++ b/chat_server/src/models/user.rs @@ -7,10 +7,13 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::mem; +use super::{ChatUser, Workspace}; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateUser { pub fullname: String, pub email: String, + pub workspace: String, pub password: String, } @@ -23,41 +26,56 @@ pub struct SigninUser { impl User { /// Find a user by email pub async fn find_by_email(email: &str, pool: &PgPool) -> Result, AppError> { - let user = - sqlx::query_as("SELECT id, fullname, email, created_at FROM users WHERE email = $1") - .bind(email) - .fetch_optional(pool) - .await?; + let user = sqlx::query_as( + "SELECT id, ws_id, fullname, email, created_at FROM users WHERE email = $1", + ) + .bind(email) + .fetch_optional(pool) + .await?; Ok(user) } /// Create a new user + // TODO: use transaction for workspace creation and user creation pub async fn create(input: &CreateUser, pool: &PgPool) -> Result { - let password_hash = hash_password(&input.password)?; // check if email exists let user = Self::find_by_email(&input.email, pool).await?; if user.is_some() { return Err(AppError::EmailAlreadyExists(input.email.clone())); } - let user = sqlx::query_as( + + // check if workspace exists, if not create one + let ws = match Workspace::find_by_name(&input.workspace, pool).await? { + Some(ws) => ws, + None => Workspace::create(&input.workspace, 0, pool).await?, + }; + + let password_hash = hash_password(&input.password)?; + let user: User = sqlx::query_as( r#" - INSERT INTO users (email, fullname, password_hash) - VALUES ($1, $2, $3) - RETURNING id, fullname, email, created_at + INSERT INTO users (ws_id, email, fullname, password_hash) + VALUES ($1, $2, $3, $4) + RETURNING id, ws_id, fullname, email, created_at "#, ) + .bind(ws.id) .bind(&input.email) .bind(&input.fullname) .bind(password_hash) .fetch_one(pool) .await?; + + if ws.owner_id == 0 { + ws.update_owner(user.id as _, pool).await?; + } + Ok(user) } /// Verify email and password pub async fn verify(input: &SigninUser, pool: &PgPool) -> Result, AppError> { let user: Option = sqlx::query_as( - "SELECT id, fullname, email, password_hash, created_at FROM users WHERE email = $1", + "SELECT id, ws_id, fullname, email, password_hash, created_at FROM users WHERE email = $1", ) .bind(&input.email) .fetch_optional(pool) @@ -78,6 +96,10 @@ impl User { } } +impl ChatUser { + // pub async fn fetch_all(user: &User) +} + fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); @@ -109,6 +131,7 @@ impl User { pub fn new(id: i64, fullname: &str, email: &str) -> Self { Self { id, + ws_id: 0, fullname: fullname.to_string(), email: email.to_string(), password_hash: None, @@ -119,9 +142,10 @@ impl User { #[cfg(test)] impl CreateUser { - pub fn new(fullname: &str, email: &str, password: &str) -> Self { + pub fn new(ws: &str, fullname: &str, email: &str, password: &str) -> Self { Self { fullname: fullname.to_string(), + workspace: ws.to_string(), email: email.to_string(), password: password.to_string(), } @@ -162,7 +186,7 @@ mod tests { ); let pool = tdb.get_pool().await; - let input = CreateUser::new("Tyr Chen", "tchen@acme.org", "hunter42"); + let input = CreateUser::new("none", "Tyr Chen", "tchen@acme.org", "hunter42"); User::create(&input, &pool).await?; let ret = User::create(&input, &pool).await; match ret { @@ -182,7 +206,7 @@ mod tests { ); let pool = tdb.get_pool().await; - let input = CreateUser::new("Tyr Chen", "tchen@acme.org", "hunter42"); + let input = CreateUser::new("none", "Tyr Chen", "tchen@acme.org", "hunter42"); let user = User::create(&input, &pool).await?; assert_eq!(user.email, input.email); assert_eq!(user.fullname, input.fullname); diff --git a/chat_server/src/models/workspace.rs b/chat_server/src/models/workspace.rs new file mode 100644 index 0000000..cb289bd --- /dev/null +++ b/chat_server/src/models/workspace.rs @@ -0,0 +1,156 @@ +use sqlx::PgPool; + +use crate::AppError; + +use super::{ChatUser, Workspace}; + +impl Workspace { + pub async fn create(name: &str, user_id: u64, pool: &PgPool) -> Result { + let ws = sqlx::query_as( + r#" + INSERT INTO workspaces (name, owner_id) + VALUES ($1, $2) + RETURNING id, name, owner_id, created_at + "#, + ) + .bind(name) + .bind(user_id as i64) + .fetch_one(pool) + .await?; + + Ok(ws) + } + + pub async fn update_owner(&self, owner_id: u64, pool: &PgPool) -> Result { + // update owner_id in two cases 1) owner_id = 0 2) owner's ws_id = id + let ws = sqlx::query_as( + r#" + UPDATE workspaces + SET owner_id = $1 + WHERE id = $2 and (SELECT ws_id FROM users WHERE id = $1) = $2 + RETURNING id, name, owner_id, created_at + "#, + ) + .bind(owner_id as i64) + .bind(self.id) + .fetch_one(pool) + .await?; + + Ok(ws) + } + + pub async fn find_by_name(name: &str, pool: &PgPool) -> Result, AppError> { + let ws = sqlx::query_as( + r#" + SELECT id, name, owner_id, created_at + FROM workspaces + WHERE name = $1 + "#, + ) + .bind(name) + .fetch_optional(pool) + .await?; + + Ok(ws) + } + + #[allow(dead_code)] + pub async fn find_by_id(id: u64, pool: &PgPool) -> Result, AppError> { + let ws = sqlx::query_as( + r#" + SELECT id, name, owner_id, created_at + FROM workspaces + WHERE id = $1 + "#, + ) + .bind(id as i64) + .fetch_optional(pool) + .await?; + + Ok(ws) + } + + #[allow(dead_code)] + pub async fn fetch_all_chat_users(id: u64, pool: &PgPool) -> Result, AppError> { + let users = sqlx::query_as( + r#" + SELECT id, fullname, email + FROM users + WHERE ws_id = $1 order by id + "#, + ) + .bind(id as i64) + .fetch_all(pool) + .await?; + + Ok(users) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use crate::{models::CreateUser, User}; + + use super::*; + use anyhow::{Ok, Result}; + use sqlx_db_tester::TestPg; + + #[tokio::test] + async fn workspace_should_create_and_set_owner() -> Result<()> { + let tdb = TestPg::new( + "postgres://postgres:postgres@localhost:5432".to_string(), + Path::new("../migrations"), + ); + let pool = tdb.get_pool().await; + let ws = Workspace::create("test", 0, &pool).await.unwrap(); + + let input = CreateUser::new(&ws.name, "Tyr Chen", "tchen@acme.org", "Hunter42"); + let user = User::create(&input, &pool).await.unwrap(); + + assert_eq!(ws.name, "test"); + + assert_eq!(user.ws_id, ws.id); + + let ws = ws.update_owner(user.id as _, &pool).await.unwrap(); + + assert_eq!(ws.owner_id, user.id); + Ok(()) + } + + #[tokio::test] + async fn workspace_should_find_by_name() -> Result<()> { + let tdb = TestPg::new( + "postgres://postgres:postgres@localhost:5432".to_string(), + Path::new("../migrations"), + ); + let pool = tdb.get_pool().await; + let _ws = Workspace::create("test", 0, &pool).await?; + let ws = Workspace::find_by_name("test", &pool).await?; + + assert_eq!(ws.unwrap().name, "test"); + Ok(()) + } + + #[tokio::test] + async fn workspace_should_fetch_all_chat_users() -> Result<()> { + let tdb = TestPg::new( + "postgres://postgres:postgres@localhost:5432".to_string(), + Path::new("../migrations"), + ); + let pool = tdb.get_pool().await; + let ws = Workspace::create("test", 0, &pool).await?; + let input = CreateUser::new(&ws.name, "Tyr Chen", "tchen@acme.org", "Hunter42"); + let user1 = User::create(&input, &pool).await?; + let input = CreateUser::new(&ws.name, "Alice Wang", "alice@acme.org", "Hunter42"); + let user2 = User::create(&input, &pool).await?; + + let users = Workspace::fetch_all_chat_users(ws.id as _, &pool).await?; + assert_eq!(users.len(), 2); + assert_eq!(users[0].id, user1.id); + assert_eq!(users[1].id, user2.id); + + Ok(()) + } +} diff --git a/migrations/20240429055434_workspace.sql b/migrations/20240429055434_workspace.sql new file mode 100644 index 0000000..b53f8b2 --- /dev/null +++ b/migrations/20240429055434_workspace.sql @@ -0,0 +1,30 @@ +-- Add migration script here +-- workspace for users +CREATE TABLE IF NOT EXISTS workspaces( + id bigserial PRIMARY KEY, + name varchar(32) NOT NULL UNIQUE, + owner_id bigint NOT NULL REFERENCES users(id), + created_at timestamptz DEFAULT CURRENT_TIMESTAMP +); + +-- alter users table to add ws_id +ALTER TABLE users + ADD COLUMN ws_id bigint REFERENCES workspaces(id); + +-- add super user 0 and workspace 0 +BEGIN; +INSERT INTO users(id, fullname, email, password_hash) + VALUES (0, 'super user', 'super@none.org', ''); +INSERT INTO workspaces(id, name, owner_id) + VALUES (0, 'none', 0); +UPDATE + users +SET + ws_id = 0 +WHERE + id = 0; +COMMIT; + +-- alter user table to make ws_id not null +ALTER TABLE users + ALTER COLUMN ws_id SET NOT NULL; diff --git a/test.rest b/test.rest index 7f58c3c..022063f 100644 --- a/test.rest +++ b/test.rest @@ -4,8 +4,9 @@ POST http://localhost:6688/api/signup Content-Type: application/json { - "fullname": "Alice Chen", - "email": "alice@acme.org", + "workspace": "acme", + "fullname": "Tyr Chen", + "email": "tchen@acme.org", "password": "123456" } @@ -38,3 +39,8 @@ Content-Type: application/json GET http://localhost:6688/api/chat Authorization: Bearer {{token}} + +### get user list + +GET http://localhost:6688/api/users +Authorization: Bearer {{token}}