feat: api endpoint for template categories and creators

This commit is contained in:
khorshuheng 2024-08-06 15:14:00 +08:00
parent ea27e87103
commit f6e78a941f
32 changed files with 1784 additions and 71 deletions

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserType>\"\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 GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ",
"query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserColumn>\"\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 GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ",
"describe": {
"columns": [
{
@ -15,7 +15,7 @@
},
{
"ordinal": 2,
"name": "react_users!: Vec<AFWebUserType>",
"name": "react_users!: Vec<AFWebUserColumn>",
"type_info": "RecordArray"
}
],
@ -30,5 +30,5 @@
null
]
},
"hash": "da2614b887d500a0660930a15ca18a083a5535f1442602475c5d62350d88761f"
"hash": "056174448a2ff0744b5943ba6d303b180ca9016cd26d284686f445c060cec4c5"
}

View file

@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH\n updated_creator AS (\n UPDATE af_template_creator\n SET name = $2, avatar_url = $3\n WHERE creator_id = $1\n RETURNING creator_id, name, avatar_url\n ),\n account_links AS (\n INSERT INTO af_template_creator_account_link (creator_id, link_type, url)\n SELECT updated_creator.creator_id as creator_id, link_type, url FROM\n UNNEST($4::text[], $5::text[]) AS t(link_type, url)\n CROSS JOIN updated_creator\n RETURNING\n creator_id,\n link_type,\n url\n )\n SELECT\n updated_creator.creator_id AS id,\n name,\n avatar_url,\n ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec<AccountLinkColumn>\"\n FROM updated_creator\n LEFT OUTER JOIN account_links\n ON updated_creator.creator_id = account_links.creator_id\n GROUP BY (id, name, avatar_url)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "avatar_url",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "account_links: Vec<AccountLinkColumn>",
"type_info": "RecordArray"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
false,
false,
null
]
},
"hash": "14dfaee23af2d3206acf9141b9bdcafb08aaf496f334e4b9292f88740f872855"
}

View file

@ -0,0 +1,43 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH\n new_creator AS (\n INSERT INTO af_template_creator (name, avatar_url)\n VALUES ($1, $2)\n RETURNING creator_id, name, avatar_url\n ),\n account_links AS (\n INSERT INTO af_template_creator_account_link (creator_id, link_type, url)\n SELECT new_creator.creator_id as creator_id, link_type, url FROM\n UNNEST($3::text[], $4::text[]) AS t(link_type, url)\n CROSS JOIN new_creator\n RETURNING\n creator_id,\n link_type,\n url\n )\n SELECT\n new_creator.creator_id AS id,\n name,\n avatar_url,\n ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec<AccountLinkColumn>\"\n FROM new_creator\n LEFT OUTER JOIN account_links\n ON new_creator.creator_id = account_links.creator_id\n GROUP BY (id, name, avatar_url)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "avatar_url",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "account_links: Vec<AccountLinkColumn>",
"type_info": "RecordArray"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
false,
false,
null
]
},
"hash": "23ce30adcad72a7beba4db6a1a9a5947b433aa28d1ac8a1e9fa328aa751fe4a2"
}

View file

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM af_template_category\n WHERE category_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "425b0b5ffbe3f1b80aedf15b8df1640c879d8d45883eee8b1e2fbd64eaf283d6"
}

View file

@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n category_id AS id,\n name,\n description,\n icon,\n bg_color,\n category_type AS \"category_type: AFTemplateCategoryTypeColumn\",\n rank\n FROM af_template_category\n WHERE category_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "icon",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "bg_color",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "category_type: AFTemplateCategoryTypeColumn",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "rank",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "4f8bb1345f524f5a11ae74357265d8e308eebb562117bb3f36c3b25f9e1c5e1b"
}

View file

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM af_template_creator_account_link\n WHERE creator_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "5d51aef40f7e0716338b406263240dbc5e4a64cec6f1be10a3676e4f86ce4557"
}

View file

@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n tc.creator_id AS \"id!\",\n name AS \"name!\",\n avatar_url AS \"avatar_url!\",\n ARRAY_AGG((al.link_type, al.url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec<AccountLinkColumn>\"\n FROM af_template_creator tc\n LEFT OUTER JOIN af_template_creator_account_link al\n ON tc.creator_id = al.creator_id\n WHERE tc.creator_id = $1\n GROUP BY (tc.creator_id, name, avatar_url)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "avatar_url!",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "account_links: Vec<AccountLinkColumn>",
"type_info": "RecordArray"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
null
]
},
"hash": "74a5cf06694c10d96b19f2d448830ebe95e0713870ad66e16416fa914857aa7d"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserType>\",\n avr.comment_id\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 GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ",
"query": "\n SELECT\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserColumn>\",\n avr.comment_id\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 GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ",
"describe": {
"columns": [
{
@ -10,7 +10,7 @@
},
{
"ordinal": 1,
"name": "react_users!: Vec<AFWebUserType>",
"name": "react_users!: Vec<AFWebUserColumn>",
"type_info": "RecordArray"
},
{
@ -30,5 +30,5 @@
false
]
},
"hash": "e4fac6abfa1b722fc1ce6832d9c262a26bcaa97d03dfd48c8bcc6f40fddd3e35"
"hash": "b58432fffcf04a9485a7db5908c1801b34f51e51f3b06f679dc62e068e1cc721"
}

View file

@ -0,0 +1,63 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO af_template_category (name, description, icon, bg_color, category_type, rank)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING\n category_id AS id,\n name,\n description,\n icon,\n bg_color,\n category_type AS \"category_type: AFTemplateCategoryTypeColumn\",\n rank\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "icon",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "bg_color",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "category_type: AFTemplateCategoryTypeColumn",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "rank",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Int4",
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "b9d4e564e23df7ab391c78848b6a9d51e30496c58e70b26f14dfe55b6a0d2e69"
}

View file

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM af_template_creator\n WHERE creator_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "c335b73ad499b67100e4ce3131a526ddf1745488597c3392ae05e4b398a8715e"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n avc.comment_id,\n avc.created_at,\n avc.updated_at AS last_updated_at,\n avc.content,\n avc.reply_comment_id,\n avc.is_deleted,\n (au.uuid, au.name, au.metadata ->> 'icon_url') AS \"user: AFWebUserType\",\n (NOT avc.is_deleted AND ($2 OR au.uuid = $3)) AS \"can_be_deleted!\"\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 ORDER BY avc.created_at DESC\n ",
"query": "\n SELECT\n avc.comment_id,\n avc.created_at,\n avc.updated_at AS last_updated_at,\n avc.content,\n avc.reply_comment_id,\n avc.is_deleted,\n (au.uuid, au.name, au.metadata ->> 'icon_url') AS \"user: AFWebUserColumn\",\n (NOT avc.is_deleted AND ($2 OR au.uuid = $3)) AS \"can_be_deleted!\"\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 ORDER BY avc.created_at DESC\n ",
"describe": {
"columns": [
{
@ -35,7 +35,7 @@
},
{
"ordinal": 6,
"name": "user: AFWebUserType",
"name": "user: AFWebUserColumn",
"type_info": "Record"
},
{
@ -62,5 +62,5 @@
null
]
},
"hash": "ab70b4ed70ff91c2a8d335a52da92c934fa1c0528b8b5d80e8e36a24539cda26"
"hash": "c5c72869f44067d90c3224a17ec0e32b10cdf9378947e2c7a8409e48423377eb"
}

View file

@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE af_template_category\n SET\n name = $2,\n description = $3,\n icon = $4,\n bg_color = $5,\n category_type = $6,\n rank = $7\n WHERE category_id = $1\n RETURNING\n category_id AS id,\n name,\n description,\n icon,\n bg_color,\n category_type AS \"category_type: AFTemplateCategoryTypeColumn\",\n rank\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "icon",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "bg_color",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "category_type: AFTemplateCategoryTypeColumn",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "rank",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Text",
"Text",
"Int4",
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "e011a91040db0ffdaf46c859e58b14ad6503b2f4eab33d3b32d677c9731aae79"
}

View file

@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n tc.creator_id AS \"id!\",\n name AS \"name!\",\n avatar_url AS \"avatar_url!\",\n ARRAY_AGG((al.link_type, al.url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec<AccountLinkColumn>\"\n FROM af_template_creator tc\n LEFT OUTER JOIN af_template_creator_account_link al\n ON tc.creator_id = al.creator_id\n WHERE name LIKE $1\n GROUP BY (tc.creator_id, name, avatar_url)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "avatar_url!",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "account_links: Vec<AccountLinkColumn>",
"type_info": "RecordArray"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
null
]
},
"hash": "e6be710a038f8fbcc979c9bd52eb42c65941003857e7004ca809d658d3005a4f"
}

View file

@ -0,0 +1,232 @@
use client_api_entity::{
AccountLink, CreateTemplateCategoryParams, CreateTemplateCreatorParams,
GetTemplateCategoriesQueryParams, GetTemplateCreatorsQueryParams, TemplateCategories,
TemplateCategory, TemplateCategoryType, TemplateCreator, TemplateCreators,
UpdateTemplateCategoryParams, UpdateTemplateCreatorParams,
};
use reqwest::Method;
use shared_entity::response::{AppResponse, AppResponseError};
use uuid::Uuid;
use crate::Client;
fn template_api_prefix(base_url: &str) -> String {
format!("{}/api/template-center", base_url)
}
fn category_resources_url(base_url: &str) -> String {
format!("{}/category", template_api_prefix(base_url))
}
fn category_resource_url(base_url: &str, category_id: &Uuid) -> String {
format!("{}/{}", category_resources_url(base_url), category_id)
}
fn template_creator_resources_url(base_url: &str) -> String {
format!("{}/creator", template_api_prefix(base_url))
}
fn template_creator_resource_url(base_url: &str, creator_id: &Uuid) -> String {
format!(
"{}/{}",
template_creator_resources_url(base_url),
creator_id
)
}
impl Client {
pub async fn create_template_category(
&self,
name: &str,
icon: &str,
bg_color: &str,
description: &str,
category_type: TemplateCategoryType,
rank: i32,
) -> Result<TemplateCategory, AppResponseError> {
let url = category_resources_url(&self.base_url);
let resp = self
.http_client_with_auth(Method::POST, &url)
.await?
.json(&CreateTemplateCategoryParams {
name: name.to_string(),
icon: icon.to_string(),
bg_color: bg_color.to_string(),
description: description.to_string(),
rank,
category_type,
})
.send()
.await?;
AppResponse::<TemplateCategory>::from_response(resp)
.await?
.into_data()
}
pub async fn get_template_categories(
&self,
name_contains: Option<&str>,
category_type: Option<TemplateCategoryType>,
) -> Result<TemplateCategories, AppResponseError> {
let url = category_resources_url(&self.base_url);
let resp = self
.http_client_without_auth(Method::GET, &url)
.await?
.query(&GetTemplateCategoriesQueryParams {
name_contains: name_contains.map(|s| s.to_string()),
category_type,
})
.send()
.await?;
AppResponse::<TemplateCategories>::from_response(resp)
.await?
.into_data()
}
pub async fn get_template_category(
&self,
category_id: &Uuid,
) -> Result<TemplateCategory, AppResponseError> {
let url = category_resource_url(&self.base_url, category_id);
let resp = self
.http_client_without_auth(Method::GET, &url)
.await?
.send()
.await?;
AppResponse::<TemplateCategory>::from_response(resp)
.await?
.into_data()
}
pub async fn delete_template_category(&self, category_id: &Uuid) -> Result<(), AppResponseError> {
let url = category_resource_url(&self.base_url, category_id);
let resp = self
.http_client_with_auth(Method::DELETE, &url)
.await?
.send()
.await?;
AppResponse::<()>::from_response(resp).await?.into_error()
}
#[allow(clippy::too_many_arguments)]
pub async fn update_template_category(
&self,
category_id: &Uuid,
name: &str,
icon: &str,
bg_color: &str,
description: &str,
category_type: TemplateCategoryType,
rank: i32,
) -> Result<TemplateCategory, AppResponseError> {
let url = category_resource_url(&self.base_url, category_id);
let resp = self
.http_client_with_auth(Method::PUT, &url)
.await?
.json(&UpdateTemplateCategoryParams {
name: name.to_string(),
icon: icon.to_string(),
bg_color: bg_color.to_string(),
description: description.to_string(),
category_type,
rank,
})
.send()
.await?;
AppResponse::<TemplateCategory>::from_response(resp)
.await?
.into_data()
}
pub async fn create_template_creator(
&self,
name: &str,
avatar_url: &str,
account_links: Vec<AccountLink>,
) -> Result<TemplateCreator, AppResponseError> {
let url = template_creator_resources_url(&self.base_url);
let resp = self
.http_client_with_auth(Method::POST, &url)
.await?
.json(&CreateTemplateCreatorParams {
name: name.to_string(),
avatar_url: avatar_url.to_string(),
account_links,
})
.send()
.await?;
AppResponse::<TemplateCreator>::from_response(resp)
.await?
.into_data()
}
pub async fn get_template_creators(
&self,
name_contains: Option<&str>,
) -> Result<TemplateCreators, AppResponseError> {
let url = template_creator_resources_url(&self.base_url);
let resp = self
.http_client_without_auth(Method::GET, &url)
.await?
.query(&GetTemplateCreatorsQueryParams {
name_contains: name_contains.map(|s| s.to_string()),
})
.send()
.await?;
AppResponse::<TemplateCreators>::from_response(resp)
.await?
.into_data()
}
pub async fn get_template_creator(
&self,
creator_id: &Uuid,
) -> Result<TemplateCreator, AppResponseError> {
let url = template_creator_resource_url(&self.base_url, creator_id);
let resp = self
.http_client_without_auth(Method::GET, &url)
.await?
.send()
.await?;
AppResponse::<TemplateCreator>::from_response(resp)
.await?
.into_data()
}
pub async fn delete_template_creator(&self, creator_id: &Uuid) -> Result<(), AppResponseError> {
let url = template_creator_resource_url(&self.base_url, creator_id);
let resp = self
.http_client_with_auth(Method::DELETE, &url)
.await?
.send()
.await?;
AppResponse::<()>::from_response(resp).await?.into_error()
}
pub async fn update_template_creator(
&self,
creator_id: &Uuid,
name: &str,
avatar_url: &str,
account_links: Vec<AccountLink>,
) -> Result<TemplateCreator, AppResponseError> {
let url = template_creator_resource_url(&self.base_url, creator_id);
let resp = self
.http_client_with_auth(Method::PUT, &url)
.await?
.json(&UpdateTemplateCreatorParams {
name: name.to_string(),
avatar_url: avatar_url.to_string(),
account_links,
})
.send()
.await?;
AppResponse::<TemplateCreator>::from_response(resp)
.await?
.into_data()
}
}

View file

@ -7,6 +7,7 @@ mod http_collab;
mod http_history;
mod http_member;
mod http_publish;
mod http_template;
pub use http::*;
#[cfg(feature = "collab-sync")]

View file

@ -997,6 +997,93 @@ pub enum IndexingStatus {
Indexed,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TemplateCategories {
pub categories: Vec<TemplateCategory>,
}
#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, Copy, Clone)]
#[repr(i32)]
pub enum TemplateCategoryType {
UseCase = 0,
Feature = 1,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TemplateCategory {
pub id: Uuid,
pub name: String,
pub icon: String,
pub bg_color: String,
pub description: String,
pub category_type: TemplateCategoryType,
pub rank: i32,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CreateTemplateCategoryParams {
pub name: String,
pub icon: String,
pub bg_color: String,
pub description: String,
pub category_type: TemplateCategoryType,
pub rank: i32,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GetTemplateCategoriesQueryParams {
pub name_contains: Option<String>,
pub category_type: Option<TemplateCategoryType>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateTemplateCategoryParams {
pub name: String,
pub icon: String,
pub bg_color: String,
pub description: String,
pub category_type: TemplateCategoryType,
pub rank: i32,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TemplateCreators {
pub creators: Vec<TemplateCreator>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct AccountLink {
pub link_type: String,
pub url: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TemplateCreator {
pub id: Uuid,
pub name: String,
pub avatar_url: String,
pub account_links: Vec<AccountLink>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CreateTemplateCreatorParams {
pub name: String,
pub avatar_url: String,
pub account_links: Vec<AccountLink>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateTemplateCreatorParams {
pub name: String,
pub avatar_url: String,
pub account_links: Vec<AccountLink>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GetTemplateCreatorsQueryParams {
pub name_contains: Option<String>,
}
#[cfg(test)]
mod test {
use crate::dto::{CollabParams, CollabParamsV0};

View file

@ -6,5 +6,6 @@ pub mod index;
pub mod listener;
pub mod pg_row;
pub mod resource_usage;
pub mod template;
pub mod user;
pub mod workspace;

View file

@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
use database_entity::dto::{
AFAccessLevel, AFRole, AFUserProfile, AFWebUser, AFWorkspace, AFWorkspaceInvitationStatus,
GlobalComment, Reaction,
AccountLink, GlobalComment, Reaction, TemplateCategory, TemplateCategoryType, TemplateCreator,
};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
@ -218,14 +218,14 @@ pub struct AFChatMessageRow {
}
#[derive(sqlx::Type, Serialize, Debug)]
pub struct AFWebUserType {
pub struct AFWebUserColumn {
uuid: Uuid,
name: String,
avatar_url: Option<String>,
}
impl From<AFWebUserType> for AFWebUser {
fn from(val: AFWebUserType) -> Self {
impl From<AFWebUserColumn> for AFWebUser {
fn from(val: AFWebUserColumn) -> Self {
AFWebUser {
uuid: val.uuid,
name: val.name,
@ -235,7 +235,7 @@ impl From<AFWebUserType> for AFWebUser {
}
pub struct AFGlobalCommentRow {
pub user: Option<AFWebUserType>,
pub user: Option<AFWebUserColumn>,
pub created_at: DateTime<Utc>,
pub last_updated_at: DateTime<Utc>,
pub content: String,
@ -262,7 +262,7 @@ impl From<AFGlobalCommentRow> for GlobalComment {
pub struct AFReactionRow {
pub reaction_type: String,
pub react_users: Vec<AFWebUserType>,
pub react_users: Vec<AFWebUserColumn>,
pub comment_id: Uuid,
}
@ -275,3 +275,93 @@ impl From<AFReactionRow> for Reaction {
}
}
}
#[derive(Debug, FromRow, Serialize)]
pub struct AFTemplateCategoryRow {
pub id: Uuid,
pub name: String,
pub icon: String,
pub bg_color: String,
pub description: String,
pub category_type: AFTemplateCategoryTypeColumn,
pub rank: i32,
}
impl From<AFTemplateCategoryRow> for TemplateCategory {
fn from(value: AFTemplateCategoryRow) -> Self {
Self {
id: value.id,
name: value.name,
icon: value.icon,
bg_color: value.bg_color,
description: value.description,
category_type: value.category_type.into(),
rank: value.rank,
}
}
}
#[derive(sqlx::Type, Serialize, Debug)]
#[repr(i32)]
pub enum AFTemplateCategoryTypeColumn {
UseCase = 0,
Feature = 1,
}
impl From<AFTemplateCategoryTypeColumn> for TemplateCategoryType {
fn from(value: AFTemplateCategoryTypeColumn) -> Self {
match value {
AFTemplateCategoryTypeColumn::UseCase => TemplateCategoryType::UseCase,
AFTemplateCategoryTypeColumn::Feature => TemplateCategoryType::Feature,
}
}
}
impl From<TemplateCategoryType> for AFTemplateCategoryTypeColumn {
fn from(val: TemplateCategoryType) -> Self {
match val {
TemplateCategoryType::UseCase => AFTemplateCategoryTypeColumn::UseCase,
TemplateCategoryType::Feature => AFTemplateCategoryTypeColumn::Feature,
}
}
}
#[derive(sqlx::Type, Serialize, Debug)]
pub struct AccountLinkColumn {
pub link_type: String,
pub url: String,
}
impl From<AccountLinkColumn> for AccountLink {
fn from(value: AccountLinkColumn) -> Self {
Self {
link_type: value.link_type,
url: value.url,
}
}
}
#[derive(Debug, Serialize)]
pub struct AFTemplateCreatorRow {
pub id: Uuid,
pub name: String,
pub avatar_url: String,
pub account_links: Option<Vec<AccountLinkColumn>>,
}
impl From<AFTemplateCreatorRow> for TemplateCreator {
fn from(value: AFTemplateCreatorRow) -> Self {
let account_links = value
.account_links
.unwrap_or_default()
.into_iter()
.map(|v| v.into())
.collect();
Self {
id: value.id,
name: value.name,
avatar_url: value.avatar_url,
account_links,
}
}
}

View file

@ -0,0 +1,372 @@
use app_error::AppError;
use database_entity::dto::{AccountLink, TemplateCategory, TemplateCategoryType, TemplateCreator};
use sqlx::{Executor, Postgres, QueryBuilder};
use uuid::Uuid;
use crate::pg_row::{
AFTemplateCategoryRow, AFTemplateCategoryTypeColumn, AFTemplateCreatorRow, AccountLinkColumn,
};
pub async fn insert_new_template_category<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
name: &str,
description: &str,
icon: &str,
bg_color: &str,
category_type: TemplateCategoryType,
rank: i32,
) -> Result<TemplateCategory, AppError> {
let category_type_column: AFTemplateCategoryTypeColumn = category_type.into();
let new_template_category = sqlx::query_as!(
TemplateCategory,
r#"
INSERT INTO af_template_category (name, description, icon, bg_color, category_type, rank)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING
category_id AS id,
name,
description,
icon,
bg_color,
category_type AS "category_type: AFTemplateCategoryTypeColumn",
rank
"#,
name,
description,
icon,
bg_color,
category_type_column as AFTemplateCategoryTypeColumn,
rank,
)
.fetch_one(executor)
.await?;
Ok(new_template_category)
}
#[allow(clippy::too_many_arguments)]
pub async fn update_template_category_by_id<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
id: &Uuid,
name: &str,
description: &str,
icon: &str,
bg_color: &str,
category_type: TemplateCategoryType,
rank: i32,
) -> Result<TemplateCategory, AppError> {
let category_type_column: AFTemplateCategoryTypeColumn = category_type.into();
let new_template_category = sqlx::query_as!(
TemplateCategory,
r#"
UPDATE af_template_category
SET
name = $2,
description = $3,
icon = $4,
bg_color = $5,
category_type = $6,
rank = $7
WHERE category_id = $1
RETURNING
category_id AS id,
name,
description,
icon,
bg_color,
category_type AS "category_type: AFTemplateCategoryTypeColumn",
rank
"#,
id,
name,
description,
icon,
bg_color,
category_type_column as AFTemplateCategoryTypeColumn,
rank,
)
.fetch_one(executor)
.await?;
Ok(new_template_category)
}
pub async fn select_template_categories<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
name_contains: Option<&str>,
category_type: Option<TemplateCategoryType>,
) -> Result<Vec<TemplateCategory>, AppError> {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
r#"
SELECT
category_id AS id,
name,
description,
icon,
bg_color,
category_type,
rank
FROM af_template_category
WHERE TRUE
"#,
);
if let Some(category_type) = category_type {
let category_type_column: AFTemplateCategoryTypeColumn = category_type.into();
query_builder.push(" AND category_type = ");
query_builder.push_bind(category_type_column);
};
if let Some(name_contains) = name_contains {
query_builder.push(" AND name ILIKE CONCAT('%', ");
query_builder.push_bind(name_contains);
query_builder.push(" , '%')");
};
query_builder.push(" ORDER BY rank ASC");
let query = query_builder.build_query_as::<AFTemplateCategoryRow>();
let category_rows: Vec<AFTemplateCategoryRow> = query.fetch_all(executor).await?;
let categories = category_rows.into_iter().map(|row| row.into()).collect();
Ok(categories)
}
pub async fn select_template_category_by_id<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
category_id: &Uuid,
) -> Result<TemplateCategory, AppError> {
let category = sqlx::query_as!(
TemplateCategory,
r#"
SELECT
category_id AS id,
name,
description,
icon,
bg_color,
category_type AS "category_type: AFTemplateCategoryTypeColumn",
rank
FROM af_template_category
WHERE category_id = $1
"#,
category_id,
)
.fetch_one(executor)
.await?;
Ok(category)
}
pub async fn delete_template_category_by_id<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
category_id: &Uuid,
) -> Result<(), AppError> {
let rows_affected = sqlx::query!(
r#"
DELETE FROM af_template_category
WHERE category_id = $1
"#,
category_id,
)
.execute(executor)
.await?
.rows_affected();
if rows_affected == 0 {
tracing::error!(
"No template category with id {} was found to delete",
category_id
);
}
Ok(())
}
pub async fn insert_template_creator<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
name: &str,
avatar_url: &str,
account_links: &[AccountLink],
) -> Result<TemplateCreator, AppError> {
let link_types: Vec<String> = account_links
.iter()
.map(|link| link.link_type.clone())
.collect();
let url: Vec<String> = account_links.iter().map(|link| link.url.clone()).collect();
let new_template_creator_row = sqlx::query_as!(
AFTemplateCreatorRow,
r#"
WITH
new_creator AS (
INSERT INTO af_template_creator (name, avatar_url)
VALUES ($1, $2)
RETURNING creator_id, name, avatar_url
),
account_links AS (
INSERT INTO af_template_creator_account_link (creator_id, link_type, url)
SELECT new_creator.creator_id as creator_id, link_type, url FROM
UNNEST($3::text[], $4::text[]) AS t(link_type, url)
CROSS JOIN new_creator
RETURNING
creator_id,
link_type,
url
)
SELECT
new_creator.creator_id AS id,
name,
avatar_url,
ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS "account_links: Vec<AccountLinkColumn>"
FROM new_creator
LEFT OUTER JOIN account_links
ON new_creator.creator_id = account_links.creator_id
GROUP BY (id, name, avatar_url)
"#,
name,
avatar_url,
link_types.as_slice(),
url.as_slice(),
)
.fetch_one(executor)
.await?;
let new_template_creator = new_template_creator_row.into();
Ok(new_template_creator)
}
pub async fn update_template_creator_by_id<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
creator_id: &Uuid,
name: &str,
avatar_url: &str,
account_links: &[AccountLink],
) -> Result<TemplateCreator, AppError> {
let link_types: Vec<String> = account_links
.iter()
.map(|link| link.link_type.clone())
.collect();
let url: Vec<String> = account_links.iter().map(|link| link.url.clone()).collect();
let updated_template_creator_row = sqlx::query_as!(
AFTemplateCreatorRow,
r#"
WITH
updated_creator AS (
UPDATE af_template_creator
SET name = $2, avatar_url = $3
WHERE creator_id = $1
RETURNING creator_id, name, avatar_url
),
account_links AS (
INSERT INTO af_template_creator_account_link (creator_id, link_type, url)
SELECT updated_creator.creator_id as creator_id, link_type, url FROM
UNNEST($4::text[], $5::text[]) AS t(link_type, url)
CROSS JOIN updated_creator
RETURNING
creator_id,
link_type,
url
)
SELECT
updated_creator.creator_id AS id,
name,
avatar_url,
ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS "account_links: Vec<AccountLinkColumn>"
FROM updated_creator
LEFT OUTER JOIN account_links
ON updated_creator.creator_id = account_links.creator_id
GROUP BY (id, name, avatar_url)
"#,
creator_id,
name,
avatar_url,
link_types.as_slice(),
url.as_slice(),
)
.fetch_one(executor)
.await?;
let updated_template_creator = updated_template_creator_row.into();
Ok(updated_template_creator)
}
pub async fn delete_template_creator_account_links<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
creator_id: &Uuid,
) -> Result<(), AppError> {
sqlx::query!(
r#"
DELETE FROM af_template_creator_account_link
WHERE creator_id = $1
"#,
creator_id,
)
.execute(executor)
.await?;
Ok(())
}
pub async fn select_template_creators_by_name<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
substr_match: &str,
) -> Result<Vec<TemplateCreator>, AppError> {
let creator_rows = sqlx::query_as!(
AFTemplateCreatorRow,
r#"
SELECT
tc.creator_id AS "id!",
name AS "name!",
avatar_url AS "avatar_url!",
ARRAY_AGG((al.link_type, al.url)) FILTER (WHERE link_type IS NOT NULL) AS "account_links: Vec<AccountLinkColumn>"
FROM af_template_creator tc
LEFT OUTER JOIN af_template_creator_account_link al
ON tc.creator_id = al.creator_id
WHERE name LIKE $1
GROUP BY (tc.creator_id, name, avatar_url)
"#,
substr_match
)
.fetch_all(executor)
.await?;
let creators = creator_rows.into_iter().map(|row| row.into()).collect();
Ok(creators)
}
pub async fn select_template_creator_by_id<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
creator_id: &Uuid,
) -> Result<TemplateCreator, AppError> {
let creator_row = sqlx::query_as!(
AFTemplateCreatorRow,
r#"
SELECT
tc.creator_id AS "id!",
name AS "name!",
avatar_url AS "avatar_url!",
ARRAY_AGG((al.link_type, al.url)) FILTER (WHERE link_type IS NOT NULL) AS "account_links: Vec<AccountLinkColumn>"
FROM af_template_creator tc
LEFT OUTER JOIN af_template_creator_account_link al
ON tc.creator_id = al.creator_id
WHERE tc.creator_id = $1
GROUP BY (tc.creator_id, name, avatar_url)
"#,
creator_id
)
.fetch_one(executor)
.await?;
let creator = creator_row.into();
Ok(creator)
}
pub async fn delete_template_creator_by_id<'a, E: Executor<'a, Database = Postgres>>(
executor: E,
creator_id: &Uuid,
) -> Result<(), AppError> {
let rows_affected = sqlx::query!(
r#"
DELETE FROM af_template_creator
WHERE creator_id = $1
"#,
creator_id,
)
.execute(executor)
.await?
.rows_affected();
if rows_affected == 0 {
tracing::error!(
"No template creator with id {} was found to delete",
creator_id
);
}
Ok(())
}

View file

@ -9,7 +9,7 @@ use tracing::{event, instrument};
use uuid::Uuid;
use crate::pg_row::{
AFGlobalCommentRow, AFPermissionRow, AFReactionRow, AFUserProfileRow, AFWebUserType,
AFGlobalCommentRow, AFPermissionRow, AFReactionRow, AFUserProfileRow, AFWebUserColumn,
AFWorkspaceInvitationMinimal, AFWorkspaceMemberPermRow, AFWorkspaceMemberRow, AFWorkspaceRow,
};
use crate::user::select_uid_from_email;
@ -1158,7 +1158,7 @@ pub async fn select_comments_for_published_view_ordered_by_recency<
avc.content,
avc.reply_comment_id,
avc.is_deleted,
(au.uuid, au.name, au.metadata ->> 'icon_url') AS "user: AFWebUserType",
(au.uuid, au.name, au.metadata ->> 'icon_url') AS "user: AFWebUserColumn",
(NOT avc.is_deleted AND ($2 OR au.uuid = $3)) AS "can_be_deleted!"
FROM af_published_view_comment avc
LEFT OUTER JOIN af_user au ON avc.created_by = au.uid
@ -1244,7 +1244,7 @@ pub async fn select_reactions_for_published_view_ordered_by_reaction_type_creati
SELECT
avr.comment_id,
avr.reaction_type,
ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS "react_users!: Vec<AFWebUserType>"
ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS "react_users!: Vec<AFWebUserColumn>"
FROM af_published_view_reaction avr
INNER JOIN af_user au ON avr.created_by = au.uid
WHERE view_id = $1
@ -1272,7 +1272,7 @@ pub async fn select_reactions_for_comment_ordered_by_reaction_type_creation_time
r#"
SELECT
avr.reaction_type,
ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS "react_users!: Vec<AFWebUserType>",
ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS "react_users!: Vec<AFWebUserColumn>",
avr.comment_id
FROM af_published_view_reaction avr
INNER JOIN af_user au ON avr.created_by = au.uid

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS af_template_category (
category_id UUID NOT NULL DEFAULT gen_random_uuid(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
name TEXT NOT NULL,
icon TEXT NOT NULL,
description TEXT NOT NULL,
bg_color TEXT NOT NULL,
category_type INT NOT NULL,
rank INT NOT NULL,
PRIMARY KEY (category_id)
);

View file

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS af_template_creator (
creator_id UUID NOT NULL DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
avatar_url TEXT NOT NULL,
PRIMARY KEY (creator_id)
);
CREATE TABLE IF NOT EXISTS af_template_creator_account_link (
creator_id UUID NOT NULL REFERENCES af_template_creator(creator_id) ON DELETE CASCADE,
link_type TEXT NOT NULL,
url TEXT NOT NULL,
UNIQUE(creator_id, link_type)
);
CREATE INDEX IF NOT EXISTS idx_creator_id_on_af_template_creator_account_link ON af_template_creator_account_link(creator_id);

View file

@ -5,6 +5,7 @@ pub mod file_storage;
pub mod history;
pub mod metrics;
pub mod search;
pub mod template;
pub mod user;
pub mod util;
pub mod workspace;

175
src/api/template.rs Normal file
View file

@ -0,0 +1,175 @@
use actix_web::{
web::{self, Data, Json},
Result, Scope,
};
use database_entity::dto::{
CreateTemplateCategoryParams, CreateTemplateCreatorParams, GetTemplateCategoriesQueryParams,
GetTemplateCreatorsQueryParams, TemplateCategories, TemplateCategory, TemplateCreator,
TemplateCreators, UpdateTemplateCategoryParams, UpdateTemplateCreatorParams,
};
use shared_entity::response::{AppResponse, JsonAppResponse};
use uuid::Uuid;
use crate::{
biz::template::ops::{
create_new_template_category, create_new_template_creator, delete_template_category,
delete_template_creator, get_template_categories, get_template_category, get_template_creator,
get_template_creators, update_template_category, update_template_creator,
},
state::AppState,
};
pub fn template_scope() -> Scope {
web::scope("/api/template-center")
.service(
web::resource("/category")
.route(web::post().to(post_template_category_handler))
.route(web::get().to(list_template_categories_handler)),
)
.service(
web::resource("/category/{category_id}")
.route(web::put().to(update_template_category_handler))
.route(web::get().to(get_template_category_handler))
.route(web::delete().to(delete_template_category_handler)),
)
.service(
web::resource("/creator")
.route(web::post().to(post_template_creator_handler))
.route(web::get().to(list_template_creators_handler)),
)
.service(
web::resource("/creator/{creator_id}")
.route(web::put().to(update_template_creator_handler))
.route(web::get().to(get_template_creator_handler))
.route(web::delete().to(delete_template_creator_handler)),
)
}
async fn post_template_category_handler(
data: Json<CreateTemplateCategoryParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<TemplateCategory>> {
let new_template_category = create_new_template_category(
&state.pg_pool,
&data.name,
&data.description,
&data.icon,
&data.bg_color,
data.category_type,
data.rank,
)
.await?;
Ok(Json(AppResponse::Ok().with_data(new_template_category)))
}
async fn list_template_categories_handler(
query: web::Query<GetTemplateCategoriesQueryParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<TemplateCategories>> {
let categories = get_template_categories(
&state.pg_pool,
query.name_contains.as_deref(),
query.category_type,
)
.await?;
Ok(Json(
AppResponse::Ok().with_data(TemplateCategories { categories }),
))
}
async fn update_template_category_handler(
category_id: web::Path<Uuid>,
data: Json<UpdateTemplateCategoryParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<TemplateCategory>> {
let category_id = category_id.into_inner();
let updated_template_category = update_template_category(
&state.pg_pool,
&category_id,
&data.name,
&data.description,
&data.icon,
&data.bg_color,
data.category_type,
data.rank,
)
.await?;
Ok(Json(AppResponse::Ok().with_data(updated_template_category)))
}
async fn get_template_category_handler(
category_id: web::Path<Uuid>,
state: Data<AppState>,
) -> Result<JsonAppResponse<TemplateCategory>> {
let category_id = category_id.into_inner();
let category = get_template_category(&state.pg_pool, &category_id).await?;
Ok(Json(AppResponse::Ok().with_data(category)))
}
async fn delete_template_category_handler(
category_id: web::Path<Uuid>,
state: Data<AppState>,
) -> Result<JsonAppResponse<()>> {
let category_id = category_id.into_inner();
delete_template_category(&state.pg_pool, &category_id).await?;
Ok(Json(AppResponse::Ok()))
}
async fn post_template_creator_handler(
data: Json<CreateTemplateCreatorParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<TemplateCreator>> {
let new_template_creator = create_new_template_creator(
&state.pg_pool,
&data.name,
&data.avatar_url,
&data.account_links,
)
.await?;
Ok(Json(AppResponse::Ok().with_data(new_template_creator)))
}
async fn list_template_creators_handler(
query: web::Query<GetTemplateCreatorsQueryParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<TemplateCreators>> {
let creators = get_template_creators(&state.pg_pool, &query.name_contains).await?;
Ok(Json(
AppResponse::Ok().with_data(TemplateCreators { creators }),
))
}
async fn update_template_creator_handler(
creator_id: web::Path<Uuid>,
data: Json<UpdateTemplateCreatorParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<TemplateCreator>> {
let creator_id = creator_id.into_inner();
let updated_creator = update_template_creator(
&state.pg_pool,
&creator_id,
&data.name,
&data.avatar_url,
&data.account_links,
)
.await?;
Ok(Json(AppResponse::Ok().with_data(updated_creator)))
}
async fn get_template_creator_handler(
creator_id: web::Path<Uuid>,
state: Data<AppState>,
) -> Result<JsonAppResponse<TemplateCreator>> {
let creator_id = creator_id.into_inner();
let template_creator = get_template_creator(&state.pg_pool, &creator_id).await?;
Ok(Json(AppResponse::Ok().with_data(template_creator)))
}
async fn delete_template_creator_handler(
creator_id: web::Path<Uuid>,
state: Data<AppState>,
) -> Result<JsonAppResponse<TemplateCreator>> {
let creator_id = creator_id.into_inner();
delete_template_creator(&state.pg_pool, &creator_id).await?;
Ok(Json(AppResponse::Ok()))
}

View file

@ -1153,7 +1153,7 @@ async fn post_published_collab_reaction_handler(
view_id: web::Path<Uuid>,
data: Json<CreateReactionParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<Reactions>> {
) -> Result<JsonAppResponse<()>> {
let view_id = view_id.into_inner();
create_reaction_on_comment(
&state.pg_pool,
@ -1170,7 +1170,7 @@ async fn delete_published_collab_reaction_handler(
user_uuid: UserUuid,
data: Json<DeleteReactionParams>,
state: Data<AppState>,
) -> Result<JsonAppResponse<Reactions>> {
) -> Result<JsonAppResponse<()>> {
remove_reaction_on_comment(
&state.pg_pool,
&data.comment_id,

View file

@ -1,6 +1,7 @@
use crate::api::metrics::metrics_scope;
use crate::api::file_storage::file_storage_scope;
use crate::api::template::template_scope;
use crate::api::user::user_scope;
use crate::api::workspace::{collab_scope, workspace_scope};
use crate::api::ws::ws_scope;
@ -163,6 +164,7 @@ pub async fn run_actix_server(
.service(history_scope())
.service(metrics_scope())
.service(search_scope())
.service(template_scope())
.app_data(Data::new(state.metrics.registry.clone()))
.app_data(Data::new(state.metrics.request_metrics.clone()))
.app_data(Data::new(state.metrics.realtime_metrics.clone()))

View file

@ -2,6 +2,7 @@ pub mod chat;
pub mod collab;
pub mod pg_listener;
pub mod search;
pub mod template;
pub mod user;
pub mod utils;
pub mod workspace;

1
src/biz/template/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod ops;

143
src/biz/template/ops.rs Normal file
View file

@ -0,0 +1,143 @@
use std::ops::DerefMut;
use anyhow::Context;
use database::template::{
delete_template_category_by_id, delete_template_creator_account_links,
delete_template_creator_by_id, insert_new_template_category, insert_template_creator,
select_template_categories, select_template_category_by_id, select_template_creator_by_id,
select_template_creators_by_name, update_template_category_by_id, update_template_creator_by_id,
};
use database_entity::dto::{AccountLink, TemplateCategory, TemplateCategoryType, TemplateCreator};
use shared_entity::response::AppResponseError;
use sqlx::PgPool;
use uuid::Uuid;
pub async fn create_new_template_category(
pg_pool: &PgPool,
name: &str,
description: &str,
icon: &str,
bg_color: &str,
category_type: TemplateCategoryType,
rank: i32,
) -> Result<TemplateCategory, AppResponseError> {
let new_template_category = insert_new_template_category(
pg_pool,
name,
description,
icon,
bg_color,
category_type,
rank,
)
.await?;
Ok(new_template_category)
}
#[allow(clippy::too_many_arguments)]
pub async fn update_template_category(
pg_pool: &PgPool,
category_id: &Uuid,
name: &str,
description: &str,
icon: &str,
bg_color: &str,
category_type: TemplateCategoryType,
rank: i32,
) -> Result<TemplateCategory, AppResponseError> {
let updated_template_category = update_template_category_by_id(
pg_pool,
category_id,
name,
description,
icon,
bg_color,
category_type,
rank,
)
.await?;
Ok(updated_template_category)
}
pub async fn get_template_categories(
pg_pool: &PgPool,
name_contains: Option<&str>,
category_type: Option<TemplateCategoryType>,
) -> Result<Vec<TemplateCategory>, AppResponseError> {
let categories = select_template_categories(pg_pool, name_contains, category_type).await?;
Ok(categories)
}
pub async fn get_template_category(
pg_pool: &PgPool,
category_id: &Uuid,
) -> Result<TemplateCategory, AppResponseError> {
let category = select_template_category_by_id(pg_pool, category_id).await?;
Ok(category)
}
pub async fn delete_template_category(
pg_pool: &PgPool,
category_id: &Uuid,
) -> Result<(), AppResponseError> {
delete_template_category_by_id(pg_pool, category_id).await?;
Ok(())
}
pub async fn create_new_template_creator(
pg_pool: &PgPool,
name: &str,
avatar_url: &str,
account_links: &[AccountLink],
) -> Result<TemplateCreator, AppResponseError> {
let new_template_creator =
insert_template_creator(pg_pool, name, avatar_url, account_links).await?;
Ok(new_template_creator)
}
pub async fn update_template_creator(
pg_pool: &PgPool,
creator_id: &Uuid,
name: &str,
avatar_url: &str,
account_links: &[AccountLink],
) -> Result<TemplateCreator, AppResponseError> {
let mut txn = pg_pool
.begin()
.await
.context("Begin transaction to update template creator")?;
delete_template_creator_account_links(txn.deref_mut(), creator_id).await?;
let updated_template_creator =
update_template_creator_by_id(txn.deref_mut(), creator_id, name, avatar_url, account_links)
.await?;
txn
.commit()
.await
.context("Commit transaction to update template creator")?;
Ok(updated_template_creator)
}
pub async fn get_template_creators(
pg_pool: &PgPool,
keyword: &Option<String>,
) -> Result<Vec<TemplateCreator>, AppResponseError> {
let substr_match = keyword.as_deref().unwrap_or("%");
let creators = select_template_creators_by_name(pg_pool, substr_match).await?;
Ok(creators)
}
pub async fn get_template_creator(
pg_pool: &PgPool,
creator_id: &Uuid,
) -> Result<TemplateCreator, AppResponseError> {
let creator = select_template_creator_by_id(pg_pool, creator_id).await?;
Ok(creator)
}
pub async fn delete_template_creator(
pg_pool: &PgPool,
creator_id: &Uuid,
) -> Result<(), AppResponseError> {
delete_template_creator_by_id(pg_pool, creator_id).await?;
Ok(())
}

View file

@ -2,6 +2,6 @@ mod edit_workspace;
mod invitation_crud;
mod member_crud;
mod publish;
mod template_test;
mod template;
mod workspace_crud;
mod workspace_settings;

233
tests/workspace/template.rs Normal file
View file

@ -0,0 +1,233 @@
use app_error::ErrorCode;
use client_api::entity::{AccountLink, TemplateCategoryType};
use client_api_test::*;
use collab::core::collab::DataSource;
use collab::core::origin::CollabOrigin;
use collab_document::document::Document;
use collab_entity::CollabType;
use database_entity::dto::{QueryCollab, QueryCollabParams};
use uuid::Uuid;
#[tokio::test]
async fn get_user_default_workspace_test() {
let email = generate_unique_email();
let password = "Hello!123#";
let c = localhost_client();
c.sign_up(&email, password).await.unwrap();
let mut test_client = TestClient::new_user().await;
let workspace_id = test_client.workspace_id().await;
let folder = test_client.get_user_folder().await;
let views = folder.get_views_belong_to(&test_client.workspace_id().await);
assert_eq!(views.len(), 1);
assert_eq!(views[0].name, "Getting started");
let document_id = views[0].id.clone();
let document =
get_document_collab_from_remote(&mut test_client, workspace_id, &document_id).await;
let document_data = document.get_document_data().unwrap();
assert_eq!(document_data.blocks.len(), 25);
}
async fn get_document_collab_from_remote(
test_client: &mut TestClient,
workspace_id: String,
document_id: &str,
) -> Document {
let params = QueryCollabParams {
workspace_id,
inner: QueryCollab {
object_id: document_id.to_string(),
collab_type: CollabType::Document,
},
};
let resp = test_client.get_collab(params).await.unwrap();
Document::from_doc_state(
CollabOrigin::Empty,
DataSource::DocStateV1(resp.encode_collab.doc_state.to_vec()),
document_id,
vec![],
)
.unwrap()
}
#[tokio::test]
async fn test_template_category_crud() {
let (authorized_client, _) = generate_unique_registered_user_client().await;
let category_name = Uuid::new_v4().to_string();
let new_template_category = authorized_client
.create_template_category(
category_name.as_str(),
"icon",
"bg_color",
"description",
TemplateCategoryType::Feature,
1,
)
.await
.unwrap();
assert_eq!(new_template_category.name, category_name);
assert_eq!(new_template_category.icon, "icon");
assert_eq!(new_template_category.bg_color, "bg_color");
assert_eq!(new_template_category.description, "description");
assert_eq!(
new_template_category.category_type,
TemplateCategoryType::Feature
);
assert_eq!(new_template_category.rank, 1);
let updated_category_name = Uuid::new_v4().to_string();
let updated_template_category = authorized_client
.update_template_category(
&new_template_category.id,
updated_category_name.as_str(),
"new_icon",
"new_bg_color",
"new_description",
TemplateCategoryType::UseCase,
2,
)
.await
.unwrap();
assert_eq!(updated_template_category.name, updated_category_name);
assert_eq!(updated_template_category.icon, "new_icon");
assert_eq!(updated_template_category.bg_color, "new_bg_color");
assert_eq!(updated_template_category.description, "new_description");
assert_eq!(
updated_template_category.category_type,
TemplateCategoryType::UseCase
);
assert_eq!(updated_template_category.rank, 2);
let guest_client = localhost_client();
let template_category = guest_client
.get_template_category(&new_template_category.id)
.await
.unwrap();
assert_eq!(template_category.name, updated_category_name);
assert_eq!(template_category.icon, "new_icon");
assert_eq!(template_category.bg_color, "new_bg_color");
assert_eq!(template_category.description, "new_description");
assert_eq!(
template_category.category_type,
TemplateCategoryType::UseCase
);
assert_eq!(template_category.rank, 2);
let second_category_name = Uuid::new_v4().to_string();
authorized_client
.create_template_category(
second_category_name.as_str(),
"second_icon",
"second_bg_color",
"second_description",
TemplateCategoryType::Feature,
3,
)
.await
.unwrap();
let guest_client = localhost_client();
let result = guest_client
.create_template_category("", "", "", "", TemplateCategoryType::Feature, 0)
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);
let name_search_substr = &second_category_name[0..second_category_name.len() - 1];
let category_by_name_search_result = guest_client
.get_template_categories(Some(name_search_substr), None)
.await
.unwrap()
.categories;
assert_eq!(category_by_name_search_result.len(), 1);
assert_eq!(category_by_name_search_result[0].name, second_category_name);
let category_by_type_search_result = guest_client
.get_template_categories(None, Some(TemplateCategoryType::Feature))
.await
.unwrap()
.categories;
// Since the table might not be in a clean state, we can't guarantee that there is only one category of type Feature
assert!(!category_by_type_search_result.is_empty());
assert!(category_by_type_search_result
.iter()
.all(|r| r.category_type == TemplateCategoryType::Feature));
assert!(category_by_type_search_result
.iter()
.any(|r| r.name == second_category_name));
let result = guest_client
.delete_template_category(&new_template_category.id)
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);
authorized_client
.delete_template_category(&new_template_category.id)
.await
.unwrap();
let result = guest_client
.get_template_category(&new_template_category.id)
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::RecordNotFound);
}
#[tokio::test]
async fn test_template_creator_crud() {
let (authorized_client, _) = generate_unique_registered_user_client().await;
let account_links = vec![AccountLink {
link_type: "reddit".to_string(),
url: "reddit_url".to_string(),
}];
let new_creator = authorized_client
.create_template_creator("name", "avatar_url", account_links)
.await
.unwrap();
assert_eq!(new_creator.name, "name");
assert_eq!(new_creator.avatar_url, "avatar_url");
assert_eq!(new_creator.account_links.len(), 1);
assert_eq!(new_creator.account_links[0].link_type, "reddit");
assert_eq!(new_creator.account_links[0].url, "reddit_url");
let guest_client = localhost_client();
let result = guest_client.create_template_creator("", "", vec![]).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);
let updated_account_links = vec![AccountLink {
link_type: "twitter".to_string(),
url: "twitter_url".to_string(),
}];
let updated_creator = authorized_client
.update_template_creator(
&new_creator.id,
"new_name",
"new_avatar_url",
updated_account_links,
)
.await
.unwrap();
assert_eq!(updated_creator.name, "new_name");
assert_eq!(updated_creator.avatar_url, "new_avatar_url");
assert_eq!(updated_creator.account_links.len(), 1);
assert_eq!(updated_creator.account_links[0].link_type, "twitter");
assert_eq!(updated_creator.account_links[0].url, "twitter_url");
let creator = guest_client
.get_template_creator(&new_creator.id)
.await
.unwrap();
assert_eq!(creator.name, "new_name");
assert_eq!(creator.avatar_url, "new_avatar_url");
assert_eq!(creator.account_links.len(), 1);
assert_eq!(creator.account_links[0].link_type, "twitter");
assert_eq!(creator.account_links[0].url, "twitter_url");
let result = guest_client.delete_template_creator(&new_creator.id).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);
authorized_client
.delete_template_creator(&new_creator.id)
.await
.unwrap();
let result = guest_client.get_template_creator(&new_creator.id).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::RecordNotFound);
}

View file

@ -1,49 +0,0 @@
use client_api_test::*;
use collab::core::collab::DataSource;
use collab::core::origin::CollabOrigin;
use collab_document::document::Document;
use collab_entity::CollabType;
use database_entity::dto::{QueryCollab, QueryCollabParams};
#[tokio::test]
async fn get_user_default_workspace_test() {
let email = generate_unique_email();
let password = "Hello!123#";
let c = localhost_client();
c.sign_up(&email, password).await.unwrap();
let mut test_client = TestClient::new_user().await;
let workspace_id = test_client.workspace_id().await;
let folder = test_client.get_user_folder().await;
let views = folder.get_views_belong_to(&test_client.workspace_id().await);
assert_eq!(views.len(), 1);
assert_eq!(views[0].name, "Getting started");
let document_id = views[0].id.clone();
let document =
get_document_collab_from_remote(&mut test_client, workspace_id, &document_id).await;
let document_data = document.get_document_data().unwrap();
assert_eq!(document_data.blocks.len(), 25);
}
async fn get_document_collab_from_remote(
test_client: &mut TestClient,
workspace_id: String,
document_id: &str,
) -> Document {
let params = QueryCollabParams {
workspace_id,
inner: QueryCollab {
object_id: document_id.to_string(),
collab_type: CollabType::Document,
},
};
let resp = test_client.get_collab(params).await.unwrap();
Document::from_doc_state(
CollabOrigin::Empty,
DataSource::DocStateV1(resp.encode_collab.doc_state.to_vec()),
document_id,
vec![],
)
.unwrap()
}