Skip to main content

max / makenotwork

Route mnw-admin CLI moderation through shared due-process service The CLI suspend/unsuspend/decide commands called db::users::suspend_user directly, skipping the audit record, notification email, session revocation, and Stripe fan-subscription pause/resume that the web admin path performs. A CLI-issued suspension left no dashboard-visible reason, sent no email, and kept charging fans -- silently diverging from the documented due-process guarantees. Extract the suspend/unsuspend/appeal logic into a shared moderation_service so both the web handlers and the CLI run identical due process. A FanoutMode enum captures the only real difference: web fans Stripe calls out on the background queue and evicts the in-memory session cache; the CLI runs them inline. The CLI builds email + Stripe clients from the server env it already loads and stamps the audit record with the configured admin actor via a new gated AdminId::from_config, so it can't attribute an action to a non-admin. Adds run_fan_sub_fanout for the CLI's no-background-queue context. Web handlers reduced to delegation; 22/22 moderation integration tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-12 16:55 UTC
Commit: f1611e0382a58e8979cea2adf64452c9f9257c2e
Parent: 885dea2
7 files changed, +352 insertions, -109 deletions
@@ -376,6 +376,19 @@ impl AdminId {
376 376 pub fn get(self) -> UserId {
377 377 self.0
378 378 }
379 +
380 + /// Mint an `AdminId` from the configured `ADMIN_USER_ID` for out-of-band admin
381 + /// contexts that have no HTTP session — specifically the `mnw-admin` CLI, which
382 + /// loads the same server env. Returns `None` when no admin is configured.
383 + ///
384 + /// This is the only constructor besides [`AdminUser::admin_id`], and it is
385 + /// gated on the exact same config value that [`require_admin`] checks, so it
386 + /// cannot attribute a moderation action to a non-admin — preserving the
387 + /// forgery-proof invariant while letting a headless admin tool stamp the audit
388 + /// trail with the real actor instead of skipping it.
389 + pub fn from_config(config: &crate::config::Config) -> Option<Self> {
390 + config.admin_user_id.map(AdminId)
391 + }
379 392 }
380 393
381 394 /// Extractor for admin users - returns NotFound (hides admin routes) if not admin.
@@ -29,10 +29,17 @@
29 29 //! key list List your SSH keys
30 30 //! key rm <fingerprint> Remove an SSH key by fingerprint
31 31
32 + use std::sync::Arc;
33 +
32 34 use clap::{Parser, Subcommand};
33 35 use sqlx::PgPool;
34 36
37 + use makenotwork::auth::AdminId;
38 + use makenotwork::config::Config;
35 39 use makenotwork::db::{self, AppealDecision, SelectionMethod, TransactionStatus, Username, WaitlistStatus};
40 + use makenotwork::email::{EmailClient, EmailConfig};
41 + use makenotwork::payments::{PaymentProvider, StripeClient};
42 + use makenotwork::routes::admin::moderation_service;
36 43
37 44 /// Truncate `s` to at most `max` bytes on a char boundary (for display only).
38 45 fn truncate_display(s: &str, max: usize) -> &str {
@@ -348,7 +355,42 @@ async fn cmd_stats(pool: &PgPool) -> anyhow::Result<()> {
348 355
349 356 // ── Suspension commands ──
350 357
358 + /// Build the collaborators the shared moderation service needs, from the same
359 + /// server env the CLI already loaded (`/etc/mnw/makenotwork.env`).
360 + ///
361 + /// Fails if no admin is configured — we refuse to issue a moderation action with
362 + /// no admin actor to attribute the audit record to, mirroring the web
363 + /// `require_admin` gate for a headless caller. Building the Stripe + email clients
364 + /// here (not just the DB pool) is the whole point of routing the CLI through the
365 + /// service: a suspension issued from the CLI now pauses fans in Stripe and emails
366 + /// the creator, instead of silently diverging from the web path.
367 + /// The env-built collaborators the shared moderation service needs: the email
368 + /// client, the optional Stripe provider, and the configured admin actor.
369 + type ModerationContext = (EmailClient, Option<Arc<dyn PaymentProvider>>, AdminId);
370 +
371 + fn moderation_context(pool: &PgPool) -> anyhow::Result<ModerationContext> {
372 + let config = Config::from_env().map_err(|e| anyhow::anyhow!("failed to load config: {e}"))?;
373 + let admin_id = AdminId::from_config(&config).ok_or_else(|| {
374 + anyhow::anyhow!(
375 + "ADMIN_USER_ID is not set; refusing to issue a moderation action with no admin actor to attribute it to"
376 + )
377 + })?;
378 + let email = EmailClient::new(EmailConfig::from_env(), Some(pool.clone()));
379 + let stripe: Option<Arc<dyn PaymentProvider>> = match config.stripe {
380 + Some(ref stripe_config) => {
381 + Some(Arc::new(StripeClient::new(stripe_config)?) as Arc<dyn PaymentProvider>)
382 + }
383 + None => None,
384 + };
385 + Ok((email, stripe, admin_id))
386 + }
387 +
351 388 async fn cmd_suspend(pool: &PgPool, username_str: &str, reason: &str) -> anyhow::Result<()> {
389 + let reason = reason.trim();
390 + if reason.is_empty() {
391 + return Err(anyhow::anyhow!("a suspension reason is required"));
392 + }
393 +
352 394 let username = Username::new(username_str)
353 395 .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
354 396
@@ -361,7 +403,17 @@ async fn cmd_suspend(pool: &PgPool, username_str: &str, reason: &str) -> anyhow:
361 403 return Ok(());
362 404 }
363 405
364 - db::users::suspend_user(pool, user.id, reason).await?;
406 + let (email, stripe, admin_id) = moderation_context(pool)?;
407 + moderation_service::suspend_creator(
408 + pool,
409 + &email,
410 + stripe.as_ref(),
411 + moderation_service::FanoutMode::Inline,
412 + &user,
413 + admin_id,
414 + reason,
415 + )
416 + .await?;
365 417
366 418 println!("Suspended '{}'. Reason: {}", username_str, reason);
367 419 Ok(())
@@ -380,7 +432,14 @@ async fn cmd_unsuspend(pool: &PgPool, username_str: &str) -> anyhow::Result<()>
380 432 return Ok(());
381 433 }
382 434
383 - db::users::unsuspend_user(pool, user.id).await?;
435 + let (_email, stripe, _admin_id) = moderation_context(pool)?;
436 + moderation_service::unsuspend_creator(
437 + pool,
438 + stripe.as_ref(),
439 + moderation_service::FanoutMode::Inline,
440 + &user,
441 + )
442 + .await?;
384 443
385 444 println!("Unsuspended '{}'.", username_str);
386 445 Ok(())
@@ -444,7 +503,22 @@ async fn cmd_decide(
444 503 .parse()
445 504 .map_err(|_| anyhow::anyhow!("invalid decision '{}': use 'approved' or 'denied'", decision_str))?;
446 505
447 - db::users::resolve_appeal(pool, user.id, decision, response).await?;
506 + let response = response.trim();
507 + if response.is_empty() {
508 + return Err(anyhow::anyhow!("a response message is required"));
509 + }
510 +
511 + let (email, stripe, _admin_id) = moderation_context(pool)?;
512 + moderation_service::decide_appeal(
513 + pool,
514 + &email,
515 + stripe.as_ref(),
516 + moderation_service::FanoutMode::Inline,
517 + &user,
518 + decision,
519 + response,
520 + )
521 + .await?;
448 522
449 523 match decision {
450 524 AppealDecision::Approved => {
@@ -67,6 +67,32 @@ impl FanSubOp {
67 67 /// surfaced for manual reconciliation rather than lost in the logs (audit Run 13
68 68 /// Resilience: fan-out had no dead-letter). The failed subscription IDs are
69 69 /// listed in the ticket body.
70 + /// Apply `op` to every subscription in `sub_ids`, awaiting each call inline.
71 + ///
72 + /// For contexts with no background queue — the `mnw-admin` CLI — where blocking on
73 + /// the serial Stripe fan-out is fine because there is no request or webhook hot
74 + /// path to protect. Per-subscription failures are logged and do not abort the
75 + /// rest; returns the number that failed so the caller can surface it. No-op (and
76 + /// returns 0) for an empty list.
77 + pub async fn run_fan_sub_fanout(
78 + stripe: &Arc<dyn PaymentProvider>,
79 + account_id: &StripeAccountId,
80 + sub_ids: &[String],
81 + op: FanSubOp,
82 + ) -> usize {
83 + let mut failed = 0usize;
84 + for sub_id in sub_ids {
85 + if let Err(e) = op.apply(stripe, sub_id, account_id.as_str()).await {
86 + failed += 1;
87 + tracing::warn!(stripe_sub_id = %sub_id, op = op.label(), error = ?e, "fan subscription op failed (inline)");
88 + }
89 + }
90 + if failed > 0 {
91 + tracing::warn!(total = sub_ids.len(), failed, op = op.label(), "inline fan subscription fan-out completed with failures");
92 + }
93 + failed
94 + }
95 +
70 96 pub fn spawn_fan_sub_fanout(
71 97 bg: &BackgroundTx,
72 98 stripe: Arc<dyn PaymentProvider>,
@@ -2,6 +2,7 @@
2 2
3 3 mod comp_codes;
4 4 mod moderation;
5 + pub mod moderation_service;
5 6 mod signups;
6 7 mod uploads;
7 8 mod users;
@@ -63,42 +63,23 @@ pub(super) async fn admin_decide_appeal(
63 63 .await?
64 64 .ok_or(AppError::NotFound)?;
65 65
66 - db::users::resolve_appeal(&state.db, user_id, form.decision, response_text).await?;
67 -
68 - // If approved, resume paused fan subscriptions
69 - if form.decision == db::AppealDecision::Approved
70 - && let Some(ref stripe) = state.stripe
71 - && let Some(ref account_id) = db_user.stripe_account_id
72 - {
73 - let resumed = db::subscriptions::resume_subscriptions_for_creator(&state.db, user_id).await?;
74 - // Fan-out the Stripe resume on the bounded background queue so the appeal
75 - // handler returns immediately instead of blocking on N Stripe round-trips
76 - // (Perf-MIN: the 6th fan-out site, now consistent with the other five
77 - // via spawn_fan_sub_fanout rather than an inline blocking JoinSet).
78 - let ids: Vec<String> = resumed.iter().map(|s| s.stripe_subscription_id.clone()).collect();
79 - if !ids.is_empty() {
80 - tracing::info!(user_id = %user_id, resumed = ids.len(), "resuming fan subscriptions on appeal approval (backgrounded)");
81 - crate::payments::fan_ops::spawn_fan_sub_fanout(
82 - &state.bg,
83 - std::sync::Arc::clone(stripe),
84 - account_id.clone(),
85 - ids,
86 - crate::payments::fan_ops::FanSubOp::Resume,
87 - state.wam.clone(),
88 - );
89 - }
90 - }
91 -
92 - // Send decision email (fire-and-forget)
93 - let decision_str = form.decision.to_string();
94 - if let Err(e) = state.email
95 - .send_appeal_decision(&db_user.email, db_user.display_name.as_deref(), &decision_str, response_text)
96 - .await
97 - {
98 - tracing::error!(error = ?e, user_id = %user_id, "failed to send appeal decision email");
99 - }
100 -
101 - tracing::info!(user_id = %user_id, decision = %decision_str, "admin decided appeal");
66 + // Delegate to the shared moderation service (same path the `mnw-admin` CLI
67 + // uses): resolve the appeal, resume fan subscriptions on approval, and email
68 + // the outcome. Web fans the Stripe resume out on the background queue.
69 + super::moderation_service::decide_appeal(
70 + &state.db,
71 + &state.email,
72 + state.stripe.as_ref(),
73 + super::moderation_service::FanoutMode::Background {
74 + bg: &state.bg,
75 + wam: state.wam.clone(),
76 + session_cache: &state.session_cache,
77 + },
78 + &db_user,
79 + form.decision,
80 + response_text,
81 + )
82 + .await?;
102 83
103 84 // Return updated appeals list
104 85 let db_users = db::users::get_pending_appeals(&state.db).await?;
@@ -0,0 +1,189 @@
1 + //! Shared moderation service: the single implementation of creator suspension,
2 + //! unsuspension, and appeal decisions, called by BOTH the web admin handlers
3 + //! (`routes::admin`) and the `mnw-admin` CLI.
4 + //!
5 + //! Before this module existed the CLI (`cmd_suspend` / `cmd_unsuspend` /
6 + //! `cmd_decide`) called `db::users::suspend_user` directly and skipped the audit
7 + //! record, the notification email, session revocation, and the Stripe
8 + //! fan-subscription pause/resume that the web path performs. A CLI-issued
9 + //! suspension therefore produced no dashboard-visible reason, no email, and left
10 + //! fans being charged — silently diverging from the documented due-process
11 + //! guarantees. Routing both callers through these functions makes that divergence
12 + //! unrepresentable: the due-process steps live in one place.
13 + //!
14 + //! The only runtime difference between the two callers is captured by
15 + //! [`FanoutMode`]: a web request fans the per-subscription Stripe calls out on the
16 + //! background queue (and evicts revoked sessions from the in-memory cache), while
17 + //! the CLI runs them inline (and has no session cache to touch).
18 +
19 + use std::sync::Arc;
20 + use std::time::Instant;
21 +
22 + use dashmap::DashMap;
23 +
24 + use crate::auth::AdminId;
25 + use crate::background::BackgroundTx;
26 + use crate::db::{self, DbUser, ModerationActionType, StripeAccountId, UserSessionId};
27 + use crate::email::EmailClient;
28 + use crate::error::Result;
29 + use crate::payments::fan_ops::{self, FanSubOp};
30 + use crate::payments::PaymentProvider;
31 + use crate::wam_client::WamClient;
32 +
33 + /// How to run the per-fan-subscription Stripe fan-out, and whether an in-memory
34 + /// session cache exists to evict from.
35 + pub enum FanoutMode<'a> {
36 + /// Server request/handler context. Stripe ops fan out on the bounded
37 + /// background queue so the handler returns without blocking on N round-trips;
38 + /// revoked sessions are also evicted from the live in-memory cache to close
39 + /// the session-touch-cache window.
40 + Background {
41 + bg: &'a BackgroundTx,
42 + wam: Option<WamClient>,
43 + session_cache: &'a Arc<DashMap<UserSessionId, Instant>>,
44 + },
45 + /// CLI context (`mnw-admin`). No background queue: Stripe ops run inline
46 + /// (blocking is fine off any hot path). No in-memory cache to evict — deleting
47 + /// the session rows in the DB is sufficient, since the running server
48 + /// re-validates against the DB within the touch-cache TTL.
49 + Inline,
50 + }
51 +
52 + impl FanoutMode<'_> {
53 + /// Fan `op` across `sub_ids` for `account_id` using this mode's mechanism.
54 + async fn apply_subscription_op(
55 + &self,
56 + stripe: &Arc<dyn PaymentProvider>,
57 + account_id: &StripeAccountId,
58 + sub_ids: Vec<String>,
59 + op: FanSubOp,
60 + ) {
61 + match self {
62 + FanoutMode::Background { bg, wam, .. } => fan_ops::spawn_fan_sub_fanout(
63 + bg,
64 + Arc::clone(stripe),
65 + account_id.clone(),
66 + sub_ids,
67 + op,
68 + wam.clone(),
69 + ),
70 + FanoutMode::Inline => {
71 + fan_ops::run_fan_sub_fanout(stripe, account_id, &sub_ids, op).await;
72 + }
73 + }
74 + }
75 +
76 + /// Evict revoked session tracking IDs from the in-memory cache (server only;
77 + /// no-op for the CLI, which has no such cache).
78 + fn evict_sessions(&self, revoked: &[UserSessionId]) {
79 + if let FanoutMode::Background { session_cache, .. } = self {
80 + for sid in revoked {
81 + session_cache.remove(sid);
82 + }
83 + }
84 + }
85 + }
86 +
87 + /// Suspend a creator: record the reason, revoke sessions, write the audit action,
88 + /// pause fan subscriptions (DB + Stripe), and email the creator.
89 + ///
90 + /// `target` must be the already-fetched account. The caller is responsible for the
91 + /// "already suspended?" short-circuit and for validating a non-empty `reason`.
92 + pub async fn suspend_creator(
93 + db: &sqlx::PgPool,
94 + email: &EmailClient,
95 + stripe: Option<&Arc<dyn PaymentProvider>>,
96 + fanout: FanoutMode<'_>,
97 + target: &DbUser,
98 + admin_id: AdminId,
99 + reason: &str,
100 + ) -> Result<()> {
101 + db::users::suspend_user(db, target.id, reason).await?;
102 +
103 + // Revoke every session so the suspended creator can't keep acting during the
104 + // touch-cache window.
105 + let revoked = db::sessions::delete_all_sessions_for_user(db, target.id).await?;
106 + fanout.evict_sessions(&revoked);
107 + if !revoked.is_empty() {
108 + tracing::info!(user_id = %target.id, revoked = revoked.len(), "revoked sessions on suspension");
109 + }
110 +
111 + // Append-only audit record — this is what surfaces the reason on the creator's
112 + // own dashboard.
113 + db::moderation::create_action(db, target.id, admin_id, ModerationActionType::Suspension, reason, None).await?;
114 +
115 + // Pause fan subscriptions so fans aren't charged while the creator is
116 + // suspended: DB state plus the matching Stripe pause.
117 + if let (Some(stripe), Some(account_id)) = (stripe, target.stripe_account_id.as_ref()) {
118 + let subs = db::subscriptions::get_active_subscriptions_by_creator(db, target.id).await?;
119 + let ids: Vec<String> = subs.into_iter().map(|s| s.stripe_subscription_id).collect();
120 + fanout.apply_subscription_op(stripe, account_id, ids, FanSubOp::Pause).await;
121 + let paused = db::subscriptions::pause_subscriptions_for_creator(db, target.id).await?;
122 + tracing::info!(user_id = %target.id, db_paused = paused, "paused fan subscriptions for suspended creator");
123 + }
124 +
125 + if let Err(e) = email
126 + .send_suspension_notification(&target.email, target.display_name.as_deref(), reason)
127 + .await
128 + {
129 + tracing::error!(error = ?e, user_id = %target.id, "failed to send suspension email");
130 + }
131 +
132 + tracing::info!(user_id = %target.id, admin_id = %admin_id.get(), reason = %reason, "suspended creator");
133 + Ok(())
134 + }
135 +
136 + /// Lift a creator's suspension: clear it, resolve the audit action, and resume fan
137 + /// subscriptions (DB + Stripe). No email, mirroring the web admin path.
138 + pub async fn unsuspend_creator(
139 + db: &sqlx::PgPool,
140 + stripe: Option<&Arc<dyn PaymentProvider>>,
141 + fanout: FanoutMode<'_>,
142 + target: &DbUser,
143 + ) -> Result<()> {
144 + db::users::unsuspend_user(db, target.id).await?;
145 + db::moderation::resolve_actions_by_type(db, target.id, ModerationActionType::Suspension).await?;
146 +
147 + if let (Some(stripe), Some(account_id)) = (stripe, target.stripe_account_id.as_ref()) {
148 + let resumed = db::subscriptions::resume_subscriptions_for_creator(db, target.id).await?;
149 + let ids: Vec<String> = resumed.into_iter().map(|s| s.stripe_subscription_id).collect();
150 + fanout.apply_subscription_op(stripe, account_id, ids, FanSubOp::Resume).await;
151 + }
152 +
153 + tracing::info!(user_id = %target.id, "unsuspended creator");
154 + Ok(())
155 + }
156 +
157 + /// Resolve a suspension appeal. On approval this clears the suspension (via
158 + /// `resolve_appeal`) and resumes fan subscriptions; either way the creator is
159 + /// emailed the outcome. The caller validates a non-empty `response`.
160 + pub async fn decide_appeal(
161 + db: &sqlx::PgPool,
162 + email: &EmailClient,
163 + stripe: Option<&Arc<dyn PaymentProvider>>,
164 + fanout: FanoutMode<'_>,
165 + target: &DbUser,
166 + decision: db::AppealDecision,
167 + response: &str,
168 + ) -> Result<()> {
169 + db::users::resolve_appeal(db, target.id, decision, response).await?;
170 +
171 + if decision == db::AppealDecision::Approved
172 + && let (Some(stripe), Some(account_id)) = (stripe, target.stripe_account_id.as_ref())
173 + {
174 + let resumed = db::subscriptions::resume_subscriptions_for_creator(db, target.id).await?;
175 + let ids: Vec<String> = resumed.into_iter().map(|s| s.stripe_subscription_id).collect();
176 + fanout.apply_subscription_op(stripe, account_id, ids, FanSubOp::Resume).await;
177 + }
178 +
179 + let decision_str = decision.to_string();
180 + if let Err(e) = email
181 + .send_appeal_decision(&target.email, target.display_name.as_deref(), &decision_str, response)
182 + .await
183 + {
184 + tracing::error!(error = ?e, user_id = %target.id, "failed to send appeal decision email");
185 + }
186 +
187 + tracing::info!(user_id = %target.id, decision = %decision_str, "decided appeal");
188 + Ok(())
189 + }
@@ -157,52 +157,24 @@ pub(super) async fn admin_suspend_user(
157 157 .await?
158 158 .ok_or(AppError::NotFound)?;
159 159
160 - db::users::suspend_user(&state.db, id, reason).await?;
161 -
162 - // Immediately revoke all sessions and evict from cache so the suspended
163 - // user cannot act during the 30-second session-touch cache window.
164 - let revoked_ids = db::sessions::delete_all_sessions_for_user(&state.db, id).await?;
165 - for sid in &revoked_ids {
166 - state.session_cache.remove(sid);
167 - }
168 - if !revoked_ids.is_empty() {
169 - tracing::info!(user_id = %id, revoked = revoked_ids.len(), "revoked sessions on suspension");
170 - }
171 -
172 - // Record moderation action for audit trail
173 - db::moderation::create_action(&state.db, id, admin_user.admin_id(), ModerationActionType::Suspension, reason, None).await?;
174 -
175 - // Pause fan subscriptions to this creator's projects. The DB pause runs
176 - // inline (fast, single statement); the per-sub Stripe calls are fanned out
177 - // on the background queue so a creator with many fans doesn't stall the
178 - // admin request.
179 - if let Some(ref stripe) = state.stripe
180 - && let Some(ref account_id) = db_user.stripe_account_id
181 - {
182 - let subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, id).await?;
183 - let ids: Vec<String> = subs.into_iter().map(|s| s.stripe_subscription_id).collect();
184 - let queued = ids.len();
185 - crate::payments::fan_ops::spawn_fan_sub_fanout(
186 - &state.bg,
187 - std::sync::Arc::clone(stripe),
188 - account_id.clone(),
189 - ids,
190 - crate::payments::fan_ops::FanSubOp::Pause,
191 - state.wam.clone(),
192 - );
193 - let paused = db::subscriptions::pause_subscriptions_for_creator(&state.db, id).await?;
194 - tracing::info!(user_id = %id, stripe_queued = queued, db_paused = paused, "paused fan subscriptions for suspended creator");
195 - }
196 -
197 - // Send notification email (fire-and-forget)
198 - if let Err(e) = state.email
199 - .send_suspension_notification(&db_user.email, db_user.display_name.as_deref(), reason)
200 - .await
201 - {
202 - tracing::error!(error = ?e, user_id = %id, "failed to send suspension email");
203 - }
204 -
205 - tracing::info!(user_id = %id, reason = %reason, "admin suspended user");
160 + // Delegate to the shared moderation service so the web path and the
161 + // `mnw-admin` CLI perform the exact same due process (audit record, session
162 + // revocation, fan-sub pause, email). Web fans the Stripe calls out on the
163 + // background queue and evicts the in-memory session cache.
164 + super::moderation_service::suspend_creator(
165 + &state.db,
166 + &state.email,
167 + state.stripe.as_ref(),
168 + super::moderation_service::FanoutMode::Background {
169 + bg: &state.bg,
170 + wam: state.wam.clone(),
171 + session_cache: &state.session_cache,
172 + },
173 + &db_user,
174 + admin_user.admin_id(),
175 + reason,
176 + )
177 + .await?;
206 178
207 179 refresh_user_entries_partial(&state).await
208 180 }
@@ -219,30 +191,17 @@ pub(super) async fn admin_unsuspend_user(
219 191 .await?
220 192 .ok_or(AppError::NotFound)?;
221 193
222 - db::users::unsuspend_user(&state.db, id).await?;
223 -
224 - // Resolve suspension action in moderation history
225 - db::moderation::resolve_actions_by_type(&state.db, id, ModerationActionType::Suspension).await?;
226 -
227 - // Resume paused fan subscriptions
228 - if let Some(ref stripe) = state.stripe
229 - && let Some(ref account_id) = db_user.stripe_account_id
230 - {
231 - let resumed = db::subscriptions::resume_subscriptions_for_creator(&state.db, id).await?;
232 - let ids: Vec<String> = resumed.into_iter().map(|s| s.stripe_subscription_id).collect();
233 - let queued = ids.len();
234 - crate::payments::fan_ops::spawn_fan_sub_fanout(
235 - &state.bg,
236 - std::sync::Arc::clone(stripe),
237 - account_id.clone(),
238 - ids,
239 - crate::payments::fan_ops::FanSubOp::Resume,
240 - state.wam.clone(),
241 - );
242 - tracing::info!(user_id = %id, resumed = queued, "resumed fan subscriptions for unsuspended creator");
243 - }
244 -
245 - tracing::info!(user_id = %id, "admin unsuspended user");
194 + super::moderation_service::unsuspend_creator(
195 + &state.db,
196 + state.stripe.as_ref(),
197 + super::moderation_service::FanoutMode::Background {
198 + bg: &state.bg,
199 + wam: state.wam.clone(),
200 + session_cache: &state.session_cache,
201 + },
202 + &db_user,
203 + )
204 + .await?;
246 205
247 206 refresh_user_entries_partial(&state).await
248 207 }