diff --git a/.sqlx/query-35622d4ebede28dd28b613edcf3970ad258286f176ce86e88bd662a602e4ad58.json b/.sqlx/query-35622d4ebede28dd28b613edcf3970ad258286f176ce86e88bd662a602e4ad58.json new file mode 100644 index 000000000..c72364a0e --- /dev/null +++ b/.sqlx/query-35622d4ebede28dd28b613edcf3970ad258286f176ce86e88bd662a602e4ad58.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO af_quick_note (workspace_id, uid, data) VALUES ($1, $2, $3)\n RETURNING quick_note_id AS id, data, created_at AS \"created_at!\", updated_at AS \"last_updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "data", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "last_updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int8", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "35622d4ebede28dd28b613edcf3970ad258286f176ce86e88bd662a602e4ad58" +} diff --git a/.sqlx/query-5cce5f82c0fb9237f724478e2167243bc772c092910f07b8226431a6dd70a7da.json b/.sqlx/query-5cce5f82c0fb9237f724478e2167243bc772c092910f07b8226431a6dd70a7da.json new file mode 100644 index 000000000..a5a6eb950 --- /dev/null +++ b/.sqlx/query-5cce5f82c0fb9237f724478e2167243bc772c092910f07b8226431a6dd70a7da.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM af_quick_note WHERE quick_note_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5cce5f82c0fb9237f724478e2167243bc772c092910f07b8226431a6dd70a7da" +} diff --git a/.sqlx/query-770a4979e137ca08c5ea625259221f9d397a56defb8e498eb92da7b3a8af612b.json b/.sqlx/query-770a4979e137ca08c5ea625259221f9d397a56defb8e498eb92da7b3a8af612b.json new file mode 100644 index 000000000..0d233d112 --- /dev/null +++ b/.sqlx/query-770a4979e137ca08c5ea625259221f9d397a56defb8e498eb92da7b3a8af612b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE af_quick_note SET data = $1, updated_at = NOW() WHERE quick_note_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "770a4979e137ca08c5ea625259221f9d397a56defb8e498eb92da7b3a8af612b" +} diff --git a/libs/client-api/src/http_quick_note.rs b/libs/client-api/src/http_quick_note.rs new file mode 100644 index 000000000..b4bca7d94 --- /dev/null +++ b/libs/client-api/src/http_quick_note.rs @@ -0,0 +1,90 @@ +use client_api_entity::{ + CreateQuickNoteParams, ListQuickNotesQueryParams, QuickNote, QuickNotes, UpdateQuickNoteParams, +}; +use reqwest::Method; +use shared_entity::response::{AppResponse, AppResponseError}; +use uuid::Uuid; + +use crate::Client; + +fn quick_note_resources_url(base_url: &str, workspace_id: Uuid) -> String { + format!("{base_url}/api/workspace/{workspace_id}/quick-note") +} + +fn quick_note_resource_url(base_url: &str, workspace_id: Uuid, quick_note_id: Uuid) -> String { + let quick_note_resources_prefix = quick_note_resources_url(base_url, workspace_id); + format!("{quick_note_resources_prefix}/{quick_note_id}") +} + +// Quick Note API +impl Client { + pub async fn create_quick_note( + &self, + workspace_id: Uuid, + data: Option, + ) -> Result { + let url = quick_note_resources_url(&self.base_url, workspace_id); + let resp = self + .http_client_with_auth(Method::POST, &url) + .await? + .json(&CreateQuickNoteParams { data }) + .send() + .await?; + AppResponse::::from_response(resp) + .await? + .into_data() + } + + pub async fn list_quick_notes( + &self, + workspace_id: Uuid, + search_term: Option, + offset: Option, + limit: Option, + ) -> Result { + let url = quick_note_resources_url(&self.base_url, workspace_id); + let resp = self + .http_client_with_auth(Method::GET, &url) + .await? + .query(&ListQuickNotesQueryParams { + search_term, + offset, + limit, + }) + .send() + .await?; + AppResponse::::from_response(resp) + .await? + .into_data() + } + + pub async fn update_quick_note( + &self, + workspace_id: Uuid, + quick_note_id: Uuid, + data: serde_json::Value, + ) -> Result<(), AppResponseError> { + let url = quick_note_resource_url(&self.base_url, workspace_id, quick_note_id); + let resp = self + .http_client_with_auth(Method::PUT, &url) + .await? + .json(&UpdateQuickNoteParams { data }) + .send() + .await?; + AppResponse::<()>::from_response(resp).await?.into_error() + } + + pub async fn delete_quick_note( + &self, + workspace_id: Uuid, + quick_note_id: Uuid, + ) -> Result<(), AppResponseError> { + let url = quick_note_resource_url(&self.base_url, workspace_id, quick_note_id); + let resp = self + .http_client_with_auth(Method::DELETE, &url) + .await? + .send() + .await?; + AppResponse::<()>::from_response(resp).await?.into_error() + } +} diff --git a/libs/client-api/src/lib.rs b/libs/client-api/src/lib.rs index 0868fd2d3..849343978 100644 --- a/libs/client-api/src/lib.rs +++ b/libs/client-api/src/lib.rs @@ -8,6 +8,7 @@ mod http_collab; mod http_history; mod http_member; mod http_publish; +mod http_quick_note; mod http_search; mod http_template; mod http_view; diff --git a/libs/database-entity/src/dto.rs b/libs/database-entity/src/dto.rs index 6f77bf80b..41d52b0ee 100644 --- a/libs/database-entity/src/dto.rs +++ b/libs/database-entity/src/dto.rs @@ -1167,6 +1167,37 @@ pub struct WorkspaceNamespace { pub is_original: bool, } +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct QuickNote { + pub id: Uuid, + pub data: serde_json::Value, + pub created_at: DateTime, + pub last_updated_at: DateTime, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct QuickNotes { + pub quick_notes: Vec, + pub has_more: bool, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CreateQuickNoteParams { + pub data: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdateQuickNoteParams { + pub data: serde_json::Value, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ListQuickNotesQueryParams { + pub search_term: Option, + pub offset: Option, + pub limit: Option, +} + #[cfg(test)] mod test { use crate::dto::{CollabParams, CollabParamsV0}; diff --git a/libs/database/src/lib.rs b/libs/database/src/lib.rs index d2beaffde..3c73cd2c1 100644 --- a/libs/database/src/lib.rs +++ b/libs/database/src/lib.rs @@ -7,6 +7,7 @@ pub mod index; pub mod listener; pub mod pg_row; pub mod publish; +pub mod quick_note; pub mod resource_usage; pub mod template; pub mod user; diff --git a/libs/database/src/pg_row.rs b/libs/database/src/pg_row.rs index 7e456bf24..84307498d 100644 --- a/libs/database/src/pg_row.rs +++ b/libs/database/src/pg_row.rs @@ -5,8 +5,9 @@ use chrono::{DateTime, Utc}; use database_entity::dto::{ AFAccessLevel, AFRole, AFUserProfile, AFWebUser, AFWorkspace, AFWorkspaceInvitationStatus, AccessRequestMinimal, AccessRequestStatus, AccessRequestWithViewId, AccessRequesterInfo, - AccountLink, GlobalComment, Reaction, Template, TemplateCategory, TemplateCategoryMinimal, - TemplateCategoryType, TemplateCreator, TemplateCreatorMinimal, TemplateGroup, TemplateMinimal, + AccountLink, GlobalComment, QuickNote, Reaction, Template, TemplateCategory, + TemplateCategoryMinimal, TemplateCategoryType, TemplateCreator, TemplateCreatorMinimal, + TemplateGroup, TemplateMinimal, }; use serde::{Deserialize, Serialize}; use sqlx::FromRow; @@ -647,3 +648,22 @@ impl TryFrom for AccessRequestWithViewId { }) } } + +#[derive(FromRow, Serialize, Debug)] +pub struct AFQuickNoteRow { + pub quick_note_id: Uuid, + pub data: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for QuickNote { + fn from(value: AFQuickNoteRow) -> Self { + Self { + id: value.quick_note_id, + data: value.data, + created_at: value.created_at, + last_updated_at: value.updated_at, + } + } +} diff --git a/libs/database/src/quick_note.rs b/libs/database/src/quick_note.rs new file mode 100644 index 000000000..375f957c5 --- /dev/null +++ b/libs/database/src/quick_note.rs @@ -0,0 +1,104 @@ +use app_error::AppError; +use database_entity::dto::QuickNote; +use sqlx::{Executor, Postgres, QueryBuilder}; +use uuid::Uuid; + +use crate::pg_row::AFQuickNoteRow; + +pub async fn insert_new_quick_note<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + workspace_id: Uuid, + uid: i64, + data: &serde_json::Value, +) -> Result { + let quick_note = sqlx::query_as!( + QuickNote, + r#" + INSERT INTO af_quick_note (workspace_id, uid, data) VALUES ($1, $2, $3) + RETURNING quick_note_id AS id, data, created_at AS "created_at!", updated_at AS "last_updated_at!" + "#, + workspace_id, + uid, + data + ) + .fetch_one(executor) + .await?; + Ok(quick_note) +} + +pub async fn select_quick_notes_with_one_more_than_limit< + 'a, + E: Executor<'a, Database = Postgres>, +>( + executor: E, + workspace_id: Uuid, + uid: i64, + search_term: Option, + offset: Option, + limit: Option, +) -> Result, AppError> { + let mut query_builder: QueryBuilder = QueryBuilder::new( + r#" + SELECT + quick_note_id, + data, + created_at, + updated_at + FROM af_quick_note WHERE workspace_id = + "#, + ); + query_builder.push_bind(workspace_id); + query_builder.push(" AND uid = "); + query_builder.push_bind(uid); + if let Some(search_term) = search_term.filter(|term| !term.is_empty()) { + query_builder.push(" AND data @? "); + let json_path_query = format!("'$.**.insert ? (@ like_regex \".*{}.*\")'", search_term); + query_builder.push(json_path_query); + } + query_builder.push(" ORDER BY created_at DESC"); + if let Some(limit) = limit { + query_builder.push(" LIMIT "); + query_builder.push_bind(limit); + query_builder.push(" + 1 "); + } + if let Some(offset) = offset { + query_builder.push(" OFFSET "); + query_builder.push_bind(offset); + } + let query = query_builder.build_query_as::(); + let quick_notes_with_one_more_than_limit = query + .fetch_all(executor) + .await? + .into_iter() + .map(Into::into) + .collect(); + Ok(quick_notes_with_one_more_than_limit) +} + +pub async fn update_quick_note_by_id<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + quick_note_id: Uuid, + data: &serde_json::Value, +) -> Result<(), AppError> { + sqlx::query!( + "UPDATE af_quick_note SET data = $1, updated_at = NOW() WHERE quick_note_id = $2", + data, + quick_note_id + ) + .execute(executor) + .await?; + Ok(()) +} + +pub async fn delete_quick_note_by_id<'a, E: Executor<'a, Database = Postgres>>( + executor: E, + quick_note_id: Uuid, +) -> Result<(), AppError> { + sqlx::query!( + "DELETE FROM af_quick_note WHERE quick_note_id = $1", + quick_note_id + ) + .execute(executor) + .await?; + Ok(()) +} diff --git a/migrations/20241216080018_quick_notes.sql b/migrations/20241216080018_quick_notes.sql new file mode 100644 index 000000000..c4d4c3bb2 --- /dev/null +++ b/migrations/20241216080018_quick_notes.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS af_quick_note ( + quick_note_id UUID NOT NULL DEFAULT gen_random_uuid (), + workspace_id UUID NOT NULL, + uid BIGINT NOT NULL REFERENCES af_user (uid) ON DELETE CASCADE, + updated_at TIMESTAMP + WITH + TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP + WITH + TIME ZONE DEFAULT CURRENT_TIMESTAMP, + data JSONB NOT NULL, + PRIMARY KEY (quick_note_id) +); + +CREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_quick_note ON af_quick_note (workspace_id); + +CREATE INDEX IF NOT EXISTS idx_uid_on_af_quick_note ON af_quick_note (uid); diff --git a/src/api/workspace.rs b/src/api/workspace.rs index fa18bb2d5..491ed5020 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -17,6 +17,9 @@ use crate::biz::workspace::page_view::{ update_page, update_page_collab_data, update_space, }; use crate::biz::workspace::publish::get_workspace_default_publish_view_info_meta; +use crate::biz::workspace::quick_note::{ + create_quick_note, delete_quick_note, list_quick_notes, update_quick_note, +}; use crate::domain::compression::{ blocking_decompress, decompress, CompressionType, X_COMPRESSION_TYPE, }; @@ -300,6 +303,16 @@ pub fn workspace_scope() -> Scope { web::resource("/{workspace_id}/database/{database_id}/row/detail") .route(web::get().to(list_database_row_details_handler)), ) + .service( + web::resource("/{workspace_id}/quick-note") + .route(web::get().to(list_quick_notes_handler)) + .route(web::post().to(post_quick_note_handler)), + ) + .service( + web::resource("/{workspace_id}/quick-note/{quick_note_id}") + .route(web::put().to(update_quick_note_handler)) + .route(web::delete().to(delete_quick_note_handler)), + ) } pub fn collab_scope() -> Scope { @@ -2430,3 +2443,80 @@ async fn collab_full_sync_handler( Err(err) => Ok(err.error_response()), } } + +async fn post_quick_note_handler( + user_uuid: UserUuid, + workspace_id: web::Path, + state: Data, + data: Json, +) -> Result> { + let workspace_id = workspace_id.into_inner(); + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + state + .workspace_access_control + .enforce_role(&uid, &workspace_id.to_string(), AFRole::Member) + .await?; + let data = data.into_inner(); + let quick_note = create_quick_note(&state.pg_pool, uid, workspace_id, data.data.as_ref()).await?; + Ok(Json(AppResponse::Ok().with_data(quick_note))) +} + +async fn list_quick_notes_handler( + user_uuid: UserUuid, + workspace_id: web::Path, + state: Data, + query: web::Query, +) -> Result> { + let workspace_id = workspace_id.into_inner(); + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + state + .workspace_access_control + .enforce_role(&uid, &workspace_id.to_string(), AFRole::Member) + .await?; + let ListQuickNotesQueryParams { + search_term, + offset, + limit, + } = query.into_inner(); + let quick_notes = list_quick_notes( + &state.pg_pool, + uid, + workspace_id, + search_term, + offset, + limit, + ) + .await?; + Ok(Json(AppResponse::Ok().with_data(quick_notes))) +} + +async fn update_quick_note_handler( + user_uuid: UserUuid, + path_param: web::Path<(Uuid, Uuid)>, + state: Data, + data: Json, +) -> Result> { + let (workspace_id, quick_note_id) = path_param.into_inner(); + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + state + .workspace_access_control + .enforce_role(&uid, &workspace_id.to_string(), AFRole::Member) + .await?; + update_quick_note(&state.pg_pool, quick_note_id, &data.data).await?; + Ok(Json(AppResponse::Ok())) +} + +async fn delete_quick_note_handler( + user_uuid: UserUuid, + path_param: web::Path<(Uuid, Uuid)>, + state: Data, +) -> Result> { + let (workspace_id, quick_note_id) = path_param.into_inner(); + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + state + .workspace_access_control + .enforce_role(&uid, &workspace_id.to_string(), AFRole::Member) + .await?; + delete_quick_note(&state.pg_pool, quick_note_id).await?; + Ok(Json(AppResponse::Ok())) +} diff --git a/src/biz/workspace/mod.rs b/src/biz/workspace/mod.rs index 1eb7ebf13..2249096d2 100644 --- a/src/biz/workspace/mod.rs +++ b/src/biz/workspace/mod.rs @@ -2,3 +2,4 @@ pub mod ops; pub mod page_view; pub mod publish; pub mod publish_dup; +pub mod quick_note; diff --git a/src/biz/workspace/quick_note.rs b/src/biz/workspace/quick_note.rs new file mode 100644 index 000000000..c2534eb03 --- /dev/null +++ b/src/biz/workspace/quick_note.rs @@ -0,0 +1,74 @@ +use app_error::AppError; +use database::quick_note::{ + delete_quick_note_by_id, insert_new_quick_note, select_quick_notes_with_one_more_than_limit, + update_quick_note_by_id, +}; +use serde_json::json; +use sqlx::PgPool; +use uuid::Uuid; + +use database_entity::dto::{QuickNote, QuickNotes}; + +pub async fn create_quick_note( + pg_pool: &PgPool, + uid: i64, + workspace_id: Uuid, + data: Option<&serde_json::Value>, +) -> Result { + let default_data = json!([ + { + "type": "paragraph", + "delta": { + "insert": "", + }, + } + ]); + let new_data = data.unwrap_or(&default_data); + let quick_note = insert_new_quick_note(pg_pool, workspace_id, uid, new_data).await?; + Ok(quick_note) +} + +pub async fn update_quick_note( + pg_pool: &PgPool, + quick_note_id: Uuid, + data: &serde_json::Value, +) -> Result<(), AppError> { + update_quick_note_by_id(pg_pool, quick_note_id, data).await +} + +pub async fn delete_quick_note(pg_pool: &PgPool, quick_note_id: Uuid) -> Result<(), AppError> { + delete_quick_note_by_id(pg_pool, quick_note_id).await +} + +pub async fn list_quick_notes( + pg_pool: &PgPool, + uid: i64, + workspace_id: Uuid, + search_term: Option, + offset: Option, + limit: Option, +) -> Result { + let mut quick_notes_with_one_more_than_limit = select_quick_notes_with_one_more_than_limit( + pg_pool, + workspace_id, + uid, + search_term, + offset, + limit, + ) + .await?; + let has_more = if let Some(limit) = limit { + quick_notes_with_one_more_than_limit.len() as i32 > limit + } else { + false + }; + if let Some(limit) = limit { + quick_notes_with_one_more_than_limit.truncate(limit as usize); + } + let quick_notes = quick_notes_with_one_more_than_limit; + + Ok(QuickNotes { + quick_notes, + has_more, + }) +} diff --git a/tests/workspace/mod.rs b/tests/workspace/mod.rs index 2f35a24f3..d254b63ce 100644 --- a/tests/workspace/mod.rs +++ b/tests/workspace/mod.rs @@ -7,6 +7,7 @@ mod member_crud; mod page_view; mod publish; mod published_data; +mod quick_note; mod template; mod workspace_crud; mod workspace_folder; diff --git a/tests/workspace/quick_note.rs b/tests/workspace/quick_note.rs new file mode 100644 index 000000000..6824b3b32 --- /dev/null +++ b/tests/workspace/quick_note.rs @@ -0,0 +1,143 @@ +use std::time::Duration; + +use client_api_test::TestClient; +use serde_json::json; +use tokio::time; +use uuid::Uuid; + +#[tokio::test] +async fn quick_note_crud_test() { + let client = TestClient::new_user_without_ws_conn().await; + let workspace_id = client.workspace_id().await; + let workspace_uuid = Uuid::parse_str(&workspace_id).unwrap(); + let mut quick_note_ids: Vec = vec![]; + for _ in 0..2 { + let quick_note = client + .api_client + .create_quick_note(workspace_uuid, None) + .await + .expect("create quick note"); + quick_note_ids.push(quick_note.id); + // To ensure that the creation time is different + time::sleep(Duration::from_millis(1)).await; + } + let quick_notes = client + .api_client + .list_quick_notes(workspace_uuid, None, None, None) + .await + .expect("list quick notes"); + assert_eq!(quick_notes.quick_notes.len(), 2); + assert!(!quick_notes.has_more); + + let quick_note_id_1 = quick_note_ids[0]; + let quick_note_id_2 = quick_note_ids[1]; + let data_1 = json!([ + { + "type": "paragraph", + "delta": { + "insert": "orange", + "attributes": { + "bold": true + }, + }, + }, + { + "type": "heading", + "data": { + "level": 1 + }, + "delta": { + "insert": "apple", + "attributes": { + "bold": true + }, + }, + }, + ]); + let data_2 = json!([ + { + "type": "paragraph", + "delta": { + "insert": "banana", + "attributes": { + "bold": true + }, + }, + }, + { + "type": "heading", + "data": { + "level": 1 + }, + "delta": { + "insert": "melon", + "attributes": { + "bold": true + }, + }, + }, + ]); + client + .api_client + .update_quick_note(workspace_uuid, quick_note_id_1, data_1) + .await + .expect("update quick note"); + client + .api_client + .update_quick_note(workspace_uuid, quick_note_id_2, data_2) + .await + .expect("update quick note"); + let quick_notes = client + .api_client + .list_quick_notes(workspace_uuid, None, None, None) + .await + .expect("list quick notes"); + assert_eq!(quick_notes.quick_notes.len(), 2); + let quick_notes = client + .api_client + .list_quick_notes(workspace_uuid, Some("".to_string()), None, None) + .await + .expect("list quick notes with empty search term"); + assert_eq!(quick_notes.quick_notes.len(), 2); + let quick_notes_with_offset_and_limit = client + .api_client + .list_quick_notes(workspace_uuid, None, Some(1), Some(1)) + .await + .expect("list quick notes with offset and limit"); + assert_eq!(quick_notes_with_offset_and_limit.quick_notes.len(), 1); + assert!(!quick_notes_with_offset_and_limit.has_more); + assert_eq!( + quick_notes_with_offset_and_limit.quick_notes[0].id, + quick_note_id_1 + ); + let quick_notes_with_offset_and_limit = client + .api_client + .list_quick_notes(workspace_uuid, None, Some(0), Some(1)) + .await + .expect("list quick notes with offset and limit"); + assert_eq!(quick_notes_with_offset_and_limit.quick_notes.len(), 1); + assert!(quick_notes_with_offset_and_limit.has_more); + assert_eq!( + quick_notes_with_offset_and_limit.quick_notes[0].id, + quick_note_id_2 + ); + let filtered_quick_notes = client + .api_client + .list_quick_notes(workspace_uuid, Some("pple".to_string()), None, None) + .await + .expect("list quick notes with filter"); + assert_eq!(filtered_quick_notes.quick_notes.len(), 1); + assert_eq!(filtered_quick_notes.quick_notes[0].id, quick_note_id_1); + client + .api_client + .delete_quick_note(workspace_uuid, quick_note_id_1) + .await + .expect("delete quick note"); + let quick_notes = client + .api_client + .list_quick_notes(workspace_uuid, None, None, None) + .await + .expect("list quick notes"); + assert_eq!(quick_notes.quick_notes.len(), 1); + assert_eq!(quick_notes.quick_notes[0].id, quick_note_id_2); +}