max / makenotwork
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( |