From 56b9f9daf4ba9fd68803c643c70d90a767000756 Mon Sep 17 00:00:00 2001 From: khorshuheng Date: Tue, 3 Sep 2024 10:43:22 +0800 Subject: [PATCH] feat: published view outline endpoint --- .dockerignore | 1 + ...df0c5daa7ad0c52f3e7e071156595262ca44f.json | 22 +++++ ...ef43c32e09e6c545a08f35fb5169e84553af6.json | 22 +++++ libs/app-error/src/lib.rs | 5 + libs/database/src/publish.rs | 36 +++++++ libs/shared-entity/src/dto/workspace_dto.rs | 17 ++++ src/api/workspace.rs | 17 ++++ src/biz/collab/folder_view.rs | 22 ++++- src/biz/collab/mod.rs | 1 + src/biz/collab/ops.rs | 42 +++++++- src/biz/collab/publish_outline.rs | 98 +++++++++++++++++++ 11 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 .sqlx/query-785c4c1e9b393a1f04a88136211df0c5daa7ad0c52f3e7e071156595262ca44f.json create mode 100644 .sqlx/query-ee58ab18f882ed89f3506d3e28eef43c32e09e6c545a08f35fb5169e84553af6.json create mode 100644 src/biz/collab/publish_outline.rs diff --git a/.dockerignore b/.dockerignore index fcc007d4..f799e7d5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ .env .dockerignore spec.yaml +**/target target/ deploy/ tests/ diff --git a/.sqlx/query-785c4c1e9b393a1f04a88136211df0c5daa7ad0c52f3e7e071156595262ca44f.json b/.sqlx/query-785c4c1e9b393a1f04a88136211df0c5daa7ad0c52f3e7e071156595262ca44f.json new file mode 100644 index 00000000..efcbc9ab --- /dev/null +++ b/.sqlx/query-785c4c1e9b393a1f04a88136211df0c5daa7ad0c52f3e7e071156595262ca44f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT workspace_id\n FROM af_workspace\n WHERE publish_namespace = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "785c4c1e9b393a1f04a88136211df0c5daa7ad0c52f3e7e071156595262ca44f" +} diff --git a/.sqlx/query-ee58ab18f882ed89f3506d3e28eef43c32e09e6c545a08f35fb5169e84553af6.json b/.sqlx/query-ee58ab18f882ed89f3506d3e28eef43c32e09e6c545a08f35fb5169e84553af6.json new file mode 100644 index 00000000..57fd2015 --- /dev/null +++ b/.sqlx/query-ee58ab18f882ed89f3506d3e28eef43c32e09e6c545a08f35fb5169e84553af6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT view_id\n FROM af_published_collab\n WHERE workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "view_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ee58ab18f882ed89f3506d3e28eef43c32e09e6c545a08f35fb5169e84553af6" +} diff --git a/libs/app-error/src/lib.rs b/libs/app-error/src/lib.rs index 17e1ce25..506cbc96 100644 --- a/libs/app-error/src/lib.rs +++ b/libs/app-error/src/lib.rs @@ -136,6 +136,9 @@ pub enum AppError { #[error("{0}")] InvalidContentType(String), + + #[error("{0}")] + InvalidPublishedOutline(String), } impl AppError { @@ -200,6 +203,7 @@ impl AppError { AppError::AIServiceUnavailable(_) => ErrorCode::AIServiceUnavailable, AppError::StringLengthLimitReached(_) => ErrorCode::StringLengthLimitReached, AppError::InvalidContentType(_) => ErrorCode::InvalidContentType, + AppError::InvalidPublishedOutline(_) => ErrorCode::InvalidPublishedOutline, } } } @@ -318,6 +322,7 @@ pub enum ErrorCode { InvalidContentType = 1036, SingleUploadLimitExceeded = 1037, AppleRevokeTokenError = 1038, + InvalidPublishedOutline = 1039, } impl ErrorCode { diff --git a/libs/database/src/publish.rs b/libs/database/src/publish.rs index 15597e07..7f3e4db8 100644 --- a/libs/database/src/publish.rs +++ b/libs/database/src/publish.rs @@ -273,3 +273,39 @@ pub async fn select_published_collab_info<'a, E: Executor<'a, Database = Postgre Ok(res) } + +pub async fn select_workspace_id_for_publish_namespace<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + publish_namespace: &str, +) -> Result { + let res = sqlx::query!( + r#" + SELECT workspace_id + FROM af_workspace + WHERE publish_namespace = $1 + "#, + publish_namespace, + ) + .fetch_one(executor) + .await?; + + Ok(res.workspace_id) +} + +pub async fn select_published_view_ids_for_workspace<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + workspace_id: Uuid, +) -> Result, AppError> { + let res = sqlx::query!( + r#" + SELECT view_id + FROM af_published_collab + WHERE workspace_id = $1 + "#, + workspace_id, + ) + .fetch_all(executor) + .await?; + + Ok(res.into_iter().map(|r| r.view_id).collect()) +} diff --git a/libs/shared-entity/src/dto/workspace_dto.rs b/libs/shared-entity/src/dto/workspace_dto.rs index 2ee1085f..188d01c5 100644 --- a/libs/shared-entity/src/dto/workspace_dto.rs +++ b/libs/shared-entity/src/dto/workspace_dto.rs @@ -164,6 +164,12 @@ pub enum ViewLayout { Chat = 4, } +impl Default for ViewLayout { + fn default() -> Self { + Self::Document + } +} + #[derive(Default, Debug, Deserialize, Serialize)] pub struct QueryWorkspaceParam { pub include_member_count: Option, @@ -173,3 +179,14 @@ pub struct QueryWorkspaceParam { pub struct QueryWorkspaceFolder { pub depth: Option, } + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct PublishedView { + pub view_id: String, + pub name: String, + pub icon: Option, + pub layout: ViewLayout, + /// contains fields like `is_space`, and font information + pub extra: Option, + pub children: Vec, +} diff --git a/src/api/workspace.rs b/src/api/workspace.rs index 62cd5af8..8eaf98a1 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -179,6 +179,10 @@ pub fn workspace_scope() -> Scope { web::resource("/{workspace_id}/folder") .route(web::get().to(get_workspace_folder_handler)) ) + .service( + web::resource("/published-outline/{publish_namespace}") + .route(web::get().to(get_workspace_publish_outline_handler)) + ) .service( web::resource("/{workspace_id}/collab/{object_id}/member/list") .route(web::get().to(get_collab_member_list_handler)), @@ -1394,6 +1398,19 @@ async fn get_workspace_folder_handler( Ok(Json(AppResponse::Ok().with_data(folder_view))) } +async fn get_workspace_publish_outline_handler( + publish_namespace: web::Path, + state: Data, +) -> Result>> { + let published_view = biz::collab::ops::get_published_view( + state.collab_access_control_storage.clone(), + publish_namespace.into_inner(), + &state.pg_pool, + ) + .await?; + Ok(Json(AppResponse::Ok().with_data(published_view))) +} + #[inline] async fn parser_realtime_msg( payload: Bytes, diff --git a/src/biz/collab/folder_view.rs b/src/biz/collab/folder_view.rs index dab741d3..a4c01a27 100644 --- a/src/biz/collab/folder_view.rs +++ b/src/biz/collab/folder_view.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; -use collab_folder::Folder; -use shared_entity::dto::workspace_dto::FolderView; +use collab_folder::{Folder, ViewLayout as CollabFolderViewLayout}; +use shared_entity::dto::workspace_dto::{FolderView, ViewLayout}; use uuid::Uuid; pub fn collab_folder_to_folder_view(folder: &Folder, depth: u32) -> FolderView { @@ -126,7 +126,7 @@ impl<'a> From> for FolderView { } } -fn view_is_space(view: &collab_folder::View) -> bool { +pub fn view_is_space(view: &collab_folder::View) -> bool { let extra = match view.extra.as_ref() { Some(extra) => extra, None => return false, @@ -144,14 +144,16 @@ fn view_is_space(view: &collab_folder::View) -> bool { } } -fn to_dto_view_icon(icon: collab_folder::ViewIcon) -> shared_entity::dto::workspace_dto::ViewIcon { +pub fn to_dto_view_icon( + icon: collab_folder::ViewIcon, +) -> shared_entity::dto::workspace_dto::ViewIcon { shared_entity::dto::workspace_dto::ViewIcon { ty: to_dto_view_icon_type(icon.ty), value: icon.value, } } -fn to_dto_view_icon_type( +pub fn to_dto_view_icon_type( icon: collab_folder::IconType, ) -> shared_entity::dto::workspace_dto::IconType { match icon { @@ -160,3 +162,13 @@ fn to_dto_view_icon_type( collab_folder::IconType::Icon => shared_entity::dto::workspace_dto::IconType::Icon, } } + +pub fn to_view_layout(collab_folder_view_layout: &CollabFolderViewLayout) -> ViewLayout { + match collab_folder_view_layout { + CollabFolderViewLayout::Document => ViewLayout::Document, + CollabFolderViewLayout::Grid => ViewLayout::Grid, + CollabFolderViewLayout::Board => ViewLayout::Board, + CollabFolderViewLayout::Calendar => ViewLayout::Calendar, + CollabFolderViewLayout::Chat => ViewLayout::Chat, + } +} diff --git a/src/biz/collab/mod.rs b/src/biz/collab/mod.rs index 1795e8b2..aac6cab4 100644 --- a/src/biz/collab/mod.rs +++ b/src/biz/collab/mod.rs @@ -1,3 +1,4 @@ pub mod access_control; pub mod folder_view; pub mod ops; +pub mod publish_outline; diff --git a/src/biz/collab/ops.rs b/src/biz/collab/ops.rs index 1520a0c9..8e50c1c0 100644 --- a/src/biz/collab/ops.rs +++ b/src/biz/collab/ops.rs @@ -6,14 +6,17 @@ use collab_entity::CollabType; use collab_entity::EncodedCollab; use collab_folder::{CollabOrigin, Folder}; use database::collab::{CollabStorage, GetCollabOrigin}; -use database_entity::dto::QueryCollab; -use database_entity::dto::QueryCollabParams; +use database::publish::select_published_view_ids_for_workspace; +use database::publish::select_workspace_id_for_publish_namespace; +use database_entity::dto::{QueryCollab, QueryCollabParams}; use sqlx::PgPool; use std::ops::DerefMut; use anyhow::Context; -use shared_entity::dto::workspace_dto::FolderView; +use shared_entity::dto::workspace_dto::{FolderView, PublishedView}; use sqlx::types::Uuid; +use std::collections::HashSet; + use tracing::{event, trace}; use validator::Validate; @@ -24,6 +27,7 @@ use database_entity::dto::{ }; use super::folder_view::collab_folder_to_folder_view; +use super::publish_outline::collab_folder_to_published_outline; /// Create a new collab member /// If the collab member already exists, return [AppError::RecordAlreadyExists] @@ -215,3 +219,35 @@ pub async fn get_latest_collab_encoded( ) .await } + +pub async fn get_published_view( + collab_storage: Arc, + publish_namespace: String, + pg_pool: &PgPool, +) -> Result { + let workspace_id = select_workspace_id_for_publish_namespace(pg_pool, &publish_namespace).await?; + let query_collab_params = QueryCollabParams::new( + workspace_id, + collab_entity::CollabType::Folder, + workspace_id, + ); + let encoded_collab = collab_storage + .get_encode_collab(GetCollabOrigin::Server, query_collab_params, true) + .await?; + let folder = Folder::from_collab_doc_state( + 0, + CollabOrigin::Server, + encoded_collab.into(), + &workspace_id.to_string(), + vec![], + ) + .map_err(|e| AppError::Unhandled(e.to_string()))?; + let publish_view_ids = select_published_view_ids_for_workspace(pg_pool, workspace_id).await?; + let publish_view_ids: HashSet = publish_view_ids + .into_iter() + .map(|id| id.to_string()) + .collect(); + let published_view: PublishedView = + collab_folder_to_published_outline(&folder, &publish_view_ids)?; + Ok(published_view) +} diff --git a/src/biz/collab/publish_outline.rs b/src/biz/collab/publish_outline.rs new file mode 100644 index 00000000..2cfceca9 --- /dev/null +++ b/src/biz/collab/publish_outline.rs @@ -0,0 +1,98 @@ +use std::collections::HashSet; + +use app_error::AppError; +use collab_folder::Folder; +use shared_entity::dto::workspace_dto::PublishedView; + +use super::folder_view::{to_dto_view_icon, to_view_layout, view_is_space}; + +pub fn collab_folder_to_published_outline( + folder: &Folder, + publish_view_ids: &HashSet, +) -> Result { + let mut unviewable = HashSet::new(); + for private_section in folder.get_all_private_sections() { + unviewable.insert(private_section.id); + } + for trash_view in folder.get_all_trash_sections() { + unviewable.insert(trash_view.id); + } + + let workspace_id = folder + .get_workspace_id() + .ok_or_else(|| AppError::InvalidPublishedOutline("failed to get workspace_id".to_string()))?; + let root = match folder.get_view(&workspace_id) { + Some(root) => root, + None => { + return Err(AppError::InvalidPublishedOutline( + "failed to get root view".to_string(), + )); + }, + }; + + let extra = root.extra.as_deref().map(|extra| { + serde_json::from_str::(extra).unwrap_or_else(|e| { + tracing::warn!("failed to parse extra field({}): {}", extra, e); + serde_json::Value::Null + }) + }); + + let published_view = PublishedView { + view_id: root.id.clone(), + name: root.name.clone(), + icon: root + .icon + .as_ref() + .map(|icon| to_dto_view_icon(icon.clone())), + layout: to_view_layout(&root.layout), + extra, + children: root + .children + .iter() + .filter(|v| !unviewable.contains(&v.id)) + .filter_map(|v| to_publish_view(&v.id, folder, &unviewable, publish_view_ids)) + .collect(), + }; + Ok(published_view) +} + +fn to_publish_view( + view_id: &str, + folder: &Folder, + unviewable: &HashSet, + publish_view_ids: &HashSet, +) -> Option { + let view = match folder.get_view(view_id) { + Some(view) => view, + None => { + return None; + }, + }; + let extra = view.extra.as_deref().map(|extra| { + serde_json::from_str::(extra).unwrap_or_else(|e| { + tracing::warn!("failed to parse extra field({}): {}", extra, e); + serde_json::Value::Null + }) + }); + let pruned_view: Vec = view + .children + .iter() + .filter(|v| !unviewable.contains(&v.id)) + .filter_map(|view_id| to_publish_view(&view_id.id, folder, unviewable, publish_view_ids)) + .collect(); + if view_is_space(&view) || publish_view_ids.contains(view_id) || !pruned_view.is_empty() { + Some(PublishedView { + view_id: view.id.clone(), + name: view.name.clone(), + icon: view + .icon + .as_ref() + .map(|icon| to_dto_view_icon(icon.clone())), + layout: to_view_layout(&view.layout), + extra, + children: pruned_view, + }) + } else { + None + } +}