diff --git a/.sqlx/query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json b/.sqlx/query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json new file mode 100644 index 00000000..f624300f --- /dev/null +++ b/.sqlx/query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json @@ -0,0 +1,24 @@ +{ + "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 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": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "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 f2402a36..ded39be7 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,48 @@ 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, + reply_comment_id: &Option, + ) -> 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: *reply_comment_id, + }) + .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 +191,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 9ea08a1b..8896657d 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -844,6 +844,40 @@ pub struct PublishCollabItem { pub data: Data, } +#[derive(Serialize, Deserialize, Debug)] +pub struct GlobalComments { + pub comments: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AFWebUser { + pub uid: Uuid, + pub name: String, + pub avatar_url: Option, +} + +#[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 61037647..2a5d8fff 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, AFWebUser, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceSettings, + GlobalComment, PublishCollabItem, PublishInfo, }; use futures_util::stream::BoxStream; use sqlx::{types::uuid, Executor, PgPool, Postgres, Transaction}; @@ -171,6 +171,37 @@ pub async fn select_user_is_collab_publisher_for_all_views( } } +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#" + SELECT EXISTS( + SELECT true + 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?; + + Ok(is_publisher_for_view.unwrap_or(false)) +} + #[inline] pub async fn select_user_role<'a, E: Executor<'a, Database = Postgres>>( exectuor: E, @@ -1086,3 +1117,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| AFWebUser { + 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 8d0ad5a1..1513fff4 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, get_comments_on_published_view, + remove_comment_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)) @@ -1079,6 +1089,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(); + remove_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 2bb6d42c..8b163809 100644 --- a/src/biz/workspace/ops.rs +++ b/src/biz/workspace/ops.rs @@ -20,20 +20,22 @@ use database::pg_row::AFWorkspaceMemberRow; 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_member_count_for_workspaces, select_publish_collab_meta, select_published_collab_blob, - select_published_collab_info, select_user_is_collab_publisher_for_all_views, + 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_member_count_for_workspaces, + 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_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, + 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, - AFWorkspaceSettings, WorkspaceUsage, + AFWorkspaceSettings, GlobalComment, WorkspaceUsage, }; use gotrue::params::{GenerateLinkParams, GenerateLinkType}; use shared_entity::dto::workspace_dto::{ @@ -174,6 +176,36 @@ 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> { + 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, + 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 remove_comment_on_published_view( + pg_pool: &PgPool, + view_id: &Uuid, + comment_id: &Uuid, + user_uuid: &Uuid, +) -> Result<(), AppError> { + 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(()) +} + pub async fn delete_published_workspace_collab( pg_pool: &PgPool, workspace_id: &Uuid, @@ -626,6 +658,22 @@ async fn check_workspace_owner_or_publisher( Ok(()) } +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_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 allowed to delete this comment".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 4850e1ca..d91ba26f 100644 --- a/tests/workspace/publish.rs +++ b/tests/workspace/publish.rs @@ -1,6 +1,11 @@ -use client_api::entity::{AFRole, PublishCollabItem, PublishCollabMetadata}; +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 itertools::Itertools; #[tokio::test] async fn test_set_publish_namespace_set() { @@ -211,6 +216,195 @@ async fn test_publish_doc() { } } +#[tokio::test] +async fn test_publish_comments() { + 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 + .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(); + + // 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() + .comments; + assert_eq!(published_view_comments.len(), 2); + let published_view_comments: Vec = first_user_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .comments; + assert_eq!(published_view_comments.len(), 2); + let mut published_view_comments: Vec = guest_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .comments; + 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(); + let mut published_view_comments: Vec = guest_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .comments; + 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(); + // 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() + .comments; + 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() + .comments; + assert_eq!(published_view_comments.len(), 3); + assert!(published_view_comments.iter().all(|c| c.is_deleted)); +} + #[tokio::test] async fn test_publish_load_test() { let (c, _user) = generate_unique_registered_user_client().await;