feat: add database operations for publish comment crd

This commit is contained in:
Khor Shu Heng 2024-07-24 17:17:37 +08:00
parent 6eea93d774
commit f535950643
11 changed files with 429 additions and 68 deletions

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -70,6 +70,7 @@ impl Client {
&self,
view_id: &uuid::Uuid,
comment_content: &str,
reply_comment_id: &Option<uuid::Uuid>,
) -> 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?;

View file

@ -849,9 +849,16 @@ pub struct PublishCollabItem<Meta, Data> {
#[derive(Serialize, Deserialize, Debug)]
pub struct GlobalComments(pub Vec<GlobalComment>);
#[derive(Serialize, Deserialize, Debug)]
pub struct GlobalCommentCreator {
pub uid: Uuid,
pub name: String,
pub avatar_url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GlobalComment {
pub user: Option<AFWorkspaceMember>,
pub user: Option<GlobalCommentCreator>,
pub created_at: DateTime<Utc>,
pub last_updated_at: DateTime<Utc>,
pub content: String,

View file

@ -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<bool, AppError> {
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<Vec<GlobalComment>, 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<Uuid>,
) -> 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(())
}

View file

@ -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);

View file

@ -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<DeleteGlobalCommentParams>,
) -> Result<JsonAppResponse<()>> {
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()))
}

View file

@ -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<Vec<GlobalComment>, 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<Uuid>,
_content: &str,
_user_uuid: &Uuid,
pg_pool: &PgPool,
view_id: &Uuid,
reply_comment_id: &Option<Uuid>,
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(())

View file

@ -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<GlobalComment> = 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<GlobalComment> = 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<GlobalComment> = 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<GlobalComment> = 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<GlobalComment> = 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<GlobalComment> = 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]