diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/datasets/create_dataset.rs b/crates/lib/mysql_queries/src/queries/voice_designer/datasets/create_dataset.rs index bfd16cedbe..d7ad30a842 100644 --- a/crates/lib/mysql_queries/src/queries/voice_designer/datasets/create_dataset.rs +++ b/crates/lib/mysql_queries/src/queries/voice_designer/datasets/create_dataset.rs @@ -13,7 +13,6 @@ pub struct CreateDatasetArgs<'a> { pub maybe_creator_user_token: Option<&'a str>, pub creator_ip_address: &'a str, pub creator_set_visibility: &'a Visibility, - pub maybe_mod_user_token: Option<&'a str>, pub mysql_pool: &'a MySqlPool } @@ -50,7 +49,6 @@ pub async fn create_dataset(args: CreateDatasetArgs<'_>) -> AnyhowResult) -> AnyhowResult, + pub creator_set_visibility: Visibility, } pub async fn get_dataset_by_token( @@ -73,6 +71,7 @@ pub async fn get_dataset_by_token_with_connection( ietf_language_tag: record.ietf_language_tag, ietf_primary_language_subtag: record.ietf_primary_language_subtag, maybe_creator_user_token: record.maybe_creator_user_token, + creator_set_visibility: record.creator_set_visibility, })) } diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/datasets/list_datasets.rs b/crates/lib/mysql_queries/src/queries/voice_designer/datasets/list_datasets.rs new file mode 100644 index 0000000000..74c3aa03a0 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/voice_designer/datasets/list_datasets.rs @@ -0,0 +1,150 @@ +use anyhow::anyhow; +use chrono::{DateTime, Utc}; +use log::{info, warn}; +use sqlx::{MySql, MySqlPool}; +use sqlx::pool::PoolConnection; + +use enums::common::visibility::Visibility; +use errors::AnyhowResult; +use tokens::tokens::zs_dataset::ZsDatasetToken; + + +// FIXME: This is the old style of query scoping and shouldn't be copied. + +#[derive(Serialize)] +pub struct DatasetRecordForList { + pub dataset_token: String, + pub title: String, + pub creator_set_visibility: Visibility, + pub ietf_language_tag: String, + pub ietf_primary_language_subtag: String, + pub maybe_creator_user_token: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub async fn list_datasets_by_user_token( + mysql_pool: &MySqlPool, + user_token: &str, + can_see_deleted: bool, + is_owner: bool, +) -> AnyhowResult> { + let mut connection = mysql_pool.acquire().await?; + list_datasets_with_connection(&mut connection, user_token, can_see_deleted, is_owner).await +} + +pub async fn list_datasets_with_connection( + mysql_connection: &mut PoolConnection, + user_token: &str, + can_see_deleted: bool, + is_owner: bool, +) -> AnyhowResult> { + + let datasets = + list_datasets_by_creator_token(mysql_connection, user_token, can_see_deleted) + .await; + + let datasets : Vec = match datasets { + Ok(datasets) => datasets, + Err(err) => { + match err { + RowNotFound => { + return Ok(Vec::new()); + }, + _ => { + warn!("dataset list query error: {:?}", err); + return Err(anyhow!("dataset list query error")); + } + } + } + }; + + Ok(datasets.into_iter() + .map(|dataset| { + DatasetRecordForList{ + dataset_token: dataset.token.to_string(), + title: dataset.title, + creator_set_visibility: dataset.creator_set_visibility, + ietf_language_tag: dataset.ietf_language_tag, + ietf_primary_language_subtag: dataset.ietf_primary_language_subtag, + maybe_creator_user_token: dataset.maybe_creator_user_token, + + created_at: dataset.created_at, + updated_at: dataset.updated_at, + } + }) + .filter(|dataset| { + is_owner || dataset.creator_set_visibility == Visibility::Public || can_see_deleted + }) + .collect::>()) +} + + +async fn list_datasets_by_creator_token( + mysql_connection: &mut PoolConnection, + creator_token: &str, + can_see_deleted: bool, +) -> AnyhowResult> { + // TODO: There has to be a better way. + // Sqlx doesn't like anything except string literals. + let maybe_datasets = if !can_see_deleted { + info!("listing datasets for user;"); + sqlx::query_as!( + InternalRawDatasetRecordForList, + r#" + SELECT + zd.token as `token: tokens::tokens::zs_dataset::ZsDatasetToken`, + zd.title, + zd.ietf_language_tag, + zd.ietf_primary_language_subtag, + zd.maybe_creator_user_token, + zd.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`, + zd.created_at, + zd.updated_at + FROM zs_voice_datasets as zd + WHERE + zd.maybe_creator_user_token = ? + AND zd.user_deleted_at IS NULL + AND zd.mod_deleted_at IS NULL + "#, + creator_token) + .fetch_all(mysql_connection) + .await? + } else { + info!("listing datasets for user"); + sqlx::query_as!( + InternalRawDatasetRecordForList + , + r#" + SELECT + zd.token as `token: tokens::tokens::zs_dataset::ZsDatasetToken`, + zd.title, + zd.ietf_language_tag, + zd.ietf_primary_language_subtag, + zd.maybe_creator_user_token, + zd.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`, + zd.created_at, + zd.updated_at + FROM zs_voice_datasets as zd + WHERE + zd.maybe_creator_user_token = ? + "#, + creator_token) + .fetch_all(mysql_connection) + .await? + }; + + Ok(maybe_datasets) +} + +struct InternalRawDatasetRecordForList { + token: ZsDatasetToken, + title: String, + ietf_language_tag: String, + ietf_primary_language_subtag: String, + maybe_creator_user_token: Option, + creator_set_visibility: Visibility, + created_at: DateTime, + updated_at: DateTime, +} diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/datasets/mod.rs b/crates/lib/mysql_queries/src/queries/voice_designer/datasets/mod.rs index 979ccf56d1..9c42e1353a 100644 --- a/crates/lib/mysql_queries/src/queries/voice_designer/datasets/mod.rs +++ b/crates/lib/mysql_queries/src/queries/voice_designer/datasets/mod.rs @@ -2,3 +2,4 @@ pub mod create_dataset; pub mod delete_dataset; pub mod get_dataset; pub mod update_dataset; +pub mod list_datasets; diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/datasets/update_dataset.rs b/crates/lib/mysql_queries/src/queries/voice_designer/datasets/update_dataset.rs index be4ddca309..b384facc5d 100644 --- a/crates/lib/mysql_queries/src/queries/voice_designer/datasets/update_dataset.rs +++ b/crates/lib/mysql_queries/src/queries/voice_designer/datasets/update_dataset.rs @@ -47,6 +47,8 @@ pub async fn update_dataset(args: UpdateDatasetArgs<'_>) -> AnyhowResult<()>{ creator_set_visibility = ?, maybe_mod_user_token = ?, maybe_creator_synthetic_id = ?, + ietf_language_tag = ?, + ietf_primary_language_subtag = ?, version = version + 1 @@ -59,13 +61,15 @@ pub async fn update_dataset(args: UpdateDatasetArgs<'_>) -> AnyhowResult<()>{ args.creator_set_visibility.to_str(), args.maybe_mod_user_token, maybe_creator_synthetic_id, + args.ietf_language_tag, + args.ietf_primary_language_subtag, args.dataset_token.as_str(), ).execute(args.mysql_pool).await; // TODO(Kasisnu): This should probably rollback transaction.commit().await?; match query_result { Ok(_) => Ok(()), - Err(err) => Err(anyhow!("zs dataset creation error: {:?}", err)), + Err(err) => Err(anyhow!("zs dataset update error: {:?}", err)), } } diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/mod.rs b/crates/lib/mysql_queries/src/queries/voice_designer/mod.rs index cf33dc01ad..70b4a115a5 100644 --- a/crates/lib/mysql_queries/src/queries/voice_designer/mod.rs +++ b/crates/lib/mysql_queries/src/queries/voice_designer/mod.rs @@ -1,3 +1,3 @@ pub mod datasets; pub mod voice_samples; -pub mod voices; \ No newline at end of file +pub mod voices; diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/voices/delete_voice.rs b/crates/lib/mysql_queries/src/queries/voice_designer/voices/delete_voice.rs new file mode 100644 index 0000000000..586822298d --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/voice_designer/voices/delete_voice.rs @@ -0,0 +1,89 @@ +use sqlx::MySqlPool; + +use errors::AnyhowResult; + +pub async fn delete_voice_as_user( + voice_token: &str, + mysql_pool: &MySqlPool +) -> AnyhowResult<()> { + let _r = sqlx::query!( + r#" +UPDATE zs_voices +SET + user_deleted_at = CURRENT_TIMESTAMP +WHERE + token = ? +LIMIT 1 + "#, + voice_token, + ) + .execute(mysql_pool) + .await?; + Ok(()) +} + +pub async fn delete_voice_as_mod( + voice_token: &str, + mod_user_token: &str, + mysql_pool: &MySqlPool +) -> AnyhowResult<()> { + let _r = sqlx::query!( + r#" +UPDATE zs_voices +SET + mod_deleted_at = CURRENT_TIMESTAMP, + maybe_mod_user_token = ? +WHERE + token = ? +LIMIT 1 + "#, + mod_user_token, + voice_token, + ) + .execute(mysql_pool) + .await?; + Ok(()) +} + +pub async fn undelete_voice_as_user( + voice_token: &str, + mysql_pool: &MySqlPool +) -> AnyhowResult<()> { + let _r = sqlx::query!( + r#" +UPDATE zs_voices +SET + user_deleted_at = NULL +WHERE + token = ? +LIMIT 1 + "#, + voice_token, + ) + .execute(mysql_pool) + .await?; + Ok(()) +} + +pub async fn undelete_voice_as_mod( + voice_token: &str, + mod_user_token: &str, + mysql_pool: &MySqlPool +) -> AnyhowResult<()> { + let _r = sqlx::query!( + r#" +UPDATE zs_voices +SET + mod_deleted_at = NULL, + maybe_mod_user_token = ? +WHERE + token = ? +LIMIT 1 + "#, + mod_user_token, + voice_token, + ) + .execute(mysql_pool) + .await?; + Ok(()) +} \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/voices/get_voice.rs b/crates/lib/mysql_queries/src/queries/voice_designer/voices/get_voice.rs new file mode 100644 index 0000000000..e7b193ec62 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/voice_designer/voices/get_voice.rs @@ -0,0 +1,132 @@ +use anyhow::anyhow; +use chrono::{DateTime, Utc}; +use log::error; +use sqlx::{MySql, MySqlPool}; +use sqlx::pool::PoolConnection; + +use enums::common::visibility::Visibility; +use errors::AnyhowResult; +use tokens::tokens::zs_voice::ZsVoiceToken; + + +pub struct ZsVoice { + pub token: ZsVoiceToken, + pub title: String, + pub ietf_language_tag: String, + pub ietf_primary_language_subtag: String, + pub maybe_creator_user_token: Option, +} + +pub async fn get_voice_by_token( + voice_token: &ZsVoiceToken, + can_see_deleted: bool, + mysql_pool: &MySqlPool, +) -> AnyhowResult> { + let mut connection = mysql_pool.acquire().await?; + get_voice_by_token_with_connection( + voice_token, + can_see_deleted, + &mut connection + ).await +} + +pub async fn get_voice_by_token_with_connection( + voice_token: &ZsVoiceToken, + can_see_deleted: bool, + mysql_connection: &mut PoolConnection, +) -> AnyhowResult> { + + let maybe_result = if can_see_deleted { + select_include_deleted( + voice_token, + mysql_connection + ).await + } else { + select_without_deleted( + voice_token, + mysql_connection + ).await + }; + + let record = match maybe_result { + Ok(record) => record, + Err(sqlx::Error::RowNotFound) => { + return Ok(None); + }, + Err(err) => { + error!( + "Error fetching voice by token: {:?}", + err + ); + return Err(anyhow!( + "Error fetching voice by token: {:?}", + err + )); + } + }; + + Ok(Some(ZsVoice { + token: record.token, + title: record.title, + ietf_language_tag: record.ietf_language_tag, + ietf_primary_language_subtag: record.ietf_primary_language_subtag, + maybe_creator_user_token: record.maybe_creator_user_token, + })) +} + +async fn select_include_deleted( + voice_token: &ZsVoiceToken, + mysql_connection: &mut PoolConnection, +) -> Result { + sqlx::query_as!( + RawVoice, + r#" + SELECT + zv.token as `token: tokens::tokens::zs_voice::ZsVoiceToken`, + zv.title, + zv.ietf_language_tag, + zv.ietf_primary_language_subtag, + zv.maybe_creator_user_token, + zv.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility` + FROM zs_voices as zv + WHERE + zv.token = ? + "#, + voice_token.as_str() + ) + .fetch_one(mysql_connection).await +} + +async fn select_without_deleted( + voice_token: &ZsVoiceToken, + mysql_connection: &mut PoolConnection, +) -> Result { + sqlx::query_as!( + RawVoice, + r#" + SELECT + zv.token as `token: tokens::tokens::zs_voice::ZsVoiceToken`, + zv.title, + zv.ietf_language_tag, + zv.ietf_primary_language_subtag, + zv.maybe_creator_user_token, + zv.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility` + FROM zs_voices as zv + WHERE + zv.token = ? + AND zv.user_deleted_at IS NULL + AND zv.mod_deleted_at IS NULL + "#, + voice_token.as_str() + ) + .fetch_one(mysql_connection).await +} +#[derive(Serialize)] +pub struct RawVoice { + token: ZsVoiceToken, + title: String, + ietf_language_tag: String, + ietf_primary_language_subtag: String, + maybe_creator_user_token: Option, + creator_set_visibility: Visibility, +} diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/voices/list_voices.rs b/crates/lib/mysql_queries/src/queries/voice_designer/voices/list_voices.rs new file mode 100644 index 0000000000..bae694f971 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/voice_designer/voices/list_voices.rs @@ -0,0 +1,149 @@ +use anyhow::anyhow; +use chrono::{DateTime, Utc}; +use log::{info, warn}; +use sqlx::{MySql, MySqlPool}; +use sqlx::pool::PoolConnection; + +use enums::common::visibility::Visibility; +use errors::AnyhowResult; +use tokens::tokens::zs_voice::ZsVoiceToken; + + +// FIXME: This is the old style of query scoping and shouldn't be copied. + +#[derive(Serialize)] +pub struct VoiceRecordForList { + pub voice_token: String, + pub title: String, + pub creator_set_visibility: Visibility, + pub ietf_language_tag: String, + pub ietf_primary_language_subtag: String, + pub maybe_creator_user_token: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub async fn list_voices_by_user_token( + mysql_pool: &MySqlPool, + user_token: &str, + can_see_deleted: bool, + is_owner: bool, +) -> AnyhowResult> { + let mut connection = mysql_pool.acquire().await?; + list_voices_with_connection(&mut connection, user_token, can_see_deleted, is_owner).await +} + +pub async fn list_voices_with_connection( + mysql_connection: &mut PoolConnection, + user_token: &str, + can_see_deleted: bool, + is_owner: bool, +) -> AnyhowResult> { + + let maybe_voices = list_dataset_by_creator(mysql_connection, user_token, can_see_deleted) + .await; + + let voices : Vec = match maybe_voices { + Ok(voices) => voices, + Err(err) => { + match err { + RowNotFound => { + return Ok(Vec::new()); + }, + _ => { + warn!("voice list query error: {:?}", err); + return Err(anyhow!("voice list query error")); + } + } + } + }; + + Ok(voices.into_iter() + .map(|voice| { + VoiceRecordForList{ + voice_token: voice.token.to_string(), + title: voice.title, + creator_set_visibility: voice.creator_set_visibility, + ietf_language_tag: voice.ietf_language_tag, + ietf_primary_language_subtag: voice.ietf_primary_language_subtag, + maybe_creator_user_token: voice.maybe_creator_user_token, + + created_at: voice.created_at, + updated_at: voice.updated_at, + } + }) + .filter(|dataset| { + is_owner || dataset.creator_set_visibility == Visibility::Public || can_see_deleted + }) + .collect::>()) +} + + +async fn list_dataset_by_creator( + mysql_connection: &mut PoolConnection, + creator_token: &str, + can_see_deleted: bool, +) -> AnyhowResult> { + // TODO: There has to be a better way. + // Sqlx doesn't like anything except string literals. + let maybe_voices = if !can_see_deleted { + info!("listing voices for user;"); + sqlx::query_as!( + InternalRawVoiceRecordForList, + r#" + SELECT + zv.token as `token: tokens::tokens::zs_voice::ZsVoiceToken`, + zv.title, + zv.ietf_language_tag, + zv.ietf_primary_language_subtag, + zv.maybe_creator_user_token, + zv.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`, + zv.created_at, + zv.updated_at + FROM zs_voices as zv + WHERE + zv.maybe_creator_user_token = ? + AND zv.user_deleted_at IS NULL + AND zv.mod_deleted_at IS NULL + "#, + creator_token) + .fetch_all(mysql_connection) + .await? + } else { + info!("listing voices for user"); + sqlx::query_as!( + InternalRawVoiceRecordForList + , + r#" + SELECT + zv.token as `token: tokens::tokens::zs_voice::ZsVoiceToken`, + zv.title, + zv.ietf_language_tag, + zv.ietf_primary_language_subtag, + zv.maybe_creator_user_token, + zv.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`, + zv.created_at, + zv.updated_at + FROM zs_voices as zv + WHERE + zv.maybe_creator_user_token = ? + "#, + creator_token) + .fetch_all(mysql_connection) + .await? + }; + + Ok(maybe_voices) +} + +struct InternalRawVoiceRecordForList { + token: ZsVoiceToken, + title: String, + ietf_language_tag: String, + ietf_primary_language_subtag: String, + maybe_creator_user_token: Option, + creator_set_visibility: Visibility, + created_at: DateTime, + updated_at: DateTime, +} diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/voices/mod.rs b/crates/lib/mysql_queries/src/queries/voice_designer/voices/mod.rs index 4f06022ccd..28334dbd6c 100644 --- a/crates/lib/mysql_queries/src/queries/voice_designer/voices/mod.rs +++ b/crates/lib/mysql_queries/src/queries/voice_designer/voices/mod.rs @@ -1 +1,5 @@ pub mod create_voice; +pub mod delete_voice; +pub mod get_voice; +pub mod update_voice; +pub mod list_voices; diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/voices/update_voice.rs b/crates/lib/mysql_queries/src/queries/voice_designer/voices/update_voice.rs new file mode 100644 index 0000000000..271f78a900 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/voice_designer/voices/update_voice.rs @@ -0,0 +1,74 @@ +use anyhow::anyhow; +use sqlx::MySqlPool; +use enums::common::visibility::Visibility; +use enums::by_table::generic_synthetic_ids::id_category::IdCategory; + +use errors::AnyhowResult; +use tokens::tokens::zs_voice::ZsVoiceToken; +use tokens::users::user::UserToken; +use crate::queries::generic_synthetic_ids::transactional_increment_generic_synthetic_id::transactional_increment_generic_synthetic_id; + +pub struct UpdateVoiceArgs<'a> { + pub voice_token: &'a ZsVoiceToken, + pub voice_title: Option<&'a str>, + pub maybe_creator_user_token: Option<&'a str>, + pub creator_ip_address: &'a str, + pub creator_set_visibility: &'a Visibility, + pub maybe_mod_user_token: Option<&'a str>, + pub ietf_language_tag: Option<&'a str>, + pub ietf_primary_language_subtag: Option<&'a str>, + pub mysql_pool: &'a MySqlPool +} + +pub async fn update_voice(args: UpdateVoiceArgs<'_>) -> AnyhowResult<()>{ + // TODO: enforce checks for idempotency token + let mut maybe_creator_synthetic_id : Option = None; + + let mut transaction = args.mysql_pool.begin().await?; + if let Some(creator_user_token) = args.maybe_creator_user_token.as_deref() { + let user_token = UserToken::new_from_str(creator_user_token); + + let next_zs_voice_synthetic_id = transactional_increment_generic_synthetic_id( + &user_token, + IdCategory::ZsVoice, + &mut transaction + ).await?; + + maybe_creator_synthetic_id = Some(next_zs_voice_synthetic_id); + } + let query_result = sqlx::query!( + r#" + UPDATE zs_voices + SET + title = ?, + maybe_creator_user_token = ?, + creator_ip_address = ?, + creator_set_visibility = ?, + maybe_mod_user_token = ?, + maybe_creator_synthetic_id = ?, + ietf_language_tag = ?, + ietf_primary_language_subtag = ?, + + + version = version + 1 + WHERE token = ? + LIMIT 1 + "#, + args.voice_title, + args.maybe_creator_user_token, + args.creator_ip_address, + args.creator_set_visibility.to_str(), + args.maybe_mod_user_token, + maybe_creator_synthetic_id, + args.ietf_language_tag, + args.ietf_primary_language_subtag, + args.voice_token.as_str(), + ).execute(args.mysql_pool).await; + // TODO(Kasisnu): This should probably rollback + transaction.commit().await?; + match query_result { + Ok(_) => Ok(()), + Err(err) => Err(anyhow!("zs voice update error: {:?}", err)), + } + +} \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/create_dataset.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/create_dataset.rs index c5586d59b4..2ed7e94158 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/create_dataset.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/create_dataset.rs @@ -76,8 +76,6 @@ pub async fn create_dataset_handler(http_request: HttpRequest, request: web::Jso }, }; - let is_mod = user_session.can_ban_users; - let idempotency_token = request.idempotency_token.clone().ok_or(CreateDatasetError::BadInput("no idempotency token provided".to_string()))?; let title = request.title.clone(); @@ -86,18 +84,11 @@ pub async fn create_dataset_handler(http_request: HttpRequest, request: web::Jso let creator_set_visibility = request.creator_set_visibility.unwrap_or(Visibility::Public); - let mut maybe_mod_user_token = None; - - if is_mod { - maybe_mod_user_token = Some(user_session.user_token.clone()); - } - let query_result = create_dataset(CreateDatasetArgs { dataset_title: &title, maybe_creator_user_token: Some(user_session.user_token.clone().as_ref()), creator_ip_address: &creator_ip_address, creator_set_visibility: &creator_set_visibility, - maybe_mod_user_token: maybe_mod_user_token.as_deref(), mysql_pool: &server_state.mysql_pool }).await; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/delete_dataset.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/delete_dataset.rs index 9b60a8229a..4a9980fe3b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/delete_dataset.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/delete_dataset.rs @@ -110,7 +110,6 @@ pub async fn delete_dataset_handler( } }; - //TODO(kasisnu): Confirm who can delete/undelete and cleanup this logic let is_creator = dataset.maybe_creator_user_token.as_deref() .map(|creator_user_token| creator_user_token == &user_session.user_token) .unwrap_or(false); @@ -145,12 +144,11 @@ pub async fn delete_dataset_handler( } else { match delete_role { DeleteRole::ErrorDoNotDelete => { - warn!("user is not allowed to undelete inference results: {}", user_session.user_token); + warn!("user is not allowed to undelete voices: {}", user_session.user_token); return Err(DeleteDatasetError::NotAuthorized); } DeleteRole::AsUser => { // NB: Technically only mods can see their own datasets - //TODO(kasisnu): Is this a case that can apply to datasets? undelete_dataset_as_user( &path.dataset_token, &server_state.mysql_pool @@ -176,5 +174,4 @@ pub async fn delete_dataset_handler( Ok(simple_json_success()) - } - \ No newline at end of file + } \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/delete_voice.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/delete_voice.rs index cbf36606a9..ea161c73f9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/delete_voice.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/delete_voice.rs @@ -1,13 +1,175 @@ +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::{error, log, warn}; +use enums::by_table::audit_logs::audit_log_entity_action::AuditLogEntityAction::Delete; -use log::{info, warn}; +use http_server_common::request::get_request_ip::get_request_ip; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use mysql_queries::queries::voice_designer::voices::delete_voice::{delete_voice_as_mod, delete_voice_as_user, undelete_voice_as_mod, undelete_voice_as_user}; +use mysql_queries::queries::voice_designer::voices::get_voice::get_voice_by_token; +use tokens::tokens::zs_voice::ZsVoiceToken; +use crate::http_server::web_utils::response_success_helpers::simple_json_success; -pub async fn delete_voice(voice_token: web::Path) -> HttpResponse { - // Implementation for deleting a voice (soft delete) - HttpResponse::Ok().json(format!("Voice {} deleted (soft delete)", voice_token)) +use crate::server_state::ServerState; +use crate::util::delete_role_disambiguation::{delete_role_disambiguation, DeleteRole}; + +#[derive(Deserialize)] +pub struct DeleteVoiceRequest { + set_delete: bool, + /// NB: this is only to disambiguate when a user is both a mod and an author. + as_mod: Option, +} + +/// For the URL PathInfo +#[derive(Deserialize)] +pub struct DeleteVoicePathInfo { + voice_token: String, +} + +// =============== Error Response =============== + +#[derive(Debug, Serialize)] +pub enum DeleteVoiceError { + BadInput(String), + NotFound, + NotAuthorized, + ServerError, +} + +impl ResponseError for DeleteVoiceError { + fn status_code(&self) -> StatusCode { + match *self { + DeleteVoiceError::BadInput(_) => StatusCode::BAD_REQUEST, + DeleteVoiceError::NotFound => StatusCode::NOT_FOUND, + DeleteVoiceError::NotAuthorized => StatusCode::UNAUTHORIZED, + DeleteVoiceError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for DeleteVoiceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +// =============== Handler =============== + +pub async fn delete_voice_handler( + http_request: HttpRequest, + path: Path, + request: web::Json, + server_state: web::Data> +) -> Result{ + let maybe_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); + DeleteVoiceError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(DeleteVoiceError::NotAuthorized); + } + }; + + let voice_token = path.voice_token.clone(); + let is_mod = user_session.can_ban_users; + + let voice_lookup_result = get_voice_by_token( + &ZsVoiceToken::new(voice_token.clone()), + is_mod, + &server_state.mysql_pool, + ).await; + + let voice = match voice_lookup_result { + Ok(Some(voice)) => voice, + Ok(None) => { + warn!("Voice not found: {:?}", voice_token); + return Err(DeleteVoiceError::NotFound); + }, + Err(err) => { + warn!("Error looking up voice: {:?}", err); + return Err(DeleteVoiceError::ServerError); + } + }; + + let is_creator = voice.maybe_creator_user_token.as_deref() + .map(|creator_user_token| creator_user_token == &user_session.user_token) + .unwrap_or(false); + + if !is_creator && !is_mod { + warn!("user is not allowed to delete this voice: {}", user_session.user_token); + return Err(DeleteVoiceError::NotAuthorized); + } + + let delete_role = delete_role_disambiguation(is_mod, is_creator, request.as_mod); + + let query_result = if request.set_delete { + match delete_role { + DeleteRole::ErrorDoNotDelete => { + warn!("user is not allowed to delete voices: {}", user_session.user_token); + return Err(DeleteVoiceError::NotAuthorized); + } + DeleteRole::AsUser => { + delete_voice_as_user( + &path.voice_token, + &server_state.mysql_pool + ).await + } + DeleteRole::AsMod => { + delete_voice_as_mod( + &path.voice_token, + &user_session.user_token, + &server_state.mysql_pool + ).await + } + } + } else { + match delete_role { + DeleteRole::ErrorDoNotDelete => { + warn!("user is not allowed to undelete voices: {}", user_session.user_token); + return Err(DeleteVoiceError::NotAuthorized); + } + DeleteRole::AsUser => { + undelete_voice_as_user( + &path.voice_token, + &server_state.mysql_pool + ).await + } + DeleteRole::AsMod => { + undelete_voice_as_mod( + &path.voice_token, + &user_session.user_token, + &server_state.mysql_pool + ).await + } + } + }; + + match query_result { + Ok(_) => {}, + Err(err) => { + warn!("Update voice mod approval status DB error: {:?}", err); + return Err(DeleteVoiceError::ServerError); + } + }; + + Ok(simple_json_success()) } \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/list_datasets_by_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/list_datasets_by_user.rs index 21e7d414d9..24a6201e4d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/list_datasets_by_user.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/list_datasets_by_user.rs @@ -5,9 +5,13 @@ 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::{info, warn}; +use mysql_queries::queries::voice_designer::datasets::list_datasets::{ list_datasets_by_user_token, list_datasets_with_connection}; + +use crate::server_state::ServerState; #[derive(Serialize, Clone)] pub struct ZsDatasetRecordForResponse { @@ -29,6 +33,11 @@ pub struct ListDatasetsByUserSuccessResponse { pub datasets: Vec, } +#[derive(Deserialize)] +pub struct ListDatasetsByUserPathInfo { + user_token: String, +} + #[derive(Debug)] pub enum ListDatasetsByUserError { NotAuthorized, @@ -50,16 +59,73 @@ impl ResponseError for ListDatasetsByUserError { } } -pub async fn list_datasets_by_user_handler(user_token: web::Path) -> HttpResponse { +pub async fn list_datasets_by_user_handler( + http_request: HttpRequest, + path: Path, + server_state: web::Data> +) -> Result { - //TOOD(kasisnu): - // [ ] confirm if we need a feature flag to disable this endpoint - // [ ] check if this needs to be cached in Redis/anywhere - what's the diff between ephemeral/durable - // [ ] double check if the fields in the struct are everything needed for the FE - // [ ] Mocks have a few datapoints I think I didn't track anywhere - stars/likes/plays/favorites + let maybe_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); + ListDatasetsByUserError::ServerError + })?; + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(ListDatasetsByUserError::NotAuthorized); + } + }; + let user_token = path.user_token.clone(); + let creator_user_token = user_session.user_token.clone(); + let is_mod = user_session.can_ban_users; - // Implementation for listing datasets for a user - HttpResponse::Ok().json(format!("List of datasets for user {}", user_token)) + let query_results = list_datasets_by_user_token( + &server_state.mysql_pool, + &user_token, + is_mod, + creator_user_token == user_token, + ).await.map_err(|e| { + warn!("Error querying for datasets: {:?}", e); + ListDatasetsByUserError::ServerError + }); + let datasets = match query_results { + Ok(datasets) => datasets, + Err(e) => { + warn!("Error querying for datasets: {:?}", e); + return Err(ListDatasetsByUserError::ServerError); + } + }; + + let datasets = datasets.into_iter().map(|dataset| { + ZsDatasetRecordForResponse { + dataset_token: dataset.dataset_token, + title: dataset.title, + creator_set_visibility: dataset.creator_set_visibility.to_string() , + ietf_language_tag: dataset.ietf_language_tag, + ietf_primary_language_subtag: dataset.ietf_primary_language_subtag, + maybe_creator_user_token: dataset.maybe_creator_user_token, + + created_at: dataset.created_at, + updated_at: dataset.updated_at, + } + }).collect(); + + let response = ListDatasetsByUserSuccessResponse { + success: true, + datasets, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| ListDatasetsByUserError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/list_voices_by_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/list_voices_by_user.rs index bf529dd85a..cf869440a3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/list_voices_by_user.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/list_voices_by_user.rs @@ -1,14 +1,134 @@ - +use std::fmt; +use std::fmt::{Formatter}; 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::{info, warn}; +use mysql_queries::queries::voice_designer::voices::list_voices::list_voices_by_user_token; + +use crate::server_state::ServerState; + +#[derive(Serialize, Clone)] +pub struct ZsVoiceRecordForResponse { + voice_token: String, + title: String, + creator_set_visibility: String, + ietf_language_tag: String, + ietf_primary_language_subtag: String, + maybe_creator_user_token: Option, + + created_at: DateTime, + updated_at: DateTime, +} + + +#[derive(Serialize)] +pub struct ListVoicesByUserSuccessResponse { + pub success: bool, + pub voices: Vec, +} + +#[derive(Deserialize)] +pub struct ListVoicesByUserPathInfo { + user_token: String, +} + +#[derive(Debug)] +pub enum ListVoicesByUserError { + NotAuthorized, + ServerError, +} + +impl fmt::Display for ListVoicesByUserError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl ResponseError for ListVoicesByUserError { + fn status_code(&self) -> StatusCode { + match *self { + ListVoicesByUserError::NotAuthorized => StatusCode::UNAUTHORIZED, + ListVoicesByUserError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +pub async fn list_voices_by_user( + http_request: HttpRequest, + path: Path, + server_state: web::Data> +) -> Result { + + let maybe_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); + ListVoicesByUserError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(ListVoicesByUserError::NotAuthorized); + } + }; + + let user_token = path.user_token.clone(); + let creator_user_token = user_session.user_token.clone(); + let is_mod = user_session.can_ban_users; + + let query_results = list_voices_by_user_token( + &server_state.mysql_pool, + &user_token, + is_mod, + creator_user_token == user_token, + ).await.map_err(|e| { + warn!("Error querying for voices: {:?}", e); + ListVoicesByUserError::ServerError + }); + let voices = match query_results { + Ok(voices) => voices, + Err(e) => { + warn!("Error querying for voices: {:?}", e); + return Err(ListVoicesByUserError::ServerError); + } + }; + + let voices = voices.into_iter().map(|voice| { + ZsVoiceRecordForResponse { + voice_token: voice.voice_token, + title: voice.title, + creator_set_visibility: voice.creator_set_visibility.to_string() , + ietf_language_tag: voice.ietf_language_tag, + ietf_primary_language_subtag: voice.ietf_primary_language_subtag, + maybe_creator_user_token: voice.maybe_creator_user_token, + + created_at: voice.created_at, + updated_at: voice.updated_at, + } + }).collect(); + + let response = ListVoicesByUserSuccessResponse { + success: true, + voices, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| ListVoicesByUserError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} + -pub async fn list_voices_by_user(user_token: web::Path) -> HttpResponse { - // Implementation for listing voices for a user - HttpResponse::Ok().json(format!("List of voices for user {}", user_token)) -} \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/update_voice.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/update_voice.rs index 284d416593..d01f9ab1e3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/update_voice.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/update_voice.rs @@ -1,13 +1,202 @@ +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::{error, log, warn}; +use enums::common::visibility::Visibility; -use log::{info, warn}; +use http_server_common::request::get_request_ip::get_request_ip; +use http_server_common::response::response_success_helpers::simple_json_success; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use mysql_queries::queries::voice_designer::voices::create_voice::{create_voice, CreateVoiceArgs}; +use mysql_queries::queries::voice_designer::voices::get_voice::get_voice_by_token; +use mysql_queries::queries::voice_designer::voices::update_voice::{update_voice, UpdateVoiceArgs}; +use tokens::tokens::zs_voice::ZsVoiceToken; +use crate::server_state::ServerState; -pub async fn update_voice(voice_token: web::Path) -> HttpResponse { - // Implementation for updating a voice - HttpResponse::Ok().json(format!("Voice {} updated successfully", voice_token)) +#[derive(Deserialize)] +pub struct UpdateVoiceRequest { + // ========== Author + Moderator options ========== + + pub title: Option, + pub creator_set_visibility: Option, + + // NB: We calculate 'ietf_primary_language_subtag' from this value. + pub ietf_language_tag: Option, +} + +#[derive(Serialize)] +pub struct UpdateVoiceResponse { + pub success: bool, +} + +/// For the URL PathInfo +#[derive(Deserialize)] +pub struct UpdateVoicePathInfo { + voice_token: String, +} + +// =============== Error Response =============== + +#[derive(Debug, Serialize)] +pub enum UpdateVoiceError { + BadInput(String), + NotFound, + NotAuthorized, + ServerError, +} + +impl ResponseError for UpdateVoiceError { + fn status_code(&self) -> StatusCode { + match *self { + UpdateVoiceError::BadInput(_) => StatusCode::BAD_REQUEST, + UpdateVoiceError::NotFound => StatusCode::NOT_FOUND, + UpdateVoiceError::NotAuthorized => StatusCode::UNAUTHORIZED, + UpdateVoiceError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for UpdateVoiceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +// =============== Handler =============== + +pub async fn update_voice_handler( + http_request: HttpRequest, + path: Path, + request: web::Json, + server_state: web::Data>) -> Result +{ + let maybe_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); + UpdateVoiceError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(UpdateVoiceError::NotAuthorized); + } + }; + + let voice_token = path.voice_token.clone(); + let is_mod = user_session.can_ban_users; + + let voice_lookup_result = get_voice_by_token( + &ZsVoiceToken::new(voice_token.clone()), + is_mod, + &server_state.mysql_pool, + ).await; + + let voice = match voice_lookup_result { + Ok(Some(voice)) => voice, + Ok(None) => { + warn!("Voice not found: {:?}", voice_token); + return Err(UpdateVoiceError::NotFound); + }, + Err(err) => { + warn!("Error looking up voice: {:?}", err); + return Err(UpdateVoiceError::ServerError); + } + }; + + // let is_creator = voice.maybe_creator_user_token == Some(user_session.user_token); + let is_creator = voice.maybe_creator_user_token.as_deref() + .map(|creator_user_token| creator_user_token == &user_session.user_token) + .unwrap_or(false); + + if !is_creator && !is_mod { + warn!("user is not allowed to edit this voice: {}", user_session.user_token); + return Err(UpdateVoiceError::NotAuthorized); + } + + let mut title = None; + let mut ietf_language_tag = None; + let mut ietf_primary_language_subtag = None; + let mut creator_set_visibility = Visibility::Public; + + if let Some(payload) = request.title.as_deref() { + if user_input_common::check_for_slurs::contains_slurs(payload) { + return Err(UpdateVoiceError::BadInput("title contains slurs".to_string())); + } + + title = Some(payload.trim().to_string()); + } + + if let Some(tag) = request.ietf_language_tag.as_deref() { + // eg. en, en-US, es-419, ja-JP, etc. + let maybe_full_canonical_tag = container_common::i18n::supported_languages_for_models::get_canonicalized_language_tag_for_model(tag); + + // eg. en, es, ja, etc. + let maybe_primary_language_subtag = maybe_full_canonical_tag + .map(|t| language_tags::LanguageTag::parse(t) + .map(|language_tag| language_tag.primary_language().to_string()) + ) + .transpose() + .map_err(|e| { + error!("Error parsing language tag '{}': {:?}", tag, e); + UpdateVoiceError::BadInput("bad locale string".to_string()) + })?; + + if let Some(full_tag) = maybe_full_canonical_tag { + if let Some(primary_subtag) = maybe_primary_language_subtag.as_deref() { + ietf_language_tag = Some(full_tag.to_string()); + ietf_primary_language_subtag = Some(primary_subtag.to_string()); + } + } + } + + if let Some(visibility) = request.creator_set_visibility.as_deref() { + creator_set_visibility = Visibility::from_str(visibility) + .map_err(|_| UpdateVoiceError::BadInput("bad record visibility".to_string()))?; + } + + + let ip_address = get_request_ip(&http_request); + let mut maybe_mod_user_token = None; + + if is_mod { + maybe_mod_user_token = Some(user_session.user_token.clone()); + } + let query_result = update_voice( + UpdateVoiceArgs { + voice_token: &ZsVoiceToken::new(voice_token.clone()), + voice_title: title.as_deref(), + maybe_creator_user_token: Some(user_session.user_token.clone().as_ref()), + creator_ip_address: ip_address.as_ref(), + creator_set_visibility: &creator_set_visibility, + maybe_mod_user_token: maybe_mod_user_token.as_deref(), + ietf_language_tag: ietf_language_tag.as_deref(), + ietf_primary_language_subtag: ietf_primary_language_subtag.as_deref(), + mysql_pool: &server_state.mysql_pool + } + ).await; + + match query_result { + Ok(_) => {}, + Err(err) => { + warn!("Update Voice DB error: {:?}", err); + return Err(UpdateVoiceError::ServerError); + } + }; + + Ok(simple_json_success()) } diff --git a/crates/service/web/storyteller_web/src/routes.rs b/crates/service/web/storyteller_web/src/routes.rs index f35cb2c3ec..d5db577349 100644 --- a/crates/service/web/storyteller_web/src/routes.rs +++ b/crates/service/web/storyteller_web/src/routes.rs @@ -126,8 +126,8 @@ use crate::http_server::endpoints::voice_designer::delete_dataset::delete_datase use crate::http_server::endpoints::voice_designer::list_datasets_by_user::list_datasets_by_user_handler; use crate::http_server::endpoints::voice_designer::create_voice::create_voice_handler; -use crate::http_server::endpoints::voice_designer::update_voice::update_voice; -use crate::http_server::endpoints::voice_designer::delete_voice::delete_voice; +use crate::http_server::endpoints::voice_designer::update_voice::update_voice_handler; +use crate::http_server::endpoints::voice_designer::delete_voice::delete_voice_handler; use crate::http_server::endpoints::voice_designer::list_available_voices::list_available_voices; use crate::http_server::endpoints::voice_designer::upload_sample::upload_sample; @@ -1151,8 +1151,8 @@ fn add_voice_designer_routes (app:App)-> App .service( web::scope("/voice") .route("/create", web::post().to(create_voice_handler)) - .route("/{voice_token}/update", web::post().to(update_voice)) - .route("/{voice_token}/delete", web::delete().to(delete_voice)) + .route("/{voice_token}/update", web::post().to(update_voice_handler)) + .route("/{voice_token}/delete", web::delete().to(delete_voice_handler)) .route("/user/{user_token}/list", web::get().to(list_voices_by_user)) ) .service(