From b861f0a7031e8b5ed5ff6b7aacd31804b082c80d Mon Sep 17 00:00:00 2001 From: Khor Shu Heng Date: Thu, 25 Jul 2024 17:29:20 +0800 Subject: [PATCH 1/3] feat: endpoints for reactions on published view --- ...12b482908f9f113c0474eeae75f6784b5e0fc.json | 17 ++ ...7ec473acad4946ff3adab9aa84d1f748c9cdf.json | 34 ++++ ...f0dec74c617a15c67ee6ff367d18be335eff3.json | 34 ++++ ...342b892cf815954e4516589493aae01555dc0.json | 16 ++ libs/client-api/src/http_publish.rs | 68 +++++++- libs/database-entity/src/dto.rs | 24 +++ libs/database/src/workspace.rs | 153 +++++++++++++++++- .../20240725065111_publish_view_reaction.sql | 9 ++ src/api/workspace.rs | 61 ++++++- src/biz/workspace/ops.rs | 51 ++++-- tests/workspace/publish.rs | 123 ++++++++++++++ 11 files changed, 569 insertions(+), 21 deletions(-) create mode 100644 .sqlx/query-16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc.json create mode 100644 .sqlx/query-3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf.json create mode 100644 .sqlx/query-3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3.json create mode 100644 .sqlx/query-3bf9811b3cfc16b677c76acee21342b892cf815954e4516589493aae01555dc0.json create mode 100644 migrations/20240725065111_publish_view_reaction.sql diff --git a/.sqlx/query-16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc.json b/.sqlx/query-16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc.json new file mode 100644 index 00000000..218c26e8 --- /dev/null +++ b/.sqlx/query-16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO af_published_view_reaction (comment_id, view_id, created_by, reaction_type)\n VALUES ($1, $2, (SELECT uid FROM af_user WHERE uuid = $3), $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc" +} diff --git a/.sqlx/query-3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf.json b/.sqlx/query-3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf.json new file mode 100644 index 00000000..3a4c26f2 --- /dev/null +++ b/.sqlx/query-3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n au.uuid AS user_uuid\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE view_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "comment_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "reaction_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "user_uuid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf" +} diff --git a/.sqlx/query-3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3.json b/.sqlx/query-3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3.json new file mode 100644 index 00000000..36b349af --- /dev/null +++ b/.sqlx/query-3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n au.uuid AS user_uuid\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE comment_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "comment_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "reaction_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "user_uuid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3" +} diff --git a/.sqlx/query-3bf9811b3cfc16b677c76acee21342b892cf815954e4516589493aae01555dc0.json b/.sqlx/query-3bf9811b3cfc16b677c76acee21342b892cf815954e4516589493aae01555dc0.json new file mode 100644 index 00000000..76dfc942 --- /dev/null +++ b/.sqlx/query-3bf9811b3cfc16b677c76acee21342b892cf815954e4516589493aae01555dc0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM af_published_view_reaction\n WHERE view_id = $1 AND created_by = (SELECT uid FROM af_user WHERE uuid = $2) AND reaction_type = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3bf9811b3cfc16b677c76acee21342b892cf815954e4516589493aae01555dc0" +} diff --git a/libs/client-api/src/http_publish.rs b/libs/client-api/src/http_publish.rs index ded39be7..4d6a457c 100644 --- a/libs/client-api/src/http_publish.rs +++ b/libs/client-api/src/http_publish.rs @@ -1,7 +1,7 @@ use bytes::Bytes; use client_api_entity::{ - CreateGlobalCommentParams, DeleteGlobalCommentParams, GlobalComments, PublishInfo, - UpdatePublishNamespace, + CreateGlobalCommentParams, CreateReactionParams, DeleteGlobalCommentParams, DeleteReactionParams, + GlobalComments, PublishInfo, Reactions, UpdatePublishNamespace, }; use reqwest::Method; use shared_entity::response::{AppResponse, AppResponseError}; @@ -107,6 +107,50 @@ impl Client { .await?; AppResponse::<()>::from_response(resp).await?.into_error() } + + pub async fn create_reaction_on_comment( + &self, + reaction_type: &str, + comment_id: &uuid::Uuid, + view_id: &uuid::Uuid, + ) -> Result<(), AppResponseError> { + let url = format!( + "{}/api/workspace/published-info/{}/reaction", + self.base_url, view_id + ); + let resp = self + .http_client_with_auth(Method::POST, &url) + .await? + .json(&CreateReactionParams { + reaction_type: reaction_type.to_string(), + comment_id: *comment_id, + }) + .send() + .await?; + AppResponse::<()>::from_response(resp).await?.into_error() + } + + pub async fn delete_reaction_on_comment( + &self, + reaction_type: &str, + view_id: &uuid::Uuid, + comment_id: &uuid::Uuid, + ) -> Result<(), AppResponseError> { + let url = format!( + "{}/api/workspace/published-info/{}/reaction", + self.base_url, view_id + ); + let resp = self + .http_client_with_auth(Method::DELETE, &url) + .await? + .json(&DeleteReactionParams { + reaction_type: reaction_type.to_string(), + comment_id: *comment_id, + }) + .send() + .await?; + AppResponse::<()>::from_response(resp).await?.into_error() + } } // Guest API (no login required) @@ -205,4 +249,24 @@ impl Client { .await? .into_data() } + + pub async fn get_published_view_reactions( + &self, + view_id: &uuid::Uuid, + comment_id: &Option, + ) -> Result { + let url = format!( + "{}/api/workspace/published-info/{}/reaction", + self.base_url, view_id + ); + let url = if let Some(comment_id) = comment_id { + format!("{}?comment_id={}", url, comment_id) + } else { + url + }; + 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 8896657d..69e69a2c 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -878,6 +878,30 @@ pub struct DeleteGlobalCommentParams { pub comment_id: Uuid, } +#[derive(Serialize, Deserialize, Debug)] +pub struct Reactions { + pub reactions: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Reaction { + pub reaction_type: String, + pub react_user_uids: Vec, + pub comment_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateReactionParams { + pub reaction_type: String, + pub comment_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DeleteReactionParams { + pub reaction_type: String, + 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 cde624aa..7feacd8d 100644 --- a/libs/database/src/workspace.rs +++ b/libs/database/src/workspace.rs @@ -1,6 +1,6 @@ use database_entity::dto::{ AFRole, AFWebUser, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceSettings, - GlobalComment, PublishCollabItem, PublishInfo, + GlobalComment, PublishCollabItem, PublishInfo, Reaction, }; use futures_util::stream::BoxStream; use sqlx::{types::uuid, Executor, PgPool, Postgres, Transaction}; @@ -1227,3 +1227,154 @@ pub async fn update_comment_deletion_status<'a, E: Executor<'a, Database = Postg Ok(()) } + +#[derive(PartialEq, Eq, Hash)] +struct ReactionKey { + comment_id: Uuid, + reaction_type: String, +} + +pub async fn select_reactions_for_published_view<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + view_id: &Uuid, +) -> Result, AppError> { + let rows = sqlx::query!( + r#" + SELECT + avr.comment_id, + avr.reaction_type, + au.uuid AS user_uuid + FROM af_published_view_reaction avr + INNER JOIN af_user au ON avr.created_by = au.uid + WHERE view_id = $1 + "#, + view_id, + ) + .fetch_all(executor) + .await?; + let reaction_to_users_map: HashMap> = rows.iter().fold( + HashMap::new(), + |mut acc: HashMap>, row| { + let users = acc + .entry(ReactionKey { + comment_id: row.comment_id, + reaction_type: row.reaction_type.clone(), + }) + .or_default(); + users.push(row.user_uuid); + acc + }, + ); + let reactions = reaction_to_users_map + .iter() + .map( + |( + ReactionKey { + comment_id, + reaction_type, + }, + user_uuids, + )| Reaction { + comment_id: *comment_id, + reaction_type: reaction_type.clone(), + react_user_uids: user_uuids.clone(), + }, + ) + .collect(); + + Ok(reactions) +} + +pub async fn select_reactions_for_comment<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + comment_id: &Uuid, +) -> Result, AppError> { + let rows = sqlx::query!( + r#" + SELECT + avr.comment_id, + avr.reaction_type, + au.uuid AS user_uuid + FROM af_published_view_reaction avr + INNER JOIN af_user au ON avr.created_by = au.uid + WHERE comment_id = $1 + "#, + comment_id, + ) + .fetch_all(executor) + .await?; + let reaction_type_to_users_map: HashMap> = rows.iter().fold( + HashMap::new(), + |mut acc: HashMap>, row| { + let users = acc.entry(row.reaction_type.clone()).or_default(); + users.push(row.user_uuid); + acc + }, + ); + let reactions = reaction_type_to_users_map + .iter() + .map(|(reaction_type, user_uuids)| Reaction { + reaction_type: reaction_type.clone(), + react_user_uids: user_uuids.clone(), + comment_id: *comment_id, + }) + .collect(); + + Ok(reactions) +} + +pub async fn insert_reaction_on_comment<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + comment_id: &Uuid, + view_id: &Uuid, + user_uuid: &Uuid, + reaction_type: &str, +) -> Result<(), AppError> { + let res = sqlx::query!( + r#" + INSERT INTO af_published_view_reaction (comment_id, view_id, created_by, reaction_type) + VALUES ($1, $2, (SELECT uid FROM af_user WHERE uuid = $3), $4) + "#, + comment_id, + view_id, + user_uuid, + reaction_type, + ) + .execute(executor) + .await?; + + if res.rows_affected() != 1 { + tracing::error!( + "Failed to insert reaction to comment, comment_id: {}, user_id: {}, reaction_type: {}, rows_affected: {}", + comment_id, user_uuid, reaction_type, res.rows_affected() + ); + }; + + Ok(()) +} + +pub async fn delete_reaction_from_comment<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + view_id: &Uuid, + user_uuid: &Uuid, + reaction_type: &str, +) -> Result<(), AppError> { + let res = sqlx::query!( + r#" + DELETE FROM af_published_view_reaction + WHERE view_id = $1 AND created_by = (SELECT uid FROM af_user WHERE uuid = $2) AND reaction_type = $3 + "#, + view_id, + user_uuid, + reaction_type, + ).execute(executor).await?; + + if res.rows_affected() != 1 { + tracing::error!( + "Failed to delete reaction from published view, view_id: {}, user_id: {}, reaction_type: {}, rows_affected: {}", + view_id, user_uuid, reaction_type, res.rows_affected() + ); + }; + + Ok(()) +} diff --git a/migrations/20240725065111_publish_view_reaction.sql b/migrations/20240725065111_publish_view_reaction.sql new file mode 100644 index 00000000..60ff2c14 --- /dev/null +++ b/migrations/20240725065111_publish_view_reaction.sql @@ -0,0 +1,9 @@ +-- stores the reactions on a published view +CREATE TABLE IF NOT EXISTS af_published_view_reaction ( + comment_id UUID NOT NULL REFERENCES af_published_view_comment(comment_id) ON DELETE CASCADE, + reaction_type TEXT NOT NULL, + created_by BIGINT NOT NULL REFERENCES af_user(uid) ON DELETE CASCADE, + view_id UUID NOT NULL, + PRIMARY KEY (comment_id, reaction_type, created_by) +); +CREATE INDEX IF NOT EXISTS idx_view_id_on_af_published_view_reaction ON af_published_view_reaction(view_id); diff --git a/src/api/workspace.rs b/src/api/workspace.rs index 1513fff4..d01b1558 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, get_comments_on_published_view, - remove_comment_on_published_view, + create_comment_on_published_view, create_reaction_on_comment, get_comments_on_published_view, + get_reactions_on_published_view, remove_comment_on_published_view, remove_reaction_on_comment, }; use actix_web::web::{Bytes, Payload}; use actix_web::web::{Data, Json, PayloadConfig}; @@ -13,6 +13,7 @@ use collab::entity::EncodedCollab; use collab_entity::CollabType; use futures_util::future::try_join_all; use prost::Message as ProstMessage; +use serde::Deserialize; use sqlx::types::uuid; use tokio::time::Instant; use tokio_stream::StreamExt; @@ -153,6 +154,12 @@ pub fn workspace_scope() -> Scope { .route(web::post().to(post_published_collab_comment_handler)) .route(web::delete().to(delete_published_collab_comment_handler)) ) + .service( + web::resource("/published-info/{view_id}/reaction") + .route(web::get().to(get_published_collab_reaction_handler)) + .route(web::post().to(post_published_collab_reaction_handler)) + .route(web::delete().to(delete_published_collab_reaction_handler)) + ) .service( web::resource("/{workspace_id}/publish-namespace") .route(web::put().to(put_publish_namespace_handler)) @@ -1128,6 +1135,56 @@ async fn delete_published_collab_comment_handler( Ok(Json(AppResponse::Ok())) } +#[derive(Deserialize)] +struct GetReactionQuery { + comment_id: Option, +} + +async fn get_published_collab_reaction_handler( + view_id: web::Path, + query: web::Query, + state: Data, +) -> Result> { + let view_id = view_id.into_inner(); + let reactions = + get_reactions_on_published_view(&state.pg_pool, &view_id, &query.comment_id).await?; + let resp = Reactions { reactions }; + Ok(Json(AppResponse::Ok().with_data(resp))) +} + +async fn post_published_collab_reaction_handler( + user_uuid: UserUuid, + view_id: web::Path, + data: Json, + state: Data, +) -> Result> { + let view_id = view_id.into_inner(); + create_reaction_on_comment( + &state.pg_pool, + &data.comment_id, + &view_id, + &data.reaction_type, + &user_uuid, + ) + .await?; + Ok(Json(AppResponse::Ok())) +} + +async fn delete_published_collab_reaction_handler( + user_uuid: UserUuid, + data: Json, + state: Data, +) -> Result> { + remove_reaction_on_comment( + &state.pg_pool, + &data.comment_id, + &data.reaction_type, + &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 50c7594f..9eb355b9 100644 --- a/src/biz/workspace/ops.rs +++ b/src/biz/workspace/ops.rs @@ -18,24 +18,10 @@ use database::file::s3_client_impl::S3BucketStorage; 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_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_orderd_by_recency, 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_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::workspace::*; use database_entity::dto::{ AFAccessLevel, AFRole, AFWorkspace, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, - AFWorkspaceSettings, GlobalComment, WorkspaceUsage, + AFWorkspaceSettings, GlobalComment, Reaction, WorkspaceUsage, }; use gotrue::params::{GenerateLinkParams, GenerateLinkType}; use shared_entity::dto::workspace_dto::{ @@ -206,6 +192,39 @@ pub async fn remove_comment_on_published_view( Ok(()) } +pub async fn get_reactions_on_published_view( + pg_pool: &PgPool, + view_id: &Uuid, + comment_id: &Option, +) -> Result, AppError> { + let reaction = match comment_id { + Some(comment_id) => select_reactions_for_comment(pg_pool, comment_id).await?, + None => select_reactions_for_published_view(pg_pool, view_id).await?, + }; + Ok(reaction) +} + +pub async fn create_reaction_on_comment( + pg_pool: &PgPool, + comment_id: &Uuid, + view_id: &Uuid, + reaction_type: &str, + user_uuid: &Uuid, +) -> Result<(), AppError> { + insert_reaction_on_comment(pg_pool, comment_id, view_id, user_uuid, reaction_type).await?; + Ok(()) +} + +pub async fn remove_reaction_on_comment( + pg_pool: &PgPool, + comment_id: &Uuid, + reaction_type: &str, + user_uuid: &Uuid, +) -> Result<(), AppError> { + delete_reaction_from_comment(pg_pool, comment_id, user_uuid, reaction_type).await?; + Ok(()) +} + pub async fn delete_published_workspace_collab( pg_pool: &PgPool, workspace_id: &Uuid, diff --git a/tests/workspace/publish.rs b/tests/workspace/publish.rs index 383e48f5..faa95673 100644 --- a/tests/workspace/publish.rs +++ b/tests/workspace/publish.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::thread::sleep; use std::time::Duration; @@ -402,6 +403,128 @@ async fn test_publish_comments() { assert!(published_view_comments.iter().all(|c| c.is_deleted)); } +#[tokio::test] +async fn test_publish_reactions() { + 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(); + page_owner_client + .create_comment_on_published_view(&view_id, "likable comment", &None) + .await + .unwrap(); + // This is to ensure that the second comment creation timestamp is later than the first one + sleep(Duration::from_millis(1)); + page_owner_client + .create_comment_on_published_view(&view_id, "party comment", &None) + .await + .unwrap(); + let mut comments = page_owner_client + .get_published_view_comments(&view_id) + .await + .unwrap() + .comments; + comments.sort_by_key(|c| c.created_at); + // Test if the reactions are created correctly based on view and comment id + let likable_comment_id = comments[0].comment_id; + let party_comment_id = comments[1].comment_id; + + let like_emoji = "👍"; + let party_emoji = "🎉"; + page_owner_client + .create_reaction_on_comment(like_emoji, &likable_comment_id, &view_id) + .await + .unwrap(); + let guest_client = localhost_client(); + let result = guest_client + .create_reaction_on_comment(like_emoji, &likable_comment_id, &view_id) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn); + + let (user_client, _) = generate_unique_registered_user_client().await; + user_client + .create_reaction_on_comment(like_emoji, &likable_comment_id, &view_id) + .await + .unwrap(); + user_client + .create_reaction_on_comment(party_emoji, &party_comment_id, &view_id) + .await + .unwrap(); + + let reactions = guest_client + .get_published_view_reactions(&view_id, &None) + .await + .unwrap() + .reactions; + let reaction_count: HashMap = reactions + .iter() + .map(|r| (r.reaction_type.clone(), r.react_user_uids.len() as i32)) + .collect(); + assert_eq!(reaction_count.len(), 2); + assert_eq!(*reaction_count.get(like_emoji).unwrap(), 2); + assert_eq!(*reaction_count.get(party_emoji).unwrap(), 1); + + // Test if the reactions are deleted correctly based on view and comment id + let result = guest_client + .delete_reaction_on_comment(like_emoji, &likable_comment_id, &view_id) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn); + user_client + .delete_reaction_on_comment(like_emoji, &likable_comment_id, &view_id) + .await + .unwrap(); + + let reactions = guest_client + .get_published_view_reactions(&view_id, &None) + .await + .unwrap() + .reactions; + let reaction_count: HashMap = reactions + .iter() + .map(|r| (r.reaction_type.clone(), r.react_user_uids.len() as i32)) + .collect(); + assert_eq!(reaction_count.len(), 2); + assert_eq!(*reaction_count.get(like_emoji).unwrap(), 1); + assert_eq!(*reaction_count.get(party_emoji).unwrap(), 1); + + // Test if we can filter the reactions by comment id + let reactions = guest_client + .get_published_view_reactions(&view_id, &Some(likable_comment_id)) + .await + .unwrap() + .reactions; + let reaction_count: HashMap = reactions + .iter() + .map(|r| (r.reaction_type.clone(), r.react_user_uids.len() as i32)) + .collect(); + assert_eq!(reaction_count.len(), 1); + assert_eq!(*reaction_count.get(like_emoji).unwrap(), 1); +} + #[tokio::test] async fn test_publish_load_test() { let (c, _user) = generate_unique_registered_user_client().await; From 62f32e87576e28cfc296b09c473541ec20386c05 Mon Sep 17 00:00:00 2001 From: Khor Shu Heng Date: Sat, 27 Jul 2024 12:55:07 +0800 Subject: [PATCH 2/3] feat: return user struct instead of uuid for reaction --- ...edb0bce2e988c6fea1eb856b7625b4d1f16f.json} | 10 ++++-- ...72ff8e1a8e63425155e88b472b91c1e24f3a.json} | 10 ++++-- libs/database-entity/src/dto.rs | 4 +-- libs/database/src/workspace.rs | 34 ++++++++++++------- tests/workspace/publish.rs | 6 ++-- 5 files changed, 43 insertions(+), 21 deletions(-) rename .sqlx/{query-3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf.json => query-304da1f7fec4fcd69c2e0e0bbb24edb0bce2e988c6fea1eb856b7625b4d1f16f.json} (58%) rename .sqlx/{query-3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3.json => query-f9a80c40a2dea06b391a065c946472ff8e1a8e63425155e88b472b91c1e24f3a.json} (58%) diff --git a/.sqlx/query-3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf.json b/.sqlx/query-304da1f7fec4fcd69c2e0e0bbb24edb0bce2e988c6fea1eb856b7625b4d1f16f.json similarity index 58% rename from .sqlx/query-3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf.json rename to .sqlx/query-304da1f7fec4fcd69c2e0e0bbb24edb0bce2e988c6fea1eb856b7625b4d1f16f.json index 3a4c26f2..df054795 100644 --- a/.sqlx/query-3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf.json +++ b/.sqlx/query-304da1f7fec4fcd69c2e0e0bbb24edb0bce2e988c6fea1eb856b7625b4d1f16f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n au.uuid AS user_uuid\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE view_id = $1\n ", + "query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n au.uuid AS user_uuid,\n au.name AS user_name\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE view_id = $1\n ", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "user_uuid", "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "user_name", + "type_info": "Text" } ], "parameters": { @@ -25,10 +30,11 @@ ] }, "nullable": [ + false, false, false, false ] }, - "hash": "3064614b0f62b3018296246bf497ec473acad4946ff3adab9aa84d1f748c9cdf" + "hash": "304da1f7fec4fcd69c2e0e0bbb24edb0bce2e988c6fea1eb856b7625b4d1f16f" } diff --git a/.sqlx/query-3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3.json b/.sqlx/query-f9a80c40a2dea06b391a065c946472ff8e1a8e63425155e88b472b91c1e24f3a.json similarity index 58% rename from .sqlx/query-3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3.json rename to .sqlx/query-f9a80c40a2dea06b391a065c946472ff8e1a8e63425155e88b472b91c1e24f3a.json index 36b349af..83d4156f 100644 --- a/.sqlx/query-3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3.json +++ b/.sqlx/query-f9a80c40a2dea06b391a065c946472ff8e1a8e63425155e88b472b91c1e24f3a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n au.uuid AS user_uuid\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE comment_id = $1\n ", + "query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n au.uuid AS user_uuid,\n au.name AS user_name\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE comment_id = $1\n ", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "user_uuid", "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "user_name", + "type_info": "Text" } ], "parameters": { @@ -25,10 +30,11 @@ ] }, "nullable": [ + false, false, false, false ] }, - "hash": "3511e9df911493be0de8543c6cbf0dec74c617a15c67ee6ff367d18be335eff3" + "hash": "f9a80c40a2dea06b391a065c946472ff8e1a8e63425155e88b472b91c1e24f3a" } diff --git a/libs/database-entity/src/dto.rs b/libs/database-entity/src/dto.rs index 69e69a2c..c3f989f2 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -849,7 +849,7 @@ pub struct GlobalComments { pub comments: Vec, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct AFWebUser { pub uid: Uuid, pub name: String, @@ -886,7 +886,7 @@ pub struct Reactions { #[derive(Serialize, Deserialize, Debug)] pub struct Reaction { pub reaction_type: String, - pub react_user_uids: Vec, + pub react_users: Vec, pub comment_id: Uuid, } diff --git a/libs/database/src/workspace.rs b/libs/database/src/workspace.rs index 7feacd8d..6679fd35 100644 --- a/libs/database/src/workspace.rs +++ b/libs/database/src/workspace.rs @@ -1243,7 +1243,8 @@ pub async fn select_reactions_for_published_view<'a, E: Executor<'a, Database = SELECT avr.comment_id, avr.reaction_type, - au.uuid AS user_uuid + au.uuid AS user_uuid, + au.name AS user_name FROM af_published_view_reaction avr INNER JOIN af_user au ON avr.created_by = au.uid WHERE view_id = $1 @@ -1252,16 +1253,20 @@ pub async fn select_reactions_for_published_view<'a, E: Executor<'a, Database = ) .fetch_all(executor) .await?; - let reaction_to_users_map: HashMap> = rows.iter().fold( + let reaction_to_users_map: HashMap> = rows.iter().fold( HashMap::new(), - |mut acc: HashMap>, row| { + |mut acc: HashMap>, row| { let users = acc .entry(ReactionKey { comment_id: row.comment_id, reaction_type: row.reaction_type.clone(), }) .or_default(); - users.push(row.user_uuid); + users.push(AFWebUser { + uid: row.user_uuid.clone(), + name: row.user_name.clone(), + avatar_url: None, + }); acc }, ); @@ -1273,11 +1278,11 @@ pub async fn select_reactions_for_published_view<'a, E: Executor<'a, Database = comment_id, reaction_type, }, - user_uuids, + users, )| Reaction { comment_id: *comment_id, reaction_type: reaction_type.clone(), - react_user_uids: user_uuids.clone(), + react_users: users.clone(), }, ) .collect(); @@ -1294,7 +1299,8 @@ pub async fn select_reactions_for_comment<'a, E: Executor<'a, Database = Postgre SELECT avr.comment_id, avr.reaction_type, - au.uuid AS user_uuid + au.uuid AS user_uuid, + au.name AS user_name FROM af_published_view_reaction avr INNER JOIN af_user au ON avr.created_by = au.uid WHERE comment_id = $1 @@ -1303,19 +1309,23 @@ pub async fn select_reactions_for_comment<'a, E: Executor<'a, Database = Postgre ) .fetch_all(executor) .await?; - let reaction_type_to_users_map: HashMap> = rows.iter().fold( + let reaction_type_to_users_map: HashMap> = rows.iter().fold( HashMap::new(), - |mut acc: HashMap>, row| { + |mut acc: HashMap>, row| { let users = acc.entry(row.reaction_type.clone()).or_default(); - users.push(row.user_uuid); + users.push(AFWebUser { + uid: row.user_uuid, + name: row.user_name.clone(), + avatar_url: None, + }); acc }, ); let reactions = reaction_type_to_users_map .iter() - .map(|(reaction_type, user_uuids)| Reaction { + .map(|(reaction_type, users)| Reaction { reaction_type: reaction_type.clone(), - react_user_uids: user_uuids.clone(), + react_users: users.clone(), comment_id: *comment_id, }) .collect(); diff --git a/tests/workspace/publish.rs b/tests/workspace/publish.rs index faa95673..6f714de6 100644 --- a/tests/workspace/publish.rs +++ b/tests/workspace/publish.rs @@ -481,7 +481,7 @@ async fn test_publish_reactions() { .reactions; let reaction_count: HashMap = reactions .iter() - .map(|r| (r.reaction_type.clone(), r.react_user_uids.len() as i32)) + .map(|r| (r.reaction_type.clone(), r.react_users.len() as i32)) .collect(); assert_eq!(reaction_count.len(), 2); assert_eq!(*reaction_count.get(like_emoji).unwrap(), 2); @@ -505,7 +505,7 @@ async fn test_publish_reactions() { .reactions; let reaction_count: HashMap = reactions .iter() - .map(|r| (r.reaction_type.clone(), r.react_user_uids.len() as i32)) + .map(|r| (r.reaction_type.clone(), r.react_users.len() as i32)) .collect(); assert_eq!(reaction_count.len(), 2); assert_eq!(*reaction_count.get(like_emoji).unwrap(), 1); @@ -519,7 +519,7 @@ async fn test_publish_reactions() { .reactions; let reaction_count: HashMap = reactions .iter() - .map(|r| (r.reaction_type.clone(), r.react_user_uids.len() as i32)) + .map(|r| (r.reaction_type.clone(), r.react_users.len() as i32)) .collect(); assert_eq!(reaction_count.len(), 1); assert_eq!(*reaction_count.get(like_emoji).unwrap(), 1); From ea8ca36b5b88aca4a07478c90dddce8058fcead4 Mon Sep 17 00:00:00 2001 From: khorshuheng Date: Mon, 29 Jul 2024 10:51:16 +0800 Subject: [PATCH 3/3] fix: share query param struct for api client and server --- libs/client-api/src/http_publish.rs | 16 +++++++++------- libs/database-entity/src/dto.rs | 5 +++++ libs/database/src/workspace.rs | 2 +- src/api/workspace.rs | 8 +------- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/libs/client-api/src/http_publish.rs b/libs/client-api/src/http_publish.rs index 4d6a457c..423627a3 100644 --- a/libs/client-api/src/http_publish.rs +++ b/libs/client-api/src/http_publish.rs @@ -1,7 +1,7 @@ use bytes::Bytes; use client_api_entity::{ CreateGlobalCommentParams, CreateReactionParams, DeleteGlobalCommentParams, DeleteReactionParams, - GlobalComments, PublishInfo, Reactions, UpdatePublishNamespace, + GetReactionQueryParams, GlobalComments, PublishInfo, Reactions, UpdatePublishNamespace, }; use reqwest::Method; use shared_entity::response::{AppResponse, AppResponseError}; @@ -259,12 +259,14 @@ impl Client { "{}/api/workspace/published-info/{}/reaction", self.base_url, view_id ); - let url = if let Some(comment_id) = comment_id { - format!("{}?comment_id={}", url, comment_id) - } else { - url - }; - let resp = self.cloud_client.get(&url).send().await?; + let resp = self + .cloud_client + .get(url) + .query(&GetReactionQueryParams { + comment_id: *comment_id, + }) + .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 c3f989f2..ea6e40cd 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -890,6 +890,11 @@ pub struct Reaction { pub comment_id: Uuid, } +#[derive(Serialize, Deserialize, Debug)] +pub struct GetReactionQueryParams { + pub comment_id: Option, +} + #[derive(Serialize, Deserialize, Debug)] pub struct CreateReactionParams { pub reaction_type: String, diff --git a/libs/database/src/workspace.rs b/libs/database/src/workspace.rs index 6679fd35..477c7c32 100644 --- a/libs/database/src/workspace.rs +++ b/libs/database/src/workspace.rs @@ -1263,7 +1263,7 @@ pub async fn select_reactions_for_published_view<'a, E: Executor<'a, Database = }) .or_default(); users.push(AFWebUser { - uid: row.user_uuid.clone(), + uid: row.user_uuid, name: row.user_name.clone(), avatar_url: None, }); diff --git a/src/api/workspace.rs b/src/api/workspace.rs index d01b1558..829f13d2 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -13,7 +13,6 @@ use collab::entity::EncodedCollab; use collab_entity::CollabType; use futures_util::future::try_join_all; use prost::Message as ProstMessage; -use serde::Deserialize; use sqlx::types::uuid; use tokio::time::Instant; use tokio_stream::StreamExt; @@ -1135,14 +1134,9 @@ async fn delete_published_collab_comment_handler( Ok(Json(AppResponse::Ok())) } -#[derive(Deserialize)] -struct GetReactionQuery { - comment_id: Option, -} - async fn get_published_collab_reaction_handler( view_id: web::Path, - query: web::Query, + query: web::Query, state: Data, ) -> Result> { let view_id = view_id.into_inner();