feat: join workspace by invite code

This commit is contained in:
khorshuheng 2025-04-03 22:12:38 +08:00
parent 0160be6ac6
commit dba8db995d
12 changed files with 314 additions and 0 deletions

View file

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO af_workspace_invite_code (workspace_id, invite_code, expires_at)\n VALUES ($1, $2, $3)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Timestamp"
]
},
"nullable": []
},
"hash": "084655c4e26f78c9c0924ea39a099dc9c00ee73dc6ade2dcff27c03042ebe8c3"
}

View file

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO af_workspace_member (workspace_id, uid, role_id)\n VALUES ($1, $2, $3)\n ON CONFLICT (workspace_id, uid) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Int8",
"Int4"
]
},
"nullable": []
},
"hash": "32fd3dcd1a3e02c32ddedb232b6af2e7f9ea160354528f3299cca62367af10f7"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT workspace_id\n FROM af_workspace_invite_code\n WHERE invite_code = $1\n AND (expires_at IS NULL OR expires_at > NOW())\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "90a302af791eeb5c5f60c3f95145e0e73c2a1652c5b547e4118bac1d005300de"
}

View file

@ -9,6 +9,10 @@ use client_api_entity::workspace_dto::TrashSectionItems;
use client_api_entity::workspace_dto::{FolderView, QueryWorkspaceFolder, QueryWorkspaceParam};
use client_api_entity::AuthProvider;
use client_api_entity::CollabType;
use client_api_entity::InvitedWorkspace;
use client_api_entity::JoinWorkspaceByInviteCodeParams;
use client_api_entity::WorkspaceInviteCodeParams;
use client_api_entity::WorkspaceInviteToken as WorkspaceInviteCode;
use gotrue::grant::PasswordGrant;
use gotrue::grant::{Grant, RefreshTokenGrant};
use gotrue::params::MagicLinkParams;
@ -777,6 +781,44 @@ impl Client {
.into_data()
}
pub async fn join_workspace_by_invitation_code(
&self,
invitation_code: &str,
) -> Result<InvitedWorkspace, AppResponseError> {
let url = format!("{}/api/workspace/join-by-invite-code", self.base_url);
let resp = self
.http_client_with_auth(Method::POST, &url)
.await?
.json(&JoinWorkspaceByInviteCodeParams {
code: invitation_code.to_string(),
})
.send()
.await?;
AppResponse::<InvitedWorkspace>::from_response(resp)
.await?
.into_data()
}
pub async fn create_workspace_invitation_code(
&self,
workspace_id: &Uuid,
params: &WorkspaceInviteCodeParams,
) -> Result<WorkspaceInviteCode, AppResponseError> {
let url = format!(
"{}/api/workspace/{}/invite-code",
self.base_url, workspace_id
);
let resp = self
.http_client_with_auth(Method::POST, &url)
.await?
.json(params)
.send()
.await?;
AppResponse::<WorkspaceInviteCode>::from_response(resp)
.await?
.into_data()
}
#[instrument(skip_all, err)]
pub async fn sign_in_password(
&self,

View file

@ -1222,6 +1222,26 @@ pub struct ListQuickNotesQueryParams {
pub limit: Option<i32>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct WorkspaceInviteCodeParams {
pub validity_period_hours: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct WorkspaceInviteToken {
pub code: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct InvitedWorkspace {
pub workspace_id: Uuid,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct JoinWorkspaceByInviteCodeParams {
pub code: String,
}
#[cfg(test)]
mod test {
use crate::dto::{CollabParams, CollabParamsV0};

View file

@ -1510,3 +1510,66 @@ pub async fn select_view_id_from_publish_name(
Ok(res)
}
pub async fn select_invited_workspace_id(
pg_pool: &PgPool,
invitation_code: &str,
) -> Result<Uuid, AppError> {
let res = sqlx::query_scalar!(
r#"
SELECT workspace_id
FROM af_workspace_invite_code
WHERE invite_code = $1
AND (expires_at IS NULL OR expires_at > NOW())
"#,
invitation_code
)
.fetch_one(pg_pool)
.await?;
Ok(res)
}
pub async fn upsert_workspace_member_uid<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
workspace_id: &Uuid,
uid: i64,
role: AFRole,
) -> Result<(), AppError> {
let role_id = role as i32;
sqlx::query!(
r#"
INSERT INTO af_workspace_member (workspace_id, uid, role_id)
VALUES ($1, $2, $3)
ON CONFLICT (workspace_id, uid) DO NOTHING
"#,
workspace_id,
uid,
role_id,
)
.execute(executor)
.await?;
Ok(())
}
pub async fn insert_workspace_invite_code<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
workspace_id: &Uuid,
code: &str,
expires_at: Option<&chrono::DateTime<Utc>>,
) -> Result<(), AppError> {
sqlx::query!(
r#"
INSERT INTO af_workspace_invite_code (workspace_id, invite_code, expires_at)
VALUES ($1, $2, $3)
"#,
workspace_id,
code,
expires_at.map(|dt| dt.naive_utc()),
)
.execute(executor)
.await?;
Ok(())
}

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS af_workspace_invite_code (
id UUID PRIMARY KEY DEFAULT UUID_GENERATE_V4 (),
invite_code TEXT NOT NULL,
expires_at TIMESTAMP,
workspace_id UUID NOT NULL REFERENCES af_workspace (workspace_id) ON DELETE CASCADE
);
CREATE INDEX idx_af_workspace_invite_code ON af_workspace_invite_code (invite_code);

View file

@ -9,6 +9,9 @@ use crate::biz::collab::utils::collab_from_doc_state;
use crate::biz::user::user_verify::verify_token;
use crate::biz::workspace;
use crate::biz::workspace::duplicate::duplicate_view_tree_and_collab;
use crate::biz::workspace::invite::{
generate_workspace_invite_token, join_workspace_invite_by_code,
};
use crate::biz::workspace::ops::{
create_comment_on_published_view, create_reaction_on_comment, get_comments_on_published_view,
get_reactions_on_published_view, remove_comment_on_published_view, remove_reaction_on_comment,
@ -108,6 +111,11 @@ pub fn workspace_scope() -> Scope {
web::resource("/accept-invite/{invite_id}")
.route(web::post().to(post_accept_workspace_invite_handler)), // accept invitation to workspace
)
.service(
web::resource("/join-by-invite-code")
.route(web::post().to(post_join_workspace_invite_by_code_handler)),
)
.service(web::resource("/{workspace_id}").route(web::delete().to(delete_workspace_handler)))
.service(
web::resource("/{workspace_id}/settings")
@ -355,6 +363,10 @@ pub fn workspace_scope() -> Scope {
.route(web::put().to(update_quick_note_handler))
.route(web::delete().to(delete_quick_note_handler)),
)
.service(
web::resource("/{workspace_id}/invite-code")
.route(web::post().to(post_workspace_invite_code_handler)),
)
}
pub fn collab_scope() -> Scope {
@ -532,6 +544,27 @@ async fn post_accept_workspace_invite_handler(
Ok(AppResponse::Ok().into())
}
async fn post_join_workspace_invite_by_code_handler(
user_uuid: UserUuid,
state: Data<AppState>,
payload: Json<JoinWorkspaceByInviteCodeParams>,
) -> Result<JsonAppResponse<InvitedWorkspace>> {
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let invited_workspace_id =
join_workspace_invite_by_code(&state.pg_pool, &payload.code, uid).await?;
state
.workspace_access_control
.insert_role(&uid, &invited_workspace_id, AFRole::Member)
.await?;
Ok(
AppResponse::Ok()
.with_data(InvitedWorkspace {
workspace_id: invited_workspace_id,
})
.into(),
)
}
#[instrument(skip_all, err, fields(user_uuid))]
async fn get_workspace_settings_handler(
user_uuid: UserUuid,
@ -2873,3 +2906,21 @@ async fn delete_quick_note_handler(
delete_quick_note(&state.pg_pool, quick_note_id).await?;
Ok(Json(AppResponse::Ok()))
}
async fn post_workspace_invite_code_handler(
user_uuid: UserUuid,
path_param: web::Path<Uuid>,
state: Data<AppState>,
data: Json<WorkspaceInviteCodeParams>,
) -> Result<JsonAppResponse<WorkspaceInviteToken>> {
let workspace_id = path_param.into_inner();
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
state
.workspace_access_control
.enforce_role(&uid, &workspace_id, AFRole::Owner)
.await?;
let workspace_invite_link =
generate_workspace_invite_token(&state.pg_pool, &workspace_id, data.validity_period_hours)
.await?;
Ok(Json(AppResponse::Ok().with_data(workspace_invite_link)))
}

View file

@ -0,0 +1,42 @@
use app_error::AppError;
use database::workspace::{
insert_workspace_invite_code, select_invited_workspace_id, upsert_workspace_member_uid,
};
use rand::{distributions::Alphanumeric, Rng};
use sqlx::PgPool;
use uuid::Uuid;
use database_entity::dto::{AFRole, WorkspaceInviteToken};
const INVITE_LINK_CODE_LENGTH: usize = 16;
pub async fn generate_workspace_invite_token(
pg_pool: &PgPool,
workspace_id: &Uuid,
validity_period_hours: Option<i64>,
) -> Result<WorkspaceInviteToken, AppError> {
let code = generate_workspace_invite_code();
let expires_at = validity_period_hours.map(|v| chrono::Utc::now() + chrono::Duration::hours(v));
insert_workspace_invite_code(pg_pool, workspace_id, &code, expires_at.as_ref()).await?;
Ok(WorkspaceInviteToken { code })
}
fn generate_workspace_invite_code() -> String {
let rng = rand::thread_rng();
rng
.sample_iter(&Alphanumeric)
.take(INVITE_LINK_CODE_LENGTH)
.map(char::from)
.collect()
}
pub async fn join_workspace_invite_by_code(
pg_pool: &PgPool,
invitation_code: &str,
uid: i64,
) -> Result<Uuid, AppError> {
let invited_workspace_id = select_invited_workspace_id(pg_pool, invitation_code).await?;
upsert_workspace_member_uid(pg_pool, &invited_workspace_id, uid, AFRole::Member).await?;
Ok(invited_workspace_id)
}

View file

@ -1,4 +1,5 @@
pub mod duplicate;
pub mod invite;
pub mod ops;
pub mod page_view;
pub mod publish;

View file

@ -0,0 +1,32 @@
use client_api::entity::WorkspaceInviteCodeParams;
use client_api_test::generate_unique_registered_user_client;
#[tokio::test]
async fn join_workspace_by_invite_code() {
let (owner_client, _) = generate_unique_registered_user_client().await;
let workspaces = owner_client.get_workspaces().await.unwrap();
let workspace_id = workspaces[0].workspace_id;
let (invitee_client, _) = generate_unique_registered_user_client().await;
let invitation_code = owner_client
.create_workspace_invitation_code(
&workspace_id,
&WorkspaceInviteCodeParams {
validity_period_hours: None,
},
)
.await
.unwrap()
.code;
let invited_workspace_id = invitee_client
.join_workspace_by_invitation_code(&invitation_code)
.await
.unwrap()
.workspace_id;
assert_eq!(workspace_id, invited_workspace_id);
assert!(invitee_client
.get_workspaces()
.await
.unwrap()
.iter()
.any(|w| w.workspace_id == invited_workspace_id));
}

View file

@ -3,6 +3,7 @@ mod default_user_workspace;
mod edit_workspace;
mod import_test;
mod invitation_crud;
mod join_workspace;
mod member_crud;
mod page_view;
mod publish;