Skip to main content

max / makenotwork

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