diff --git a/.idea/dictionaries/bt.xml b/.idea/dictionaries/bt.xml index be0599beae..1addafbd82 100644 --- a/.idea/dictionaries/bt.xml +++ b/.idea/dictionaries/bt.xml @@ -6,6 +6,7 @@ cashapp cheermote crockford + downvote fakeyou ffprobe hifigan diff --git a/_migrations/2023-01-25-054024_user_ratings/up.sql b/_migrations/2023-01-25-054024_user_ratings/up.sql index 1a51f76547..d41bf3826d 100644 --- a/_migrations/2023-01-25-054024_user_ratings/up.sql +++ b/_migrations/2023-01-25-054024_user_ratings/up.sql @@ -20,6 +20,14 @@ CREATE TABLE user_ratings ( -- Whether the vote is up/positive (TRUE) or down/negative (FALSE) is_positive_rating BOOLEAN NOT NULL DEFAULT FALSE, + -- Whether the vote is "soft deleted" (="neutral"), up/positive, or down/negative. + -- Rather than a nullable bool for ratings, we'll provide an enum. + rating_type ENUM( + 'neutral', + 'positive', + 'negative' + ) NOT NULL DEFAULT 'neutral', + -- For abuse tracking. -- Wide enough for IPv4/6 vote_ip_address VARCHAR(40) NOT NULL, @@ -38,6 +46,7 @@ CREATE TABLE user_ratings ( PRIMARY KEY (id), UNIQUE KEY (user_token, entity_type, entity_token), KEY index_entity_type_entity_token (entity_type, entity_token), - KEY index_user_token (user_token) + KEY index_user_token (user_token), + KEY index_rating_type (rating_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/crates/_types/enums/src/by_table/mod.rs b/crates/_types/enums/src/by_table/mod.rs index 4ee11bc1ac..c099576241 100644 --- a/crates/_types/enums/src/by_table/mod.rs +++ b/crates/_types/enums/src/by_table/mod.rs @@ -1,3 +1,4 @@ pub mod media_uploads; pub mod model_categories; pub mod trending_model_analytics; +pub mod user_ratings; diff --git a/crates/_types/enums/src/by_table/user_ratings/entity_type.rs b/crates/_types/enums/src/by_table/user_ratings/entity_type.rs new file mode 100644 index 0000000000..670a984aa2 --- /dev/null +++ b/crates/_types/enums/src/by_table/user_ratings/entity_type.rs @@ -0,0 +1,93 @@ +use serde::Deserialize; +use serde::Serialize; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `user_ratings` 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 UserRatingEntityType { + /// 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, +} + +// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests! +impl_enum_display_and_debug_using_to_str!(UserRatingEntityType); +impl_enum_sqlx_coders!(UserRatingEntityType); + +/// NB: Legacy API for older code. +impl UserRatingEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::TtsModel => "tts_model", + Self::TtsResult => "tts_result", + Self::W2lTemplate => "w2l_template", + Self::W2lResult => "w2l_result", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "tts_model" => Ok(Self::TtsModel), + "tts_result" => Ok(Self::TtsResult), + "w2l_template" => Ok(Self::W2lTemplate), + "w2l_result" => Ok(Self::W2lResult), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::user_ratings::entity_type::UserRatingEntityType; + use crate::test_helpers::assert_serialization; + + mod serde { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(UserRatingEntityType::TtsModel, "tts_model"); + assert_serialization(UserRatingEntityType::TtsResult, "tts_result"); + assert_serialization(UserRatingEntityType::W2lTemplate, "w2l_template"); + assert_serialization(UserRatingEntityType::W2lResult, "w2l_result"); + } + } + + mod impl_methods { + use super::*; + + #[test] + fn test_to_str() { + assert_eq!(UserRatingEntityType::TtsModel.to_str(), "tts_model"); + assert_eq!(UserRatingEntityType::TtsResult.to_str(), "tts_result"); + assert_eq!(UserRatingEntityType::W2lTemplate.to_str(), "w2l_template"); + assert_eq!(UserRatingEntityType::W2lResult.to_str(), "w2l_result"); + } + + #[test] + fn test_from_str() { + assert_eq!(UserRatingEntityType::from_str("tts_model").unwrap(), UserRatingEntityType::TtsModel); + assert_eq!(UserRatingEntityType::from_str("tts_result").unwrap(), UserRatingEntityType::TtsResult); + assert_eq!(UserRatingEntityType::from_str("w2l_template").unwrap(), UserRatingEntityType::W2lTemplate); + assert_eq!(UserRatingEntityType::from_str("w2l_result").unwrap(), UserRatingEntityType::W2lResult); + assert!(UserRatingEntityType::from_str("foo").is_err()); + } + } +} diff --git a/crates/_types/enums/src/by_table/user_ratings/mod.rs b/crates/_types/enums/src/by_table/user_ratings/mod.rs new file mode 100644 index 0000000000..7c7a1b6ae4 --- /dev/null +++ b/crates/_types/enums/src/by_table/user_ratings/mod.rs @@ -0,0 +1,2 @@ +pub mod entity_type; +pub mod rating_type; \ No newline at end of file diff --git a/crates/_types/enums/src/by_table/user_ratings/rating_type.rs b/crates/_types/enums/src/by_table/user_ratings/rating_type.rs new file mode 100644 index 0000000000..e4d059923a --- /dev/null +++ b/crates/_types/enums/src/by_table/user_ratings/rating_type.rs @@ -0,0 +1,161 @@ +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// UserRatingType +/// +/// - Used in the `user_ratings` table as an `ENUM` field named `rating_type`. +/// - Used in the HTTP API. +/// +/// To use this in a query, the query must have type annotations. +/// See: https://www.gitmemory.com/issue/launchbadge/sqlx/1241/847154375 +/// eg. preferred_tts_result_visibility as `rating_type: enums::by_table::user_ratings::rating_type::UserRatingType` +/// +/// See also: https://docs.rs/sqlx/0.4.0-beta.1/sqlx/trait.Type.html +/// +/// *DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY!* +/// +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize, sqlx::Type)] +#[serde(rename_all = "lowercase")] +#[sqlx(rename_all = "lowercase")] +pub enum UserRatingType { + /// This is considered a ratings "soft deletion" and does not count towards a total score. + /// This is the default rating. + Neutral, + /// This is a positive vote / upvote / like. + /// They are available to non-logged-in users as long as they have the URL. + Positive, + /// This is a negative vote / downvote / dislike. + Negative, +} + + +impl_enum_display_and_debug_using_to_str!(UserRatingType); + +impl Default for UserRatingType { + fn default() -> Self { Self::Neutral } +} + +/// NB: Legacy API for older code. +impl UserRatingType { + pub fn to_str(&self) -> &'static str { + match self { + Self::Neutral => "neutral", + Self::Positive => "positive", + Self::Negative => "negative", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "neutral" => Ok(Self::Neutral), + "positive" => Ok(Self::Positive), + "negative" => Ok(Self::Negative), + _ => Err(format!("invalid value: {:?}", value)), + } + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::user_ratings::rating_type::UserRatingType; + use crate::test_helpers::assert_serialization; + + #[test] + fn test_serialization() { + assert_serialization(UserRatingType::Neutral, "neutral"); + assert_serialization(UserRatingType::Positive, "positive"); + assert_serialization(UserRatingType::Negative, "negative"); + } + + mod impl_methods { + use super::*; + + #[test] + fn to_str() { + assert_eq!(UserRatingType::Neutral.to_str(), "neutral"); + assert_eq!(UserRatingType::Positive.to_str(), "positive"); + assert_eq!(UserRatingType::Negative.to_str(), "negative"); + } + + #[test] + fn from_str() { + assert_eq!(UserRatingType::from_str("neutral").unwrap(), UserRatingType::Neutral); + assert_eq!(UserRatingType::from_str("positive").unwrap(), UserRatingType::Positive); + assert_eq!(UserRatingType::from_str("negative").unwrap(), UserRatingType::Negative); + assert!(UserRatingType::from_str("foo").is_err()); + } + } + + mod traits { + use super::*; + + #[test] + fn default() { + assert_eq!(UserRatingType::default(), UserRatingType::Neutral); + } + + #[test] + fn display() { + let visibility = UserRatingType::Positive; + assert_eq!(format!("{}", visibility), "positive".to_string()); + } + + #[test] + fn debug() { + let visibility = UserRatingType::Negative; + assert_eq!(format!("{:?}", visibility), "negative".to_string()); + } + } + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct CompositeType { + visibility: UserRatingType, + string: String, + } + + mod serde_serialization { + use super::*; + + #[test] + fn serialize() { + let expected = "\"positive\"".to_string(); // NB: Quoted + + assert_eq!(expected, toml::to_string(&UserRatingType::Positive).unwrap()); + + // Just to show this serializes the same as a string + assert_eq!(expected, toml::to_string("positive").unwrap()); + } + + #[test] + fn nested_serialize() { + let value = CompositeType { visibility: UserRatingType::Negative, string: "bar".to_string() }; + let expected = r#"{"visibility":"negative","string":"bar"}"#.to_string(); + assert_eq!(expected, serde_json::to_string(&value).unwrap()); + } + } + + mod serde_deserialization { + use super::*; + + #[test] + fn deserialize() { + let payload = "\"positive\""; // NB: Quoted + let value: UserRatingType = serde_json::from_str(payload).unwrap(); + assert_eq!(value, UserRatingType::Positive); + } + + #[test] + fn nested_deserialize() { + let payload = r#"{"visibility":"neutral","string":"bar"}"#.to_string(); + let expected = CompositeType { + visibility: UserRatingType::Neutral, + string: "bar".to_string(), + }; + + assert_eq!(expected, serde_json::from_str::(&payload).unwrap()); + } + } +} diff --git a/crates/_types/enums/src/lib.rs b/crates/_types/enums/src/lib.rs index 7d2706dbe9..d08c8d20c3 100644 --- a/crates/_types/enums/src/lib.rs +++ b/crates/_types/enums/src/lib.rs @@ -4,6 +4,10 @@ //! This should also work for CHAR/VARCHAR fields that work similarly to enums (typically //! as part of a composite key) //! +//! These types should also be friendly for API usage in JSON payloads. +//! +//! In the future this should be *CODEGEN DRIVEN* and should get checked into source control. +//! // Never allow these #![forbid(private_in_public)] diff --git a/crates/lib/database_queries/src/queries/users/mod.rs b/crates/lib/database_queries/src/queries/users/mod.rs index 0db9fcd652..57802a1b2b 100644 --- a/crates/lib/database_queries/src/queries/users/mod.rs +++ b/crates/lib/database_queries/src/queries/users/mod.rs @@ -1,6 +1,7 @@ pub mod user; pub mod user_badges; pub mod user_profiles; +pub mod user_ratings; pub mod user_roles; pub mod user_sessions; pub mod user_subscriptions; diff --git a/crates/lib/database_queries/src/queries/users/user_ratings/mod.rs b/crates/lib/database_queries/src/queries/users/user_ratings/mod.rs new file mode 100644 index 0000000000..849a75798c --- /dev/null +++ b/crates/lib/database_queries/src/queries/users/user_ratings/mod.rs @@ -0,0 +1 @@ +pub mod upsert_user_rating; \ No newline at end of file diff --git a/crates/lib/database_queries/src/queries/users/user_ratings/upsert_user_rating.rs b/crates/lib/database_queries/src/queries/users/user_ratings/upsert_user_rating.rs new file mode 100644 index 0000000000..d80897102e --- /dev/null +++ b/crates/lib/database_queries/src/queries/users/user_ratings/upsert_user_rating.rs @@ -0,0 +1,58 @@ +use container_common::anyhow_result::AnyhowResult; +use enums::by_table::user_ratings::entity_type::UserRatingEntityType; +use sqlx::MySql; +use sqlx::pool::PoolConnection; +use enums::by_table::user_ratings::rating_type::UserRatingType; +use tokens::tokens::tts_models::TtsModelToken; +use tokens::tokens::w2l_templates::W2lTemplateToken; +use tokens::users::user::UserToken; + +pub enum UserRatingEntityToken<'a> { + TtsModel(&'a TtsModelToken), + W2lTemplate(&'a W2lTemplateToken), +} + +pub struct Args<'a> { + pub user_token: &'a UserToken, + pub entity_token: UserRatingEntityToken<'a>, + pub rating_type: UserRatingType, + pub ip_address: &'a str, + pub mysql_connection: &'a mut PoolConnection, +} + +pub async fn upsert_user_rating(args: Args<'_>) -> AnyhowResult<()> { + + let (entity_type, entity_token) = match args.entity_token { + EntityToken::TtsModel(token) => (UserRatingEntityType::TtsModel, token.as_str()), + EntityToken::W2lTemplate(token) => (UserRatingEntityType::W2lTemplate, token.as_str()), + }; + + let query = sqlx::query!( + r#" +INSERT INTO user_ratings +SET + user_token = ?, + entity_type = ?, + entity_token = ?, + rating_type = ?, + vote_ip_address = ?, + version = 1 + +ON DUPLICATE KEY UPDATE + rating_type = ?, + vote_ip_address = ?, + version = version + 1 + "#, + // Insert + args.user_token.as_str(), + entity_type, + entity_token, + args.rating_type.to_str(), + args.ip_address, + args.rating_type.to_str(), + args.ip_address + ); + + let _r = query.execute(args.mysql_connection).await?; + Ok(()) +}