Skip to main content

max / makenotwork

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