//! Admin moderation: appeals queue and content reports. use crate::extractors::ValidatedQuery; use axum::{ Form, extract::{Path, State}, response::IntoResponse, }; use serde::Deserialize; use sqlx::PgPool; use crate::{ AppCaches, Billing, Integrations, auth::AdminUser, background::BackgroundTx, db::{self, AppealDecision, ItemId, ModerationActionType, ReportId, ReportStatus, UserId}, email::EmailClient, error::{AppError, Result}, helpers::get_csrf_token, templates::{ AdminAppealEntriesTemplate, AdminAppealsTemplate, AdminReportEntriesTemplate, AdminReportsTemplate, }, types::{AdminAppealRow, AdminReportRow, ReportStats}, }; // --- Appeals --- /// Render the admin appeals queue. #[tracing::instrument(skip_all, name = "admin::admin_appeals")] pub(super) async fn admin_appeals( State(db): State, session: tower_sessions::Session, AdminUser(user): AdminUser, ) -> Result { let csrf_token = get_csrf_token(&session).await; let db_users = db::users::get_pending_appeals(&db).await?; let appeals: Vec = db_users.iter().map(AdminAppealRow::from_db).collect(); Ok(AdminAppealsTemplate { csrf_token, session_user: Some(user), appeals, admin_active_page: "appeals", }) } #[derive(Debug, Deserialize)] pub(super) struct AppealDecisionForm { pub decision: AppealDecision, pub response: String, } /// Decide an appeal (approve or deny) and send notification email. #[tracing::instrument(skip_all, name = "admin::admin_decide_appeal")] #[allow(clippy::too_many_arguments)] pub(super) async fn admin_decide_appeal( State(db): State, State(email): State, State(bg): State, State(caches): State, State(payments): State, State(integrations): State, AdminUser(_admin): AdminUser, Path(user_id): Path, Form(form): Form, ) -> Result { let response_text = form.response.trim(); if response_text.is_empty() { return Err(AppError::validation("Response is required".to_string())); } // Get user for email notification let db_user = db::users::get_user_by_id(&db, user_id) .await? .ok_or(AppError::NotFound)?; // Delegate to the shared moderation service (same path the `mnw-admin` CLI // uses): resolve the appeal, resume fan subscriptions on approval, and email // the outcome. Web fans the Stripe resume out on the background queue. super::moderation_service::decide_appeal( &db, &email, payments.stripe.as_ref(), super::moderation_service::FanoutMode::Background { bg: &bg, wam: integrations.wam.clone(), session_cache: &caches.session_cache, }, &db_user, form.decision, response_text, ) .await?; // Return updated appeals list let db_users = db::users::get_pending_appeals(&db).await?; let appeals: Vec = db_users.iter().map(AdminAppealRow::from_db).collect(); Ok(AdminAppealEntriesTemplate { appeals }) } // --- Reports --- #[derive(Debug, Deserialize)] pub(super) struct ReportFilterQuery { pub status: Option, } /// Render the admin reports queue. #[tracing::instrument(skip_all, name = "admin::admin_reports")] pub(super) async fn admin_reports( State(db): State, session: tower_sessions::Session, AdminUser(user): AdminUser, ValidatedQuery(query): ValidatedQuery, ) -> Result { let csrf_token = get_csrf_token(&session).await; let current_filter = query.status.clone().unwrap_or_default(); let db_stats = db::reports::get_report_stats(&db).await?; let stats = ReportStats { open: db_stats.open as u32, resolved: db_stats.resolved as u32, dismissed: db_stats.dismissed as u32, }; let db_reports = db::reports::get_admin_reports(&db, query.status.as_deref(), 100, 0).await?; let reports: Vec = db_reports.iter().map(AdminReportRow::from_db).collect(); Ok(AdminReportsTemplate { csrf_token, session_user: Some(user), reports, stats, current_filter, admin_active_page: "reports", }) } /// Return filtered report entries as an HTMX partial. #[tracing::instrument(skip_all, name = "admin::admin_report_entries")] pub(super) async fn admin_report_entries( State(db): State, AdminUser(_user): AdminUser, ValidatedQuery(query): ValidatedQuery, ) -> Result { let db_reports = db::reports::get_admin_reports(&db, query.status.as_deref(), 100, 0).await?; let reports: Vec = db_reports.iter().map(AdminReportRow::from_db).collect(); Ok(AdminReportEntriesTemplate { reports }) } #[derive(Debug, Deserialize)] pub(super) struct ReportDecisionForm { pub decision: String, #[serde(default)] pub admin_notes: String, } /// Resolve or dismiss a report. #[tracing::instrument(skip_all, name = "admin::admin_resolve_report")] pub(super) async fn admin_resolve_report( State(db): State, admin_user: AdminUser, Path(id): Path, Form(form): Form, ) -> Result { let status = match form.decision.as_str() { "resolve" => ReportStatus::Resolved, "dismiss" => ReportStatus::Dismissed, _ => return Err(AppError::validation("Invalid decision".to_string())), }; db::reports::resolve_report( &db, id, status, form.admin_notes.trim(), admin_user.admin_id(), ) .await?; tracing::info!(report_id = %id, decision = %form.decision, "admin resolved report"); // Return updated entries (open filter) let db_reports = db::reports::get_admin_reports(&db, Some("open"), 100, 0).await?; let reports: Vec = db_reports.iter().map(AdminReportRow::from_db).collect(); Ok(AdminReportEntriesTemplate { reports }) } // --- Per-item content removal --- #[derive(Debug, Deserialize)] pub(super) struct ItemRemovalForm { pub reason: String, } /// Remove a specific item (enforcement ladder step 2: content removal, account stays active). /// /// Sets `removed_by_admin = true`, hides the item, and emails the creator with the reason. #[tracing::instrument(skip_all, name = "admin::admin_remove_item")] pub(super) async fn admin_remove_item( State(db): State, State(email): State, State(bg): State, admin_user: AdminUser, Path(item_id): Path, Form(form): Form, ) -> Result { let reason = form.reason.trim(); if reason.is_empty() { return Err(AppError::validation( "Removal reason is required".to_string(), )); } let item = db::items::admin_remove_item(&db, item_id, reason).await?; // Look up the creator to send notification email let owner_id = db::items::get_item_owner(&db, item_id) .await? .ok_or(AppError::NotFound)?; if let Ok(Some(owner)) = db::users::get_user_by_id(&db, owner_id).await { let owner_email = owner.email.clone(); let owner_name = owner.display_name; let item_title = item.title.clone(); let reason = reason.to_string(); let email = email.clone(); bg.spawn("content removal notification", async move { if let Err(e) = email .send_content_removal(&owner_email, owner_name.as_deref(), &item_title, &reason) .await { tracing::error!(error = ?e, "failed to send content removal notification"); } }); } // Record moderation action against the item owner db::moderation::create_action( &db, owner_id, admin_user.admin_id(), ModerationActionType::ContentRemoval, reason, Some(&item_id.to_string()), ) .await?; tracing::info!( item_id = %item_id, admin_id = %admin_user.id(), reason = %reason, "admin removed item" ); Ok(crate::helpers::htmx_toast_response( "Item removed", "success", )) } /// Restore a previously admin-removed item (clears removal, creator must re-publish). #[tracing::instrument(skip_all, name = "admin::admin_restore_item")] pub(super) async fn admin_restore_item( State(db): State, State(email): State, State(bg): State, admin_user: AdminUser, Path(item_id): Path, ) -> Result { let item = db::items::admin_restore_item(&db, item_id).await?; // Notify creator their item was restored let owner_id = db::items::get_item_owner(&db, item_id) .await? .ok_or(AppError::NotFound)?; if let Ok(Some(owner)) = db::users::get_user_by_id(&db, owner_id).await { let owner_email = owner.email.clone(); let owner_name = owner.display_name; let item_title = item.title.clone(); let email = email.clone(); bg.spawn("content restore notification", async move { if let Err(e) = email .send_content_restored(&owner_email, owner_name.as_deref(), &item_title) .await { tracing::error!(error = ?e, "failed to send content restore notification"); } }); } // Resolve the content_removal moderation action db::moderation::resolve_content_removal(&db, &item_id.to_string()).await?; tracing::info!( item_id = %item_id, admin_id = %admin_user.id(), "admin restored item" ); Ok(crate::helpers::htmx_toast_response( "Item restored", "success", )) }