diff --git a/crates/schema/database/mysql_queries/src/queries/characters/mod.rs b/crates/schema/database/mysql_queries/src/queries/characters/mod.rs index 37ba404e33..92dcb32bae 100644 --- a/crates/schema/database/mysql_queries/src/queries/characters/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/characters/mod.rs @@ -4,3 +4,4 @@ pub mod delete_character; pub mod get_character_by_token; pub mod get_character_by_token_including_deleted; pub mod list_active_characters_for_user; +pub mod update_character_name_and_description; diff --git a/crates/schema/database/mysql_queries/src/queries/characters/update_character_name_and_description.rs b/crates/schema/database/mysql_queries/src/queries/characters/update_character_name_and_description.rs new file mode 100644 index 0000000000..c0637a3bd5 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/characters/update_character_name_and_description.rs @@ -0,0 +1,36 @@ +use sqlx::pool::PoolConnection; +use sqlx::MySql; + +use tokens::tokens::characters::CharacterToken; + +/// Update a character's name, kinovi name, and/or description. +/// +/// All fields are always SET in the query — the caller should pass the +/// existing values for fields that should not change. +pub async fn update_character_name_and_description( + character_token: &CharacterToken, + character_name: &str, + kinovi_character_name: &str, + maybe_description: Option<&str>, + connection: &mut PoolConnection, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" +UPDATE characters +SET + character_name = ?, + kinovi_character_name = ?, + maybe_description = ? +WHERE token = ? + AND deleted_at IS NULL + "#, + character_name, + kinovi_character_name, + maybe_description, + character_token.as_str(), + ) + .execute(&mut **connection) + .await?; + + Ok(()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs index 709279938d..198f7028e8 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs @@ -2,10 +2,16 @@ use std::sync::Arc; use actix_web::web::Json; use actix_web::{web, HttpRequest}; +use log::{error, info, warn}; use artcraft_api_defs::characters::edit_character::{EditCharacterRequest, EditCharacterResponse}; +use mysql_queries::queries::characters::get_character_by_token::get_character_by_token; +use mysql_queries::queries::characters::update_character_name_and_description::update_character_name_and_description; +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; +use seedance2pro_client::requests::update_character::update_character::{update_character, UpdateCharacterArgs}; -use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; use crate::state::server_state::ServerState; /// Edit a character's name or description. @@ -23,9 +29,128 @@ use crate::state::server_state::ServerState; ), )] pub async fn edit_character_handler( - _http_request: HttpRequest, - _request: Json, - _server_state: web::Data>, -) -> Result, CommonWebError> { - todo!() + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, AdvancedCommonWebError> { + + // --- Auth --- + + let mut mysql_connection = server_state.mysql_pool.acquire().await?; + + let user_session = require_user_session_using_connection( + &http_request, + &server_state.session_checker, + &mut mysql_connection, + ).await?; + + let user_token = &user_session.user_token; + let is_mod = user_session.is_mod(); + + // --- Look up character --- + + let character = get_character_by_token(&request.token, &mut mysql_connection) + .await? + .ok_or_else(|| { + warn!("Character not found: {}", request.token); + AdvancedCommonWebError::NotFound + })?; + + // --- Ownership check --- + + let is_owner = character.maybe_creator_user_token + .as_ref() + .map(|owner| owner == user_token) + .unwrap_or(false); + + if !is_owner && !is_mod { + warn!("User {} tried to edit character {} they don't own", user_token, request.token); + return Err(AdvancedCommonWebError::NotFound); + } + + // --- Determine what to update --- + + let new_name = resolve_name_update(&request.updated_name); + let new_description = resolve_description_update(&request.updated_description); + + let has_name_change = new_name.is_some(); + let has_description_change = new_description.is_some(); + + if !has_name_change && !has_description_change { + return Ok(Json(EditCharacterResponse { success: true })); + } + + // --- If renaming, update Kinovi first --- + + let final_name = new_name.unwrap_or_else(|| character.character_name.clone()); + let final_kinovi_name = if has_name_change { final_name.clone() } else { + character.kinovi_character_name.clone().unwrap_or_else(|| character.character_name.clone()) + }; + + let final_description = match new_description { + Some(desc) => desc, + None => character.maybe_description.clone(), + }; + + if has_name_change { + if let Some(ref kinovi_id) = character.kinovi_character_id { + let session = Seedance2ProSession::from_cookies_string( + server_state.seedance2pro.cookies.clone() + ); + + update_character(UpdateCharacterArgs { + session: &session, + character_id: kinovi_id.clone(), + name: final_name.clone(), + description: final_description.clone().unwrap_or_default(), + host_override: None, + }) + .await + .map_err(|err| { + error!("Error updating character on Kinovi: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + info!("Updated character {} on Kinovi (name='{}')", kinovi_id, final_name); + } + } + + // --- Update database --- + + update_character_name_and_description( + &request.token, + &final_name, + &final_kinovi_name, + final_description.as_deref(), + &mut mysql_connection, + ).await?; + + info!("Updated character {} in database", request.token); + + Ok(Json(EditCharacterResponse { success: true })) +} + +// =============== Private helpers =============== + +/// Determine the new name, if any. +/// Returns None if the name should not be updated. +fn resolve_name_update(updated_name: &Option) -> Option { + let name = updated_name.as_ref()?; + let trimmed = name.trim(); + if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } +} + +/// Determine the new description, if any. +/// Returns: +/// - None if the description should not be updated +/// - Some(None) if the description should be cleared +/// - Some(Some(text)) if the description should be set +fn resolve_description_update(updated_description: &Option) -> Option> { + let desc = updated_description.as_ref()?; + let trimmed = desc.trim(); + if trimmed.is_empty() { + Some(None) // clear the description + } else { + Some(Some(trimmed.to_string())) // set the description + } }