diff --git a/_sql/migrations/2023-11-17-094126_favorites/down.sql b/_sql/migrations/2023-11-17-094126_favorites/down.sql new file mode 100644 index 0000000000..26a2a6d8fc --- /dev/null +++ b/_sql/migrations/2023-11-17-094126_favorites/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +DROP TABLE favorites; \ No newline at end of file diff --git a/_sql/migrations/2023-11-17-094126_favorites/up.sql b/_sql/migrations/2023-11-17-094126_favorites/up.sql new file mode 100644 index 0000000000..f819a241eb --- /dev/null +++ b/_sql/migrations/2023-11-17-094126_favorites/up.sql @@ -0,0 +1,46 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +-- Favorites that can be attached to just about anything. +-- This is a polymorphic table and can apply to a wide variety of entities. +CREATE TABLE favorites +( + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- Effective "primary key" (PUBLIC) + token VARCHAR(32) NOT NULL, + + -- User making the favorite + user_token VARCHAR(32) NOT NULL, + + -- The type of entity the favorite applies to + entity_type VARCHAR(32) NOT NULL, + + -- The token of the entity the favorite applies to + entity_token VARCHAR(32) NOT NULL, + + -- ========== VECTOR CLOCK ========== + + -- Incremented with every update. + version INT NOT NULL DEFAULT 0, + + -- ========== TIMESTAMPS ========== + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (token), + KEY index_entity_type_entity_token (entity_type, entity_token), + KEY index_user_token_entity_token (user_token, entity_type), + KEY index_user_token (user_token), + KEY index_created_at (created_at), + KEY index_user_deleted_at (deleted_at) + +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_bin; diff --git a/crates/_types/enums/src/by_table/favorites/favorite_entity_type.rs b/crates/_types/enums/src/by_table/favorites/favorite_entity_type.rs new file mode 100644 index 0000000000..5b96603ff1 --- /dev/null +++ b/crates/_types/enums/src/by_table/favorites/favorite_entity_type.rs @@ -0,0 +1,128 @@ +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `favorites` table in a `VARCHAR(32)` field named `entity_type`. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Deserialize, Serialize)] +pub enum FavoriteEntityType { + /// User + #[serde(rename = "user")] + User, + + /// TTS model (architecture does not matter) + #[serde(rename = "tts_model")] + TtsModel, + + /// TTS result (architecture does not matter) + #[serde(rename = "tts_result")] + TtsResult, + + /// W2L template + #[serde(rename = "w2l_template")] + W2lTemplate, + + /// W2L result + #[serde(rename = "w2l_result")] + W2lResult, + + /// MediaFile + #[serde(rename = "media_file")] + MediaFile, + + /// VoiceConversionModel + #[serde(rename = "voice_conversion_model")] + VoiceConversionModel, + + /// ZsVoice + #[serde(rename = "zs_voice")] + ZsVoice, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(FavoriteEntityType); +impl_mysql_enum_coders!(FavoriteEntityType); + +/// NB: Legacy API for older code. +impl FavoriteEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::User => "user", + Self::TtsModel => "tts_model", + Self::TtsResult => "tts_result", + Self::W2lTemplate => "w2l_template", + Self::W2lResult => "w2l_result", + Self::MediaFile => "media_file", + Self::VoiceConversionModel => "voice_conversion_model", + Self::ZsVoice => "zs_voice", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "user" => Ok(Self::User), + "tts_model" => Ok(Self::TtsModel), + "tts_result" => Ok(Self::TtsResult), + "w2l_template" => Ok(Self::W2lTemplate), + "w2l_result" => Ok(Self::W2lResult), + "media_file" => Ok(Self::MediaFile), + "voice_conversion_model" => Ok(Self::VoiceConversionModel), + "zs_voice" => Ok(Self::ZsVoice), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::favorites::favorite_entity_type::FavoriteEntityType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(FavoriteEntityType::User, "user"); + assert_serialization(FavoriteEntityType::TtsModel, "tts_model"); + assert_serialization(FavoriteEntityType::TtsResult, "tts_result"); + assert_serialization(FavoriteEntityType::W2lTemplate, "w2l_template"); + assert_serialization(FavoriteEntityType::W2lResult, "w2l_result"); + assert_serialization(FavoriteEntityType::MediaFile, "media_file"); + assert_serialization(FavoriteEntityType::VoiceConversionModel, "voice_conversion_model"); + assert_serialization(FavoriteEntityType::ZsVoice, "zs_voice"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(FavoriteEntityType::User.to_str(), "user"); + assert_eq!(FavoriteEntityType::TtsModel.to_str(), "tts_model"); + assert_eq!(FavoriteEntityType::TtsResult.to_str(), "tts_result"); + assert_eq!(FavoriteEntityType::W2lTemplate.to_str(), "w2l_template"); + assert_eq!(FavoriteEntityType::W2lResult.to_str(), "w2l_result"); + assert_eq!(FavoriteEntityType::MediaFile.to_str(), "media_file"); + assert_eq!(FavoriteEntityType::VoiceConversionModel.to_str(), "voice_conversion_model"); + assert_eq!(FavoriteEntityType::ZsVoice.to_str(), "zs_voice"); + } + + #[test] + fn test_from_str() { + assert_eq!(FavoriteEntityType::from_str("user").unwrap(), FavoriteEntityType::User); + assert_eq!(FavoriteEntityType::from_str("tts_model").unwrap(), FavoriteEntityType::TtsModel); + assert_eq!(FavoriteEntityType::from_str("tts_result").unwrap(), FavoriteEntityType::TtsResult); + assert_eq!(FavoriteEntityType::from_str("w2l_template").unwrap(), FavoriteEntityType::W2lTemplate); + assert_eq!(FavoriteEntityType::from_str("w2l_result").unwrap(), FavoriteEntityType::W2lResult); + assert_eq!(FavoriteEntityType::from_str("media_file").unwrap(), FavoriteEntityType::MediaFile); + assert_eq!(FavoriteEntityType::from_str("voice_conversion_model").unwrap(), FavoriteEntityType::VoiceConversionModel); + assert_eq!(FavoriteEntityType::from_str("zs_voice").unwrap(), FavoriteEntityType::ZsVoice); + assert!(FavoriteEntityType::from_str("foo").is_err()); + } + } +} diff --git a/crates/_types/enums/src/by_table/favorites/mod.rs b/crates/_types/enums/src/by_table/favorites/mod.rs new file mode 100644 index 0000000000..18ac93ed9c --- /dev/null +++ b/crates/_types/enums/src/by_table/favorites/mod.rs @@ -0,0 +1 @@ +pub mod favorite_entity_type; \ No newline at end of file diff --git a/crates/_types/enums/src/by_table/mod.rs b/crates/_types/enums/src/by_table/mod.rs index 1061281e12..6226efae12 100644 --- a/crates/_types/enums/src/by_table/mod.rs +++ b/crates/_types/enums/src/by_table/mod.rs @@ -19,3 +19,4 @@ pub mod zs_voices; pub mod tts_render_tasks; pub mod web_rendition_targets; pub mod web_scraping_targets; +pub mod favorites; diff --git a/crates/_types/tokens/src/prefixes.rs b/crates/_types/tokens/src/prefixes.rs index 41b6217e91..6e24a9802b 100644 --- a/crates/_types/tokens/src/prefixes.rs +++ b/crates/_types/tokens/src/prefixes.rs @@ -27,6 +27,7 @@ pub(crate) enum TokenPrefix { Comment, DownloadJob, EmailSenderJob, + Favorite, InferenceJob, MediaFile, MediaUpload, @@ -93,6 +94,7 @@ impl PrefixGenerator for TokenPrefix { Self::DownloadJob => "jdown_", // NB: Previously "JGUP:" Self::EmailSenderJob => "email_job_", Self::InferenceJob => "jinf_", + Self::Favorite => "fav_", Self::MediaFile => "m_", Self::MediaUpload => "mu_", Self::ModelWeight => "weight_", diff --git a/crates/_types/tokens/src/tokens/favorites.rs b/crates/_types/tokens/src/tokens/favorites.rs new file mode 100644 index 0000000000..78ecb7e0b9 --- /dev/null +++ b/crates/_types/tokens/src/tokens/favorites.rs @@ -0,0 +1,14 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use crate::prefixes::TokenPrefix; + + +/// The primary key for Audit Logs. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type, Debug, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct FavoriteToken(pub String); + +impl_string_token!(FavoriteToken); +impl_crockford_generator!(FavoriteToken, 32usize, TokenPrefix::Favorite, CrockfordLower); diff --git a/crates/_types/tokens/src/tokens/mod.rs b/crates/_types/tokens/src/tokens/mod.rs index 1777a47e2f..e75f732b55 100644 --- a/crates/_types/tokens/src/tokens/mod.rs +++ b/crates/_types/tokens/src/tokens/mod.rs @@ -34,3 +34,4 @@ pub mod w2l_templates; pub mod zs_voice_dataset_samples; pub mod zs_voice_datasets; pub mod zs_voices; +pub mod favorites; diff --git a/crates/lib/mysql_queries/src/queries/favorites/create_favorite.rs b/crates/lib/mysql_queries/src/queries/favorites/create_favorite.rs new file mode 100644 index 0000000000..a6decf5fff --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/favorites/create_favorite.rs @@ -0,0 +1,63 @@ +use std::marker::PhantomData; + +use anyhow::anyhow; +use sqlx::{Executor, MySql}; + +use errors::AnyhowResult; +use tokens::tokens::favorites::FavoriteToken; +use tokens::tokens::users::UserToken; + +use crate::queries::favorites::favorite_entity_token::FavoriteEntityToken; + +pub struct CreateFavoriteArgs<'e, 'c, E> + where E: 'e + Executor<'c, Database = MySql> +{ + pub entity_token: &'e FavoriteEntityToken, + + pub user_token: &'e UserToken, + + pub mysql_executor: E, + + // TODO: Not sure if this works to tell the compiler we need the lifetime annotation. + // See: https://doc.rust-lang.org/std/marker/struct.PhantomData.html#unused-lifetime-parameters + pub phantom: PhantomData<&'c E>, +} + +pub async fn create_favorite<'e, 'c : 'e, E>( + args: CreateFavoriteArgs<'e, 'c, E>, +) + -> AnyhowResult + where E: 'e + Executor<'c, Database = MySql> +{ + + let favorite_token = FavoriteToken::generate(); + let (entity_type, entity_token) = args.entity_token.get_composite_keys(); + + let query_result = sqlx::query!( + r#" +INSERT INTO favorites +SET + token = ?, + user_token = ?, + entity_type = ?, + entity_token = ? + +ON DUPLICATE KEY UPDATE + deleted_at = NULL, + version = version + 1 + "#, + &favorite_token, + args.user_token, + entity_type, + entity_token, + ) + .execute(args.mysql_executor) + .await; + + let _record_id = match query_result { + Ok(res) => res.last_insert_id(), + Err(err) => return Err(anyhow!("Mysql error: {:?}", err)), + }; + + Ok(favorite_token) +} diff --git a/crates/lib/mysql_queries/src/queries/favorites/delete_favorite.rs b/crates/lib/mysql_queries/src/queries/favorites/delete_favorite.rs new file mode 100644 index 0000000000..0ec20c4e6a --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/favorites/delete_favorite.rs @@ -0,0 +1,35 @@ +use sqlx::{Executor, MySql}; + +use errors::AnyhowResult; +use tokens::tokens::favorites::FavoriteToken; +use tokens::tokens::users::UserToken; + +// NB: UserToken is only supplied as an optimistic form of validation +pub async fn delete_favorite<'e, 'c, E>( + favorite_token: &'e FavoriteToken, + user_token: &'e UserToken, + mysql_executor: E +) + -> AnyhowResult<()> + where E: 'e + Executor<'c, Database = MySql> +{ + + sqlx::query!( + r#" +UPDATE favorites +SET +deleted_at = CURRENT_TIMESTAMP, +version = version + 1 +WHERE +token = ? +AND user_token = ? +LIMIT 1 + "#, + favorite_token, + user_token + ) + .execute(mysql_executor) + .await?; + + Ok(()) +} diff --git a/crates/lib/mysql_queries/src/queries/favorites/favorite_entity_token.rs b/crates/lib/mysql_queries/src/queries/favorites/favorite_entity_token.rs new file mode 100644 index 0000000000..3a0007253f --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/favorites/favorite_entity_token.rs @@ -0,0 +1,48 @@ +use enums::by_table::favorites::favorite_entity_type::FavoriteEntityType; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::tts_models::TtsModelToken; +use tokens::tokens::tts_results::TtsResultToken; +use tokens::tokens::users::UserToken; +use tokens::tokens::voice_conversion_models::VoiceConversionModelToken; +use tokens::tokens::w2l_results::W2lResultToken; +use tokens::tokens::w2l_templates::W2lTemplateToken; +use tokens::tokens::zs_voices::ZsVoiceToken; + +pub enum FavoriteEntityToken { + User(UserToken), + TtsModel(TtsModelToken), + TtsResult(TtsResultToken), + W2lTemplate(W2lTemplateToken), + W2lResult(W2lResultToken), + MediaFile(MediaFileToken), + VoiceConversionModel(VoiceConversionModelToken), + ZsVoice(ZsVoiceToken), +} + +impl FavoriteEntityToken { + pub fn from_entity_type_and_token(entity_type: FavoriteEntityType, token: &str) -> Self { + match entity_type { + FavoriteEntityType::User => Self::User(UserToken::new_from_str(token)), + FavoriteEntityType::TtsModel => Self::TtsModel(TtsModelToken::new_from_str(token)), + FavoriteEntityType::TtsResult => Self::TtsResult(TtsResultToken::new_from_str(token)), + FavoriteEntityType::W2lTemplate => Self::W2lTemplate(W2lTemplateToken::new_from_str(token)), + FavoriteEntityType::W2lResult => Self::W2lResult(W2lResultToken::new_from_str(token)), + FavoriteEntityType::MediaFile => Self::MediaFile(MediaFileToken::new_from_str(token)), + FavoriteEntityType::VoiceConversionModel => Self::VoiceConversionModel(VoiceConversionModelToken::new_from_str(token)), + FavoriteEntityType::ZsVoice => Self::ZsVoice(ZsVoiceToken::new_from_str(token)), + } + } + + pub fn get_composite_keys(&self) -> (FavoriteEntityType, &str) { + match self { + FavoriteEntityToken::User(user_token) => (FavoriteEntityType::User, user_token.as_str()), + FavoriteEntityToken::TtsModel(tts_model_token) => (FavoriteEntityType::TtsModel, tts_model_token.as_str()), + FavoriteEntityToken::TtsResult(tts_result_token) => (FavoriteEntityType::TtsResult, tts_result_token.as_str()), + FavoriteEntityToken::W2lTemplate(w2l_template_token) => (FavoriteEntityType::W2lTemplate, w2l_template_token.as_str()), + FavoriteEntityToken::W2lResult(w2l_result_token) => (FavoriteEntityType::W2lResult, w2l_result_token.as_str()), + FavoriteEntityToken::MediaFile(media_file_token) => (FavoriteEntityType::MediaFile, media_file_token.as_str()), + FavoriteEntityToken::VoiceConversionModel(voice_conversion_model_token) => (FavoriteEntityType::VoiceConversionModel, voice_conversion_model_token.as_str()), + FavoriteEntityToken::ZsVoice(zs_voice_token) => (FavoriteEntityType::ZsVoice, zs_voice_token.as_str()), + } + } +} diff --git a/crates/lib/mysql_queries/src/queries/favorites/get_favorite.rs b/crates/lib/mysql_queries/src/queries/favorites/get_favorite.rs new file mode 100644 index 0000000000..01d46404bc --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/favorites/get_favorite.rs @@ -0,0 +1,98 @@ +use anyhow::anyhow; +use chrono::{DateTime, Utc}; +use sqlx::{Executor, MySql}; + +use enums::by_table::favorites::favorite_entity_type::FavoriteEntityType; +use errors::AnyhowResult; +use tokens::tokens::favorites::FavoriteToken; +use tokens::tokens::users::UserToken; + +pub struct Favorite { + pub token: FavoriteToken, + + pub entity_type: FavoriteEntityType, + pub entity_token: String, + + pub user_token: UserToken, + pub username: String, + pub user_display_name: String, + pub user_gravatar_hash: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + pub maybe_deleted_at: Option>, +} + +pub async fn get_favorite<'e, 'c, E>( + favorite_token: &'e FavoriteToken, + mysql_executor: E +) + -> AnyhowResult> + where E: 'e + Executor<'c, Database = MySql> +{ + + let maybe_results = sqlx::query_as!( + RawFavorite, + r#" +SELECT + f.token as `token: tokens::tokens::favorites::FavoriteToken`, + + f.entity_type as `entity_type: enums::by_table::favorites::favorite_entity_type::FavoriteEntityType`, + f.entity_token, + + f.user_token as `user_token: tokens::tokens::users::UserToken`, + u.username, + u.display_name as user_display_name, + u.email_gravatar_hash as user_gravatar_hash, + + f.created_at, + f.updated_at, + f.deleted_at + +FROM + favorites AS f +JOIN users AS u + ON f.user_token = u.token +WHERE + f.token = ? + "#, + favorite_token + ) + .fetch_one(mysql_executor) + .await; + + match maybe_results { + Ok(favorite) => Ok(Some(Favorite { + token: favorite.token, + entity_type: favorite.entity_type, + entity_token: favorite.entity_token, + user_token: favorite.user_token, + username: favorite.username, + user_display_name: favorite.user_display_name, + user_gravatar_hash: favorite.user_gravatar_hash, + created_at: favorite.created_at, + updated_at: favorite.updated_at, + maybe_deleted_at: favorite.deleted_at, + })), + Err(err) => match err { + sqlx::Error::RowNotFound => Ok(None), + _ => Err(anyhow!("Error querying for IP ban: {:?}", err)), + } + } +} + +pub struct RawFavorite { + pub token: FavoriteToken, + + pub entity_type: FavoriteEntityType, + pub entity_token: String, + + pub user_token: UserToken, + pub username: String, + pub user_display_name: String, + pub user_gravatar_hash: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} diff --git a/crates/lib/mysql_queries/src/queries/favorites/list_favorites_for_entity.rs b/crates/lib/mysql_queries/src/queries/favorites/list_favorites_for_entity.rs new file mode 100644 index 0000000000..e1080ac694 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/favorites/list_favorites_for_entity.rs @@ -0,0 +1,103 @@ +use anyhow::anyhow; +use chrono::{DateTime, Utc}; +use log::warn; +use sqlx::MySqlPool; + +use errors::AnyhowResult; +use tokens::tokens::favorites::FavoriteToken; +use tokens::tokens::users::UserToken; + +use crate::queries::favorites::favorite_entity_token::FavoriteEntityToken; + +pub struct Favorite { + pub token: FavoriteToken, + + pub user_token: UserToken, + pub username: String, + pub user_display_name: String, + pub user_gravatar_hash: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + pub maybe_deleted_at: Option>, +} + +pub async fn list_favorites_for_entity( + favorite_entity_token: FavoriteEntityToken, + mysql_pool: &MySqlPool +) -> AnyhowResult> { + + let (entity_type, entity_token) = favorite_entity_token.get_composite_keys(); + + let maybe_results= sqlx::query_as!( + RawFavoriteRecord, + r#" +SELECT + f.token as `token: tokens::tokens::favorites::FavoriteToken`, + f.user_token as `user_token: tokens::tokens::users::UserToken`, + u.username, + u.display_name as user_display_name, + u.email_gravatar_hash as user_gravatar_hash, + + f.created_at, + f.updated_at, + f.deleted_at + +FROM + favorites AS f +JOIN users AS u + ON f.user_token = u.token +WHERE + f.entity_type = ? + AND f.entity_token = ? + AND f.deleted_at IS NULL +ORDER BY f.id DESC +LIMIT 50 + "#, + entity_type, + entity_token + ) + .fetch_all(mysql_pool) + .await; + + match maybe_results { + Err(err) => match err { + sqlx::Error::RowNotFound => Ok(Vec::new()), + _ => { + warn!("list favorites db error: {:?}", err); + Err(anyhow!("error with query: {:?}", err)) + } + }, + Ok(results) => Ok(results.into_iter() + .map(|favorite| favorite.into_public_type()) + .collect()), + } +} + +pub struct RawFavoriteRecord { + token: FavoriteToken, + + user_token: UserToken, + username: String, + user_display_name: String, + user_gravatar_hash: String, + + created_at: DateTime, + updated_at: DateTime, + deleted_at: Option>, +} + +impl RawFavoriteRecord { + pub fn into_public_type(self) -> Favorite { + Favorite { + token: self.token, + user_token: self.user_token, + username: self.username, + user_display_name: self.user_display_name, + user_gravatar_hash: self.user_gravatar_hash, + created_at: self.created_at, + updated_at: self.updated_at, + maybe_deleted_at: self.deleted_at, + } + } +} diff --git a/crates/lib/mysql_queries/src/queries/favorites/list_user_favorites.rs b/crates/lib/mysql_queries/src/queries/favorites/list_user_favorites.rs new file mode 100644 index 0000000000..501677846f --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/favorites/list_user_favorites.rs @@ -0,0 +1,147 @@ +use anyhow::anyhow; +use log::warn; +use sqlx::MySqlPool; +use enums::by_table::favorites::favorite_entity_type::FavoriteEntityType; + +use errors::AnyhowResult; +use tokens::tokens::users::UserToken; + +use crate::queries::favorites::favorite_entity_token::FavoriteEntityToken; +use crate::queries::favorites::list_user_favorites_result::{UserFavorite, RawUserFavoriteRecord}; + +pub async fn list_user_favorites( + username: &str, + mysql_pool: &MySqlPool +) -> AnyhowResult> { + + /// TODO(bt,2023-11-21): Maybe this query can use a switch + /// See: https://stackoverflow.com/questions/2761574/mysql-use-case-else-value-as-join-parameter + let maybe_results= sqlx::query_as!( + RawUserFavoriteRecord, + r#" +SELECT + f.token as `token: tokens::tokens::favorites::FavoriteToken`, + f.entity_type as `entity_type: enums::by_table::favorites::favorite_entity_type::FavoriteEntityType`, + f.entity_token, + f.user_token as `user_token: tokens::tokens::users::UserToken`, + u.username, + u.display_name as user_display_name, + u.email_gravatar_hash as user_gravatar_hash, + f.created_at, + f.updated_at, + f.deleted_at, + + media_files.media_type as `maybe_media_file_type: enums::by_table::media_files::media_file_type::MediaFileType`, + media_files.origin_product_category as `maybe_media_file_origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`, + + tts_models.title as maybe_descriptive_text_tts_model_title, + tts_results.raw_inference_text as maybe_descriptive_text_tts_result_inference_text, + users.display_name as maybe_descriptive_text_user_display_name, + voice_conversion_models.title as maybe_descriptive_text_voice_conversion_model_title, + zs_voices.title as maybe_descriptive_text_zs_voice_title + +FROM + favorites AS f +JOIN users AS u + ON f.user_token = u.token + +LEFT OUTER JOIN media_files ON media_files.token = f.entity_token +LEFT OUTER JOIN tts_models ON tts_models.token = f.entity_token +LEFT OUTER JOIN tts_results ON tts_results.token = f.entity_token +LEFT OUTER JOIN users ON users.token = f.entity_token +LEFT OUTER JOIN voice_conversion_models ON voice_conversion_models.token = f.entity_token +LEFT OUTER JOIN zs_voices ON zs_voices.token = f.entity_token + +WHERE + u.username = ? + AND f.deleted_at IS NULL +ORDER BY f.id DESC +LIMIT 50 + "#, + username + ) + .fetch_all(mysql_pool) + .await; + + match maybe_results { + Err(err) => match err { + sqlx::Error::RowNotFound => Ok(Vec::new()), + _ => { + warn!("list favorites db error: {:?}", err); + Err(anyhow!("error with query: {:?}", err)) + } + }, + Ok(results) => Ok(results.into_iter() + .map(|favorite| favorite.into_public_type()) + .collect()), + } +} + +pub async fn list_user_favorites_by_entity_type( + username: &str, + entity_type: FavoriteEntityType, + mysql_pool: &MySqlPool +) -> AnyhowResult> { + + let maybe_results= sqlx::query_as!( + RawUserFavoriteRecord, + r#" +SELECT + f.token as `token: tokens::tokens::favorites::FavoriteToken`, + f.entity_type as `entity_type: enums::by_table::favorites::favorite_entity_type::FavoriteEntityType`, + f.entity_token, + f.user_token as `user_token: tokens::tokens::users::UserToken`, + u.username, + u.display_name as user_display_name, + u.email_gravatar_hash as user_gravatar_hash, + f.created_at, + f.updated_at, + f.deleted_at, + + media_files.media_type as `maybe_media_file_type: enums::by_table::media_files::media_file_type::MediaFileType`, + media_files.origin_product_category as `maybe_media_file_origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`, + + tts_models.title as maybe_descriptive_text_tts_model_title, + tts_results.raw_inference_text as maybe_descriptive_text_tts_result_inference_text, + users.display_name as maybe_descriptive_text_user_display_name, + voice_conversion_models.title as maybe_descriptive_text_voice_conversion_model_title, + zs_voices.title as maybe_descriptive_text_zs_voice_title + +FROM + favorites AS f +JOIN users AS u + ON f.user_token = u.token + +LEFT OUTER JOIN media_files ON media_files.token = f.entity_token +LEFT OUTER JOIN tts_models ON tts_models.token = f.entity_token +LEFT OUTER JOIN tts_results ON tts_results.token = f.entity_token +LEFT OUTER JOIN users ON users.token = f.entity_token +LEFT OUTER JOIN voice_conversion_models ON voice_conversion_models.token = f.entity_token +LEFT OUTER JOIN zs_voices ON zs_voices.token = f.entity_token + +WHERE + u.username = ? + AND f.entity_type = ? + AND f.deleted_at IS NULL +ORDER BY f.id DESC +LIMIT 50 + "#, + username, + entity_type + ) + .fetch_all(mysql_pool) + .await; + + match maybe_results { + Err(err) => match err { + sqlx::Error::RowNotFound => Ok(Vec::new()), + _ => { + warn!("list favorites db error: {:?}", err); + Err(anyhow!("error with query: {:?}", err)) + } + }, + Ok(results) => Ok(results.into_iter() + .map(|favorite| favorite.into_public_type()) + .collect()), + } +} diff --git a/crates/lib/mysql_queries/src/queries/favorites/list_user_favorites_result.rs b/crates/lib/mysql_queries/src/queries/favorites/list_user_favorites_result.rs new file mode 100644 index 0000000000..c391a5d8e6 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/favorites/list_user_favorites_result.rs @@ -0,0 +1,85 @@ +use chrono::{DateTime, Utc}; + +use enums::by_table::favorites::favorite_entity_type::FavoriteEntityType; +use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory; +use enums::by_table::media_files::media_file_type::MediaFileType; +use tokens::tokens::favorites::FavoriteToken; +use tokens::tokens::users::UserToken; + +pub struct UserFavorite { + pub token: FavoriteToken, + + pub entity_type: FavoriteEntityType, + pub entity_token: String, + + /// Something descriptive about the favorited entity. + /// This might be TTS text, a username, etc. It depends on the entity type. + pub maybe_entity_descriptive_text: Option, + + pub user_token: UserToken, + pub username: String, + pub user_display_name: String, + pub user_gravatar_hash: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + pub maybe_deleted_at: Option>, +} + +pub struct RawUserFavoriteRecord { + pub (crate) token: FavoriteToken, + + pub (crate) entity_type: FavoriteEntityType, + pub (crate) entity_token: String, + + pub (crate) user_token: UserToken, + pub (crate) username: String, + pub (crate) user_display_name: String, + pub (crate) user_gravatar_hash: String, + + pub (crate) created_at: DateTime, + pub (crate) updated_at: DateTime, + pub (crate) deleted_at: Option>, + + pub (crate) maybe_media_file_type: Option, + pub (crate) maybe_media_file_origin_category: Option, + + pub (crate) maybe_descriptive_text_tts_model_title: Option, + pub (crate) maybe_descriptive_text_tts_result_inference_text: Option, + pub (crate) maybe_descriptive_text_user_display_name: Option, + pub (crate) maybe_descriptive_text_voice_conversion_model_title: Option, + pub (crate) maybe_descriptive_text_zs_voice_title: Option, +} + +impl RawUserFavoriteRecord { + pub fn into_public_type(self) -> UserFavorite { + UserFavorite { + token: self.token, + entity_type: self.entity_type, + entity_token: self.entity_token, + maybe_entity_descriptive_text: match self.entity_type { + FavoriteEntityType::User => self.maybe_descriptive_text_user_display_name, + FavoriteEntityType::TtsModel => self.maybe_descriptive_text_tts_model_title, + FavoriteEntityType::TtsResult => self.maybe_descriptive_text_tts_result_inference_text, + FavoriteEntityType::W2lTemplate => None, + FavoriteEntityType::W2lResult => None, + // TODO(bt,2023-11-21): Summary text needs to be better enriched. + FavoriteEntityType::MediaFile => self.maybe_media_file_type + .and_then(|media_file_type| match media_file_type { + MediaFileType::Audio => Some("audio media file".to_string()), + MediaFileType::Image => Some("image media file".to_string()), + MediaFileType::Video => Some("video media file".to_string()), + }), + FavoriteEntityType::VoiceConversionModel => self.maybe_descriptive_text_voice_conversion_model_title, + FavoriteEntityType::ZsVoice => self.maybe_descriptive_text_zs_voice_title, + }, + user_token: self.user_token, + username: self.username, + user_display_name: self.user_display_name, + user_gravatar_hash: self.user_gravatar_hash, + created_at: self.created_at, + updated_at: self.updated_at, + maybe_deleted_at: self.deleted_at, + } + } +} diff --git a/crates/lib/mysql_queries/src/queries/favorites/mod.rs b/crates/lib/mysql_queries/src/queries/favorites/mod.rs new file mode 100644 index 0000000000..8cc8a3c7c5 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/favorites/mod.rs @@ -0,0 +1,7 @@ +pub mod create_favorite; +pub mod delete_favorite; +pub mod favorite_entity_token; +pub mod get_favorite; +pub mod list_favorites_for_entity; +pub mod list_user_favorites; +pub mod list_user_favorites_result; \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/queries/mod.rs b/crates/lib/mysql_queries/src/queries/mod.rs index 523ec28d2f..8d216155ef 100644 --- a/crates/lib/mysql_queries/src/queries/mod.rs +++ b/crates/lib/mysql_queries/src/queries/mod.rs @@ -3,6 +3,7 @@ pub mod audit_logs; pub mod billing; pub mod comments; pub mod email_sender_jobs; +pub mod favorites; pub mod generic_download; pub mod generic_inference; pub mod generic_synthetic_ids; @@ -21,4 +22,4 @@ pub mod vocoder; pub mod voice_clone_requests; pub mod voice_conversion; pub mod voice_designer; -pub mod w2l; +pub mod w2l; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/create_favorite_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/create_favorite_handler.rs new file mode 100644 index 0000000000..cb4aa5f5f4 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/create_favorite_handler.rs @@ -0,0 +1,127 @@ +// NB: Incrementally getting rid of build warnings... +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +use std::fmt; +use std::sync::Arc; + +use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use log::warn; + +use enums::by_table::favorites::favorite_entity_type::FavoriteEntityType; +use mysql_queries::queries::favorites::favorite_entity_token::FavoriteEntityToken; +use mysql_queries::queries::favorites::create_favorite::{create_favorite, CreateFavoriteArgs}; +use tokens::tokens::favorites::FavoriteToken; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::server_state::ServerState; + +#[derive(Deserialize)] +pub struct CreateFavoriteRequest { + entity_token: String, + entity_type: FavoriteEntityType, +} + +#[derive(Serialize)] +pub struct CreateFavoriteSuccessResponse { + pub success: bool, + pub favorite_token: FavoriteToken, +} + +#[derive(Debug)] +pub enum CreateFavoriteError { + BadInput(String), + NotAuthorized, + ServerError, +} + +impl ResponseError for CreateFavoriteError { + fn status_code(&self) -> StatusCode { + match *self { + CreateFavoriteError::BadInput(_) => StatusCode::BAD_REQUEST, + CreateFavoriteError::NotAuthorized => StatusCode::UNAUTHORIZED, + CreateFavoriteError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + CreateFavoriteError::BadInput(reason) => reason.to_string(), + CreateFavoriteError::NotAuthorized => "unauthorized".to_string(), + CreateFavoriteError::ServerError => "server error".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for CreateFavoriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +pub async fn create_favorite_handler( + http_request: HttpRequest, + request: web::Json, + server_state: web::Data>, +) -> Result +{ + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + warn!("MySql pool error: {:?}", err); + CreateFavoriteError::ServerError + })?; + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + CreateFavoriteError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(CreateFavoriteError::NotAuthorized); + } + }; + + let entity_token = FavoriteEntityToken::from_entity_type_and_token( + request.entity_type, &request.entity_token); + + let query_result = create_favorite(CreateFavoriteArgs { + entity_token: &entity_token, + user_token: &user_session.user_token_typed, + mysql_executor: &mut *mysql_connection, + phantom: Default::default(), + }).await; + + let favorite_token = match query_result { + Ok(token) => token, + Err(err) => { + warn!("error inserting favorite: {:?}", err); + return Err(CreateFavoriteError::ServerError); + } + }; + + let response = CreateFavoriteSuccessResponse { + success: true, + favorite_token, + }; + + let body = serde_json::to_string(&response) + .map_err(|_e| CreateFavoriteError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/delete_favorite_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/delete_favorite_handler.rs new file mode 100644 index 0000000000..be28d24534 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/delete_favorite_handler.rs @@ -0,0 +1,116 @@ +// NB: Incrementally getting rid of build warnings... +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +use std::fmt; +use std::sync::Arc; + +use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::Path; +use log::warn; + +use mysql_queries::queries::favorites::delete_favorite::delete_favorite; +use tokens::tokens::favorites::FavoriteToken; + +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::http_server::web_utils::response_success_helpers::simple_json_success; +use crate::server_state::ServerState; + +/// For the URL PathInfo +#[derive(Deserialize)] +pub struct DeleteFavoritePathInfo { + favorite_token: FavoriteToken, +} + +#[derive(Deserialize)] +pub struct DeleteFavoriteRequest { + /// NB: this is only to disambiguate when a user is both a mod and an author. + as_mod: Option, +} + +#[derive(Debug)] +pub enum DeleteFavoriteError { + BadInput(String), + NotAuthorized, + NotFound, + ServerError, +} + +impl ResponseError for DeleteFavoriteError { + fn status_code(&self) -> StatusCode { + match *self { + DeleteFavoriteError::BadInput(_) => StatusCode::BAD_REQUEST, + DeleteFavoriteError::NotAuthorized => StatusCode::UNAUTHORIZED, + DeleteFavoriteError::NotFound => StatusCode::NOT_FOUND, + DeleteFavoriteError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + DeleteFavoriteError::BadInput(reason) => reason.to_string(), + DeleteFavoriteError::NotAuthorized => "unauthorized".to_string(), + DeleteFavoriteError::NotFound => "not found".to_string(), + DeleteFavoriteError::ServerError => "server error".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for DeleteFavoriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +pub async fn delete_favorite_handler( + http_request: HttpRequest, + path: Path, + _request: web::Json, + server_state: web::Data> +) -> Result { + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + warn!("MySql pool error: {:?}", err); + DeleteFavoriteError::ServerError + })?; + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + DeleteFavoriteError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + return Err(DeleteFavoriteError::NotAuthorized); + } + }; + + let query_result = delete_favorite( + &path.favorite_token, + &user_session.user_token_typed, + &mut *mysql_connection + ).await; + + match query_result { + Ok(_) => {}, + Err(err) => { + warn!("Update tts mod approval status DB error: {:?}", err); + return Err(DeleteFavoriteError::ServerError); + } + }; + + Ok(simple_json_success()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/list_favorites_for_entity_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/list_favorites_for_entity_handler.rs new file mode 100644 index 0000000000..efbb773b84 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/list_favorites_for_entity_handler.rs @@ -0,0 +1,122 @@ +// NB: Incrementally getting rid of build warnings... +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +use std::fmt; +use std::sync::Arc; + +use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::Path; +use chrono::{DateTime, Utc}; +use log::warn; + +use enums::by_table::favorites::favorite_entity_type::FavoriteEntityType; +use mysql_queries::queries::favorites::favorite_entity_token::FavoriteEntityToken; +use mysql_queries::queries::favorites::list_favorites_for_entity::list_favorites_for_entity; +use tokens::tokens::favorites::FavoriteToken; + +use crate::http_server::common_responses::user_details_lite::{DefaultAvatarInfo, UserDetailsLight}; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::server_state::ServerState; + +/// For the URL PathInfo +#[derive(Deserialize)] +pub struct ListFavoritesPathInfo { + entity_type: FavoriteEntityType, + entity_token: String, +} + +#[derive(Serialize)] +pub struct ListFavoritesSuccessResponse { + pub success: bool, + pub favorites: Vec, +} + +#[derive(Serialize)] +pub struct Favorite { + pub token: FavoriteToken, + + pub user: UserDetailsLight, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug)] +pub enum ListFavoritesError { + ServerError, +} + +impl ResponseError for ListFavoritesError { + fn status_code(&self) -> StatusCode { + match *self { + ListFavoritesError::ServerError=> StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + ListFavoritesError::ServerError => "server error".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for ListFavoritesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +pub async fn list_favorites_for_entity_handler( + _http_request: HttpRequest, + path: Path, + server_state: web::Data> +) -> Result +{ + let entity_token = FavoriteEntityToken::from_entity_type_and_token( + path.entity_type, &path.entity_token); + + let query_results = list_favorites_for_entity( + entity_token, + &server_state.mysql_pool, + ).await; + + let favorites = match query_results { + Ok(results) => results, + Err(e) => { + warn!("Query error: {:?}", e); + return Err(ListFavoritesError::ServerError); + } + }; + + let response = ListFavoritesSuccessResponse { + success: true, + favorites: favorites.into_iter() + .map(|favorite| Favorite { + token: favorite.token, + user: UserDetailsLight { + user_token: favorite.user_token.clone(), + username: favorite.username.to_string(), // NB: Cloned because of ref use for avatar below + display_name: favorite.user_display_name.clone(), + gravatar_hash: favorite.user_gravatar_hash.clone(), + default_avatar: DefaultAvatarInfo::from_username(&favorite.username), + }, + created_at: favorite.created_at, + updated_at: favorite.updated_at, + }) + .collect(), + }; + + let body = serde_json::to_string(&response) + .map_err(|_e| ListFavoritesError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/list_favorites_for_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/list_favorites_for_session_handler.rs new file mode 100644 index 0000000000..cdc11f8550 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/list_favorites_for_session_handler.rs @@ -0,0 +1,141 @@ +// NB: Incrementally getting rid of build warnings... +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +use std::fmt; +use std::sync::Arc; + +use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::Query; +use chrono::{DateTime, Utc}; +use log::warn; + +use enums::by_table::favorites::favorite_entity_type::FavoriteEntityType; +use mysql_queries::queries::favorites::list_user_favorites::{list_user_favorites, list_user_favorites_by_entity_type}; +use tokens::tokens::favorites::FavoriteToken; + +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::server_state::ServerState; + +#[derive(Deserialize)] +pub struct ListFavoritesQueryData { + maybe_scoped_entity_type: Option, +} + +#[derive(Serialize)] +pub struct ListFavoritesSuccessResponse { + pub success: bool, + pub favorites: Vec, +} + +#[derive(Serialize)] +pub struct Favorite { + pub token: FavoriteToken, + + pub details: FavoriteDetails, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Serialize)] +pub struct FavoriteDetails { + // TODO: This needs titles or some other summary metadata. + pub entity_type: FavoriteEntityType, + pub entity_token: String, + + // TODO: Populate this for TTS + pub maybe_summary_text: Option, + + // TODO: Populate this for images, video, etc. + pub maybe_thumbnail_url: Option, +} + +#[derive(Debug)] +pub enum ListFavoritesError { + ServerError, + NotAuthorizedError, +} + +impl ResponseError for ListFavoritesError { + fn status_code(&self) -> StatusCode { + match *self { + ListFavoritesError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + ListFavoritesError::NotAuthorizedError => StatusCode::UNAUTHORIZED, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + ListFavoritesError::ServerError => "server error".to_string(), + ListFavoritesError::NotAuthorizedError => "not authorized".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for ListFavoritesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +pub async fn list_favorites_for_session_handler( + http_request: HttpRequest, + query: Query, + server_state: web::Data> +) -> Result +{ + let user_session = server_state.session_checker + .maybe_get_user_session(&http_request, &server_state.mysql_pool) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + ListFavoritesError::ServerError + })? + .ok_or(ListFavoritesError::NotAuthorizedError)?; + + let query_results = match query.maybe_scoped_entity_type { + None => list_user_favorites(&user_session.username, &server_state.mysql_pool).await, + Some(entity_type) => + list_user_favorites_by_entity_type(&user_session.username, entity_type, &server_state.mysql_pool) + .await, + }; + + let favorites = match query_results { + Ok(results) => results, + Err(e) => { + warn!("Query error: {:?}", e); + return Err(ListFavoritesError::ServerError); + } + }; + + let response = ListFavoritesSuccessResponse { + success: true, + favorites: favorites.into_iter() + .map(|favorite| Favorite { + token: favorite.token, + details: FavoriteDetails { + entity_type: favorite.entity_type, + entity_token: favorite.entity_token, + maybe_summary_text: None, + maybe_thumbnail_url: None, + }, + created_at: favorite.created_at, + updated_at: favorite.updated_at, + }) + .collect(), + }; + + let body = serde_json::to_string(&response) + .map_err(|_e| ListFavoritesError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/list_favorites_for_user_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/list_favorites_for_user_handler.rs new file mode 100644 index 0000000000..071b1fcb0e --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/list_favorites_for_user_handler.rs @@ -0,0 +1,138 @@ +// NB: Incrementally getting rid of build warnings... +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +use std::fmt; +use std::sync::Arc; + +use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::{Path, Query}; +use chrono::{DateTime, Utc}; +use log::warn; + +use enums::by_table::favorites::favorite_entity_type::FavoriteEntityType; +use mysql_queries::queries::favorites::list_user_favorites::{list_user_favorites, list_user_favorites_by_entity_type}; +use tokens::tokens::favorites::FavoriteToken; + +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::server_state::ServerState; + +#[derive(Deserialize)] +pub struct ListFavoritesPathInfo { + username: String, +} + +#[derive(Deserialize)] +pub struct ListFavoritesQueryData { + maybe_scoped_entity_type: Option, +} + +#[derive(Serialize)] +pub struct ListFavoritesSuccessResponse { + pub success: bool, + pub favorites: Vec, +} + +#[derive(Serialize)] +pub struct Favorite { + pub token: FavoriteToken, + + pub details: FavoriteDetails, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Serialize)] +pub struct FavoriteDetails { + // TODO: This needs titles or some other summary metadata. + pub entity_type: FavoriteEntityType, + pub entity_token: String, + + // TODO: Populate this for TTS + pub maybe_summary_text: Option, + + // TODO: Populate this for images, video, etc. + pub maybe_thumbnail_url: Option, +} + +#[derive(Debug)] +pub enum ListFavoritesError { + ServerError, +} + +impl ResponseError for ListFavoritesError { + fn status_code(&self) -> StatusCode { + match *self { + ListFavoritesError::ServerError=> StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + ListFavoritesError::ServerError => "server error".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for ListFavoritesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +pub async fn list_favorites_for_user_handler( + _http_request: HttpRequest, + path: Path, + query: Query, + server_state: web::Data> +) -> Result +{ + + let query_results = match query.maybe_scoped_entity_type { + None => list_user_favorites(&path.username, &server_state.mysql_pool).await, + Some(entity_type) => + list_user_favorites_by_entity_type(&path.username, entity_type, &server_state.mysql_pool) + .await, + }; + + let favorites = match query_results { + Ok(results) => results, + Err(e) => { + warn!("Query error: {:?}", e); + return Err(ListFavoritesError::ServerError); + } + }; + + let response = ListFavoritesSuccessResponse { + success: true, + favorites: favorites.into_iter() + .map(|favorite| Favorite { + token: favorite.token, + details: FavoriteDetails { + entity_type: favorite.entity_type, + entity_token: favorite.entity_token, + maybe_summary_text: favorite.maybe_entity_descriptive_text, + // TODO(bt,2023-11-21): Thumbnails need proper support. We should build them as a + // first-class system before handling the backfill here. + maybe_thumbnail_url: None, + }, + created_at: favorite.created_at, + updated_at: favorite.updated_at, + }) + .collect(), + }; + + let body = serde_json::to_string(&response) + .map_err(|_e| ListFavoritesError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/mod.rs new file mode 100644 index 0000000000..4529d2f18b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/favorites/mod.rs @@ -0,0 +1,5 @@ +pub mod create_favorite_handler; +pub mod delete_favorite_handler; +pub mod list_favorites_for_entity_handler; +pub mod list_favorites_for_session_handler; +pub mod list_favorites_for_user_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs index c862f9055e..81af5ee4b2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs @@ -25,3 +25,4 @@ pub mod voice_clone_requests; pub mod voice_conversion; pub mod voice_designer; pub mod w2l; +pub mod favorites; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/postman_collections/Base Service Collection.postman_collection.json b/crates/service/web/storyteller_web/src/postman_collections/Base Service Collection.postman_collection.json index cd118356fe..dc5132bbff 100644 --- a/crates/service/web/storyteller_web/src/postman_collections/Base Service Collection.postman_collection.json +++ b/crates/service/web/storyteller_web/src/postman_collections/Base Service Collection.postman_collection.json @@ -1,9 +1,9 @@ { "info": { - "_postman_id": "febcb082-e71c-46c4-a172-adf736e01298", + "_postman_id": "5b9aab26-f591-4264-b75b-3075dbeaf6df", "name": "Base Service Collection", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "30159615" + "_exporter_id": "10633600" }, "item": [ { @@ -84,6 +84,77 @@ } ] }, + { + "name": "Favorites", + "item": [ + { + "name": "List Favorites", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/v1/favorites/list/user/U:0XTEZ7HX29B9C", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "favorites", + "list", + "user", + "U:0XTEZ7HX29B9C" + ] + } + }, + "response": [] + }, + { + "name": "Create Favorite", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/v1/comments/new", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "comments", + "new" + ] + } + }, + "response": [] + }, + { + "name": "Delete Favorite", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/v1/favorites/delete/:favorite_token", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "favorites", + "delete", + ":favorite_token" + ], + "variable": [ + { + "key": "favorite_token", + "value": "" + } + ] + } + }, + "response": [] + } + ] + }, { "name": "Animations", "item": [ diff --git a/crates/service/web/storyteller_web/src/routes.rs b/crates/service/web/storyteller_web/src/routes.rs index 870977024e..9b6f920061 100644 --- a/crates/service/web/storyteller_web/src/routes.rs +++ b/crates/service/web/storyteller_web/src/routes.rs @@ -3,7 +3,6 @@ use actix_service::ServiceFactory; use actix_web::{App, HttpResponse, web}; use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::error::Error; -use log::warn; use actix_helpers::route_builder::RouteBuilder; use billing_component::default_routes::add_suggested_stripe_billing_routes; @@ -29,6 +28,11 @@ use crate::http_server::endpoints::comments::list_comments_handler::list_comment use crate::http_server::endpoints::download_job::enqueue_generic_download::enqueue_generic_download_handler; use crate::http_server::endpoints::download_job::get_generic_upload_job_status::get_generic_download_job_status_handler; use crate::http_server::endpoints::events::list_events::list_events_handler; +use crate::http_server::endpoints::favorites::create_favorite_handler::create_favorite_handler; +use crate::http_server::endpoints::favorites::delete_favorite_handler::delete_favorite_handler; +use crate::http_server::endpoints::favorites::list_favorites_for_entity_handler::list_favorites_for_entity_handler; +use crate::http_server::endpoints::favorites::list_favorites_for_session_handler::list_favorites_for_session_handler; +use crate::http_server::endpoints::favorites::list_favorites_for_user_handler::list_favorites_for_user_handler; use crate::http_server::endpoints::flags::design_refresh_flag::disable_design_refresh_flag_handler::disable_design_refresh_flag_handler; use crate::http_server::endpoints::flags::design_refresh_flag::enable_design_refresh_flag_handler::enable_design_refresh_flag_handler; use crate::http_server::endpoints::inference_job::get_inference_job_status::get_inference_job_status_handler; @@ -186,6 +190,16 @@ pub fn add_routes (app: App, server_environment: ServerEnvironment) -> .add_post("/v1/comments/delete/{comment_token}", delete_comment_handler) .into_app(); + // ==================== Favorites ==================== + + let mut app = RouteBuilder::from_app(app) + .add_post("/v1/favorites/create", create_favorite_handler) + .add_post("/v1/favorites/delete/{favorite_token}", delete_favorite_handler) + .add_get("/v1/favorites/list/session", list_favorites_for_session_handler) + .add_get("/v1/favorites/list/user/{username}", list_favorites_for_user_handler) + .add_get("/v1/favorites/list/entity/{entity_type}/{entity_token}", list_favorites_for_entity_handler) + .into_app(); + // ==================== Animations ==================== let mut app = RouteBuilder::from_app(app)