//! Community settings handlers (owner only). use axum::{ extract::Path, http::StatusCode, response::{IntoResponse, Redirect, Response}, Form, }; use tower_sessions::Session; use crate::auth::MaybeUser; use crate::csrf; use crate::templates::*; use crate::AppState; use mt_core::types::ModAction; use super::{ log_mod_action, parse_uuid, require_owner, template_user, validate_title, CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData, MoveCategoryForm, UpdateCommunityForm, }; #[tracing::instrument(skip_all)] pub(super) async fn community_settings( axum::extract::State(state): axum::extract::State, Path(slug): Path, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let community = require_owner(&state, &slug, &user).await?; if community.suspended_at.is_some() { return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); } let db_categories = mt_db::queries::list_categories_for_settings(&state.db, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing categories"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let cat_count = db_categories.len(); let categories = db_categories .into_iter() .enumerate() .map(|(i, c)| SettingsCategoryRow { id: c.id.to_string(), name: c.name, slug: c.slug, description: c.description, sort_order: c.sort_order, is_first: i == 0, is_last: i == cat_count - 1, }) .collect(); let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing tags"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let tags = db_tags .into_iter() .map(|t| TagBadge { id: t.id.to_string(), name: t.name, slug: t.slug }) .collect(); Ok(CommunitySettingsTemplate { csrf_token, session_user: Some(template_user(&user, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, community_description: community.description, auto_hide_threshold: community.auto_hide_threshold, categories, tags, }) } #[tracing::instrument(skip_all)] pub(super) async fn update_community_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let community = require_owner(&state, &slug, &user).await?; let name = validate_title(&form.name)?; let description = form.description.trim(); if description.len() > 2048 { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Description must be at most 2048 characters.", ) .into_response()); } let desc_opt = if description.is_empty() { None } else { Some(description) }; // Parse auto_hide_threshold: empty or "0" = disabled (None), otherwise positive integer let threshold = form .auto_hide_threshold .as_deref() .and_then(|s| s.trim().parse::().ok()) .filter(|&n| n > 0); mt_db::mutations::update_community(&state.db, community.id, name, desc_opt, threshold) .await .map_err(|e| { tracing::error!(error = ?e, "db error updating community"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::EditSettings, None, None, None, ).await; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Settings+saved" ))) } #[tracing::instrument(skip_all)] pub(super) async fn create_category_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let community = require_owner(&state, &slug, &user).await?; let name = validate_title(&form.name)?; let cat_slug = form.slug.trim().to_lowercase(); if cat_slug.is_empty() || cat_slug.len() > 128 || !cat_slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Slug must be 1-128 characters, lowercase letters/numbers/hyphens only.", ) .into_response()); } let description = form.description.trim(); if description.len() > 1024 { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Description must be at most 1024 characters.", ) .into_response()); } let desc_opt = if description.is_empty() { None } else { Some(description) }; // Put new category at the end let existing = mt_db::queries::list_categories_for_settings(&state.db, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing categories"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let next_order = existing.iter().map(|c| c.sort_order).max().unwrap_or(0) + 1; mt_db::mutations::create_category(&state.db, community.id, name, &cat_slug, desc_opt, next_order) .await .map_err(|e| { tracing::error!(error = ?e, "db error creating category"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::CreateCategory, None, None, Some(name), ).await; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Category+created" ))) } #[tracing::instrument(skip_all)] pub(super) async fn edit_category_form( axum::extract::State(state): axum::extract::State, Path((slug, cat_id_str)): Path<(String, String)>, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let community = require_owner(&state, &slug, &user).await?; let cat_id = parse_uuid(&cat_id_str)?; let cat = mt_db::queries::get_category_by_id(&state.db, cat_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching category"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; Ok(EditCategoryTemplate { csrf_token, session_user: Some(template_user(&user, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, category_id: cat_id_str, category_name: cat.name, category_description: cat.description, }) } #[tracing::instrument(skip_all)] pub(super) async fn edit_category_handler( axum::extract::State(state): axum::extract::State, Path((slug, cat_id_str)): Path<(String, String)>, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let community = require_owner(&state, &slug, &user).await?; let cat_id = parse_uuid(&cat_id_str)?; let name = validate_title(&form.name)?; let description = form.description.trim(); if description.len() > 1024 { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Description must be at most 1024 characters.", ) .into_response()); } let desc_opt = if description.is_empty() { None } else { Some(description) }; mt_db::mutations::update_category(&state.db, cat_id, name, desc_opt) .await .map_err(|e| { tracing::error!(error = ?e, "db error updating category"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::EditCategory, None, Some(cat_id), None, ).await; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Category+updated" ))) } #[tracing::instrument(skip_all)] pub(super) async fn move_category_handler( axum::extract::State(state): axum::extract::State, Path((slug, cat_id_str)): Path<(String, String)>, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let community = require_owner(&state, &slug, &user).await?; let cat_id = parse_uuid(&cat_id_str)?; let categories = mt_db::queries::list_categories_for_settings(&state.db, community.id) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing categories"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let pos = categories.iter().position(|c| c.id == cat_id) .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; let swap_pos = match form.direction.as_str() { "up" if pos > 0 => pos - 1, "down" if pos < categories.len() - 1 => pos + 1, _ => return Ok(Redirect::to(&format!("/p/{slug}/settings"))), }; mt_db::mutations::swap_category_order( &state.db, categories[pos].id, categories[pos].sort_order, categories[swap_pos].id, categories[swap_pos].sort_order, ) .await .map_err(|e| { tracing::error!(error = ?e, "db error swapping category order"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Category+moved" ))) } // ============================================================================ // Tag management (owner only) // ============================================================================ #[tracing::instrument(skip_all)] pub(super) async fn create_tag_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let community = require_owner(&state, &slug, &user).await?; let name = validate_title(&form.name)?; const MT_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { max_depth: 3, max_length: 64, semantic_depth: 0, }; let tag_slug = form.slug.trim().to_lowercase(); tagtree::validate_with(&tag_slug, &MT_TAG_CONFIG).map_err(|e| { (StatusCode::UNPROCESSABLE_ENTITY, format!("Invalid tag slug: {}", e.0)).into_response() })?; mt_db::mutations::create_tag(&state.db, community.id, name, &tag_slug) .await .map_err(|e| { tracing::error!(error = ?e, "db error creating tag"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Tag+created" ))) } #[tracing::instrument(skip_all)] pub(super) async fn delete_tag_handler( axum::extract::State(state): axum::extract::State, Path(slug): Path, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let _community = require_owner(&state, &slug, &user).await?; let tag_id = parse_uuid(&form.tag_id)?; mt_db::mutations::delete_tag(&state.db, tag_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error deleting tag"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; Ok(Redirect::to(&format!( "/p/{slug}/settings?toast=Tag+deleted" ))) }