Skip to main content

max / makenotwork

Add admin enforcement ladder: content removal and account termination Per-item content removal (step 2): admin_remove_item/admin_restore_item routes, email notifications to creator with reason, publish guards prevent re-listing. Account termination (step 4): admin_terminate_user route (requires prior suspension), cancels all Stripe subscriptions, sends termination email with 30-day export window instructions. Scheduler handles deletion after 30 days. Stripe: add cancel_subscription to PaymentProvider trait and StripeClient. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 19:43 UTC
Commit: c9720b24524bb8f1d73c38538f15a86e5ad9ff20
Parent: dc8dfe9
6 files changed, +255 insertions, -3 deletions
@@ -227,6 +227,83 @@ Log in to your dashboard for more details.
227 227 self.transport.send_email(to_email, subject, &body).await
228 228 }
229 229
230 + /// Notify a creator that their item was removed by an admin.
231 + pub async fn send_content_removal(
232 + &self,
233 + to_email: &str,
234 + to_name: Option<&str>,
235 + item_title: &str,
236 + reason: &str,
237 + ) -> Result<()> {
238 + let subject = format!("Content removed: {}", item_title);
239 + let body = format!(
240 + r#"Hi{name},
241 +
242 + Your item "{item_title}" has been removed from public access.
243 +
244 + Reason: {reason}
245 +
246 + Your account remains active. You can still access the item in your dashboard. If you believe this was a mistake, you can reply to this email or submit an appeal from your dashboard.
247 +
248 + - Makenotwork"#,
249 + name = crate::email::greeting(to_name),
250 + item_title = item_title,
251 + reason = reason,
252 + );
253 +
254 + self.transport.send_email(to_email, &subject, &body).await
255 + }
256 +
257 + /// Notify a creator that their previously removed item has been restored.
258 + pub async fn send_content_restored(
259 + &self,
260 + to_email: &str,
261 + to_name: Option<&str>,
262 + item_title: &str,
263 + ) -> Result<()> {
264 + let subject = format!("Content restored: {}", item_title);
265 + let body = format!(
266 + r#"Hi{name},
267 +
268 + Your item "{item_title}" has been restored. You can now re-publish it from your dashboard.
269 +
270 + - Makenotwork"#,
271 + name = crate::email::greeting(to_name),
272 + item_title = item_title,
273 + );
274 +
275 + self.transport.send_email(to_email, &subject, &body).await
276 + }
277 +
278 + /// Notify a user that their account has been permanently terminated.
279 + pub async fn send_account_termination(
280 + &self,
281 + to_email: &str,
282 + to_name: Option<&str>,
283 + ) -> Result<()> {
284 + let subject = "Your Makenot.work account has been terminated";
285 + let body = format!(
286 + r#"Hi{name},
287 +
288 + Your Makenot.work account has been permanently terminated for repeated or serious policy violations.
289 +
290 + You have 30 days from today to export your data:
291 +
292 + - Log in at makenot.work
293 + - Go to Dashboard > Export
294 + - Download your content and transaction records
295 +
296 + After 30 days, your account and all associated data will be permanently deleted.
297 +
298 + If you believe this was a mistake, you can reply to this email.
299 +
300 + - Makenotwork"#,
301 + name = crate::email::greeting(to_name),
302 + );
303 +
304 + self.transport.send_email(to_email, subject, &body).await
305 + }
306 +
230 307 /// Send a platform shutdown notice to a user
231 308 pub async fn send_shutdown_notice(
232 309 &self,
@@ -215,4 +215,32 @@ impl StripeClient {
215 215
216 216 Ok(())
217 217 }
218 +
219 + /// Cancel a subscription on a connected account (permanent, not pausable).
220 + ///
221 + /// Used when a creator's account is terminated.
222 + pub async fn cancel_subscription(
223 + &self,
224 + stripe_sub_id: &str,
225 + connected_account_id: &str,
226 + ) -> Result<()> {
227 + let connected_client = self.client.clone().with_stripe_account(
228 + connected_account_id.parse().map_err(|_| {
229 + AppError::Internal(anyhow::anyhow!("Invalid Stripe account ID"))
230 + })?,
231 + );
232 +
233 + let sub_id = stripe_sub_id.parse::<stripe::SubscriptionId>().map_err(|e| {
234 + AppError::Internal(anyhow::anyhow!("Invalid Stripe subscription ID '{}': {}", stripe_sub_id, e))
235 + })?;
236 +
237 + stripe::Subscription::cancel(&connected_client, &sub_id, stripe::CancelSubscription::default())
238 + .await
239 + .map_err(|e| {
240 + tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel Stripe subscription");
241 + AppError::Internal(anyhow::anyhow!("Failed to cancel subscription"))
242 + })?;
243 +
244 + Ok(())
245 + }
218 246 }
@@ -74,6 +74,7 @@ pub trait PaymentProvider: Send + Sync {
74 74 // Subscription lifecycle
75 75 async fn pause_subscription(&self, stripe_sub_id: &str, connected_account_id: &str) -> crate::error::Result<()>;
76 76 async fn resume_subscription(&self, stripe_sub_id: &str, connected_account_id: &str) -> crate::error::Result<()>;
77 + async fn cancel_subscription(&self, stripe_sub_id: &str, connected_account_id: &str) -> crate::error::Result<()>;
77 78
78 79 // Webhooks
79 80 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<stripe::Event>;
@@ -148,6 +149,10 @@ impl PaymentProvider for StripeClient {
148 149 StripeClient::resume_subscription(self, stripe_sub_id, connected_account_id).await
149 150 }
150 151
152 + async fn cancel_subscription(&self, stripe_sub_id: &str, connected_account_id: &str) -> crate::error::Result<()> {
153 + StripeClient::cancel_subscription(self, stripe_sub_id, connected_account_id).await
154 + }
155 +
151 156 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<stripe::Event> {
152 157 StripeClient::verify_webhook(self, payload, signature)
153 158 }
@@ -36,6 +36,7 @@ pub fn admin_routes() -> Router<AppState> {
36 36 .route("/admin/users/entries", get(users::admin_user_entries))
37 37 .route("/api/admin/users/{id}/suspend", post(users::admin_suspend_user))
38 38 .route("/api/admin/users/{id}/unsuspend", post(users::admin_unsuspend_user))
39 + .route("/api/admin/users/{id}/terminate", post(users::admin_terminate_user))
39 40 // Upload review queue
40 41 .route("/admin/uploads", get(uploads::admin_uploads))
41 42 .route("/api/admin/uploads/items/{id}/approve", post(uploads::admin_approve_item_upload))
@@ -59,6 +60,9 @@ pub fn admin_routes() -> Router<AppState> {
59 60 .route("/admin/reports", get(moderation::admin_reports))
60 61 .route("/admin/reports/entries", get(moderation::admin_report_entries))
61 62 .route("/api/admin/reports/{id}/resolve", post(moderation::admin_resolve_report))
63 + // Per-item content removal
64 + .route("/api/admin/items/{id}/remove", post(moderation::admin_remove_item))
65 + .route("/api/admin/items/{id}/restore", post(moderation::admin_restore_item))
62 66 // MT provisioning
63 67 .route("/api/admin/mt/provision", post(admin_mt_provision))
64 68 // Storage overrides
@@ -9,9 +9,9 @@ use serde::Deserialize;
9 9
10 10 use crate::{
11 11 auth::AdminUser,
12 - db::{self, AppealDecision, ReportId, ReportStatus, UserId},
12 + db::{self, AppealDecision, ItemId, ReportId, ReportStatus, UserId},
13 13 error::{AppError, Result},
14 - helpers::get_csrf_token,
14 + helpers::{get_csrf_token, spawn_email},
15 15 templates::*,
16 16 types::*,
17 17 AppState,
@@ -187,3 +187,84 @@ pub(super) async fn admin_resolve_report(
187 187
188 188 Ok(AdminReportEntriesTemplate { reports })
189 189 }
190 +
191 + // ── Per-item content removal ──
192 +
193 + #[derive(Debug, Deserialize)]
194 + pub(super) struct ItemRemovalForm {
195 + pub reason: String,
196 + }
197 +
198 + /// Remove a specific item (enforcement ladder step 2: content removal, account stays active).
199 + ///
200 + /// Sets `removed_by_admin = true`, hides the item, and emails the creator with the reason.
201 + #[tracing::instrument(skip_all, name = "admin::admin_remove_item")]
202 + pub(super) async fn admin_remove_item(
203 + State(state): State<AppState>,
204 + AdminUser(admin): AdminUser,
205 + Path(item_id): Path<ItemId>,
206 + Form(form): Form<ItemRemovalForm>,
207 + ) -> Result<impl IntoResponse> {
208 + let reason = form.reason.trim();
209 + if reason.is_empty() {
210 + return Err(AppError::Validation("Removal reason is required".to_string()));
211 + }
212 +
213 + let item = db::items::admin_remove_item(&state.db, item_id, reason).await?;
214 +
215 + // Look up the creator to send notification email
216 + let owner_id = db::items::get_item_owner(&state.db, item_id)
217 + .await?
218 + .ok_or(AppError::NotFound)?;
219 +
220 + if let Ok(Some(owner)) = db::users::get_user_by_id(&state.db, owner_id).await {
221 + let owner_email = owner.email.clone();
222 + let owner_name = owner.display_name.clone();
223 + let item_title = item.title.clone();
224 + let reason = reason.to_string();
225 + spawn_email!(state.email, "content removal notification", |email| {
226 + email.send_content_removal(&owner_email, owner_name.as_deref(), &item_title, &reason)
227 + });
228 + }
229 +
230 + tracing::info!(
231 + item_id = %item_id,
232 + admin_id = %admin.id,
233 + reason = %reason,
234 + "admin removed item"
235 + );
236 +
237 + Ok(crate::helpers::htmx_toast_response("Item removed", "success"))
238 + }
239 +
240 + /// Restore a previously admin-removed item (clears removal, creator must re-publish).
241 + #[tracing::instrument(skip_all, name = "admin::admin_restore_item")]
242 + pub(super) async fn admin_restore_item(
243 + State(state): State<AppState>,
244 + AdminUser(admin): AdminUser,
245 + Path(item_id): Path<ItemId>,
246 + ) -> Result<impl IntoResponse> {
247 + let item = db::items::admin_restore_item(&state.db, item_id).await?;
248 +
249 + // Notify creator their item was restored
250 + let owner_id = db::items::get_item_owner(&state.db, item_id)
251 + .await?
252 + .ok_or(AppError::NotFound)?;
253 +
254 + if let Ok(Some(owner)) = db::users::get_user_by_id(&state.db, owner_id).await {
255 + let owner_email = owner.email.clone();
256 + let owner_name = owner.display_name.clone();
257 + let item_title = item.title.clone();
258 + spawn_email!(state.email, "content restore notification", |email| {
259 + email.send_content_restored(&owner_email, owner_name.as_deref(), &item_title)
260 + });
261 + }
262 +
263 + tracing::info!(
264 + item_id = %item_id,
265 + admin_id = %admin.id,
266 + "admin restored item"
267 + );
268 +
269 + Ok(crate::helpers::htmx_toast_response("Item restored", "success"))
270 + }
@@ -11,7 +11,7 @@ use crate::{
11 11 auth::AdminUser,
12 12 db::{self, UserId},
13 13 error::{AppError, Result},
14 - helpers::get_csrf_token,
14 + helpers::{get_csrf_token, spawn_email},
15 15 templates::*,
16 16 types::*,
17 17 AppState,
@@ -171,6 +171,63 @@ pub(super) async fn admin_unsuspend_user(
171 171 refresh_user_entries_partial(&state).await
172 172 }
173 173
174 + /// Permanently terminate a user account (enforcement ladder step 4).
175 + ///
176 + /// The account must already be suspended. Sets `terminated_at`, hides all items,
177 + /// cancels subscriptions, and emails the user. The user has 30 days to export
178 + /// data before the scheduler deletes the account.
179 + #[tracing::instrument(skip_all, name = "admin::admin_terminate_user")]
180 + pub(super) async fn admin_terminate_user(
181 + State(state): State<AppState>,
182 + AdminUser(admin): AdminUser,
183 + Path(id): Path<UserId>,
184 + ) -> Result<impl IntoResponse> {
185 + let db_user = db::users::get_user_by_id(&state.db, id)
186 + .await?
187 + .ok_or(AppError::NotFound)?;
188 +
189 + if !db_user.is_suspended() {
190 + return Err(AppError::Validation(
191 + "Account must be suspended before termination".to_string(),
192 + ));
193 + }
194 +
195 + if db_user.terminated_at.is_some() {
196 + return Err(AppError::Validation(
197 + "Account is already terminated".to_string(),
198 + ));
199 + }
200 +
201 + db::users::terminate_user(&state.db, id).await?;
202 +
203 + // Cancel all fan subscriptions (not just pause)
204 + if let Some(ref stripe) = state.stripe {
205 + if let Some(ref account_id) = db_user.stripe_account_id {
206 + let subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, id).await?;
207 + for sub in &subs {
208 + if let Err(e) = stripe.cancel_subscription(&sub.stripe_subscription_id, account_id).await {
209 + tracing::error!(stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to cancel subscription on termination");
210 + }
211 + }
212 + }
213 + }
214 +
215 + // Send termination email
216 + let user_email = db_user.email.clone();
217 + let user_name = db_user.display_name.clone();
218 + spawn_email!(state.email, "account termination notification", |email| {
219 + email.send_account_termination(&user_email, user_name.as_deref())
220 + });
221 +
222 + tracing::info!(
223 + user_id = %id,
224 + admin_id = %admin.id,
225 + "admin terminated user account (30-day export window started)"
226 + );
227 +
228 + refresh_user_entries_partial(&state).await
229 + }
230 +
174 231 /// Trust a user (uploads auto-publish).
175 232 #[tracing::instrument(skip_all, name = "admin::admin_trust_user")]
176 233 pub(super) async fn admin_trust_user(