diff --git a/crates/schema/database/mysql_queries/src/queries/characters/delete_character.rs b/crates/schema/database/mysql_queries/src/queries/characters/delete_character.rs new file mode 100644 index 0000000000..2e2a2735dc --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/characters/delete_character.rs @@ -0,0 +1,26 @@ +use sqlx::pool::PoolConnection; +use sqlx::MySql; + +use tokens::tokens::characters::CharacterToken; + +/// Soft-delete a character by setting deleted_at to NOW(). +/// +/// This is idempotent — calling it on an already-deleted character is a no-op. +pub async fn delete_character( + character_token: &CharacterToken, + connection: &mut PoolConnection, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" +UPDATE characters +SET deleted_at = NOW() +WHERE token = ? + AND deleted_at IS NULL + "#, + ) + .bind(character_token.as_str()) + .execute(&mut **connection) + .await?; + + Ok(()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/characters/get_character_by_token_including_deleted.rs b/crates/schema/database/mysql_queries/src/queries/characters/get_character_by_token_including_deleted.rs new file mode 100644 index 0000000000..4a43c35e50 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/characters/get_character_by_token_including_deleted.rs @@ -0,0 +1,39 @@ +use sqlx::pool::PoolConnection; +use sqlx::{FromRow, MySql}; + +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::users::UserToken; + +/// Minimal character record for ownership and deletion checks. +#[derive(Debug, FromRow)] +pub struct CharacterOwnershipRecord { + pub token: CharacterToken, + pub maybe_creator_user_token: Option, + pub is_active: bool, + pub is_deleted: bool, +} + +/// Look up a character by token, including soft-deleted records. +/// +/// Returns `None` if the character does not exist at all. +pub async fn get_character_by_token_including_deleted( + character_token: &CharacterToken, + connection: &mut PoolConnection, +) -> Result, sqlx::Error> { + let result = sqlx::query_as::<_, CharacterOwnershipRecord>( + r#" +SELECT + token, + maybe_creator_user_token, + is_active, + (deleted_at IS NOT NULL) as is_deleted +FROM characters +WHERE token = ? + "#, + ) + .bind(character_token.as_str()) + .fetch_optional(&mut **connection) + .await?; + + Ok(result) +} 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 fbac52e67f..bcf9dd693e 100644 --- a/crates/schema/database/mysql_queries/src/queries/characters/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/characters/mod.rs @@ -1,2 +1,4 @@ pub mod batch_lookup_characters_by_token_for_prompting; pub mod create_pending_character; +pub mod delete_character; +pub mod get_character_by_token_including_deleted; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs index 6ef15cccc8..23bcff002f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs @@ -2,11 +2,15 @@ use std::sync::Arc; use actix_web::web::{Json, Path}; use actix_web::{web, HttpRequest}; +use log::{info, warn}; use artcraft_api_defs::characters::delete_character::{DeleteCharacterPathInfo, DeleteCharacterResponse}; use tokens::tokens::characters::CharacterToken; +use mysql_queries::queries::characters::delete_character::delete_character; +use mysql_queries::queries::characters::get_character_by_token_including_deleted::get_character_by_token_including_deleted; -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; /// Delete a character. @@ -25,9 +29,61 @@ use crate::state::server_state::ServerState; ) )] pub async fn delete_character_handler( - _http_request: HttpRequest, - _path: Path, - _server_state: web::Data>, -) -> Result, CommonWebError> { - todo!() + http_request: HttpRequest, + path: Path, + server_state: web::Data>, +) -> Result, AdvancedCommonWebError> { + + let character_token = &path.character_token; + + // --- 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 the character (including deleted) --- + + let character = get_character_by_token_including_deleted(character_token, &mut mysql_connection) + .await? + .ok_or_else(|| { + warn!("Character not found: {}", character_token); + AdvancedCommonWebError::NotFound + })?; + + // Already deleted — return success idempotently. + if character.is_deleted { + info!("Character {} is already deleted", character_token); + return Ok(Json(DeleteCharacterResponse { success: true })); + } + + // --- 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 {} attempted to delete character {} owned by {:?}", + user_token, character_token, character.maybe_creator_user_token, + ); + return Err(AdvancedCommonWebError::NotAuthorized); + } + + // --- Soft delete --- + + delete_character(character_token, &mut mysql_connection).await?; + + info!("Character {} deleted by user {}", character_token, user_token); + + Ok(Json(DeleteCharacterResponse { success: true })) }