Skip to main content

max / makenotwork

9.8 KB · 311 lines History Blame Raw
1 //! Admin moderation: appeals queue and content reports.
2
3 use crate::extractors::ValidatedQuery;
4 use axum::{
5 Form,
6 extract::{Path, State},
7 response::IntoResponse,
8 };
9 use serde::Deserialize;
10 use sqlx::PgPool;
11
12 use crate::{
13 AppCaches, Billing, Integrations,
14 auth::AdminUser,
15 background::BackgroundTx,
16 db::{self, AppealDecision, ItemId, ModerationActionType, ReportId, ReportStatus, UserId},
17 email::EmailClient,
18 error::{AppError, Result},
19 helpers::get_csrf_token,
20 templates::{
21 AdminAppealEntriesTemplate, AdminAppealsTemplate, AdminReportEntriesTemplate,
22 AdminReportsTemplate,
23 },
24 types::{AdminAppealRow, AdminReportRow, ReportStats},
25 };
26
27 // --- Appeals ---
28
29 /// Render the admin appeals queue.
30 #[tracing::instrument(skip_all, name = "admin::admin_appeals")]
31 pub(super) async fn admin_appeals(
32 State(db): State<PgPool>,
33 session: tower_sessions::Session,
34 AdminUser(user): AdminUser,
35 ) -> Result<impl IntoResponse> {
36 let csrf_token = get_csrf_token(&session).await;
37
38 let db_users = db::users::get_pending_appeals(&db).await?;
39 let appeals: Vec<AdminAppealRow> = db_users.iter().map(AdminAppealRow::from_db).collect();
40
41 Ok(AdminAppealsTemplate {
42 csrf_token,
43 session_user: Some(user),
44 appeals,
45 admin_active_page: "appeals",
46 })
47 }
48
49 #[derive(Debug, Deserialize)]
50 pub(super) struct AppealDecisionForm {
51 pub decision: AppealDecision,
52 pub response: String,
53 }
54
55 /// Decide an appeal (approve or deny) and send notification email.
56 #[tracing::instrument(skip_all, name = "admin::admin_decide_appeal")]
57 #[allow(clippy::too_many_arguments)]
58 pub(super) async fn admin_decide_appeal(
59 State(db): State<PgPool>,
60 State(email): State<EmailClient>,
61 State(bg): State<BackgroundTx>,
62 State(caches): State<AppCaches>,
63 State(payments): State<Billing>,
64 State(integrations): State<Integrations>,
65 AdminUser(_admin): AdminUser,
66 Path(user_id): Path<UserId>,
67 Form(form): Form<AppealDecisionForm>,
68 ) -> Result<impl IntoResponse> {
69 let response_text = form.response.trim();
70 if response_text.is_empty() {
71 return Err(AppError::validation("Response is required".to_string()));
72 }
73
74 // Get user for email notification
75 let db_user = db::users::get_user_by_id(&db, user_id)
76 .await?
77 .ok_or(AppError::NotFound)?;
78
79 // Delegate to the shared moderation service (same path the `mnw-admin` CLI
80 // uses): resolve the appeal, resume fan subscriptions on approval, and email
81 // the outcome. Web fans the Stripe resume out on the background queue.
82 super::moderation_service::decide_appeal(
83 &db,
84 &email,
85 payments.stripe.as_ref(),
86 super::moderation_service::FanoutMode::Background {
87 bg: &bg,
88 wam: integrations.wam.clone(),
89 session_cache: &caches.session_cache,
90 },
91 &db_user,
92 form.decision,
93 response_text,
94 )
95 .await?;
96
97 // Return updated appeals list
98 let db_users = db::users::get_pending_appeals(&db).await?;
99 let appeals: Vec<AdminAppealRow> = db_users.iter().map(AdminAppealRow::from_db).collect();
100 Ok(AdminAppealEntriesTemplate { appeals })
101 }
102
103 // --- Reports ---
104
105 #[derive(Debug, Deserialize)]
106 pub(super) struct ReportFilterQuery {
107 pub status: Option<String>,
108 }
109
110 /// Render the admin reports queue.
111 #[tracing::instrument(skip_all, name = "admin::admin_reports")]
112 pub(super) async fn admin_reports(
113 State(db): State<PgPool>,
114 session: tower_sessions::Session,
115 AdminUser(user): AdminUser,
116 ValidatedQuery(query): ValidatedQuery<ReportFilterQuery>,
117 ) -> Result<impl IntoResponse> {
118 let csrf_token = get_csrf_token(&session).await;
119 let current_filter = query.status.clone().unwrap_or_default();
120
121 let db_stats = db::reports::get_report_stats(&db).await?;
122 let stats = ReportStats {
123 open: db_stats.open as u32,
124 resolved: db_stats.resolved as u32,
125 dismissed: db_stats.dismissed as u32,
126 };
127
128 let db_reports = db::reports::get_admin_reports(&db, query.status.as_deref(), 100, 0).await?;
129 let reports: Vec<AdminReportRow> = db_reports.iter().map(AdminReportRow::from_db).collect();
130
131 Ok(AdminReportsTemplate {
132 csrf_token,
133 session_user: Some(user),
134 reports,
135 stats,
136 current_filter,
137 admin_active_page: "reports",
138 })
139 }
140
141 /// Return filtered report entries as an HTMX partial.
142 #[tracing::instrument(skip_all, name = "admin::admin_report_entries")]
143 pub(super) async fn admin_report_entries(
144 State(db): State<PgPool>,
145 AdminUser(_user): AdminUser,
146 ValidatedQuery(query): ValidatedQuery<ReportFilterQuery>,
147 ) -> Result<impl IntoResponse> {
148 let db_reports = db::reports::get_admin_reports(&db, query.status.as_deref(), 100, 0).await?;
149 let reports: Vec<AdminReportRow> = db_reports.iter().map(AdminReportRow::from_db).collect();
150
151 Ok(AdminReportEntriesTemplate { reports })
152 }
153
154 #[derive(Debug, Deserialize)]
155 pub(super) struct ReportDecisionForm {
156 pub decision: String,
157 #[serde(default)]
158 pub admin_notes: String,
159 }
160
161 /// Resolve or dismiss a report.
162 #[tracing::instrument(skip_all, name = "admin::admin_resolve_report")]
163 pub(super) async fn admin_resolve_report(
164 State(db): State<PgPool>,
165 admin_user: AdminUser,
166 Path(id): Path<ReportId>,
167 Form(form): Form<ReportDecisionForm>,
168 ) -> Result<impl IntoResponse> {
169 let status = match form.decision.as_str() {
170 "resolve" => ReportStatus::Resolved,
171 "dismiss" => ReportStatus::Dismissed,
172 _ => return Err(AppError::validation("Invalid decision".to_string())),
173 };
174
175 db::reports::resolve_report(
176 &db,
177 id,
178 status,
179 form.admin_notes.trim(),
180 admin_user.admin_id(),
181 )
182 .await?;
183
184 tracing::info!(report_id = %id, decision = %form.decision, "admin resolved report");
185
186 // Return updated entries (open filter)
187 let db_reports = db::reports::get_admin_reports(&db, Some("open"), 100, 0).await?;
188 let reports: Vec<AdminReportRow> = db_reports.iter().map(AdminReportRow::from_db).collect();
189
190 Ok(AdminReportEntriesTemplate { reports })
191 }
192
193 // --- Per-item content removal ---
194
195 #[derive(Debug, Deserialize)]
196 pub(super) struct ItemRemovalForm {
197 pub reason: String,
198 }
199
200 /// Remove a specific item (enforcement ladder step 2: content removal, account stays active).
201 ///
202 /// Sets `removed_by_admin = true`, hides the item, and emails the creator with the reason.
203 #[tracing::instrument(skip_all, name = "admin::admin_remove_item")]
204 pub(super) async fn admin_remove_item(
205 State(db): State<PgPool>,
206 State(email): State<EmailClient>,
207 State(bg): State<BackgroundTx>,
208 admin_user: AdminUser,
209 Path(item_id): Path<ItemId>,
210 Form(form): Form<ItemRemovalForm>,
211 ) -> Result<impl IntoResponse> {
212 let reason = form.reason.trim();
213 if reason.is_empty() {
214 return Err(AppError::validation(
215 "Removal reason is required".to_string(),
216 ));
217 }
218
219 let item = db::items::admin_remove_item(&db, item_id, reason).await?;
220
221 // Look up the creator to send notification email
222 let owner_id = db::items::get_item_owner(&db, item_id)
223 .await?
224 .ok_or(AppError::NotFound)?;
225
226 if let Ok(Some(owner)) = db::users::get_user_by_id(&db, owner_id).await {
227 let owner_email = owner.email.clone();
228 let owner_name = owner.display_name;
229 let item_title = item.title.clone();
230 let reason = reason.to_string();
231 let email = email.clone();
232 bg.spawn("content removal notification", async move {
233 if let Err(e) = email
234 .send_content_removal(&owner_email, owner_name.as_deref(), &item_title, &reason)
235 .await
236 {
237 tracing::error!(error = ?e, "failed to send content removal notification");
238 }
239 });
240 }
241
242 // Record moderation action against the item owner
243 db::moderation::create_action(
244 &db,
245 owner_id,
246 admin_user.admin_id(),
247 ModerationActionType::ContentRemoval,
248 reason,
249 Some(&item_id.to_string()),
250 )
251 .await?;
252
253 tracing::info!(
254 item_id = %item_id,
255 admin_id = %admin_user.id(),
256 reason = %reason,
257 "admin removed item"
258 );
259
260 Ok(crate::helpers::htmx_toast_response(
261 "Item removed",
262 "success",
263 ))
264 }
265
266 /// Restore a previously admin-removed item (clears removal, creator must re-publish).
267 #[tracing::instrument(skip_all, name = "admin::admin_restore_item")]
268 pub(super) async fn admin_restore_item(
269 State(db): State<PgPool>,
270 State(email): State<EmailClient>,
271 State(bg): State<BackgroundTx>,
272 admin_user: AdminUser,
273 Path(item_id): Path<ItemId>,
274 ) -> Result<impl IntoResponse> {
275 let item = db::items::admin_restore_item(&db, item_id).await?;
276
277 // Notify creator their item was restored
278 let owner_id = db::items::get_item_owner(&db, item_id)
279 .await?
280 .ok_or(AppError::NotFound)?;
281
282 if let Ok(Some(owner)) = db::users::get_user_by_id(&db, owner_id).await {
283 let owner_email = owner.email.clone();
284 let owner_name = owner.display_name;
285 let item_title = item.title.clone();
286 let email = email.clone();
287 bg.spawn("content restore notification", async move {
288 if let Err(e) = email
289 .send_content_restored(&owner_email, owner_name.as_deref(), &item_title)
290 .await
291 {
292 tracing::error!(error = ?e, "failed to send content restore notification");
293 }
294 });
295 }
296
297 // Resolve the content_removal moderation action
298 db::moderation::resolve_content_removal(&db, &item_id.to_string()).await?;
299
300 tracing::info!(
301 item_id = %item_id,
302 admin_id = %admin_user.id(),
303 "admin restored item"
304 );
305
306 Ok(crate::helpers::htmx_toast_response(
307 "Item restored",
308 "success",
309 ))
310 }
311