Merge pull request #819 from AppFlowy-IO/feat/workspace-invite-get
Feat/workspace invite get
This commit is contained in:
commit
9422110eaf
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -139,3 +139,6 @@ APPFLOWY_INDEXER_REDIS_URL=redis://redis:6379
|
||||||
# AppFlowy Collaborate
|
# AppFlowy Collaborate
|
||||||
APPFLOWY_COLLABORATE_MULTI_THREAD=false
|
APPFLOWY_COLLABORATE_MULTI_THREAD=false
|
||||||
APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100
|
APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100
|
||||||
|
|
||||||
|
# AppFlowy Web
|
||||||
|
APPFLOWY_WEB_URL=
|
||||||
|
|
|
||||||
3
dev.env
3
dev.env
|
|
@ -125,3 +125,6 @@ APPFLOWY_INDEXER_REDIS_URL=redis://redis:6379
|
||||||
# AppFlowy Collaborate
|
# AppFlowy Collaborate
|
||||||
APPFLOWY_COLLABORATE_MULTI_THREAD=false
|
APPFLOWY_COLLABORATE_MULTI_THREAD=false
|
||||||
APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100
|
APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100
|
||||||
|
|
||||||
|
# AppFlowy Web
|
||||||
|
APPFLOWY_WEB_URL=
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,6 @@ impl Client {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(level = "info", skip_all, err)]
|
|
||||||
pub async fn list_workspace_invitations(
|
pub async fn list_workspace_invitations(
|
||||||
&self,
|
&self,
|
||||||
status: Option<AFWorkspaceInvitationStatus>,
|
status: Option<AFWorkspaceInvitationStatus>,
|
||||||
|
|
@ -81,6 +80,21 @@ impl Client {
|
||||||
res.into_data()
|
res.into_data()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_workspace_invitation(
|
||||||
|
&self,
|
||||||
|
invite_uuid: &str,
|
||||||
|
) -> Result<AFWorkspaceInvitation, AppResponseError> {
|
||||||
|
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<AFWorkspaceInvitation> = AppResponse::from_response(resp).await?;
|
||||||
|
res.into_data()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn accept_workspace_invitation(
|
pub async fn accept_workspace_invitation(
|
||||||
&self,
|
&self,
|
||||||
invitation_id: &str,
|
invitation_id: &str,
|
||||||
|
|
|
||||||
|
|
@ -428,6 +428,33 @@ pub async fn select_workspace_invitations_for_user(
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub async fn select_workspace_invitation_for_user(
|
||||||
|
pg_pool: &PgPool,
|
||||||
|
invitee_uuid: &Uuid,
|
||||||
|
invite_id: &Uuid,
|
||||||
|
) -> Result<AFWorkspaceInvitation, AppError> {
|
||||||
|
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]
|
#[inline]
|
||||||
#[instrument(level = "trace", skip(pool, email, role), err)]
|
#[instrument(level = "trace", skip(pool, email, role), err)]
|
||||||
pub async fn upsert_workspace_member(
|
pub async fn upsert_workspace_member(
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,21 @@ pub fn get_env_var(key: &str, default: &str) -> String {
|
||||||
default.to_owned()
|
default.to_owned()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optionally get an environment variable.
|
||||||
|
/// if value is empty, return None.
|
||||||
|
pub fn get_env_var_opt(key: &str) -> Option<String> {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,9 @@ pub fn workspace_scope() -> Scope {
|
||||||
.service(
|
.service(
|
||||||
web::resource("/invite").route(web::get().to(get_workspace_invite_handler)), // show invites for user
|
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(
|
.service(
|
||||||
web::resource("/accept-invite/{invite_id}")
|
web::resource("/accept-invite/{invite_id}")
|
||||||
.route(web::post().to(post_accept_workspace_invite_handler)), // accept invitation to workspace
|
.route(web::post().to(post_accept_workspace_invite_handler)), // accept invitation to workspace
|
||||||
|
|
@ -299,6 +302,7 @@ async fn post_workspace_invite_handler(
|
||||||
&user_uuid,
|
&user_uuid,
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
invited_members,
|
invited_members,
|
||||||
|
state.config.appflowy_web_url.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(AppResponse::Ok().into())
|
Ok(AppResponse::Ok().into())
|
||||||
|
|
@ -316,6 +320,18 @@ async fn get_workspace_invite_handler(
|
||||||
Ok(AppResponse::Ok().with_data(res).into())
|
Ok(AppResponse::Ok().with_data(res).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_workspace_invite_by_id_handler(
|
||||||
|
user_uuid: UserUuid,
|
||||||
|
state: Data<AppState>,
|
||||||
|
invite_id: web::Path<Uuid>,
|
||||||
|
) -> Result<JsonAppResponse<AFWorkspaceInvitation>> {
|
||||||
|
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(
|
async fn post_accept_workspace_invite_handler(
|
||||||
user_uuid: UserUuid,
|
user_uuid: UserUuid,
|
||||||
invite_id: web::Path<Uuid>,
|
invite_id: web::Path<Uuid>,
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,7 @@ pub async fn accept_workspace_invite(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(level = "debug", skip_all, err)]
|
#[instrument(level = "debug", skip_all, err)]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn invite_workspace_members(
|
pub async fn invite_workspace_members(
|
||||||
mailer: &Mailer,
|
mailer: &Mailer,
|
||||||
gotrue_admin: &GoTrueAdmin,
|
gotrue_admin: &GoTrueAdmin,
|
||||||
|
|
@ -327,6 +328,7 @@ pub async fn invite_workspace_members(
|
||||||
inviter: &Uuid,
|
inviter: &Uuid,
|
||||||
workspace_id: &Uuid,
|
workspace_id: &Uuid,
|
||||||
invitations: Vec<WorkspaceMemberInvitation>,
|
invitations: Vec<WorkspaceMemberInvitation>,
|
||||||
|
appflowy_web_url: Option<&str>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let mut txn = pg_pool
|
let mut txn = pg_pool
|
||||||
.begin()
|
.begin()
|
||||||
|
|
@ -352,12 +354,17 @@ pub async fn invite_workspace_members(
|
||||||
let pending_invitations =
|
let pending_invitations =
|
||||||
database::workspace::select_workspace_pending_invitations(pg_pool, workspace_id).await?;
|
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) {
|
if workspace_members_by_email.contains_key(&invitation.email) {
|
||||||
tracing::warn!("User already in workspace: {}", invitation.email);
|
return Err(AppError::InvalidRequest(format!(
|
||||||
continue;
|
"User with email {} is already a member of the workspace",
|
||||||
|
invitation.email
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for invitation in invitations {
|
||||||
let inviter_name = inviter_name.clone();
|
let inviter_name = inviter_name.clone();
|
||||||
let workspace_name = workspace_name.clone();
|
let workspace_name = workspace_name.clone();
|
||||||
let workspace_member_count = workspace_member_count.to_string();
|
let workspace_member_count = workspace_member_count.to_string();
|
||||||
|
|
@ -384,32 +391,39 @@ pub async fn invite_workspace_members(
|
||||||
.await?;
|
.await?;
|
||||||
invite_id
|
invite_id
|
||||||
},
|
},
|
||||||
Some(inv) => {
|
Some(invite_id) => {
|
||||||
tracing::warn!("User already invited: {}", invitation.email);
|
tracing::warn!("User already invited: {}", invitation.email);
|
||||||
*inv
|
*invite_id
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate a link such that when clicked, the user is added to the workspace.
|
// Generate a link such that when clicked, the user is added to the workspace.
|
||||||
let accept_url = gotrue_client
|
let accept_url = {
|
||||||
.admin_generate_link(
|
match appflowy_web_url {
|
||||||
&admin_token,
|
Some(appflowy_web_url) => format!("{}/accept-invitation?invitated_id={}", appflowy_web_url, invite_id),
|
||||||
&GenerateLinkParams {
|
None => {
|
||||||
type_: GenerateLinkType::MagicLink,
|
gotrue_client
|
||||||
email: invitation.email.clone(),
|
.admin_generate_link(
|
||||||
redirect_to: format!(
|
&admin_token,
|
||||||
"/web/login-callback?action=accept_workspace_invite&workspace_invitation_id={}&workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}",
|
&GenerateLinkParams {
|
||||||
invite_id, workspace_name,
|
type_: GenerateLinkType::MagicLink,
|
||||||
workspace_icon_url,
|
email: invitation.email.clone(),
|
||||||
inviter_name,
|
redirect_to: format!(
|
||||||
user_icon_url,
|
"/web/login-callback?action=accept_workspace_invite&workspace_invitation_id={}&workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}",
|
||||||
workspace_member_count,
|
invite_id, workspace_name,
|
||||||
),
|
workspace_icon_url,
|
||||||
..Default::default()
|
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
|
// send email can be slow, so send email in background
|
||||||
let cloned_mailer = mailer.clone();
|
let cloned_mailer = mailer.clone();
|
||||||
|
|
@ -450,6 +464,15 @@ pub async fn list_workspace_invitations_for_user(
|
||||||
Ok(invis)
|
Ok(invis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_workspace_invitations_for_user(
|
||||||
|
pg_pool: &PgPool,
|
||||||
|
user_uuid: &Uuid,
|
||||||
|
invite_id: &Uuid,
|
||||||
|
) -> Result<AFWorkspaceInvitation, AppError> {
|
||||||
|
let invitation = select_workspace_invitation_for_user(pg_pool, user_uuid, invite_id).await?;
|
||||||
|
Ok(invitation)
|
||||||
|
}
|
||||||
|
|
||||||
// use in tests only
|
// use in tests only
|
||||||
pub async fn add_workspace_members_db_only(
|
pub async fn add_workspace_members_db_only(
|
||||||
pg_pool: &PgPool,
|
pg_pool: &PgPool,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use semver::Version;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::postgres::{PgConnectOptions, PgSslMode};
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
|
@ -24,6 +24,7 @@ pub struct Config {
|
||||||
pub published_collab: PublishedCollabSetting,
|
pub published_collab: PublishedCollabSetting,
|
||||||
pub mailer: MailerSetting,
|
pub mailer: MailerSetting,
|
||||||
pub apple_oauth: AppleOAuthSetting,
|
pub apple_oauth: AppleOAuthSetting,
|
||||||
|
pub appflowy_web_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Clone, Debug)]
|
#[derive(serde::Deserialize, Clone, Debug)]
|
||||||
|
|
@ -244,6 +245,7 @@ pub fn get_configuration() -> Result<Config, anyhow::Error> {
|
||||||
client_id: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_ID", ""),
|
client_id: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_ID", ""),
|
||||||
client_secret: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_SECRET", "").into(),
|
client_secret: get_env_var("APPFLOWY_APPLE_OAUTH_CLIENT_SECRET", "").into(),
|
||||||
},
|
},
|
||||||
|
appflowy_web_url: get_env_var_opt("APPFLOWY_WEB_URL"),
|
||||||
};
|
};
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use shared_entity::dto::workspace_dto::{QueryWorkspaceParam, WorkspaceMemberInvi
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invite_workspace_crud() {
|
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
|
let alice_workspace_id = alice_client
|
||||||
.get_workspaces()
|
.get_workspaces()
|
||||||
.await
|
.await
|
||||||
|
|
@ -49,14 +49,19 @@ async fn invite_workspace_crud() {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(pending_invs.len(), 1);
|
assert_eq!(pending_invs.len(), 1);
|
||||||
|
let invite_id = pending_invs.first().unwrap().invite_id.to_string();
|
||||||
|
|
||||||
// accept invitation
|
// get invitation by id
|
||||||
let target_invite = pending_invs
|
let invitation = bob_client
|
||||||
.iter()
|
.get_workspace_invitation(&invite_id)
|
||||||
.find(|i| i.workspace_id == alice_workspace_id)
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(invitation.inviter_email, Some(alice.email));
|
||||||
|
assert_eq!(invitation.status, AFWorkspaceInvitationStatus::Pending);
|
||||||
|
|
||||||
bob_client
|
bob_client
|
||||||
.accept_workspace_invitation(target_invite.invite_id.to_string().as_str())
|
.accept_workspace_invitation(&invite_id)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,9 @@ async fn add_duplicate_workspace_members() {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// next invite should do nothing since c2 is already a workspace member
|
// next invite should return error since the user is already in the workspace
|
||||||
c1.api_client
|
let err = c1
|
||||||
|
.api_client
|
||||||
.invite_workspace_members(
|
.invite_workspace_members(
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
vec![WorkspaceMemberInvitation {
|
vec![WorkspaceMemberInvitation {
|
||||||
|
|
@ -103,7 +104,8 @@ async fn add_duplicate_workspace_members() {
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code, ErrorCode::InvalidRequest, "{:?}", err);
|
||||||
|
|
||||||
// should not find any invitation
|
// should not find any invitation
|
||||||
let invitations = c2
|
let invitations = c2
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue