diff --git a/crates/lib/mysql_queries/src/queries/user_bookmarks/batch_get_user_bookmarks.rs b/crates/lib/mysql_queries/src/queries/user_bookmarks/batch_get_user_bookmarks.rs new file mode 100644 index 0000000000..5b9b028eaa --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/user_bookmarks/batch_get_user_bookmarks.rs @@ -0,0 +1,83 @@ +use std::collections::HashSet; + +use anyhow::anyhow; +use log::error; +use sqlx::{MySql, QueryBuilder}; +use sqlx::pool::PoolConnection; + +use enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEntityType; +use errors::AnyhowResult; +use tokens::tokens::user_bookmarks::UserBookmarkToken; +use tokens::tokens::users::UserToken; + +pub struct BatchUserBookmark { + pub token: UserBookmarkToken, + pub entity_token: String, + pub entity_type: UserBookmarkEntityType, +} + +pub async fn batch_get_user_bookmarks( + user_token: &UserToken, + tokens: &HashSet, + mysql_connection: &mut PoolConnection, +) -> AnyhowResult> +{ + if tokens.len() == 0 { + return Ok(Vec::new()); + } + + let mut query_builder: QueryBuilder = QueryBuilder::new( + r#" +SELECT + token, + entity_type, + entity_token + +FROM user_bookmarks + +WHERE user_token = + "#); + + query_builder.push_bind(user_token.as_str()); + + query_builder.push(" AND entity_token IN ( "); + + // NB: Syntax will be wrong if list has zero length + let mut separated = query_builder.separated(", "); + + for token in tokens.iter() { + separated.push_bind(token); + } + + separated.push_unseparated(") "); + + let query = query_builder.build_query_as::(); + + let maybe_results = query.fetch_all(&mut **mysql_connection).await; + + match maybe_results { + Ok(records) => Ok(records + .into_iter() + .map(|record| BatchUserBookmark { + token: UserBookmarkToken::new_from_str(&record.token), + // NB: Fail open; W2lTemplates are dead, so this is a good sentinel value + entity_type: UserBookmarkEntityType::from_str(&record.entity_type) + .unwrap_or(UserBookmarkEntityType::W2lTemplate), + entity_token: record.entity_token, + }).collect::>()), + Err(err) => match err { + sqlx::Error::RowNotFound => Ok(Vec::new()), + _ => { + error!("error querying : {:?}", err); + Err(anyhow!("error querying : {:?}", err)) + } + } + } +} + +#[derive(sqlx::FromRow)] +struct RawRating { + token: String, + entity_token: String, + entity_type: String, +} diff --git a/crates/lib/mysql_queries/src/queries/user_bookmarks/mod.rs b/crates/lib/mysql_queries/src/queries/user_bookmarks/mod.rs index da3ed97d84..53e69c5a75 100644 --- a/crates/lib/mysql_queries/src/queries/user_bookmarks/mod.rs +++ b/crates/lib/mysql_queries/src/queries/user_bookmarks/mod.rs @@ -1,3 +1,4 @@ +pub mod batch_get_user_bookmarks; pub mod create_user_bookmark; pub mod delete_user_bookmark; pub mod get_total_bookmark_count_for_entity; diff --git a/crates/lib/mysql_queries/src/queries/users/user_ratings/batch_get_user_rating.rs b/crates/lib/mysql_queries/src/queries/users/user_ratings/batch_get_user_rating.rs index 47ad57b168..22039c42f9 100644 --- a/crates/lib/mysql_queries/src/queries/users/user_ratings/batch_get_user_rating.rs +++ b/crates/lib/mysql_queries/src/queries/users/user_ratings/batch_get_user_rating.rs @@ -15,7 +15,6 @@ pub struct BatchUserRating { pub rating_value: UserRatingValue, } -/// Look up the user rating pub async fn batch_get_user_ratings( user_token: &UserToken, tokens: &HashSet, @@ -70,8 +69,8 @@ WHERE user_token = Err(err) => match err { sqlx::Error::RowNotFound => Ok(Vec::new()), _ => { - error!("error querying job record: {:?}", err); - Err(anyhow!("error querying job record: {:?}", err)) + error!("error querying : {:?}", err); + Err(anyhow!("error querying : {:?}", err)) } } } diff --git a/crates/service/web/storyteller_web/src/api_doc.rs b/crates/service/web/storyteller_web/src/api_doc.rs index 2230ef9250..2d82ad936b 100644 --- a/crates/service/web/storyteller_web/src/api_doc.rs +++ b/crates/service/web/storyteller_web/src/api_doc.rs @@ -25,6 +25,7 @@ use crate::http_server::endpoints::media_files::list_media_files::*; use crate::http_server::endpoints::media_files::list_media_files_for_user::*; use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError; use crate::http_server::endpoints::media_files::upload_media_file::*; +use crate::http_server::endpoints::user_bookmarks::batch_get_user_bookmarks_handler::*; use crate::http_server::endpoints::user_bookmarks::create_user_bookmark_handler::*; use crate::http_server::endpoints::user_bookmarks::delete_user_bookmark_handler::*; use crate::http_server::endpoints::user_bookmarks::list_user_bookmarks_for_entity_handler::*; @@ -49,6 +50,7 @@ use crate::http_server::web_utils::response_success_helpers::*; crate::http_server::endpoints::media_files::list_media_files::list_media_files_handler, crate::http_server::endpoints::media_files::list_media_files_for_user::list_media_files_for_user_handler, crate::http_server::endpoints::media_files::upload_media_file::upload_media_file_handler, + crate::http_server::endpoints::user_bookmarks::batch_get_user_bookmarks_handler::batch_get_user_bookmarks_handler, crate::http_server::endpoints::user_bookmarks::create_user_bookmark_handler::create_user_bookmark_handler, crate::http_server::endpoints::user_bookmarks::delete_user_bookmark_handler::delete_user_bookmark_handler, crate::http_server::endpoints::user_bookmarks::list_user_bookmarks_for_entity_handler::list_user_bookmarks_for_entity_handler, @@ -104,6 +106,7 @@ use crate::http_server::web_utils::response_success_helpers::*; ListWeightsByUserError,ListWeightsByUserSuccessResponse,ListWeightsByUserPathInfo,Weight, WeightsData,MediaFileData, BatchGetUserRatingQueryParams,BatchGetUserRatingResponse,BatchGetUserRatingError,RatingRow, + BatchGetUserBookmarksQueryParams,BatchGetUserBookmarksResponse,BatchGetUserBookmarksError,BookmarkRow, ListFeaturedMediaFilesSuccessResponse,MediaFile, ListFeaturedMediaFilesError, ListMediaFilesSuccessResponse, ListMediaFilesError, MediaFileListItem, ListMediaFilesQueryParams, ListMediaFilesForUserSuccessResponse,ListMediaFilesForUserQueryParams,ListMediaFilesForUserError,ListMediaFilesForUserPathInfo, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/batch_get_user_bookmarks_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/batch_get_user_bookmarks_handler.rs new file mode 100644 index 0000000000..dc79232748 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/batch_get_user_bookmarks_handler.rs @@ -0,0 +1,199 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use actix_web::{HttpRequest, HttpResponse, ResponseError, web}; +use actix_web::http::StatusCode; +use actix_web_lab::extract::Query; +use log::error; +use utoipa::{IntoParams, ToSchema}; + +use enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEntityType; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use mysql_queries::queries::user_bookmarks::batch_get_user_bookmarks::{batch_get_user_bookmarks, BatchUserBookmark}; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::tts_models::TtsModelToken; + +use crate::server_state::ServerState; + +const MAX_BATCH_SIZE : usize = 200; + +// =============== Request =============== + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct BatchGetUserBookmarksQueryParams { + /// A grab bag of tokens of various types. + /// Technically we should pair with token types, as that's the unique index. + /// But since each token family generally has its own prefix, this should be fine. + /// + /// NB: We're using actix_web_lab's Query, because the default actix_web Query doesn't support URL + /// decoding sequences yet. + /// See https://github.com/actix/actix-web/issues/1301 + /// + pub tokens: HashSet, +} + +// =============== Success Response =============== + +#[derive(Serialize, ToSchema)] +pub struct BatchGetUserBookmarksResponse { + pub success: bool, + + /// Ratings on each item passed to us. + pub bookmarks: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct BookmarkRow { + /// The passed token + pub entity_token: String, + /// The type of entity + pub entity_type: UserBookmarkEntityType, + /// Whether the entity is bookmarked or not + pub is_bookmarked: bool, +} + +// =============== Error Response =============== + +#[derive(Debug, Serialize, ToSchema)] +pub enum BatchGetUserBookmarksError { + BadInput(String), + NotAuthorized, + ServerError, +} + +impl ResponseError for BatchGetUserBookmarksError { + fn status_code(&self) -> StatusCode { + match *self { + BatchGetUserBookmarksError::BadInput(_) => StatusCode::BAD_REQUEST, + BatchGetUserBookmarksError::NotAuthorized => StatusCode::UNAUTHORIZED, + BatchGetUserBookmarksError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +// NB: Not using DeriveMore since Clion doesn't understand it. +impl std::fmt::Display for BatchGetUserBookmarksError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +// =============== Handler =============== + + +#[utoipa::path( + get, + path = "/v1/user_bookmarks/batch", + params( + BatchGetUserBookmarksQueryParams, + ), + responses( + (status = 200, description = "List User Bookmarks", body = BatchGetUserBookmarksResponse), + (status = 400, description = "Bad input", body = BatchGetUserBookmarksError), + (status = 401, description = "Not authorized", body = BatchGetUserBookmarksError), + (status = 500, description = "Server error", body = BatchGetUserBookmarksError), + ), +)] +pub async fn batch_get_user_bookmarks_handler( + http_request: HttpRequest, + query: Query, + server_state: web::Data>) -> Result +{ + let mut mysql_connection = server_state.mysql_pool.acquire() + .await + .map_err(|e| { + error!("Could not acquire DB pool: {:?}", e); + BatchGetUserBookmarksError::ServerError + })?; + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + error!("Session checker error: {:?}", e); + BatchGetUserBookmarksError::ServerError + })?; + + // NB: Force move of tokens from the Query. + // The auto-magical Query will ordinarily try to force a Copy, which isn't on HashSet. + let mut tokens = query.0.tokens; + + // Don't allow bad actors to flood our DB. + tokens.shrink_to(MAX_BATCH_SIZE); + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + let bookmarks = fill_in_missed_bookmarks(&tokens, Vec::new()); + + // NB: Just return "neutral" for everything. + return Ok(HttpResponse::Ok() + .content_type("application/json") + .json(BatchGetUserBookmarksResponse { + success: true, + bookmarks, + })); + } + }; + + batch_get_user_bookmarks( + &user_session.user_token_typed, + &tokens, + &mut mysql_connection + ).await + .map_err(|e| { + error!("Batch get user ratings DB error: {:?}", e); + BatchGetUserBookmarksError::ServerError + }) + .map(|bookmarks| { + HttpResponse::Ok() + .content_type("application/json") + .json(BatchGetUserBookmarksResponse { + success: true, + bookmarks: fill_in_missed_bookmarks(&tokens, bookmarks), + }) + }) +} + +fn fill_in_missed_bookmarks(request_tokens: &HashSet, db_response: Vec) -> Vec { + let mut outputs = HashMap::with_capacity(request_tokens.len()); + + for record in db_response.into_iter() { + outputs.insert(record.entity_token.clone(), BookmarkRow { + entity_token: record.entity_token, + entity_type: record.entity_type, + is_bookmarked: true, + }); + } + + for request_token in request_tokens.iter() { + if !outputs.contains_key(request_token) { + outputs.insert(request_token.clone(), BookmarkRow { + entity_token: request_token.clone(), + entity_type: { + if request_token.starts_with(MediaFileToken::token_prefix()) { + UserBookmarkEntityType::MediaFile + } else if request_token.starts_with(ModelWeightToken::token_prefix()) { + UserBookmarkEntityType::ModelWeight + } else if request_token.starts_with(TtsModelToken::token_prefix()) { + UserBookmarkEntityType::TtsModel + } else { + // NB: Fail open; W2lTemplates are dead, so this is a good sentinel value + UserBookmarkEntityType::W2lTemplate + } + }, + is_bookmarked: false, + }); + } + } + + outputs.into_iter() + .map(|(_key, value)| value) + .collect::>() +} \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/mod.rs index 11881f5bb3..d84a82cf83 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_bookmarks/mod.rs @@ -1,3 +1,4 @@ +pub mod batch_get_user_bookmarks_handler; pub mod create_user_bookmark_handler; pub mod delete_user_bookmark_handler; pub mod list_user_bookmarks_for_entity_handler; diff --git a/crates/service/web/storyteller_web/src/routes.rs b/crates/service/web/storyteller_web/src/routes.rs index ef300bd97b..c63903237e 100644 --- a/crates/service/web/storyteller_web/src/routes.rs +++ b/crates/service/web/storyteller_web/src/routes.rs @@ -108,6 +108,7 @@ use crate::http_server::endpoints::twitch::oauth::check_oauth_status::check_oaut use crate::http_server::endpoints::twitch::oauth::oauth_begin_json::oauth_begin_enroll_json; use crate::http_server::endpoints::twitch::oauth::oauth_begin_redirect::oauth_begin_enroll_redirect; use crate::http_server::endpoints::twitch::oauth::oauth_end::oauth_end_enroll_from_redirect; +use crate::http_server::endpoints::user_bookmarks::batch_get_user_bookmarks_handler::batch_get_user_bookmarks_handler; use crate::http_server::endpoints::user_bookmarks::create_user_bookmark_handler::create_user_bookmark_handler; use crate::http_server::endpoints::user_bookmarks::delete_user_bookmark_handler::delete_user_bookmark_handler; use crate::http_server::endpoints::user_bookmarks::list_user_bookmarks_for_entity_handler::list_user_bookmarks_for_entity_handler; @@ -210,6 +211,7 @@ pub fn add_routes (app: App, server_environment: ServerEnvironment) -> // ==================== User Bookmarks ==================== let mut app = RouteBuilder::from_app(app) + .add_get("/v1/user_bookmarks/batch", batch_get_user_bookmarks_handler) .add_post("/v1/user_bookmarks/create", create_user_bookmark_handler) .add_post("/v1/user_bookmarks/delete/{user_bookmark_token}", delete_user_bookmark_handler) .add_get("/v1/user_bookmarks/list/session", list_user_bookmarks_for_session_handler)