diff --git a/.sqlx/query-33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7.json b/.sqlx/query-33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7.json new file mode 100644 index 00000000..d734efbf --- /dev/null +++ b/.sqlx/query-33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT true\n FROM af_published_collab\n WHERE view_id = $1\n AND published_by = (SELECT uid FROM af_user WHERE uuid = $2)\n ) AS \"exists\";\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7" +} diff --git a/libs/client-api/src/http_publish.rs b/libs/client-api/src/http_publish.rs index f2402a36..8fbbf81d 100644 --- a/libs/client-api/src/http_publish.rs +++ b/libs/client-api/src/http_publish.rs @@ -1,5 +1,8 @@ use bytes::Bytes; -use client_api_entity::{PublishInfo, UpdatePublishNamespace}; +use client_api_entity::{ + CreateGlobalCommentParams, DeleteGlobalCommentParams, GlobalComments, PublishInfo, + UpdatePublishNamespace, +}; use reqwest::Method; use shared_entity::response::{AppResponse, AppResponseError}; use tracing::instrument; @@ -62,6 +65,47 @@ impl Client { .await?; AppResponse::<()>::from_response(resp).await?.into_error() } + + pub async fn create_comment_on_published_view( + &self, + view_id: &uuid::Uuid, + comment_content: &str, + ) -> Result<(), AppResponseError> { + let url = format!( + "{}/api/workspace/published-info/{}/comment", + self.base_url, view_id + ); + let resp = self + .http_client_with_auth(Method::POST, &url) + .await? + .json(&CreateGlobalCommentParams { + content: comment_content.to_string(), + reply_comment_id: None, + }) + .send() + .await?; + AppResponse::<()>::from_response(resp).await?.into_error() + } + + pub async fn delete_comment_on_published_view( + &self, + view_id: &uuid::Uuid, + comment_id: &uuid::Uuid, + ) -> Result<(), AppResponseError> { + let url = format!( + "{}/api/workspace/published-info/{}/comment", + self.base_url, view_id + ); + let resp = self + .http_client_with_auth(Method::DELETE, &url) + .await? + .json(&DeleteGlobalCommentParams { + comment_id: *comment_id, + }) + .send() + .await?; + AppResponse::<()>::from_response(resp).await?.into_error() + } } // Guest API (no login required) @@ -146,4 +190,18 @@ impl Client { Ok(bytes) } + + pub async fn get_published_view_comments( + &self, + view_id: &uuid::Uuid, + ) -> Result { + let url = format!( + "{}/api/workspace/published-info/{}/comment", + self.base_url, view_id + ); + let resp = self.cloud_client.get(&url).send().await?; + AppResponse::::from_response(resp) + .await? + .into_data() + } } diff --git a/libs/database-entity/src/dto.rs b/libs/database-entity/src/dto.rs index 80da8cc4..ce262280 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -846,6 +846,31 @@ pub struct PublishCollabItem { pub data: Data, } +#[derive(Serialize, Deserialize, Debug)] +pub struct GlobalComments(pub Vec); + +#[derive(Serialize, Deserialize, Debug)] +pub struct GlobalComment { + pub user: Option, + pub created_at: DateTime, + pub last_updated_at: DateTime, + pub content: String, + pub reply_comment_id: Option, + pub comment_id: Uuid, + pub is_deleted: bool, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateGlobalCommentParams { + pub content: String, + pub reply_comment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DeleteGlobalCommentParams { + pub comment_id: Uuid, +} + /// Indexing status of a document. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum IndexingStatus { diff --git a/libs/database/src/workspace.rs b/libs/database/src/workspace.rs index f654aa05..f982d077 100644 --- a/libs/database/src/workspace.rs +++ b/libs/database/src/workspace.rs @@ -171,6 +171,29 @@ pub async fn select_user_is_collab_publisher_for_all_views( } } +pub async fn select_user_is_collab_publisher_for_view( + pg_pool: &PgPool, + user_uuid: &Uuid, + view_id: &Uuid, +) -> Result { + let is_publisher_for_view = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT true + FROM af_published_collab + WHERE view_id = $1 + AND published_by = (SELECT uid FROM af_user WHERE uuid = $2) + ) AS "exists"; + "#, + view_id, + user_uuid, + ) + .fetch_one(pg_pool) + .await?; + + Ok(is_publisher_for_view.unwrap_or(false)) +} + #[inline] pub async fn select_user_role<'a, E: Executor<'a, Database = Postgres>>( exectuor: E, diff --git a/src/api/workspace.rs b/src/api/workspace.rs index f612bde8..b8461cac 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -1,4 +1,8 @@ use crate::api::util::PayloadReader; +use crate::biz::workspace::ops::{ + create_comment_on_published_view, delete_comment_on_published_view, + get_comments_on_published_view, +}; use actix_web::web::{Bytes, Payload}; use actix_web::web::{Data, Json, PayloadConfig}; use actix_web::{web, Scope}; @@ -143,6 +147,12 @@ pub fn workspace_scope() -> Scope { web::resource("/published-info/{view_id}") .route(web::get().to(get_published_collab_info_handler)) ) + .service( + web::resource("/published-info/{view_id}/comment") + .route(web::get().to(get_published_collab_comment_handler)) + .route(web::post().to(post_published_collab_comment_handler)) + .route(web::delete().to(delete_published_collab_comment_handler)) + ) .service( web::resource("/{workspace_id}/publish-namespace") .route(web::put().to(put_publish_namespace_handler)) @@ -1087,6 +1097,45 @@ async fn get_published_collab_info_handler( Ok(Json(AppResponse::Ok().with_data(collab_data))) } +async fn get_published_collab_comment_handler( + view_id: web::Path, + state: Data, +) -> Result> { + let view_id = view_id.into_inner(); + let comments = get_comments_on_published_view(&state.pg_pool, &view_id).await?; + let resp = GlobalComments(comments); + Ok(Json(AppResponse::Ok().with_data(resp))) +} + +async fn post_published_collab_comment_handler( + user_uuid: UserUuid, + view_id: web::Path, + state: Data, + data: Json, +) -> Result> { + let view_id = view_id.into_inner(); + create_comment_on_published_view( + &state.pg_pool, + &view_id, + &data.reply_comment_id, + &data.content, + &user_uuid, + ) + .await?; + Ok(Json(AppResponse::Ok())) +} + +async fn delete_published_collab_comment_handler( + user_uuid: UserUuid, + view_id: web::Path, + state: Data, + data: Json, +) -> Result> { + let view_id = view_id.into_inner(); + delete_comment_on_published_view(&state.pg_pool, &view_id, &data.comment_id, &user_uuid).await?; + Ok(Json(AppResponse::Ok())) +} + async fn post_publish_collabs_handler( workspace_id: web::Path, user_uuid: UserUuid, diff --git a/src/biz/workspace/ops.rs b/src/biz/workspace/ops.rs index 27bf708d..9f9685bf 100644 --- a/src/biz/workspace/ops.rs +++ b/src/biz/workspace/ops.rs @@ -23,16 +23,17 @@ use database::workspace::{ get_invitation_by_id, insert_or_replace_publish_collab_metas, insert_user_workspace, insert_workspace_invitation, rename_workspace, select_all_user_workspaces, select_publish_collab_meta, select_published_collab_blob, select_published_collab_info, - select_user_is_collab_publisher_for_all_views, select_user_is_workspace_owner, select_workspace, - select_workspace_invitations_for_user, select_workspace_member, select_workspace_member_list, - select_workspace_publish_namespace, select_workspace_publish_namespace_exists, - select_workspace_settings, select_workspace_total_collab_bytes, update_updated_at_of_workspace, + select_user_is_collab_publisher_for_all_views, select_user_is_collab_publisher_for_view, + select_user_is_workspace_owner, select_workspace, select_workspace_invitations_for_user, + select_workspace_member, select_workspace_member_list, select_workspace_publish_namespace, + select_workspace_publish_namespace_exists, select_workspace_settings, + select_workspace_total_collab_bytes, update_updated_at_of_workspace, update_workspace_invitation_set_status_accepted, update_workspace_publish_namespace, upsert_workspace_member, upsert_workspace_member_with_txn, upsert_workspace_settings, }; use database_entity::dto::{ AFAccessLevel, AFRole, AFWorkspace, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, - AFWorkspaceSettings, WorkspaceUsage, + AFWorkspaceSettings, GlobalComment, WorkspaceUsage, }; use gotrue::params::{GenerateLinkParams, GenerateLinkType}; use shared_entity::dto::workspace_dto::{ @@ -173,6 +174,33 @@ pub async fn get_published_collab_info( select_published_collab_info(pg_pool, view_id).await } +pub async fn get_comments_on_published_view( + _pg_pool: &PgPool, + _view_id: &Uuid, +) -> Result, AppError> { + Ok(vec![]) +} + +pub async fn create_comment_on_published_view( + _pg_pool: &PgPool, + _view_id: &Uuid, + _replay_comment_id: &Option, + _content: &str, + _user_uuid: &Uuid, +) -> Result<(), AppError> { + Ok(()) +} + +pub async fn delete_comment_on_published_view( + pg_pool: &PgPool, + view_id: &Uuid, + _comment_id: &Uuid, + user_uuid: &Uuid, +) -> Result<(), AppError> { + check_if_user_is_publisher(pg_pool, user_uuid, view_id).await?; + Ok(()) +} + pub async fn delete_published_workspace_collab( pg_pool: &PgPool, workspace_id: &Uuid, @@ -601,6 +629,20 @@ async fn check_workspace_owner_or_publisher( Ok(()) } +async fn check_if_user_is_publisher( + pg_pool: &PgPool, + user_uuid: &Uuid, + view_id: &Uuid, +) -> Result<(), AppError> { + let is_publisher = select_user_is_collab_publisher_for_view(pg_pool, user_uuid, view_id).await?; + if !is_publisher { + return Err(AppError::UserUnAuthorized( + "User is not the publisher of the document".to_string(), + )); + } + Ok(()) +} + fn check_collab_publish_name(publish_name: &str) -> Result<(), AppError> { // Check len if publish_name.len() > 128 { diff --git a/tests/workspace/publish.rs b/tests/workspace/publish.rs index bb15b636..4f09e107 100644 --- a/tests/workspace/publish.rs +++ b/tests/workspace/publish.rs @@ -1,6 +1,8 @@ -use client_api::entity::{AFRole, PublishCollabItem, PublishCollabMetadata}; +use app_error::ErrorCode; +use client_api::entity::{AFRole, GlobalComment, PublishCollabItem, PublishCollabMetadata}; use client_api_test::TestClient; use client_api_test::{generate_unique_registered_user_client, localhost_client}; +use uuid::Uuid; #[tokio::test] async fn test_set_publish_namespace_set() { @@ -211,6 +213,81 @@ async fn test_publish_doc() { } } +#[tokio::test] +async fn test_publish_comments() { + let (page_owner_client, _) = generate_unique_registered_user_client().await; + let workspace_id = get_first_workspace_string(&page_owner_client).await; + let published_view_namespace = uuid::Uuid::new_v4().to_string(); + page_owner_client + .set_workspace_publish_namespace(&workspace_id.to_string(), &published_view_namespace) + .await + .unwrap(); + + let publish_name = "published-view"; + let view_id = uuid::Uuid::new_v4(); + page_owner_client + .publish_collabs::( + &workspace_id, + vec![PublishCollabItem { + meta: PublishCollabMetadata { + view_id, + publish_name: publish_name.to_string(), + metadata: MyCustomMetadata { + title: "some_title".to_string(), + }, + }, + data: "yrs_encoded_data_1".as_bytes(), + }], + ) + .await + .unwrap(); + // TODO: replace the placeholder with actual comment id once the API implementation is completed + let place_holder_comment_id = Uuid::new_v4(); + let published_view_comments: Vec = page_owner_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .0; + assert_eq!(published_view_comments.len(), 0); + page_owner_client + .create_comment_on_published_view(&view_id, "comment from page owner") + .await + .unwrap(); + page_owner_client + .delete_comment_on_published_view(&view_id, &place_holder_comment_id) + .await + .unwrap(); + let guest_client = localhost_client(); + let published_view_comments: Vec = guest_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .0; + assert_eq!(published_view_comments.len(), 0); + let guest_client = localhost_client(); + let result = guest_client + .create_comment_on_published_view(&view_id, "comment from anonymous") + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn); + let result = guest_client + .delete_comment_on_published_view(&view_id, &place_holder_comment_id) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn); + + let (authenticated_user_client, _) = generate_unique_registered_user_client().await; + authenticated_user_client + .create_comment_on_published_view(&view_id, "comment from authenticated user") + .await + .unwrap(); + let result = authenticated_user_client + .delete_comment_on_published_view(&view_id, &place_holder_comment_id) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::UserUnAuthorized) +} + #[tokio::test] async fn test_publish_load_test() { let (c, _user) = generate_unique_registered_user_client().await;