Merge pull request #821 from AppFlowy-IO/feat/workspace-invite-fields

feat: additional fields for workspace invitation
This commit is contained in:
Zack 2024-09-15 16:15:28 +08:00 committed by GitHub
commit eed23a353a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 306 additions and 155 deletions

View File

@ -1,59 +0,0 @@
{
"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\n public.af_workspace_invitation\n WHERE af_workspace_invitation.invitee_email = (SELECT email FROM public.af_user WHERE uuid = $1)\n AND ($2::SMALLINT IS NULL OR status = $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",
"Int2"
]
},
"nullable": [
false,
false,
null,
null,
null,
false,
false
]
},
"hash": "2c1152b8867bebcb63b637820eb44ac932ee88c6326ecaf9b4b4c7f690eff41c"
}

View File

@ -0,0 +1,77 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n i.id AS invite_id,\n i.workspace_id,\n w.workspace_name,\n u_inviter.email AS inviter_email,\n u_inviter.name AS inviter_name,\n i.status,\n i.updated_at,\n u_inviter.metadata->>'icon_url' AS inviter_icon,\n w.icon AS workspace_icon,\n (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\n FROM\n public.af_workspace_invitation i\n JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\n JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\n JOIN public.af_user u_invitee ON u_invitee.uuid = $1\n WHERE\n i.invitee_email = u_invitee.email\n AND i.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"
},
{
"ordinal": 7,
"name": "inviter_icon",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "workspace_icon",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "member_count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
null,
false,
null
]
},
"hash": "2ee385e58e042071290226289646965553938838c83085273f68f687c976767a"
}

View File

@ -1,59 +0,0 @@
{
"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"
}

View File

@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM af_workspace_invitation\n WHERE id = $1 AND invitee_email = (SELECT email FROM af_user WHERE uuid = $2)\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "7c8b84da6d70cb4ae59ae618e6f7aa7bde3dbd6630bd7e7fcafe1606d63651c8"
}

View File

@ -0,0 +1,77 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n i.id AS invite_id,\n i.workspace_id,\n w.workspace_name,\n u_inviter.email AS inviter_email,\n u_inviter.name AS inviter_name,\n i.status,\n i.updated_at,\n u_inviter.metadata->>'icon_url' AS inviter_icon,\n w.icon AS workspace_icon,\n (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\n FROM\n public.af_workspace_invitation i\n JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\n JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\n JOIN public.af_user u_invitee ON u_invitee.uuid = $1\n WHERE\n i.invitee_email = u_invitee.email\n AND ($2::SMALLINT IS NULL OR i.status = $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"
},
{
"ordinal": 7,
"name": "inviter_icon",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "workspace_icon",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "member_count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Int2"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
null,
false,
null
]
},
"hash": "de595bd6554d8e1f58c9c4bb94ea14b5ae2fd15b3c5d2b84d5dd5a551954ecde"
}

View File

@ -66,7 +66,7 @@
</table>
<br />
<h4>Pending Invitation</h4>
<h4>Invitation(s) from other user(s)</h4>
<table class="purple-table table">
<thead>
<tr>

View File

@ -142,6 +142,9 @@ pub enum AppError {
#[error("{0}")]
InvalidFolderView(String),
#[error("{0}")]
NotInviteeOfWorkspaceInvitation(String),
}
impl AppError {
@ -208,6 +211,7 @@ impl AppError {
AppError::InvalidContentType(_) => ErrorCode::InvalidContentType,
AppError::InvalidPublishedOutline(_) => ErrorCode::InvalidPublishedOutline,
AppError::InvalidFolderView(_) => ErrorCode::InvalidFolderView,
AppError::NotInviteeOfWorkspaceInvitation(_) => ErrorCode::NotInviteeOfWorkspaceInvitation,
}
}
}
@ -328,6 +332,7 @@ pub enum ErrorCode {
AppleRevokeTokenError = 1038,
InvalidPublishedOutline = 1039,
InvalidFolderView = 1040,
NotInviteeOfWorkspaceInvitation = 1041,
}
impl ErrorCode {

View File

@ -601,6 +601,9 @@ pub struct AFWorkspaceInvitation {
pub inviter_name: Option<String>,
pub status: AFWorkspaceInvitationStatus,
pub updated_at: DateTime<Utc>,
pub inviter_icon: Option<String>,
pub workspace_icon: String,
pub member_count: Option<i64>, // use unwrap_or(0) to get the value
}
#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)]

View File

@ -409,22 +409,31 @@ pub async fn select_workspace_invitations_for_user(
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 ($2::SMALLINT IS NULL OR status = $2)
SELECT
i.id AS invite_id,
i.workspace_id,
w.workspace_name,
u_inviter.email AS inviter_email,
u_inviter.name AS inviter_name,
i.status,
i.updated_at,
u_inviter.metadata->>'icon_url' AS inviter_icon,
w.icon AS workspace_icon,
(SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count
FROM
public.af_workspace_invitation i
JOIN public.af_workspace w ON i.workspace_id = w.workspace_id
JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid
JOIN public.af_user u_invitee ON u_invitee.uuid = $1
WHERE
i.invitee_email = u_invitee.email
AND ($2::SMALLINT IS NULL OR i.status = $2);
"#,
invitee_uuid,
status_filter.map(|s| s as i16)
).fetch_all(pg_pool).await?;
)
.fetch_all(pg_pool)
.await?;
Ok(res)
}
@ -437,21 +446,31 @@ pub async fn select_workspace_invitation_for_user(
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
SELECT
i.id AS invite_id,
i.workspace_id,
w.workspace_name,
u_inviter.email AS inviter_email,
u_inviter.name AS inviter_name,
i.status,
i.updated_at,
u_inviter.metadata->>'icon_url' AS inviter_icon,
w.icon AS workspace_icon,
(SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count
FROM
public.af_workspace_invitation i
JOIN public.af_workspace w ON i.workspace_id = w.workspace_id
JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid
JOIN public.af_user u_invitee ON u_invitee.uuid = $1
WHERE
i.invitee_email = u_invitee.email
AND i.id = $2;
"#,
invitee_uuid,
invite_id,
).fetch_one(pg_pool).await?;
)
.fetch_one(pg_pool)
.await?;
Ok(res)
}
@ -1357,3 +1376,24 @@ pub async fn delete_reaction_from_comment<'a, E: Executor<'a, Database = Postgre
Ok(())
}
pub async fn select_user_is_invitee_for_workspace_invitation(
pg_pool: &PgPool,
invitee_uuid: &Uuid,
invite_id: &Uuid,
) -> Result<bool, AppError> {
let res = sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM af_workspace_invitation
WHERE id = $1 AND invitee_email = (SELECT email FROM af_user WHERE uuid = $2)
)
"#,
invite_id,
invitee_uuid,
)
.fetch_one(pg_pool)
.await?;
res.map_or(Ok(false), Ok)
}

View File

@ -21,7 +21,7 @@ use access_control::collab::CollabAccessControl;
use app_error::AppError;
use appflowy_collaborate::actix_ws::entities::ClientStreamMessage;
use appflowy_collaborate::indexer::IndexerProvider;
use authentication::jwt::{OptionalUserUuid, UserUuid};
use authentication::jwt::{Authorization, OptionalUserUuid, UserUuid};
use collab_rt_entity::realtime_proto::HttpRealtimeMessage;
use collab_rt_entity::RealtimeMessage;
use collab_rt_protocol::validate_encode_collab;
@ -41,6 +41,7 @@ use crate::biz;
use crate::biz::collab::ops::{
get_user_favorite_folder_views, get_user_recent_folder_views, get_user_trash_folder_views,
};
use crate::biz::user::user_verify::verify_token;
use crate::biz::workspace;
use crate::biz::workspace::ops::{
create_comment_on_published_view, create_reaction_on_comment, get_comments_on_published_view,
@ -333,16 +334,20 @@ async fn get_workspace_invite_by_id_handler(
}
async fn post_accept_workspace_invite_handler(
user_uuid: UserUuid,
auth: Authorization,
invite_id: web::Path<Uuid>,
state: Data<AppState>,
) -> Result<JsonAppResponse<()>> {
let _is_new = verify_token(&auth.token, state.as_ref()).await?;
let user_uuid = auth.uuid()?;
let user_uid = state.user_cache.get_user_uid(&user_uuid).await?;
let invite_id = invite_id.into_inner();
// TODO(zack): insert a workspace member in the af_workspace_member by calling workspace::ops::add_workspace_members.
// Currently, when the server get restarted, the policy in access control will be lost.
workspace::ops::accept_workspace_invite(
&state.pg_pool,
&state.workspace_access_control,
user_uid,
&user_uuid,
&invite_id,
)

View File

@ -302,12 +302,21 @@ pub async fn open_workspace(
pub async fn accept_workspace_invite(
pg_pool: &PgPool,
workspace_access_control: &impl WorkspaceAccessControl,
user_uid: i64,
user_uuid: &Uuid,
invite_id: &Uuid,
) -> Result<(), AppError> {
let mut txn = pg_pool.begin().await?;
update_workspace_invitation_set_status_accepted(&mut txn, user_uuid, invite_id).await?;
let inv = get_invitation_by_id(&mut txn, invite_id).await?;
if let Some(invitee_uid) = inv.invitee_uid {
if invitee_uid != user_uid {
return Err(AppError::NotInviteeOfWorkspaceInvitation(format!(
"User with uid {} is not the invitee for invite_id {}",
user_uid, invite_id
)));
}
}
update_workspace_invitation_set_status_accepted(&mut txn, user_uuid, invite_id).await?;
let invited_uid = inv
.invitee_uid
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invitee uid is missing for {:?}", inv)))?;
@ -400,7 +409,7 @@ pub async fn invite_workspace_members(
// Generate a link such that when clicked, the user is added to the workspace.
let accept_url = {
match appflowy_web_url {
Some(appflowy_web_url) => format!("{}/accept-invitation?invitated_id={}", appflowy_web_url, invite_id),
Some(appflowy_web_url) => format!("{}/accept-invitation?invited_id={}", appflowy_web_url, invite_id),
None => {
gotrue_client
.admin_generate_link(
@ -469,6 +478,14 @@ pub async fn get_workspace_invitations_for_user(
user_uuid: &Uuid,
invite_id: &Uuid,
) -> Result<AFWorkspaceInvitation, AppError> {
let user_is_invitee =
select_user_is_invitee_for_workspace_invitation(pg_pool, user_uuid, invite_id).await?;
if !user_is_invitee {
return Err(AppError::NotInviteeOfWorkspaceInvitation(format!(
"User with uuid {} is not the invitee for invite_id {}",
user_uuid, invite_id
)));
}
let invitation = select_workspace_invitation_for_user(pg_pool, user_uuid, invite_id).await?;
Ok(invitation)
}

View File

@ -36,9 +36,9 @@ impl Mailer {
HANDLEBARS
.write()
.unwrap()
.map_err(|err| anyhow::anyhow!(format!("Failed to write handlebars: {}", err)))?
.register_template_string("workspace_invite", workspace_invite_template)
.unwrap();
.map_err(|err| anyhow::anyhow!(format!("Failed to register handlebars template: {}", err)))?;
Ok(Self {
smtp_transport,
@ -51,10 +51,10 @@ impl Mailer {
email: String,
param: WorkspaceInviteMailerParam,
) -> Result<(), anyhow::Error> {
let rendered = HANDLEBARS
.read()
.unwrap()
.render("workspace_invite", &param)?;
let rendered = match HANDLEBARS.read() {
Ok(registory) => registory.render("workspace_invite", &param)?,
Err(err) => anyhow::bail!(format!("Failed to render handlebars template: {}", err)),
};
let email = Message::builder()
.from(lettre::message::Mailbox::new(
@ -63,7 +63,7 @@ impl Mailer {
))
.to(lettre::message::Mailbox::new(
Some(param.username.clone()),
email.parse().unwrap(),
email.parse()?,
))
.subject(format!(
"Action required: {} invited you to {} in AppFlowy",

View File

@ -1,3 +1,4 @@
use app_error::ErrorCode;
use client_api_test::generate_unique_registered_user_client;
use database_entity::dto::{AFRole, AFWorkspaceInvitationStatus};
use shared_entity::dto::workspace_dto::{QueryWorkspaceParam, WorkspaceMemberInvitation};
@ -59,12 +60,33 @@ async fn invite_workspace_crud() {
assert_eq!(invitation.inviter_email, Some(alice.email));
assert_eq!(invitation.status, AFWorkspaceInvitationStatus::Pending);
assert_eq!(invitation.member_count.unwrap_or(0), 1);
let (charlie_client, _charlie) = generate_unique_registered_user_client().await;
let err = charlie_client
.get_workspace_invitation(&invite_id)
.await
.unwrap_err();
assert_eq!(err.code, ErrorCode::NotInviteeOfWorkspaceInvitation);
let err = charlie_client
.accept_workspace_invitation(&invite_id)
.await
.unwrap_err();
assert_eq!(err.code, ErrorCode::NotInviteeOfWorkspaceInvitation);
bob_client
.accept_workspace_invitation(&invite_id)
.await
.unwrap();
let invitation = bob_client
.get_workspace_invitation(&invite_id)
.await
.unwrap();
assert_eq!(invitation.status, AFWorkspaceInvitationStatus::Accepted);
assert_eq!(invitation.member_count.unwrap_or(0), 2);
// list invitation with accepted filter
let accepted_invs = bob_client
.list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Accepted))