//! Shared moderation service: the single implementation of creator suspension, //! unsuspension, and appeal decisions, called by BOTH the web admin handlers //! (`routes::admin`) and the `mnw-admin` CLI. //! //! Before this module existed the CLI (`cmd_suspend` / `cmd_unsuspend` / //! `cmd_decide`) called `db::users::suspend_user` directly and skipped the audit //! record, the notification email, session revocation, and the Stripe //! fan-subscription pause/resume that the web path performs. A CLI-issued //! suspension therefore produced no dashboard-visible reason, no email, and left //! fans being charged, silently diverging from the documented due-process //! guarantees. Routing both callers through these functions makes that divergence //! unrepresentable: the due-process steps live in one place. //! //! The only runtime difference between the two callers is captured by //! [`FanoutMode`]: a web request fans the per-subscription Stripe calls out on the //! background queue (and evicts revoked sessions from the in-memory cache), while //! the CLI runs them inline (and has no session cache to touch). use std::sync::Arc; use std::time::Instant; use dashmap::DashMap; use crate::auth::AdminId; use crate::background::BackgroundTx; use crate::db::{self, DbUser, ModerationActionType, StripeAccountId, UserSessionId}; use crate::email::EmailClient; use crate::error::Result; use crate::payments::PaymentProvider; use crate::payments::fan_ops::{self, FanSubOp}; use crate::wam_client::WamClient; /// How to run the per-fan-subscription Stripe fan-out, and whether an in-memory /// session cache exists to evict from. pub enum FanoutMode<'a> { /// Server request/handler context. Stripe ops fan out on the bounded /// background queue so the handler returns without blocking on N round-trips; /// revoked sessions are also evicted from the live in-memory cache to close /// the session-touch-cache window. Background { bg: &'a BackgroundTx, wam: Option, session_cache: &'a Arc>, }, /// CLI context (`mnw-admin`). No background queue: Stripe ops run inline /// (blocking is fine off any hot path). No in-memory cache to evict, deleting /// the session rows in the DB is sufficient, since the running server /// re-validates against the DB within the touch-cache TTL. Inline, } impl FanoutMode<'_> { /// Fan `op` across `sub_ids` for `account_id` using this mode's mechanism. async fn apply_subscription_op( &self, stripe: &Arc, account_id: &StripeAccountId, sub_ids: Vec, op: FanSubOp, ) { match self { FanoutMode::Background { bg, wam, .. } => fan_ops::spawn_fan_sub_fanout( bg, Arc::clone(stripe), account_id.clone(), sub_ids, op, wam.clone(), ), FanoutMode::Inline => { fan_ops::run_fan_sub_fanout(stripe, account_id, &sub_ids, op).await; } } } /// Evict revoked session tracking IDs from the in-memory cache (server only; /// no-op for the CLI, which has no such cache). fn evict_sessions(&self, revoked: &[UserSessionId]) { if let FanoutMode::Background { session_cache, .. } = self { for sid in revoked { session_cache.remove(sid); } } } } /// Suspend a creator: record the reason, revoke sessions, write the audit action, /// pause fan subscriptions (DB + Stripe), and email the creator. /// /// `target` must be the already-fetched account. The caller is responsible for the /// "already suspended?" short-circuit and for validating a non-empty `reason`. pub async fn suspend_creator( db: &sqlx::PgPool, email: &EmailClient, stripe: Option<&Arc>, fanout: FanoutMode<'_>, target: &DbUser, admin_id: AdminId, reason: &str, ) -> Result<()> { db::users::suspend_user(db, target.id, reason).await?; // Revoke every session so the suspended creator can't keep acting during the // touch-cache window. let revoked = db::sessions::delete_all_sessions_for_user(db, target.id).await?; fanout.evict_sessions(&revoked); if !revoked.is_empty() { tracing::info!(user_id = %target.id, revoked = revoked.len(), "revoked sessions on suspension"); } // Append-only audit record, this is what surfaces the reason on the creator's // own dashboard. db::moderation::create_action( db, target.id, admin_id, ModerationActionType::Suspension, reason, None, ) .await?; // Pause fan subscriptions so fans aren't charged while the creator is // suspended: DB state plus the matching Stripe pause. if let (Some(stripe), Some(account_id)) = (stripe, target.stripe_account_id.as_ref()) { let subs = db::subscriptions::get_active_subscriptions_by_creator(db, target.id).await?; let ids: Vec = subs.into_iter().map(|s| s.stripe_subscription_id).collect(); fanout .apply_subscription_op(stripe, account_id, ids, FanSubOp::Pause) .await; let paused = db::subscriptions::pause_subscriptions_for_creator(db, target.id).await?; tracing::info!(user_id = %target.id, db_paused = paused, "paused fan subscriptions for suspended creator"); } if let Err(e) = email .send_suspension_notification(&target.email, target.display_name.as_deref(), reason) .await { tracing::error!(error = ?e, user_id = %target.id, "failed to send suspension email"); } tracing::info!(user_id = %target.id, admin_id = %admin_id.get(), reason = %reason, "suspended creator"); Ok(()) } /// Lift a creator's suspension: clear it, resolve the audit action, and resume fan /// subscriptions (DB + Stripe). No email, mirroring the web admin path. pub async fn unsuspend_creator( db: &sqlx::PgPool, stripe: Option<&Arc>, fanout: FanoutMode<'_>, target: &DbUser, ) -> Result<()> { db::users::unsuspend_user(db, target.id).await?; db::moderation::resolve_actions_by_type(db, target.id, ModerationActionType::Suspension) .await?; if let (Some(stripe), Some(account_id)) = (stripe, target.stripe_account_id.as_ref()) { let resumed = db::subscriptions::resume_subscriptions_for_creator(db, target.id).await?; let ids: Vec = resumed .into_iter() .map(|s| s.stripe_subscription_id) .collect(); fanout .apply_subscription_op(stripe, account_id, ids, FanSubOp::Resume) .await; } tracing::info!(user_id = %target.id, "unsuspended creator"); Ok(()) } /// Resolve a suspension appeal. On approval this clears the suspension (via /// `resolve_appeal`) and resumes fan subscriptions; either way the creator is /// emailed the outcome. The caller validates a non-empty `response`. pub async fn decide_appeal( db: &sqlx::PgPool, email: &EmailClient, stripe: Option<&Arc>, fanout: FanoutMode<'_>, target: &DbUser, decision: db::AppealDecision, response: &str, ) -> Result<()> { db::users::resolve_appeal(db, target.id, decision, response).await?; if decision == db::AppealDecision::Approved && let (Some(stripe), Some(account_id)) = (stripe, target.stripe_account_id.as_ref()) { let resumed = db::subscriptions::resume_subscriptions_for_creator(db, target.id).await?; let ids: Vec = resumed .into_iter() .map(|s| s.stripe_subscription_id) .collect(); fanout .apply_subscription_op(stripe, account_id, ids, FanSubOp::Resume) .await; } let decision_str = decision.to_string(); if let Err(e) = email .send_appeal_decision( &target.email, target.display_name.as_deref(), &decision_str, response, ) .await { tracing::error!(error = ?e, user_id = %target.id, "failed to send appeal decision email"); } tracing::info!(user_id = %target.id, decision = %decision_str, "decided appeal"); Ok(()) }