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(())
+}