//! Admin moderation: appeals queue and content reports. use axum::{ extract::{Path, Query, State}, response::IntoResponse, Form, }; use serde::Deserialize; use crate::{ auth::AdminUser, db::{self, AppealDecision, ItemId, ModerationActionType, ReportId, ReportStatus, UserId}, error::{AppError, Result}, helpers::{get_csrf_token, spawn_email}, templates::*, types::*, AppState, }; // ── Appeals ── /// Render the admin appeals queue. #[tracing::instrument(skip_all, name = "admin::admin_appeals")] pub(super) async fn admin_appeals( State(state): 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(&state.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")] pub(super) async fn admin_decide_appeal( State(state): 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(&state.db, user_id) .await? .ok_or(AppError::NotFound)?; db::users::resolve_appeal(&state.db, user_id, form.decision, response_text).await?; // If approved, resume paused fan subscriptions if form.decision == db::AppealDecision::Approved && let Some(ref stripe) = state.stripe && let Some(ref account_id) = db_user.stripe_account_id { let resumed = db::subscriptions::resume_subscriptions_for_creator(&state.db, user_id).await?; for sub in &resumed { if let Err(e) = stripe.resume_subscription(&sub.stripe_subscription_id, account_id).await { tracing::error!(stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to resume subscription on appeal approval"); } } if !resumed.is_empty() { tracing::info!(user_id = %user_id, resumed = resumed.len(), "resumed fan subscriptions on appeal approval"); } } // Send decision email (fire-and-forget) let decision_str = form.decision.to_string(); if let Err(e) = state.email .send_appeal_decision(&db_user.email, db_user.display_name.as_deref(), &decision_str, response_text) .await { tracing::error!(error = ?e, user_id = %user_id, "failed to send appeal decision email"); } tracing::info!(user_id = %user_id, decision = %decision_str, "admin decided appeal"); // Return updated appeals list let db_users = db::users::get_pending_appeals(&state.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(state): State, session: tower_sessions::Session, AdminUser(user): AdminUser, Query(query): Query, ) -> 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(&state.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(&state.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(state): State, AdminUser(_user): AdminUser, Query(query): Query, ) -> Result { let db_reports = db::reports::get_admin_reports(&state.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(state): State, AdminUser(admin): 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( &state.db, id, status, form.admin_notes.trim(), 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(&state.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(state): State, AdminUser(admin): 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(&state.db, item_id, reason).await?; // Look up the creator to send notification email let owner_id = db::items::get_item_owner(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; if let Ok(Some(owner)) = db::users::get_user_by_id(&state.db, owner_id).await { let owner_email = owner.email.clone(); let owner_name = owner.display_name.clone(); let item_title = item.title.clone(); let reason = reason.to_string(); spawn_email!(state, "content removal notification", |email| { email.send_content_removal(&owner_email, owner_name.as_deref(), &item_title, &reason) }); } // Record moderation action against the item owner db::moderation::create_action( &state.db, owner_id, admin.id, ModerationActionType::ContentRemoval, reason, Some(&item_id.to_string()), ).await?; tracing::info!( item_id = %item_id, admin_id = %admin.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(state): State, AdminUser(admin): AdminUser, Path(item_id): Path, ) -> Result { let item = db::items::admin_restore_item(&state.db, item_id).await?; // Notify creator their item was restored let owner_id = db::items::get_item_owner(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; if let Ok(Some(owner)) = db::users::get_user_by_id(&state.db, owner_id).await { let owner_email = owner.email.clone(); let owner_name = owner.display_name.clone(); let item_title = item.title.clone(); spawn_email!(state, "content restore notification", |email| { email.send_content_restored(&owner_email, owner_name.as_deref(), &item_title) }); } // Resolve the content_removal moderation action db::moderation::resolve_content_removal(&state.db, &item_id.to_string()).await?; tracing::info!( item_id = %item_id, admin_id = %admin.id, "admin restored item" ); Ok(crate::helpers::htmx_toast_response("Item restored", "success")) }