From 6eea93d774a45789592f49a3df2fe46e70176cf5 Mon Sep 17 00:00:00 2001 From: Khor Shu Heng Date: Tue, 23 Jul 2024 16:02:52 +0800 Subject: [PATCH 1/4] feat: add create, retrieve, delete API for publish view global comment --- ...a532d2b2576f5f4066d08455634b05cf380c7.json | 23 ++++++ libs/client-api/src/http_publish.rs | 60 +++++++++++++- libs/database-entity/src/dto.rs | 25 ++++++ libs/database/src/workspace.rs | 23 ++++++ src/api/workspace.rs | 49 ++++++++++++ src/biz/workspace/ops.rs | 52 ++++++++++-- tests/workspace/publish.rs | 79 ++++++++++++++++++- 7 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 .sqlx/query-33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7.json 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; From f535950643d2749277730d203a9f024551c8c9b1 Mon Sep 17 00:00:00 2001 From: Khor Shu Heng Date: Wed, 24 Jul 2024 17:17:37 +0800 Subject: [PATCH 2/4] feat: add database operations for publish comment crd --- ...73e37d1d55572f936988528178bfa10158e5.json} | 5 +- ...94bbfdbe0d5f11a5e2ffc8c1973217b80307b.json | 17 ++ ...2cadaec9513b76f79965d591cf5bf5cc68707.json | 64 ++++++ ...69448384fda4350aa630becebd0e5add632f4.json | 14 ++ libs/client-api/src/http_publish.rs | 3 +- libs/database-entity/src/dto.rs | 9 +- libs/database/src/workspace.rs | 120 +++++++++++- .../20240723090305_publish_view_comment.sql | 20 ++ src/api/workspace.rs | 6 +- src/biz/workspace/ops.rs | 56 +++--- tests/workspace/publish.rs | 183 ++++++++++++++---- 11 files changed, 429 insertions(+), 68 deletions(-) rename .sqlx/{query-33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7.json => query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json} (52%) create mode 100644 .sqlx/query-9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b.json create mode 100644 .sqlx/query-c2e4e6e5db677977c00654223532cadaec9513b76f79965d591cf5bf5cc68707.json create mode 100644 .sqlx/query-e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4.json create mode 100644 migrations/20240723090305_publish_view_comment.sql diff --git a/.sqlx/query-33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7.json b/.sqlx/query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json similarity index 52% rename from .sqlx/query-33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7.json rename to .sqlx/query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json index d734efbf..f624300f 100644 --- a/.sqlx/query-33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7.json +++ b/.sqlx/query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json @@ -1,6 +1,6 @@ { "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 ", + "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 UNION ALL\n SELECT true\n FROM af_published_view_comment\n WHERE view_id = $1\n AND comment_id = $3\n AND created_by = (SELECT uid FROM af_user WHERE uuid = $2)\n ) AS \"exists\";\n ", "describe": { "columns": [ { @@ -11,6 +11,7 @@ ], "parameters": { "Left": [ + "Uuid", "Uuid", "Uuid" ] @@ -19,5 +20,5 @@ null ] }, - "hash": "33ca377a92ff965e348f23fae86a532d2b2576f5f4066d08455634b05cf380c7" + "hash": "30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5" } diff --git a/.sqlx/query-9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b.json b/.sqlx/query-9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b.json new file mode 100644 index 00000000..6548f875 --- /dev/null +++ b/.sqlx/query-9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO af_published_view_comment (view_id, created_by, content, reply_comment_id)\n VALUES ($1, (SELECT uid FROM af_user WHERE uuid = $2), $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b" +} diff --git a/.sqlx/query-c2e4e6e5db677977c00654223532cadaec9513b76f79965d591cf5bf5cc68707.json b/.sqlx/query-c2e4e6e5db677977c00654223532cadaec9513b76f79965d591cf5bf5cc68707.json new file mode 100644 index 00000000..a49cfc46 --- /dev/null +++ b/.sqlx/query-c2e4e6e5db677977c00654223532cadaec9513b76f79965d591cf5bf5cc68707.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n avc.comment_id,\n avc.created_at,\n avc.updated_at,\n avc.content,\n avc.reply_comment_id,\n avc.is_deleted,\n au.uuid AS \"user_uuid?\",\n au.name AS \"user_name?\"\n FROM af_published_view_comment avc\n LEFT OUTER JOIN af_user au ON avc.created_by = au.uid\n WHERE view_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "comment_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "content", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "reply_comment_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "is_deleted", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "user_uuid?", + "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "user_name?", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "c2e4e6e5db677977c00654223532cadaec9513b76f79965d591cf5bf5cc68707" +} diff --git a/.sqlx/query-e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4.json b/.sqlx/query-e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4.json new file mode 100644 index 00000000..26a0ca24 --- /dev/null +++ b/.sqlx/query-e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE af_published_view_comment\n SET is_deleted = true\n WHERE comment_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4" +} diff --git a/libs/client-api/src/http_publish.rs b/libs/client-api/src/http_publish.rs index 8fbbf81d..ded39be7 100644 --- a/libs/client-api/src/http_publish.rs +++ b/libs/client-api/src/http_publish.rs @@ -70,6 +70,7 @@ impl Client { &self, view_id: &uuid::Uuid, comment_content: &str, + reply_comment_id: &Option, ) -> Result<(), AppResponseError> { let url = format!( "{}/api/workspace/published-info/{}/comment", @@ -80,7 +81,7 @@ impl Client { .await? .json(&CreateGlobalCommentParams { content: comment_content.to_string(), - reply_comment_id: None, + reply_comment_id: *reply_comment_id, }) .send() .await?; diff --git a/libs/database-entity/src/dto.rs b/libs/database-entity/src/dto.rs index ce262280..1d8786e7 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -849,9 +849,16 @@ pub struct PublishCollabItem { #[derive(Serialize, Deserialize, Debug)] pub struct GlobalComments(pub Vec); +#[derive(Serialize, Deserialize, Debug)] +pub struct GlobalCommentCreator { + pub uid: Uuid, + pub name: String, + pub avatar_url: Option, +} + #[derive(Serialize, Deserialize, Debug)] pub struct GlobalComment { - pub user: Option, + pub user: Option, pub created_at: DateTime, pub last_updated_at: DateTime, pub content: String, diff --git a/libs/database/src/workspace.rs b/libs/database/src/workspace.rs index f982d077..f03d7276 100644 --- a/libs/database/src/workspace.rs +++ b/libs/database/src/workspace.rs @@ -1,6 +1,6 @@ use database_entity::dto::{ - AFRole, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceSettings, - PublishCollabItem, PublishInfo, + AFRole, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceSettings, GlobalComment, + GlobalCommentCreator, PublishCollabItem, PublishInfo, }; use futures_util::stream::BoxStream; use sqlx::{types::uuid, Executor, PgPool, Postgres, Transaction}; @@ -171,10 +171,11 @@ pub async fn select_user_is_collab_publisher_for_all_views( } } -pub async fn select_user_is_collab_publisher_for_view( +pub async fn select_user_is_allowed_to_delete_comment( pg_pool: &PgPool, user_uuid: &Uuid, view_id: &Uuid, + comment_id: &Uuid, ) -> Result { let is_publisher_for_view = sqlx::query_scalar!( r#" @@ -183,10 +184,17 @@ pub async fn select_user_is_collab_publisher_for_view( FROM af_published_collab WHERE view_id = $1 AND published_by = (SELECT uid FROM af_user WHERE uuid = $2) + UNION ALL + SELECT true + FROM af_published_view_comment + WHERE view_id = $1 + AND comment_id = $3 + AND created_by = (SELECT uid FROM af_user WHERE uuid = $2) ) AS "exists"; "#, view_id, user_uuid, + comment_id, ) .fetch_one(pg_pool) .await?; @@ -1076,3 +1084,109 @@ pub async fn select_published_collab_info<'a, E: Executor<'a, Database = Postgre Ok(res) } + +pub async fn select_comments_for_published_view<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + view_id: &Uuid, +) -> Result, AppError> { + let rows = sqlx::query!( + r#" + SELECT + avc.comment_id, + avc.created_at, + avc.updated_at, + avc.content, + avc.reply_comment_id, + avc.is_deleted, + au.uuid AS "user_uuid?", + au.name AS "user_name?" + FROM af_published_view_comment avc + LEFT OUTER JOIN af_user au ON avc.created_by = au.uid + WHERE view_id = $1 + "#, + view_id, + ) + .fetch_all(executor) + .await?; + let result = rows + .iter() + .map(|row| { + let comment_creator = row.user_uuid.map(|uuid| GlobalCommentCreator { + uid: uuid, + name: row + .user_name + .as_ref() + .map(|s| s.to_string()) + .unwrap_or("".to_string()), + avatar_url: None, + }); + GlobalComment { + user: comment_creator, + comment_id: row.comment_id, + created_at: row.created_at, + last_updated_at: row.updated_at, + content: row.content.clone(), + reply_comment_id: row.reply_comment_id, + is_deleted: row.is_deleted, + } + }) + .collect(); + + Ok(result) +} + +pub async fn insert_comment_to_published_view<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + view_id: &Uuid, + user_uuid: &Uuid, + content: &str, + reply_comment_id: &Option, +) -> Result<(), AppError> { + let res = sqlx::query!( + r#" + INSERT INTO af_published_view_comment (view_id, created_by, content, reply_comment_id) + VALUES ($1, (SELECT uid FROM af_user WHERE uuid = $2), $3, $4) + "#, + view_id, + user_uuid, + content, + reply_comment_id.clone(), + ) + .execute(executor) + .await?; + + if res.rows_affected() != 1 { + tracing::error!( + "Failed to insert comment to published view, view_id: {}, user_id: {}, content: {}, rows_affected: {}", + view_id, user_uuid, content, res.rows_affected() + ); + } + + Ok(()) +} + +pub async fn update_comment_deletion_status<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + comment_id: &Uuid, +) -> Result<(), AppError> { + let res = sqlx::query!( + r#" + UPDATE af_published_view_comment + SET is_deleted = true + WHERE comment_id = $1 + "#, + comment_id, + ) + .execute(executor) + .await?; + + if res.rows_affected() != 1 { + tracing::error!( + "Failed to update deletion status for comment, comment_id: {}, rows_affected: {}", + comment_id, + res.rows_affected() + ); + } + + Ok(()) +} diff --git a/migrations/20240723090305_publish_view_comment.sql b/migrations/20240723090305_publish_view_comment.sql new file mode 100644 index 00000000..4383d4fb --- /dev/null +++ b/migrations/20240723090305_publish_view_comment.sql @@ -0,0 +1,20 @@ +-- stores the comments on a published view +CREATE TABLE IF NOT EXISTS af_published_view_comment ( + comment_id UUID NOT NULL DEFAULT gen_random_uuid(), + -- comments are never deleted, only marked as deleted, unless we intentionally wants to clean + -- the tables by removing the comments from the database + reply_comment_id UUID REFERENCES af_published_view_comment(comment_id) ON DELETE CASCADE, + -- The view id should exists on af_published_collab, However, we can't enforce this foreign key + -- constraint because af_published_collab primary key is (workspace_id, view_id). + -- We also have the requirement to keep the comments even if the view is unpublished. + view_id UUID NOT NULL, + content TEXT NOT NULL, + -- preserve comment when user is removed + created_by BIGINT NOT NULL REFERENCES af_user(uid) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + + PRIMARY KEY (comment_id) +); +CREATE INDEX IF NOT EXISTS idx_view_id_on_af_published_view_comment ON af_published_view_comment(view_id); diff --git a/src/api/workspace.rs b/src/api/workspace.rs index b8461cac..b70a2eef 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -1,7 +1,7 @@ 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, + create_comment_on_published_view, get_comments_on_published_view, + remove_comment_on_published_view, }; use actix_web::web::{Bytes, Payload}; use actix_web::web::{Data, Json, PayloadConfig}; @@ -1132,7 +1132,7 @@ async fn delete_published_collab_comment_handler( 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?; + remove_comment_on_published_view(&state.pg_pool, &view_id, &data.comment_id, &user_uuid).await?; Ok(Json(AppResponse::Ok())) } diff --git a/src/biz/workspace/ops.rs b/src/biz/workspace/ops.rs index 9f9685bf..54ec397c 100644 --- a/src/biz/workspace/ops.rs +++ b/src/biz/workspace/ops.rs @@ -20,16 +20,17 @@ use database::pg_row::{AFWorkspaceMemberRow, AFWorkspaceRow}; use database::user::select_uid_from_email; use database::workspace::{ change_workspace_icon, delete_from_workspace, delete_published_collabs, delete_workspace_members, - 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_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, + get_invitation_by_id, insert_comment_to_published_view, insert_or_replace_publish_collab_metas, + insert_user_workspace, insert_workspace_invitation, rename_workspace, select_all_user_workspaces, + select_comments_for_published_view, select_publish_collab_meta, select_published_collab_blob, + select_published_collab_info, select_user_is_allowed_to_delete_comment, + 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_comment_deletion_status, + 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, @@ -175,29 +176,32 @@ pub async fn get_published_collab_info( } pub async fn get_comments_on_published_view( - _pg_pool: &PgPool, - _view_id: &Uuid, + pg_pool: &PgPool, + view_id: &Uuid, ) -> Result, AppError> { - Ok(vec![]) + let comments = select_comments_for_published_view(pg_pool, view_id).await?; + Ok(comments) } pub async fn create_comment_on_published_view( - _pg_pool: &PgPool, - _view_id: &Uuid, - _replay_comment_id: &Option, - _content: &str, - _user_uuid: &Uuid, + pg_pool: &PgPool, + view_id: &Uuid, + reply_comment_id: &Option, + content: &str, + user_uuid: &Uuid, ) -> Result<(), AppError> { + insert_comment_to_published_view(pg_pool, view_id, user_uuid, content, reply_comment_id).await?; Ok(()) } -pub async fn delete_comment_on_published_view( +pub async fn remove_comment_on_published_view( pg_pool: &PgPool, view_id: &Uuid, - _comment_id: &Uuid, + comment_id: &Uuid, user_uuid: &Uuid, ) -> Result<(), AppError> { - check_if_user_is_publisher(pg_pool, user_uuid, view_id).await?; + check_if_user_is_allowed_to_delete_comment(pg_pool, user_uuid, view_id, comment_id).await?; + update_comment_deletion_status(pg_pool, comment_id).await?; Ok(()) } @@ -629,15 +633,17 @@ async fn check_workspace_owner_or_publisher( Ok(()) } -async fn check_if_user_is_publisher( +async fn check_if_user_is_allowed_to_delete_comment( pg_pool: &PgPool, user_uuid: &Uuid, view_id: &Uuid, + comment_id: &Uuid, ) -> Result<(), AppError> { - let is_publisher = select_user_is_collab_publisher_for_view(pg_pool, user_uuid, view_id).await?; - if !is_publisher { + let is_allowed = + select_user_is_allowed_to_delete_comment(pg_pool, user_uuid, view_id, comment_id).await?; + if !is_allowed { return Err(AppError::UserUnAuthorized( - "User is not the publisher of the document".to_string(), + "User is not allowed to delete this comment".to_string(), )); } Ok(()) diff --git a/tests/workspace/publish.rs b/tests/workspace/publish.rs index 4f09e107..e6d6c3b4 100644 --- a/tests/workspace/publish.rs +++ b/tests/workspace/publish.rs @@ -1,8 +1,11 @@ +use std::thread::sleep; +use std::time::Duration; + 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; +use itertools::Itertools; #[tokio::test] async fn test_set_publish_namespace_set() { @@ -215,7 +218,7 @@ async fn test_publish_doc() { #[tokio::test] async fn test_publish_comments() { - let (page_owner_client, _) = generate_unique_registered_user_client().await; + let (page_owner_client, page_owner) = 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 @@ -241,51 +244,165 @@ async fn test_publish_comments() { ) .await .unwrap(); - // TODO: replace the placeholder with actual comment id once the API implementation is completed - let place_holder_comment_id = Uuid::new_v4(); + + // Test if only authenticated users can create + let page_owner_comment_content = "comment from page owner"; + page_owner_client + .create_comment_on_published_view(&view_id, page_owner_comment_content, &None) + .await + .unwrap(); + let (first_user_client, first_user) = generate_unique_registered_user_client().await; + let first_user_comment_content = "comment from first authenticated user"; + // This is to ensure that the second comment creation timestamp is later than the first one + sleep(Duration::from_millis(1)); + first_user_client + .create_comment_on_published_view(&view_id, first_user_comment_content, &None) + .await + .unwrap(); + let guest_client = localhost_client(); + let result = guest_client + .create_comment_on_published_view(&view_id, "comment from anonymous", &None) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn); + + // Test if only all users, authenticated or not, can view all the comments 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") + assert_eq!(published_view_comments.len(), 2); + let published_view_comments: Vec = first_user_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .0; + assert_eq!(published_view_comments.len(), 2); + let mut published_view_comments: Vec = guest_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .0; + assert_eq!(published_view_comments.len(), 2); + assert!(published_view_comments.iter().all(|c| !c.is_deleted)); + + // Test if the comments have the correct content when sorted by creation time + published_view_comments.sort_by_key(|c| c.created_at); + let comment_creators = published_view_comments + .iter() + .map(|c| { + c.user + .as_ref() + .map(|u| u.name.clone()) + .unwrap_or("".to_string()) + }) + .collect_vec(); + assert_eq!( + comment_creators, + vec![page_owner.email.clone(), first_user.email.clone()] + ); + let comment_content = published_view_comments + .iter() + .map(|c| c.content.clone()) + .collect_vec(); + assert_eq!( + comment_content, + vec![page_owner_comment_content, first_user_comment_content] + ); + + // Test if it's possible to reply to another user's comment + let second_user_comment_content = "comment from second authenticated user"; + let (second_user_client, second_user) = generate_unique_registered_user_client().await; + // User 2 reply to user 1 + second_user_client + .create_comment_on_published_view( + &view_id, + second_user_comment_content, + &Some(published_view_comments[1].comment_id), + ) .await .unwrap(); - page_owner_client - .delete_comment_on_published_view(&view_id, &place_holder_comment_id) + let mut published_view_comments: Vec = guest_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .0; + published_view_comments.sort_by_key(|c| c.created_at); + let comment_creators = published_view_comments + .iter() + .map(|c| { + c.user + .as_ref() + .map(|u| u.name.clone()) + .unwrap_or("".to_string()) + }) + .collect_vec(); + assert_eq!( + comment_creators, + vec![ + page_owner.email.clone(), + first_user.email.clone(), + second_user.email.clone() + ] + ); + assert_eq!( + published_view_comments[2].reply_comment_id, + Some(published_view_comments[1].comment_id) + ); + + // Test if only the page owner or the comment creator can delete a comment + // User 1 attempt to delete page owner's comment + let result = first_user_client + .delete_comment_on_published_view(&view_id, &published_view_comments[0].comment_id) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::UserUnAuthorized); + // User 1 deletes own comment + first_user_client + .delete_comment_on_published_view(&view_id, &published_view_comments[1].comment_id) .await .unwrap(); - let guest_client = localhost_client(); + // Guest client attempt to delete user 2's comment + let result = guest_client + .delete_comment_on_published_view(&view_id, &published_view_comments[2].comment_id) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn); + // Verify that the comments are not deleted from the database, only the is_deleted status changes. + let mut published_view_comments: Vec = guest_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .0; + published_view_comments.sort_by_key(|c| c.created_at); + assert_eq!( + published_view_comments + .iter() + .map(|c| c.is_deleted) + .collect_vec(), + vec![false, true, false] + ); + // Verify that the reference id is still preserved + assert_eq!( + published_view_comments[2].reply_comment_id, + Some(published_view_comments[1].comment_id) + ); + + for comment in &published_view_comments { + page_owner_client + .delete_comment_on_published_view(&view_id, &comment.comment_id) + .await + .unwrap(); + } + 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) + assert_eq!(published_view_comments.len(), 3); + assert!(published_view_comments.iter().all(|c| c.is_deleted)); } #[tokio::test] From f71ac07ae76716be553a75b3e81fea3be0cbf5f6 Mon Sep 17 00:00:00 2001 From: Khor Shu Heng Date: Thu, 25 Jul 2024 14:39:02 +0800 Subject: [PATCH 3/4] chore: rename GlobalCommentCreator to AFWebUser to allow potential reuse --- libs/database-entity/src/dto.rs | 4 ++-- libs/database/src/workspace.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/database-entity/src/dto.rs b/libs/database-entity/src/dto.rs index 1d8786e7..42aa42a8 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -850,7 +850,7 @@ pub struct PublishCollabItem { pub struct GlobalComments(pub Vec); #[derive(Serialize, Deserialize, Debug)] -pub struct GlobalCommentCreator { +pub struct AFWebUser { pub uid: Uuid, pub name: String, pub avatar_url: Option, @@ -858,7 +858,7 @@ pub struct GlobalCommentCreator { #[derive(Serialize, Deserialize, Debug)] pub struct GlobalComment { - pub user: Option, + pub user: Option, pub created_at: DateTime, pub last_updated_at: DateTime, pub content: String, diff --git a/libs/database/src/workspace.rs b/libs/database/src/workspace.rs index f03d7276..5c5be795 100644 --- a/libs/database/src/workspace.rs +++ b/libs/database/src/workspace.rs @@ -1,6 +1,6 @@ use database_entity::dto::{ - AFRole, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceSettings, GlobalComment, - GlobalCommentCreator, PublishCollabItem, PublishInfo, + AFRole, AFWebUser, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceSettings, + GlobalComment, PublishCollabItem, PublishInfo, }; use futures_util::stream::BoxStream; use sqlx::{types::uuid, Executor, PgPool, Postgres, Transaction}; @@ -1111,7 +1111,7 @@ pub async fn select_comments_for_published_view<'a, E: Executor<'a, Database = P let result = rows .iter() .map(|row| { - let comment_creator = row.user_uuid.map(|uuid| GlobalCommentCreator { + let comment_creator = row.user_uuid.map(|uuid| AFWebUser { uid: uuid, name: row .user_name From 69a6ac48c828a23154c2975f450589352fceedf9 Mon Sep 17 00:00:00 2001 From: Khor Shu Heng Date: Thu, 25 Jul 2024 16:09:26 +0800 Subject: [PATCH 4/4] chore: use struct instead of tuple struct for GlobalComments --- libs/database-entity/src/dto.rs | 4 +++- src/api/workspace.rs | 2 +- tests/workspace/publish.rs | 12 ++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/libs/database-entity/src/dto.rs b/libs/database-entity/src/dto.rs index 42aa42a8..f3363d13 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -847,7 +847,9 @@ pub struct PublishCollabItem { } #[derive(Serialize, Deserialize, Debug)] -pub struct GlobalComments(pub Vec); +pub struct GlobalComments { + pub comments: Vec, +} #[derive(Serialize, Deserialize, Debug)] pub struct AFWebUser { diff --git a/src/api/workspace.rs b/src/api/workspace.rs index b70a2eef..91a3fcab 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -1103,7 +1103,7 @@ async fn get_published_collab_comment_handler( ) -> 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); + let resp = GlobalComments { comments }; Ok(Json(AppResponse::Ok().with_data(resp))) } diff --git a/tests/workspace/publish.rs b/tests/workspace/publish.rs index e6d6c3b4..96617181 100644 --- a/tests/workspace/publish.rs +++ b/tests/workspace/publish.rs @@ -271,19 +271,19 @@ async fn test_publish_comments() { .get_published_view_comments(&view_id) .await .unwrap() - .0; + .comments; assert_eq!(published_view_comments.len(), 2); let published_view_comments: Vec = first_user_client .get_published_view_comments(&view_id) .await .unwrap() - .0; + .comments; assert_eq!(published_view_comments.len(), 2); let mut published_view_comments: Vec = guest_client .get_published_view_comments(&view_id) .await .unwrap() - .0; + .comments; assert_eq!(published_view_comments.len(), 2); assert!(published_view_comments.iter().all(|c| !c.is_deleted)); @@ -327,7 +327,7 @@ async fn test_publish_comments() { .get_published_view_comments(&view_id) .await .unwrap() - .0; + .comments; published_view_comments.sort_by_key(|c| c.created_at); let comment_creators = published_view_comments .iter() @@ -374,7 +374,7 @@ async fn test_publish_comments() { .get_published_view_comments(&view_id) .await .unwrap() - .0; + .comments; published_view_comments.sort_by_key(|c| c.created_at); assert_eq!( published_view_comments @@ -400,7 +400,7 @@ async fn test_publish_comments() { .get_published_view_comments(&view_id) .await .unwrap() - .0; + .comments; assert_eq!(published_view_comments.len(), 3); assert!(published_view_comments.iter().all(|c| c.is_deleted)); }