From f25066f51f770e38cb01ed1b3c01c4929c4a6ceb Mon Sep 17 00:00:00 2001 From: Zack Fu Zi Xiang Date: Thu, 12 Sep 2024 15:10:48 +0800 Subject: [PATCH 1/6] feat: add get invitation by id --- ...e111d06e327edd9f3a98a4b0636bfe8fd6319.json | 59 +++++++++++++++++++ libs/client-api/src/http_member.rs | 16 ++++- libs/database/src/workspace.rs | 27 +++++++++ src/api/workspace.rs | 15 +++++ src/biz/workspace/ops.rs | 9 +++ tests/workspace/invitation_crud.rs | 17 ++++-- 6 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 .sqlx/query-6dd7f6db2d364cc37b1b46c611fe111d06e327edd9f3a98a4b0636bfe8fd6319.json diff --git a/.sqlx/query-6dd7f6db2d364cc37b1b46c611fe111d06e327edd9f3a98a4b0636bfe8fd6319.json b/.sqlx/query-6dd7f6db2d364cc37b1b46c611fe111d06e327edd9f3a98a4b0636bfe8fd6319.json new file mode 100644 index 00000000..6c269e95 --- /dev/null +++ b/.sqlx/query-6dd7f6db2d364cc37b1b46c611fe111d06e327edd9f3a98a4b0636bfe8fd6319.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS invite_id,\n workspace_id,\n (SELECT workspace_name FROM public.af_workspace WHERE workspace_id = af_workspace_invitation.workspace_id),\n (SELECT email FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_email,\n (SELECT name FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_name,\n status,\n updated_at\n FROM public.af_workspace_invitation\n WHERE af_workspace_invitation.invitee_email = (SELECT email FROM public.af_user WHERE uuid = $1)\n AND id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "invite_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "workspace_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "inviter_email", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "inviter_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Int2" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + null, + null, + null, + false, + false + ] + }, + "hash": "6dd7f6db2d364cc37b1b46c611fe111d06e327edd9f3a98a4b0636bfe8fd6319" +} diff --git a/libs/client-api/src/http_member.rs b/libs/client-api/src/http_member.rs index b198e9bf..350b1070 100644 --- a/libs/client-api/src/http_member.rs +++ b/libs/client-api/src/http_member.rs @@ -65,7 +65,6 @@ impl Client { Ok(()) } - #[instrument(level = "info", skip_all, err)] pub async fn list_workspace_invitations( &self, status: Option, @@ -81,6 +80,21 @@ impl Client { res.into_data() } + pub async fn get_workspace_invitation( + &self, + invite_uuid: &str, + ) -> Result { + let url = format!("{}/api/workspace/invite/{}", self.base_url, invite_uuid); + let resp = self + .http_client_with_auth(Method::GET, &url) + .await? + .send() + .await?; + log_request_id(&resp); + let res: AppResponse = AppResponse::from_response(resp).await?; + res.into_data() + } + pub async fn accept_workspace_invitation( &self, invitation_id: &str, diff --git a/libs/database/src/workspace.rs b/libs/database/src/workspace.rs index 8b0d5427..85d2e692 100644 --- a/libs/database/src/workspace.rs +++ b/libs/database/src/workspace.rs @@ -428,6 +428,33 @@ pub async fn select_workspace_invitations_for_user( Ok(res) } +#[inline] +pub async fn select_workspace_invitation_for_user( + pg_pool: &PgPool, + invitee_uuid: &Uuid, + invite_id: &Uuid, +) -> Result { + let res = sqlx::query_as!( + AFWorkspaceInvitation, + r#" + SELECT + id AS invite_id, + workspace_id, + (SELECT workspace_name FROM public.af_workspace WHERE workspace_id = af_workspace_invitation.workspace_id), + (SELECT email FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_email, + (SELECT name FROM public.af_user WHERE uid = af_workspace_invitation.inviter) AS inviter_name, + status, + updated_at + FROM public.af_workspace_invitation + WHERE af_workspace_invitation.invitee_email = (SELECT email FROM public.af_user WHERE uuid = $1) + AND id = $2 + "#, + invitee_uuid, + invite_id, + ).fetch_one(pg_pool).await?; + Ok(res) +} + #[inline] #[instrument(level = "trace", skip(pool, email, role), err)] pub async fn upsert_workspace_member( diff --git a/src/api/workspace.rs b/src/api/workspace.rs index 08655e1d..c0088970 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -74,6 +74,9 @@ pub fn workspace_scope() -> Scope { .service( web::resource("/invite").route(web::get().to(get_workspace_invite_handler)), // show invites for user ) + .service( + web::resource("/invite/{invite_id}").route(web::get().to(get_workspace_invite_by_id_handler)), + ) .service( web::resource("/accept-invite/{invite_id}") .route(web::post().to(post_accept_workspace_invite_handler)), // accept invitation to workspace @@ -308,6 +311,18 @@ async fn get_workspace_invite_handler( Ok(AppResponse::Ok().with_data(res).into()) } +async fn get_workspace_invite_by_id_handler( + user_uuid: UserUuid, + state: Data, + invite_id: web::Path, +) -> Result> { + let invite_id = invite_id.into_inner(); + let res = + workspace::ops::get_workspace_invitations_for_user(&state.pg_pool, &user_uuid, &invite_id) + .await?; + Ok(AppResponse::Ok().with_data(res).into()) +} + async fn post_accept_workspace_invite_handler( user_uuid: UserUuid, invite_id: web::Path, diff --git a/src/biz/workspace/ops.rs b/src/biz/workspace/ops.rs index d3f4b804..e4d01ea6 100644 --- a/src/biz/workspace/ops.rs +++ b/src/biz/workspace/ops.rs @@ -450,6 +450,15 @@ pub async fn list_workspace_invitations_for_user( Ok(invis) } +pub async fn get_workspace_invitations_for_user( + pg_pool: &PgPool, + user_uuid: &Uuid, + invite_id: &Uuid, +) -> Result { + let invitation = select_workspace_invitation_for_user(pg_pool, user_uuid, invite_id).await?; + Ok(invitation) +} + // use in tests only pub async fn add_workspace_members_db_only( pg_pool: &PgPool, diff --git a/tests/workspace/invitation_crud.rs b/tests/workspace/invitation_crud.rs index 73a832a9..ee78293a 100644 --- a/tests/workspace/invitation_crud.rs +++ b/tests/workspace/invitation_crud.rs @@ -4,7 +4,7 @@ use shared_entity::dto::workspace_dto::{QueryWorkspaceParam, WorkspaceMemberInvi #[tokio::test] async fn invite_workspace_crud() { - let (alice_client, _alice) = generate_unique_registered_user_client().await; + let (alice_client, alice) = generate_unique_registered_user_client().await; let alice_workspace_id = alice_client .get_workspaces() .await @@ -49,14 +49,19 @@ async fn invite_workspace_crud() { .await .unwrap(); assert_eq!(pending_invs.len(), 1); + let invite_id = pending_invs.first().unwrap().invite_id.to_string(); - // accept invitation - let target_invite = pending_invs - .iter() - .find(|i| i.workspace_id == alice_workspace_id) + // get invitation by id + let invitation = bob_client + .get_workspace_invitation(&invite_id) + .await .unwrap(); + + assert_eq!(invitation.workspace_id, alice_workspace_id); + assert_eq!(invitation.inviter_email, Some(alice.email)); + bob_client - .accept_workspace_invitation(target_invite.invite_id.to_string().as_str()) + .accept_workspace_invitation(&invite_id) .await .unwrap(); From e33db6d8f7a4936ffe7e85948f5c650045df0792 Mon Sep 17 00:00:00 2001 From: Zack Fu Zi Xiang Date: Thu, 12 Sep 2024 15:12:57 +0800 Subject: [PATCH 2/6] chore: check status instead of workspace id --- tests/workspace/invitation_crud.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/workspace/invitation_crud.rs b/tests/workspace/invitation_crud.rs index ee78293a..7c2cbfc7 100644 --- a/tests/workspace/invitation_crud.rs +++ b/tests/workspace/invitation_crud.rs @@ -57,8 +57,8 @@ async fn invite_workspace_crud() { .await .unwrap(); - assert_eq!(invitation.workspace_id, alice_workspace_id); assert_eq!(invitation.inviter_email, Some(alice.email)); + assert_eq!(invitation.status , AFWorkspaceInvitationStatus::Pending); bob_client .accept_workspace_invitation(&invite_id) From ab715aff7afc41dd8ef36656e2749423ac5acfdc Mon Sep 17 00:00:00 2001 From: Zack Fu Zi Xiang Date: Thu, 12 Sep 2024 16:55:34 +0800 Subject: [PATCH 3/6] chore: add appflowy web url config --- src/api/workspace.rs | 1 + src/biz/workspace/ops.rs | 59 ++++++++++++++++++------------ src/config/config.rs | 2 + tests/workspace/invitation_crud.rs | 2 +- 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/api/workspace.rs b/src/api/workspace.rs index c0088970..36a119b6 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -294,6 +294,7 @@ async fn post_workspace_invite_handler( &user_uuid, &workspace_id, invited_members, + state.config.appflowy_web_url.as_deref(), ) .await?; Ok(AppResponse::Ok().into()) diff --git a/src/biz/workspace/ops.rs b/src/biz/workspace/ops.rs index e4d01ea6..2e1f9ae6 100644 --- a/src/biz/workspace/ops.rs +++ b/src/biz/workspace/ops.rs @@ -327,6 +327,7 @@ pub async fn invite_workspace_members( inviter: &Uuid, workspace_id: &Uuid, invitations: Vec, + appflowy_web_url: Option<&str>, ) -> Result<(), AppError> { let mut txn = pg_pool .begin() @@ -352,12 +353,17 @@ pub async fn invite_workspace_members( let pending_invitations = database::workspace::select_workspace_pending_invitations(pg_pool, workspace_id).await?; - for invitation in invitations { + // check if any of the invited users are already members of the workspace + for invitation in &invitations { if workspace_members_by_email.contains_key(&invitation.email) { - tracing::warn!("User already in workspace: {}", invitation.email); - continue; + return Err(AppError::InvalidRequest(format!( + "User with email {} is already a member of the workspace", + invitation.email + ))); } + } + for invitation in invitations { let inviter_name = inviter_name.clone(); let workspace_name = workspace_name.clone(); let workspace_member_count = workspace_member_count.to_string(); @@ -384,32 +390,39 @@ pub async fn invite_workspace_members( .await?; invite_id }, - Some(inv) => { + Some(invite_id) => { tracing::warn!("User already invited: {}", invitation.email); - *inv + *invite_id }, }; // Generate a link such that when clicked, the user is added to the workspace. - let accept_url = gotrue_client - .admin_generate_link( - &admin_token, - &GenerateLinkParams { - type_: GenerateLinkType::MagicLink, - email: invitation.email.clone(), - redirect_to: format!( - "/web/login-callback?action=accept_workspace_invite&workspace_invitation_id={}&workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}", - invite_id, workspace_name, - workspace_icon_url, - inviter_name, - user_icon_url, - workspace_member_count, - ), - ..Default::default() + let accept_url = { + match appflowy_web_url { + Some(appflowy_web_url) => format!("{}/accept-invitation?invitated_id={}", appflowy_web_url, invite_id), + None => { + gotrue_client + .admin_generate_link( + &admin_token, + &GenerateLinkParams { + type_: GenerateLinkType::MagicLink, + email: invitation.email.clone(), + redirect_to: format!( + "/web/login-callback?action=accept_workspace_invite&workspace_invitation_id={}&workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}", + invite_id, workspace_name, + workspace_icon_url, + inviter_name, + user_icon_url, + workspace_member_count, + ), + ..Default::default() + }, + ) + .await? + .action_link }, - ) - .await? - .action_link; + } + }; // send email can be slow, so send email in background let cloned_mailer = mailer.clone(); diff --git a/src/config/config.rs b/src/config/config.rs index 2e1ccb8a..4fdc639d 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -24,6 +24,7 @@ pub struct Config { pub published_collab: PublishedCollabSetting, pub mailer: MailerSetting, pub apple_oauth: AppleOAuthSetting, + pub appflowy_web_url: Option, } #[derive(serde::Deserialize, Clone, Debug)] @@ -244,6 +245,7 @@ pub fn get_configuration() -> Result { client_id: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_ID", ""), client_secret: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_SECRET", "").into(), }, + appflowy_web_url: std::env::var("APPFLOWY_WEB_URL").ok(), }; Ok(config) } diff --git a/tests/workspace/invitation_crud.rs b/tests/workspace/invitation_crud.rs index 7c2cbfc7..e84cc655 100644 --- a/tests/workspace/invitation_crud.rs +++ b/tests/workspace/invitation_crud.rs @@ -58,7 +58,7 @@ async fn invite_workspace_crud() { .unwrap(); assert_eq!(invitation.inviter_email, Some(alice.email)); - assert_eq!(invitation.status , AFWorkspaceInvitationStatus::Pending); + assert_eq!(invitation.status, AFWorkspaceInvitationStatus::Pending); bob_client .accept_workspace_invitation(&invite_id) From eb818a42823793743b1933a463ca180188114d5c Mon Sep 17 00:00:00 2001 From: Zack Fu Zi Xiang Date: Fri, 13 Sep 2024 10:04:51 +0800 Subject: [PATCH 4/6] chore: cargo clippy --- src/biz/workspace/ops.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/biz/workspace/ops.rs b/src/biz/workspace/ops.rs index 2e1f9ae6..9534bfd0 100644 --- a/src/biz/workspace/ops.rs +++ b/src/biz/workspace/ops.rs @@ -319,6 +319,7 @@ pub async fn accept_workspace_invite( } #[instrument(level = "debug", skip_all, err)] +#[allow(clippy::too_many_arguments)] pub async fn invite_workspace_members( mailer: &Mailer, gotrue_admin: &GoTrueAdmin, From 53c98c8bd6d2783ec8ac5e29c7728296671c29b3 Mon Sep 17 00:00:00 2001 From: Zack Fu Zi Xiang Date: Fri, 13 Sep 2024 11:25:57 +0800 Subject: [PATCH 5/6] chore: use gen env var optional --- deploy.env | 3 +++ dev.env | 3 +++ libs/infra/src/env_util.rs | 18 ++++++++++++++++++ src/config/config.rs | 4 ++-- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/deploy.env b/deploy.env index fbeffa1f..e161ffb3 100644 --- a/deploy.env +++ b/deploy.env @@ -139,3 +139,6 @@ APPFLOWY_INDEXER_REDIS_URL=redis://redis:6379 # AppFlowy Collaborate APPFLOWY_COLLABORATE_MULTI_THREAD=false APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100 + +# AppFlowy Web +APPFLOWY_WEB_URL= diff --git a/dev.env b/dev.env index 4b4d5bde..bbec40c5 100644 --- a/dev.env +++ b/dev.env @@ -125,3 +125,6 @@ APPFLOWY_INDEXER_REDIS_URL=redis://redis:6379 # AppFlowy Collaborate APPFLOWY_COLLABORATE_MULTI_THREAD=false APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100 + +# AppFlowy Web +APPFLOWY_WEB_URL= diff --git a/libs/infra/src/env_util.rs b/libs/infra/src/env_util.rs index f9f4e35f..697ad209 100644 --- a/libs/infra/src/env_util.rs +++ b/libs/infra/src/env_util.rs @@ -8,3 +8,21 @@ pub fn get_env_var(key: &str, default: &str) -> String { default.to_owned() }) } + +/// Optionally get an environment variable. +/// if value is empty, return None. +pub fn get_env_var_opt(key: &str) -> Option { + match std::env::var(key) { + Ok(val) => { + if val.is_empty() { + None + } else { + Some(val) + } + }, + Err(e) => { + tracing::warn!("failed to read environment variable: {}, None set", e); + None + }, + } +} diff --git a/src/config/config.rs b/src/config/config.rs index 4fdc639d..0786f463 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -7,7 +7,7 @@ use semver::Version; use serde::Deserialize; use sqlx::postgres::{PgConnectOptions, PgSslMode}; -use infra::env_util::get_env_var; +use infra::env_util::{get_env_var, get_env_var_opt}; #[derive(Clone, Debug)] pub struct Config { @@ -245,7 +245,7 @@ pub fn get_configuration() -> Result { client_id: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_ID", ""), client_secret: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_SECRET", "").into(), }, - appflowy_web_url: std::env::var("APPFLOWY_WEB_URL").ok(), + appflowy_web_url: get_env_var_opt("APPFLOWY_WEB_URL"), }; Ok(config) } From 0fd23d0b9fa09afa6e42de29dc42479d3284f752 Mon Sep 17 00:00:00 2001 From: Zack Fu Zi Xiang Date: Fri, 13 Sep 2024 12:19:07 +0800 Subject: [PATCH 6/6] chore: fix tests --- tests/workspace/member_crud.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/workspace/member_crud.rs b/tests/workspace/member_crud.rs index 300f18b3..536f429a 100644 --- a/tests/workspace/member_crud.rs +++ b/tests/workspace/member_crud.rs @@ -93,8 +93,9 @@ async fn add_duplicate_workspace_members() { .await .unwrap(); - // next invite should do nothing since c2 is already a workspace member - c1.api_client + // next invite should return error since the user is already in the workspace + let err = c1 + .api_client .invite_workspace_members( &workspace_id, vec![WorkspaceMemberInvitation { @@ -103,7 +104,8 @@ async fn add_duplicate_workspace_members() { }], ) .await - .unwrap(); + .unwrap_err(); + assert_eq!(err.code, ErrorCode::InvalidRequest, "{:?}", err); // should not find any invitation let invitations = c2