feat: endpoints for reactions on published view

This commit is contained in:
Khor Shu Heng 2024-07-25 17:29:20 +08:00
parent a81355e0f0
commit b861f0a703
11 changed files with 569 additions and 21 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<uuid::Uuid>,
) -> Result<Reactions, AppResponseError> {
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::<Reactions>::from_response(resp)
.await?
.into_data()
}
}

View file

@ -878,6 +878,30 @@ pub struct DeleteGlobalCommentParams {
pub comment_id: Uuid,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Reactions {
pub reactions: Vec<Reaction>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Reaction {
pub reaction_type: String,
pub react_user_uids: Vec<Uuid>,
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 {

View file

@ -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<Vec<Reaction>, 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<ReactionKey, Vec<Uuid>> = rows.iter().fold(
HashMap::new(),
|mut acc: HashMap<ReactionKey, Vec<Uuid>>, 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<Vec<Reaction>, 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<String, Vec<Uuid>> = rows.iter().fold(
HashMap::new(),
|mut acc: HashMap<String, Vec<Uuid>>, 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(())
}

View file

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

View file

@ -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<Uuid>,
}
async fn get_published_collab_reaction_handler(
view_id: web::Path<Uuid>,
query: web::Query<GetReactionQuery>,
state: Data<AppState>,
) -> Result<JsonAppResponse<Reactions>> {
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<Uuid>,
data: Json<CreateReactionParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<Reactions>> {
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<DeleteReactionParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<Reactions>> {
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<Uuid>,
user_uuid: UserUuid,

View file

@ -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<Uuid>,
) -> Result<Vec<Reaction>, 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,

View file

@ -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::<MyCustomMetadata, &[u8]>(
&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<String, i32> = 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<String, i32> = 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<String, i32> = 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;