//! Post flagging handlers — flag, dismiss, mod-remove via flag. use axum::{ extract::Path, http::StatusCode, response::{IntoResponse, Redirect, Response}, Form, }; use serde::Deserialize; use crate::auth::MaybeUser; use crate::AppState; use mt_core::types::ModAction; use super::{ check_community_access, get_community, log_mod_action, parse_uuid, require_mod_or_owner, }; #[derive(Deserialize)] pub(super) struct FlagForm { pub(super) reason: String, pub(super) detail: Option, } /// POST /p/{slug}/{cat}/{thread_id}/posts/{post_id}/flag #[tracing::instrument(skip_all)] pub(super) async fn flag_post_handler( axum::extract::State(state): axum::extract::State, Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>, MaybeUser(session_user): MaybeUser, Form(form): Form, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let post_id = parse_uuid(&post_id_str)?; // Validate reason if !matches!(form.reason.as_str(), "spam" | "rule_breaking" | "off_topic") { return Err((StatusCode::UNPROCESSABLE_ENTITY, "Invalid flag reason.").into_response()); } // Fetch post to check ownership and existence let post_data = mt_db::queries::get_post_for_edit(&state.db, post_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching post for flag"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })? .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; // Cannot flag own post if user.user_id == post_data.author_id { return Err((StatusCode::FORBIDDEN, "You cannot flag your own post.").into_response()); } // Check community access let community = get_community(&state.db, &slug).await?; check_community_access(&state.db, &community, Some(user.user_id)).await?; let detail = form.detail.as_deref().filter(|d| !d.trim().is_empty()); if let Some(d) = detail && d.len() > 1024 { return Err((StatusCode::UNPROCESSABLE_ENTITY, "Flag detail too long (max 1024 bytes).").into_response()); } mt_db::mutations::insert_flag(&state.db, post_id, user.user_id, &form.reason, detail) .await .map_err(|e| { tracing::error!(error = ?e, "db error inserting flag"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; // Auto-hide: atomically check flag count and remove post if threshold met if let Some(threshold) = community.auto_hide_threshold && threshold > 0 { match mt_db::mutations::auto_hide_if_threshold_met( &state.db, post_id, user.user_id, threshold, ).await { Ok(true) => { log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::AutoHidePost, Some(post_data.author_id), Some(post_id), None, ).await; } Ok(false) => {} // threshold not met or already removed Err(e) => tracing::error!(error = ?e, "auto-hide: failed to check/remove post"), } } Ok(Redirect::to(&format!( "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+flagged" ))) } /// POST /p/{slug}/moderation/flags/{flag_id}/dismiss #[tracing::instrument(skip_all)] pub(super) async fn dismiss_flag_handler( axum::extract::State(state): axum::extract::State, Path((slug, flag_id_str)): Path<(String, String)>, MaybeUser(session_user): MaybeUser, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let (_community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let flag_id = parse_uuid(&flag_id_str)?; mt_db::mutations::resolve_flag(&state.db, flag_id, user.user_id, "dismissed") .await .map_err(|e| { tracing::error!(error = ?e, "db error dismissing flag"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=Flag+dismissed" ))) } /// POST /p/{slug}/moderation/flags/{flag_id}/remove /// Mod-removes the flagged post and resolves all flags on that post. #[tracing::instrument(skip_all)] pub(super) async fn remove_flagged_post_handler( axum::extract::State(state): axum::extract::State, Path((slug, flag_id_str)): Path<(String, String)>, MaybeUser(session_user): MaybeUser, ) -> Result { let user = session_user .ok_or_else(|| Redirect::to("/auth/login").into_response())?; let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?; let flag_id = parse_uuid(&flag_id_str)?; // Get the flag to find the post_id let flag_row: Option<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as( "SELECT post_id, (SELECT author_id FROM posts WHERE id = post_flags.post_id) FROM post_flags WHERE id = $1", ) .bind(flag_id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching flag"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let (post_id, author_id) = flag_row .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; // Mod-remove the post (idempotent — returns false if already removed) let _ = mt_db::mutations::mod_remove_post(&state.db, post_id, user.user_id) .await .map_err(|e| { tracing::error!(error = ?e, "db error removing flagged post"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; // Resolve all flags on this post mt_db::mutations::resolve_all_flags_for_post(&state.db, post_id, user.user_id, "removed") .await .map_err(|e| { tracing::error!(error = ?e, "db error resolving flags"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; log_mod_action( &state.db, Some(community.id), user.user_id, ModAction::RemovePostViaFlag, Some(author_id), Some(post_id), None, ).await; Ok(Redirect::to(&format!( "/p/{slug}/moderation?toast=Post+removed" ))) }