//! Platform admin handlers. use axum::{ Form, extract::{Path, Query}, http::StatusCode, response::{IntoResponse, Redirect, Response}, }; use tower_sessions::Session; use crate::AppState; use crate::auth::PlatformAdmin; use crate::csrf; use crate::templates::{ AdminCommunityTemplate, AdminCommunityViewRow, AdminDashboardTemplate, AdminUserViewRow, TemplateSessionUser, }; use mt_core::types::{ModAction, ModActor}; use super::{ AdminSearchQuery, CleanSlateForm, SuspendForm, audit, begin_tx, commit_tx, db_error, get_community, parse_uuid, template_user, }; #[tracing::instrument(skip_all)] pub(super) async fn admin_dashboard( axum::extract::State(state): axum::extract::State, session: Session, PlatformAdmin(admin): PlatformAdmin, Query(query): Query, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); // Cap the listing and surface overflow rather than silently truncating // (mirrors the moderation lists' CAP+1). Fetch CAP+1 so we can tell whether // more communities exist than we show. const ADMIN_COMMUNITY_CAP: usize = 500; let mut community_rows = mt_db::queries::list_all_communities(&state.db, ADMIN_COMMUNITY_CAP as i64 + 1) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing communities"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; let communities_truncated = community_rows.len() > ADMIN_COMMUNITY_CAP; community_rows.truncate(ADMIN_COMMUNITY_CAP); let communities = community_rows .into_iter() .map(|c| AdminCommunityViewRow { id: c.id.to_string(), name: c.name, slug: c.slug, is_suspended: c.suspended_at.is_some(), suspension_reason: c.suspension_reason, }) .collect(); let search_query = query.q.unwrap_or_default(); let users = if search_query.is_empty() { vec![] } else { mt_db::queries::search_users(&state.db, &search_query) .await .map_err(|e| { tracing::error!(error = ?e, "db error searching users"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })? .into_iter() .map(|u| AdminUserViewRow { id: u.id.to_string(), username: u.username, display_name: u.display_name, is_suspended: u.suspended_at.is_some(), suspension_reason: u.suspension_reason, }) .collect() }; Ok(AdminDashboardTemplate { csrf_token, session_user: Some(TemplateSessionUser { is_platform_admin: true, username: admin.username, }), mnw_base_url: state.config.mnw_base_url.clone(), communities, communities_truncated, users, search_query, }) } #[tracing::instrument(skip_all)] pub(super) async fn suspend_community_handler( axum::extract::State(state): axum::extract::State, PlatformAdmin(admin): PlatformAdmin, Path(id): Path, Form(form): Form, ) -> Result { let community_id = parse_uuid(&id)?; let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty()); let mut tx = begin_tx(&state.db).await?; mt_db::mutations::suspend_community(&mut *tx, community_id, reason) .await .map_err(|e| { tracing::error!(error = ?e, "db error suspending community"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; audit( &mut tx, None, ModActor::User(admin.user_id), ModAction::SuspendCommunity, None, Some(community_id), reason, ) .await?; commit_tx(tx).await?; Ok(Redirect::to("/_admin?toast=Community+suspended")) } #[tracing::instrument(skip_all)] pub(super) async fn unsuspend_community_handler( axum::extract::State(state): axum::extract::State, PlatformAdmin(admin): PlatformAdmin, Path(id): Path, ) -> Result { let community_id = parse_uuid(&id)?; let mut tx = begin_tx(&state.db).await?; mt_db::mutations::unsuspend_community(&mut *tx, community_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error unsuspending community"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; audit( &mut tx, None, ModActor::User(admin.user_id), ModAction::UnsuspendCommunity, None, Some(community_id), None, ) .await?; commit_tx(tx).await?; Ok(Redirect::to("/_admin?toast=Community+unsuspended")) } #[tracing::instrument(skip_all)] pub(super) async fn suspend_user_handler( axum::extract::State(state): axum::extract::State, PlatformAdmin(admin): PlatformAdmin, Path(id): Path, Form(form): Form, ) -> Result { let user_id = parse_uuid(&id)?; let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty()); let mut tx = begin_tx(&state.db).await?; mt_db::mutations::suspend_user(&mut *tx, user_id, reason) .await .map_err(|e| { tracing::error!(error = ?e, "db error suspending user"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; audit( &mut tx, None, ModActor::User(admin.user_id), ModAction::SuspendUser, Some(user_id), None, reason, ) .await?; commit_tx(tx).await?; Ok(Redirect::to("/_admin?toast=User+suspended")) } #[tracing::instrument(skip_all)] pub(super) async fn unsuspend_user_handler( axum::extract::State(state): axum::extract::State, PlatformAdmin(admin): PlatformAdmin, Path(id): Path, ) -> Result { let user_id = parse_uuid(&id)?; let mut tx = begin_tx(&state.db).await?; mt_db::mutations::unsuspend_user(&mut *tx, user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error unsuspending user"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; audit( &mut tx, None, ModActor::User(admin.user_id), ModAction::UnsuspendUser, Some(user_id), None, None, ) .await?; commit_tx(tx).await?; Ok(Redirect::to("/_admin?toast=User+unsuspended")) } // Dedicated admin view per community: state machine + clean-slate. #[tracing::instrument(skip_all)] pub(super) async fn admin_community_detail( axum::extract::State(state): axum::extract::State, session: Session, PlatformAdmin(admin): PlatformAdmin, Path(slug): Path, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await?); let community = get_community(&state.db, &slug).await?; let thread_count = mt_db::queries::count_threads_in_community(&state.db, community.id) .await .map_err(db_error)?; let member_count = mt_db::queries::count_community_members(&state.db, community.id) .await .map_err(db_error)?; let suspension_reason: Option = if community.suspended_at.is_some() { mt_db::queries::get_community_suspension_reason(&state.db, community.id) .await .ok() .flatten() } else { None }; Ok(AdminCommunityTemplate { csrf_token, session_user: Some(template_user(&admin, state.config.platform_admin_id)), mnw_base_url: state.config.mnw_base_url.clone(), community_name: community.name, community_slug: slug, current_state: community.state.as_str(), thread_count, member_count, is_suspended: community.suspended_at.is_some(), suspension_reason, }) } #[tracing::instrument(skip_all)] pub(super) async fn admin_community_clean_slate_handler( axum::extract::State(state): axum::extract::State, PlatformAdmin(admin): PlatformAdmin, Path(slug): Path, Form(form): Form, ) -> Result { let community = get_community(&state.db, &slug).await?; // GitHub-style typed-phrase confirmation: must match the community slug // exactly. Trim only, case is significant. if form.confirm.trim() != slug { return Err(( StatusCode::UNPROCESSABLE_ENTITY, "Confirmation phrase did not match the community slug.", ) .into_response()); } let mut tx = begin_tx(&state.db).await?; let result = mt_db::mutations::clean_slate_community( &mut tx, community.id, admin.user_id, &admin.username, ) .await .map_err(|e| { tracing::error!(error = ?e, "clean-slate failed"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() })?; audit( &mut tx, Some(community.id), ModActor::User(admin.user_id), ModAction::CleanSlateCommunity, None, result.system_thread_id, Some(&format!("deleted {} threads", result.deleted_thread_count)), ) .await?; commit_tx(tx).await?; Ok(Redirect::to(&format!( "/_admin/communities/{slug}?toast=Community+reset" ))) }