From 813fa29253fbf5d40432451ad1e13d1d915c1414 Mon Sep 17 00:00:00 2001 From: khorshuheng Date: Thu, 26 Sep 2024 09:03:39 +0800 Subject: [PATCH] feat: add endpoints to allow workspace owner to approve web page view request --- ...ee2894e380a7c3ae3cbc782f438fabc45de8b.json | 14 ++ ...b5bc6c74d24c1a8018a981d2175a483dc699c.json | 25 ++++ ...fc929f05df7ccfc0b7cb955787d0f88f91c45.json | 52 +++++++ ...6f42f8229f150c2384671802ee7c1ef9e376d.json | 15 +++ libs/app-error/src/lib.rs | 11 ++ libs/client-api/src/http_access_request.rs | 76 +++++++++++ libs/client-api/src/lib.rs | 1 + libs/database-entity/src/dto.rs | 45 +++++++ libs/database/src/access_request.rs | 127 ++++++++++++++++++ libs/database/src/lib.rs | 1 + libs/database/src/pg_row.rs | 84 +++++++++++- .../src/dto/access_request_dto.rs | 23 ++++ libs/shared-entity/src/dto/mod.rs | 1 + libs/shared-entity/src/dto/workspace_dto.rs | 8 ++ migrations/20240924045045_access_request.sql | 13 ++ src/api/access_request.rs | 77 +++++++++++ src/api/mod.rs | 1 + src/application.rs | 2 + src/biz/access_request/mod.rs | 1 + src/biz/access_request/ops.rs | 105 +++++++++++++++ src/biz/mod.rs | 1 + tests/workspace/access_request.rs | 70 ++++++++++ tests/workspace/mod.rs | 1 + 23 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 .sqlx/query-4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b.json create mode 100644 .sqlx/query-598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c.json create mode 100644 .sqlx/query-6317de690a65f0cb63e2f9d4889fc929f05df7ccfc0b7cb955787d0f88f91c45.json create mode 100644 .sqlx/query-95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d.json create mode 100644 libs/client-api/src/http_access_request.rs create mode 100644 libs/database/src/access_request.rs create mode 100644 libs/shared-entity/src/dto/access_request_dto.rs create mode 100644 migrations/20240924045045_access_request.sql create mode 100644 src/api/access_request.rs create mode 100644 src/biz/access_request/mod.rs create mode 100644 src/biz/access_request/ops.rs create mode 100644 tests/workspace/access_request.rs diff --git a/.sqlx/query-4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b.json b/.sqlx/query-4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b.json new file mode 100644 index 00000000..3557c222 --- /dev/null +++ b/.sqlx/query-4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM af_access_request\n WHERE request_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b" +} diff --git a/.sqlx/query-598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c.json b/.sqlx/query-598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c.json new file mode 100644 index 00000000..dc93bbc6 --- /dev/null +++ b/.sqlx/query-598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO af_access_request (\n workspace_id,\n view_id,\n uid,\n status\n )\n VALUES ($1, $2, $3, $4)\n RETURNING request_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "request_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Int8", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c" +} diff --git a/.sqlx/query-6317de690a65f0cb63e2f9d4889fc929f05df7ccfc0b7cb955787d0f88f91c45.json b/.sqlx/query-6317de690a65f0cb63e2f9d4889fc929f05df7ccfc0b7cb955787d0f88f91c45.json new file mode 100644 index 00000000..ec5bcaf1 --- /dev/null +++ b/.sqlx/query-6317de690a65f0cb63e2f9d4889fc929f05df7ccfc0b7cb955787d0f88f91c45.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n request_id,\n view_id,\n (\n workspace_id,\n af_workspace.database_storage_id,\n af_workspace.owner_uid,\n owner_profile.name,\n af_workspace.created_at,\n af_workspace.workspace_type,\n af_workspace.deleted_at,\n af_workspace.workspace_name,\n af_workspace.icon\n ) AS \"workspace!: AFWorkspaceRow\",\n (\n af_user.uuid,\n af_user.email,\n af_user.name,\n af_user.metadata ->> 'avatar'\n ) AS \"requester!: AFAccessRequesterColumn\",\n status AS \"status: AFAccessRequestStatusColumn\",\n af_access_request.created_at AS created_at\n FROM af_access_request\n JOIN af_user USING (uid)\n JOIN af_workspace USING (workspace_id)\n JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\n WHERE request_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "request_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "view_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "workspace!: AFWorkspaceRow", + "type_info": "Record" + }, + { + "ordinal": 3, + "name": "requester!: AFAccessRequesterColumn", + "type_info": "Record" + }, + { + "ordinal": 4, + "name": "status: AFAccessRequestStatusColumn", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + null, + null, + false, + false + ] + }, + "hash": "6317de690a65f0cb63e2f9d4889fc929f05df7ccfc0b7cb955787d0f88f91c45" +} diff --git a/.sqlx/query-95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d.json b/.sqlx/query-95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d.json new file mode 100644 index 00000000..962d3838 --- /dev/null +++ b/.sqlx/query-95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE af_access_request\n SET status = $2\n WHERE request_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d" +} diff --git a/libs/app-error/src/lib.rs b/libs/app-error/src/lib.rs index d7c59f96..5b682555 100644 --- a/libs/app-error/src/lib.rs +++ b/libs/app-error/src/lib.rs @@ -11,6 +11,7 @@ use appflowy_ai_client::error::AIError; use reqwest::StatusCode; use serde::Serialize; use thiserror::Error; +use uuid::Uuid; #[derive(Debug, Error, Default)] pub enum AppError { @@ -145,6 +146,12 @@ pub enum AppError { #[error("{0}")] NotInviteeOfWorkspaceInvitation(String), + + #[error("{0}")] + MissingView(String), + + #[error("There is existing access request for workspace {workspace_id} and view {view_id}")] + AccessRequestAlreadyExists { workspace_id: Uuid, view_id: Uuid }, } impl AppError { @@ -212,6 +219,8 @@ impl AppError { AppError::InvalidPublishedOutline(_) => ErrorCode::InvalidPublishedOutline, AppError::InvalidFolderView(_) => ErrorCode::InvalidFolderView, AppError::NotInviteeOfWorkspaceInvitation(_) => ErrorCode::NotInviteeOfWorkspaceInvitation, + AppError::MissingView(_) => ErrorCode::MissingView, + AppError::AccessRequestAlreadyExists { .. } => ErrorCode::AccessRequestAlreadyExists, } } } @@ -339,6 +348,8 @@ pub enum ErrorCode { InvalidPublishedOutline = 1039, InvalidFolderView = 1040, NotInviteeOfWorkspaceInvitation = 1041, + MissingView = 1042, + AccessRequestAlreadyExists = 1043, } impl ErrorCode { diff --git a/libs/client-api/src/http_access_request.rs b/libs/client-api/src/http_access_request.rs new file mode 100644 index 00000000..4649349b --- /dev/null +++ b/libs/client-api/src/http_access_request.rs @@ -0,0 +1,76 @@ +use client_api_entity::{ + access_request_dto::AccessRequest, AccessRequestMinimal, ApproveAccessRequestParams, + CreateAccessRequestParams, +}; +use reqwest::Method; +use shared_entity::response::{AppResponse, AppResponseError}; +use uuid::Uuid; + +use crate::Client; + +impl Client { + pub async fn get_access_request( + &self, + access_request_id: Uuid, + ) -> Result { + let url = format!("{}/api/access-request/{}", self.base_url, access_request_id); + let resp = self + .http_client_with_auth(Method::GET, &url) + .await? + .send() + .await?; + AppResponse::::from_response(resp) + .await? + .into_data() + } + + pub async fn create_access_request( + &self, + data: CreateAccessRequestParams, + ) -> Result { + let url = format!("{}/api/access-request", self.base_url); + let resp = self + .http_client_with_auth(Method::POST, &url) + .await? + .json(&data) + .send() + .await?; + AppResponse::::from_response(resp) + .await? + .into_data() + } + + pub async fn approve_access_request( + &self, + access_request_id: Uuid, + ) -> Result<(), AppResponseError> { + let url = format!( + "{}/api/access-request/{}/approve", + self.base_url, access_request_id + ); + let resp = self + .http_client_with_auth(Method::POST, &url) + .await? + .json(&ApproveAccessRequestParams { is_approved: true }) + .send() + .await?; + AppResponse::<()>::from_response(resp).await?.into_error() + } + + pub async fn reject_access_request( + &self, + access_request_id: Uuid, + ) -> Result<(), AppResponseError> { + let url = format!( + "{}/api/access-request/{}/approve", + self.base_url, access_request_id + ); + let resp = self + .http_client_with_auth(Method::POST, &url) + .await? + .json(&ApproveAccessRequestParams { is_approved: false }) + .send() + .await?; + AppResponse::<()>::from_response(resp).await?.into_error() + } +} diff --git a/libs/client-api/src/lib.rs b/libs/client-api/src/lib.rs index fe6e9498..2dea43cb 100644 --- a/libs/client-api/src/lib.rs +++ b/libs/client-api/src/lib.rs @@ -2,6 +2,7 @@ mod http; mod http_ai; mod http_billing; +mod http_access_request; mod http_blob; mod http_collab; mod http_history; diff --git a/libs/database-entity/src/dto.rs b/libs/database-entity/src/dto.rs index 38744f89..f53625dd 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -1238,6 +1238,51 @@ pub struct AvatarImageSource { pub file_id: String, } +#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, Copy, Clone)] +#[repr(i32)] +pub enum AccessRequestStatus { + Pending = 0, + Approved = 1, + Rejected = 2, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AccessRequestWithViewId { + pub request_id: Uuid, + pub workspace: AFWorkspace, + pub requester: AccessRequesterInfo, + pub view_id: Uuid, + pub status: AccessRequestStatus, + pub created_at: DateTime, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AccessRequesterInfo { + pub uuid: Uuid, + pub email: String, + pub name: String, + pub avatar_url: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AccessRequestMinimal { + pub request_id: Uuid, + pub workspace_id: Uuid, + pub requester_id: Uuid, + pub view_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CreateAccessRequestParams { + pub workspace_id: Uuid, + pub view_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ApproveAccessRequestParams { + pub is_approved: bool, +} + #[cfg(test)] mod test { use crate::dto::{CollabParams, CollabParamsV0}; diff --git a/libs/database/src/access_request.rs b/libs/database/src/access_request.rs new file mode 100644 index 00000000..79e447d8 --- /dev/null +++ b/libs/database/src/access_request.rs @@ -0,0 +1,127 @@ +use crate::pg_row::{ + AFAccessRequestStatusColumn, AFAccessRequestWithViewIdColumn, AFAccessRequesterColumn, + AFWorkspaceRow, +}; +use app_error::AppError; +use database_entity::dto::AccessRequestWithViewId; +use sqlx::{Executor, Postgres}; +use uuid::Uuid; + +pub async fn insert_new_access_request<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + workspace_id: Uuid, + view_id: Uuid, + uid: i64, +) -> Result { + let request_id_result = sqlx::query_scalar!( + r#" + INSERT INTO af_access_request ( + workspace_id, + view_id, + uid, + status + ) + VALUES ($1, $2, $3, $4) + RETURNING request_id + "#, + workspace_id, + view_id, + uid, + AFAccessRequestStatusColumn::Pending as _, + ) + .fetch_one(executor) + .await; + match request_id_result { + Err(e) + if e + .as_database_error() + .map_or(false, |e| e.constraint().is_some()) => + { + Err(AppError::AccessRequestAlreadyExists { + workspace_id, + view_id, + }) + }, + Err(e) => Err(e.into()), + Ok(request_id) => Ok(request_id), + } +} + +pub async fn select_access_request_by_request_id<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + request_id: Uuid, +) -> Result { + let access_request = sqlx::query_as!( + AFAccessRequestWithViewIdColumn, + r#" + SELECT + request_id, + view_id, + ( + workspace_id, + af_workspace.database_storage_id, + af_workspace.owner_uid, + owner_profile.name, + af_workspace.created_at, + af_workspace.workspace_type, + af_workspace.deleted_at, + af_workspace.workspace_name, + af_workspace.icon + ) AS "workspace!: AFWorkspaceRow", + ( + af_user.uuid, + af_user.email, + af_user.name, + af_user.metadata ->> 'avatar' + ) AS "requester!: AFAccessRequesterColumn", + status AS "status: AFAccessRequestStatusColumn", + af_access_request.created_at AS created_at + FROM af_access_request + JOIN af_user USING (uid) + JOIN af_workspace USING (workspace_id) + JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid + WHERE request_id = $1 + "#, + request_id, + ) + .fetch_one(executor) + .await?; + + let access_request: AccessRequestWithViewId = access_request.try_into()?; + Ok(access_request) +} + +pub async fn update_access_request_status<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + request_id: Uuid, + status: AFAccessRequestStatusColumn, +) -> Result<(), AppError> { + sqlx::query!( + r#" + UPDATE af_access_request + SET status = $2 + WHERE request_id = $1 + "#, + request_id, + status as _, + ) + .execute(executor) + .await?; + Ok(()) +} + +pub async fn delete_access_request<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + request_id: Uuid, +) -> Result<(), AppError> { + sqlx::query!( + r#" + DELETE FROM af_access_request + WHERE request_id = $1 + "#, + request_id, + ) + .execute(executor) + .await?; + Ok(()) +} diff --git a/libs/database/src/lib.rs b/libs/database/src/lib.rs index 0ffbe1e7..d2beaffd 100644 --- a/libs/database/src/lib.rs +++ b/libs/database/src/lib.rs @@ -1,3 +1,4 @@ +pub mod access_request; pub mod chat; pub mod collab; pub mod file; diff --git a/libs/database/src/pg_row.rs b/libs/database/src/pg_row.rs index 64b06712..70b3d802 100644 --- a/libs/database/src/pg_row.rs +++ b/libs/database/src/pg_row.rs @@ -4,6 +4,7 @@ use chrono::{DateTime, Utc}; use database_entity::dto::{ AFAccessLevel, AFRole, AFUserProfile, AFWebUser, AFWorkspace, AFWorkspaceInvitationStatus, + AccessRequestMinimal, AccessRequestStatus, AccessRequestWithViewId, AccessRequesterInfo, AccountLink, GlobalComment, Reaction, Template, TemplateCategory, TemplateCategoryMinimal, TemplateCategoryType, TemplateCreator, TemplateCreatorMinimal, TemplateGroup, TemplateMinimal, }; @@ -12,7 +13,7 @@ use sqlx::FromRow; use uuid::Uuid; /// Represent the row of the af_workspace table -#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, sqlx::Type)] pub struct AFWorkspaceRow { pub workspace_id: Uuid, pub database_storage_id: Option, @@ -502,3 +503,84 @@ impl From for TemplateGroup { } } } + +#[derive(sqlx::Type, Serialize, Deserialize, Debug)] +#[repr(i32)] +pub enum AFAccessRequestStatusColumn { + Pending = 0, + Approved = 1, + Rejected = 2, +} + +impl From for AccessRequestStatus { + fn from(value: AFAccessRequestStatusColumn) -> Self { + match value { + AFAccessRequestStatusColumn::Pending => AccessRequestStatus::Pending, + AFAccessRequestStatusColumn::Approved => AccessRequestStatus::Approved, + AFAccessRequestStatusColumn::Rejected => AccessRequestStatus::Rejected, + } + } +} + +#[derive(sqlx::Type, Serialize, Debug)] +pub struct AFAccessRequesterColumn { + pub uuid: Uuid, + pub name: String, + pub email: String, + pub avatar_url: Option, +} + +impl From for AccessRequesterInfo { + fn from(value: AFAccessRequesterColumn) -> Self { + Self { + uuid: value.uuid, + name: value.name, + email: value.email, + avatar_url: value.avatar_url, + } + } +} + +#[derive(sqlx::Type, Serialize, Debug)] +pub struct AFAccessRequestMinimalColumn { + pub request_id: Uuid, + pub workspace_id: Uuid, + pub requester_id: Uuid, + pub view_id: Uuid, +} + +impl From for AccessRequestMinimal { + fn from(value: AFAccessRequestMinimalColumn) -> Self { + Self { + request_id: value.request_id, + workspace_id: value.workspace_id, + requester_id: value.requester_id, + view_id: value.view_id, + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AFAccessRequestWithViewIdColumn { + pub request_id: Uuid, + pub workspace: AFWorkspaceRow, + pub requester: AccessRequesterInfo, + pub view_id: Uuid, + pub status: AFAccessRequestStatusColumn, + pub created_at: DateTime, +} + +impl TryFrom for AccessRequestWithViewId { + type Error = anyhow::Error; + + fn try_from(value: AFAccessRequestWithViewIdColumn) -> Result { + Ok(Self { + request_id: value.request_id, + workspace: value.workspace.try_into()?, + requester: value.requester, + view_id: value.view_id, + status: value.status.into(), + created_at: value.created_at, + }) + } +} diff --git a/libs/shared-entity/src/dto/access_request_dto.rs b/libs/shared-entity/src/dto/access_request_dto.rs new file mode 100644 index 00000000..d0690324 --- /dev/null +++ b/libs/shared-entity/src/dto/access_request_dto.rs @@ -0,0 +1,23 @@ +use database_entity::dto::{AFWorkspace, AccessRequestStatus, AccessRequesterInfo}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::workspace_dto::{ViewIcon, ViewLayout}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct AccessRequestView { + pub view_id: String, + pub name: String, + pub icon: Option, + pub layout: ViewLayout, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AccessRequest { + pub request_id: Uuid, + pub workspace: AFWorkspace, + pub requester: AccessRequesterInfo, + pub view: AccessRequestView, + pub status: AccessRequestStatus, + pub created_at: chrono::DateTime, +} diff --git a/libs/shared-entity/src/dto/mod.rs b/libs/shared-entity/src/dto/mod.rs index 5eef0c86..339e7b89 100644 --- a/libs/shared-entity/src/dto/mod.rs +++ b/libs/shared-entity/src/dto/mod.rs @@ -1,3 +1,4 @@ +pub mod access_request_dto; pub mod ai_dto; pub mod auth_dto; pub mod billing_dto; diff --git a/libs/shared-entity/src/dto/workspace_dto.rs b/libs/shared-entity/src/dto/workspace_dto.rs index ff6d2c67..cd77797c 100644 --- a/libs/shared-entity/src/dto/workspace_dto.rs +++ b/libs/shared-entity/src/dto/workspace_dto.rs @@ -158,6 +158,14 @@ pub struct FolderView { pub children: Vec, } +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct FolderViewMinimal { + pub view_id: String, + pub name: String, + pub icon: Option, + pub layout: ViewLayout, +} + #[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct SectionItems { pub views: Vec, diff --git a/migrations/20240924045045_access_request.sql b/migrations/20240924045045_access_request.sql new file mode 100644 index 00000000..45aebef5 --- /dev/null +++ b/migrations/20240924045045_access_request.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS af_access_request ( + request_id UUID NOT NULL DEFAULT gen_random_uuid(), + uid BIGINT NOT NULL REFERENCES af_user(uid) ON DELETE CASCADE, + workspace_id UUID NOT NULL, + view_id UUID NOT NULL, + status INT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (uid, workspace_id, view_id), + PRIMARY KEY(request_id) +); + +CREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_access_request ON af_access_request(workspace_id); +CREATE INDEX IF NOT EXISTS idx_uid_on_af_access_request ON af_access_request(uid); diff --git a/src/api/access_request.rs b/src/api/access_request.rs new file mode 100644 index 00000000..f8b87e67 --- /dev/null +++ b/src/api/access_request.rs @@ -0,0 +1,77 @@ +use actix_web::{ + web::{self, Data, Json}, + Result, Scope, +}; +use authentication::jwt::UserUuid; +use database_entity::dto::{ + AccessRequestMinimal, ApproveAccessRequestParams, CreateAccessRequestParams, +}; +use shared_entity::{ + dto::access_request_dto::AccessRequest, + response::{AppResponse, JsonAppResponse}, +}; +use uuid::Uuid; + +use crate::{ + biz::access_request::ops::{ + approve_or_reject_access_request, create_access_request, get_access_request, + }, + state::AppState, +}; + +pub fn access_request_scope() -> Scope { + web::scope("/api/access-request") + .service(web::resource("").route(web::post().to(post_access_request_handler))) + .service(web::resource("/{request_id}").route(web::get().to(get_access_request_handler))) + .service( + web::resource("/{request_id}/approve") + .route(web::post().to(post_approve_access_request_handler)), + ) +} + +async fn get_access_request_handler( + _uuid: UserUuid, + access_request_id: web::Path, + state: Data, +) -> Result> { + let access_request_id = access_request_id.into_inner(); + let access_request = get_access_request( + &state.pg_pool, + state.collab_access_control_storage.clone(), + access_request_id, + ) + .await?; + Ok(Json(AppResponse::Ok().with_data(access_request))) +} + +async fn post_access_request_handler( + uuid: UserUuid, + create_access_request_params: Json, + state: Data, +) -> Result> { + let uid = state.user_cache.get_user_uid(&uuid).await?; + let workspace_id = create_access_request_params.workspace_id; + let view_id = create_access_request_params.view_id; + let request_id = create_access_request(&state.pg_pool, workspace_id, view_id, uid).await?; + let access_request = AccessRequestMinimal { + request_id, + workspace_id, + requester_id: *uuid, + view_id, + }; + Ok(Json(AppResponse::Ok().with_data(access_request))) +} + +async fn post_approve_access_request_handler( + uuid: UserUuid, + access_request_id: web::Path, + approve_access_request_params: Json, + state: Data, +) -> Result> { + let uid = state.user_cache.get_user_uid(&uuid).await?; + let access_request_id = access_request_id.into_inner(); + let is_approved = approve_access_request_params.is_approved; + approve_or_reject_access_request(&state.pg_pool, access_request_id, uid, *uuid, is_approved) + .await?; + Ok(Json(AppResponse::Ok())) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 131c5a18..74935b68 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod ai; pub mod chat; pub mod file_storage; +pub mod access_request; pub mod history; pub mod metrics; pub mod search; diff --git a/src/application.rs b/src/application.rs index 5bf71feb..52e9d876 100644 --- a/src/application.rs +++ b/src/application.rs @@ -42,6 +42,7 @@ use snowflake::Snowflake; use tonic_proto::history::history_client::HistoryClient; use workspace_access::WorkspaceAccessControlImpl; +use crate::api::access_request::access_request_scope; use crate::api::ai::ai_completion_scope; use crate::api::chat::chat_scope; use crate::api::file_storage::file_storage_scope; @@ -169,6 +170,7 @@ pub async fn run_actix_server( .service(metrics_scope()) .service(search_scope()) .service(template_scope()) + .service(access_request_scope()) .app_data(Data::new(state.metrics.registry.clone())) .app_data(Data::new(state.metrics.request_metrics.clone())) .app_data(Data::new(state.metrics.realtime_metrics.clone())) diff --git a/src/biz/access_request/mod.rs b/src/biz/access_request/mod.rs new file mode 100644 index 00000000..01eafd2e --- /dev/null +++ b/src/biz/access_request/mod.rs @@ -0,0 +1 @@ +pub mod ops; diff --git a/src/biz/access_request/ops.rs b/src/biz/access_request/ops.rs new file mode 100644 index 00000000..5f5f60e4 --- /dev/null +++ b/src/biz/access_request/ops.rs @@ -0,0 +1,105 @@ +use std::{ops::DerefMut, sync::Arc}; + +use anyhow::Context; +use app_error::AppError; +use appflowy_collaborate::collab::storage::CollabAccessControlStorage; +use database::{ + access_request::{ + insert_new_access_request, select_access_request_by_request_id, update_access_request_status, + }, + collab::GetCollabOrigin, + pg_row::AFAccessRequestStatusColumn, + workspace::upsert_workspace_member_with_txn, +}; +use database_entity::dto::AFRole; +use shared_entity::dto::access_request_dto::{AccessRequest, AccessRequestView}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::biz::collab::{ + folder_view::{to_dto_view_icon, to_view_layout}, + ops::get_latest_collab_folder, +}; + +pub async fn create_access_request( + pg_pool: &PgPool, + workspace_id: Uuid, + view_id: Uuid, + uid: i64, +) -> Result { + let request_id = insert_new_access_request(pg_pool, workspace_id, view_id, uid).await?; + Ok(request_id) +} + +pub async fn get_access_request( + pg_pool: &PgPool, + collab_storage: Arc, + access_request_id: Uuid, +) -> Result { + let access_request_with_view_id = + select_access_request_by_request_id(pg_pool, access_request_id).await?; + let folder = get_latest_collab_folder( + collab_storage, + GetCollabOrigin::Server, + &access_request_with_view_id + .workspace + .workspace_id + .to_string(), + ) + .await?; + let view = folder.get_view(&access_request_with_view_id.view_id.to_string()); + let access_request_view = view + .map(|v| AccessRequestView { + view_id: v.id.clone(), + name: v.name.clone(), + icon: v.icon.as_ref().map(|icon| to_dto_view_icon(icon.clone())), + layout: to_view_layout(&v.layout), + }) + .ok_or(AppError::MissingView(format!( + "the view {} is missing", + access_request_with_view_id.view_id + )))?; + let access_request = AccessRequest { + request_id: access_request_with_view_id.request_id, + workspace: access_request_with_view_id.workspace, + requester: access_request_with_view_id.requester, + view: access_request_view, + status: access_request_with_view_id.status, + created_at: access_request_with_view_id.created_at, + }; + Ok(access_request) +} + +pub async fn approve_or_reject_access_request( + pg_pool: &PgPool, + request_id: Uuid, + uid: i64, + user_uuid: Uuid, + is_approved: bool, +) -> Result<(), AppError> { + let access_request = select_access_request_by_request_id(pg_pool, request_id).await?; + if access_request.workspace.owner_uid != uid { + return Err(AppError::NotEnoughPermissions { + user: user_uuid.to_string(), + action: "approve access request".to_string(), + }); + } + + let mut txn = pg_pool.begin().await.context("approving request")?; + let role = AFRole::Member; + upsert_workspace_member_with_txn( + &mut txn, + &access_request.workspace.workspace_id, + &access_request.requester.email, + role, + ) + .await?; + let status = if is_approved { + AFAccessRequestStatusColumn::Approved + } else { + AFAccessRequestStatusColumn::Rejected + }; + update_access_request_status(txn.deref_mut(), request_id, status).await?; + txn.commit().await.context("committing transaction")?; + Ok(()) +} diff --git a/src/biz/mod.rs b/src/biz/mod.rs index cea29522..9496d008 100644 --- a/src/biz/mod.rs +++ b/src/biz/mod.rs @@ -1,3 +1,4 @@ +pub mod access_request; pub mod chat; pub mod collab; pub mod pg_listener; diff --git a/tests/workspace/access_request.rs b/tests/workspace/access_request.rs new file mode 100644 index 00000000..81613289 --- /dev/null +++ b/tests/workspace/access_request.rs @@ -0,0 +1,70 @@ +use app_error::ErrorCode; +use client_api::entity::CreateAccessRequestParams; +use client_api_test::generate_unique_registered_user_client; +use shared_entity::dto::workspace_dto::ViewLayout; +use uuid::Uuid; + +#[tokio::test] +async fn access_request_test() { + 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 folder_view = owner_client + .get_workspace_folder(&workspace_id.to_string(), Some(2), None) + .await + .unwrap(); + let view_id = folder_view + .children + .into_iter() + .find(|v| v.name == "General") + .unwrap() + .children + .iter() + .find(|v| v.name == "To-dos") + .unwrap() + .view_id + .clone(); + let view_id = Uuid::parse_str(&view_id).unwrap(); + let data = CreateAccessRequestParams { + workspace_id, + view_id, + }; + let (requester_client, requester) = generate_unique_registered_user_client().await; + let access_request = requester_client + .create_access_request(data.clone()) + .await + .unwrap(); + let resp = requester_client.create_access_request(data).await; + assert!(resp.is_err()); + assert_eq!( + resp.unwrap_err().code, + ErrorCode::AccessRequestAlreadyExists + ); + let access_request_id = access_request.request_id; + let access_request_to_be_approved = owner_client + .get_access_request(access_request_id) + .await + .unwrap(); + assert_eq!( + access_request_to_be_approved.requester.email, + requester.email + ); + assert_eq!( + access_request_to_be_approved.view.view_id, + view_id.to_string() + ); + assert_eq!(access_request_to_be_approved.view.layout, ViewLayout::Board); + assert_eq!( + access_request_to_be_approved.workspace.workspace_id, + workspace_id + ); + owner_client + .approve_access_request(access_request_id) + .await + .unwrap(); + let workspace_members = owner_client + .get_workspace_members(workspace_id.to_string()) + .await + .unwrap(); + assert!(workspace_members.iter().any(|m| m.email == requester.email)); +} diff --git a/tests/workspace/mod.rs b/tests/workspace/mod.rs index 9af101c2..4ce5c9b2 100644 --- a/tests/workspace/mod.rs +++ b/tests/workspace/mod.rs @@ -1,3 +1,4 @@ +mod access_request; mod default_user_workspace; mod edit_workspace; mod invitation_crud;