max / makenotwork
7 files changed,
+298 insertions,
-224 deletions
| @@ -12,6 +12,8 @@ use axum::{ | |||
| 12 | 12 | }; | |
| 13 | 13 | use serde::Deserialize; | |
| 14 | 14 | ||
| 15 | + | use sqlx::PgPool; | |
| 16 | + | ||
| 15 | 17 | use crate::{ | |
| 16 | 18 | auth::AdminUser, | |
| 17 | 19 | db, | |
| @@ -19,18 +21,17 @@ use crate::{ | |||
| 19 | 21 | helpers::get_csrf_token, | |
| 20 | 22 | templates::*, | |
| 21 | 23 | types::*, | |
| 22 | - | AppState, | |
| 23 | 24 | }; | |
| 24 | 25 | ||
| 25 | 26 | /// Render the comp-codes dashboard: mint form plus a status list. | |
| 26 | 27 | #[tracing::instrument(skip_all, name = "admin::admin_comp_codes")] | |
| 27 | 28 | pub(super) async fn admin_comp_codes( | |
| 28 | - | State(state): State<AppState>, | |
| 29 | + | State(db): State<PgPool>, | |
| 29 | 30 | session: tower_sessions::Session, | |
| 30 | 31 | AdminUser(user): AdminUser, | |
| 31 | 32 | ) -> Result<impl IntoResponse> { | |
| 32 | 33 | let csrf_token = get_csrf_token(&session).await; | |
| 33 | - | let comp_codes = load_comp_code_rows(&state).await?; | |
| 34 | + | let comp_codes = load_comp_code_rows(&db).await?; | |
| 34 | 35 | Ok(AdminCompCodesTemplate { | |
| 35 | 36 | csrf_token, | |
| 36 | 37 | session_user: Some(user), | |
| @@ -76,7 +77,7 @@ where | |||
| 76 | 77 | /// new code appears immediately. | |
| 77 | 78 | #[tracing::instrument(skip_all, name = "admin::admin_create_comp_code")] | |
| 78 | 79 | pub(super) async fn admin_create_comp_code( | |
| 79 | - | State(state): State<AppState>, | |
| 80 | + | State(db): State<PgPool>, | |
| 80 | 81 | AdminUser(admin): AdminUser, | |
| 81 | 82 | Form(form): Form<CompCodeForm>, | |
| 82 | 83 | ) -> Result<Response> { | |
| @@ -92,7 +93,7 @@ pub(super) async fn admin_create_comp_code( | |||
| 92 | 93 | .map(|d| chrono::Utc::now() + chrono::Duration::days(d)); | |
| 93 | 94 | ||
| 94 | 95 | db::promo_codes::create_platform_promo_code( | |
| 95 | - | &state.db, | |
| 96 | + | &db, | |
| 96 | 97 | admin.id, | |
| 97 | 98 | &code, | |
| 98 | 99 | db::CodePurpose::FreeTrial, | |
| @@ -107,12 +108,12 @@ pub(super) async fn admin_create_comp_code( | |||
| 107 | 108 | ||
| 108 | 109 | tracing::info!(code = %code, trial_days = form.trial_days, "minted creator-tier comp code"); | |
| 109 | 110 | ||
| 110 | - | let comp_codes = load_comp_code_rows(&state).await?; | |
| 111 | + | let comp_codes = load_comp_code_rows(&db).await?; | |
| 111 | 112 | Ok(AdminCompCodesEntriesTemplate { comp_codes }.into_response()) | |
| 112 | 113 | } | |
| 113 | 114 | ||
| 114 | 115 | /// Load and format the comp-code rows for the dashboard. | |
| 115 | - | async fn load_comp_code_rows(state: &AppState) -> Result<Vec<AdminCompCodeRow>> { | |
| 116 | - | let codes = db::promo_codes::get_platform_trial_codes(&state.db).await?; | |
| 116 | + | async fn load_comp_code_rows(db: &PgPool) -> Result<Vec<AdminCompCodeRow>> { | |
| 117 | + | let codes = db::promo_codes::get_platform_trial_codes(db).await?; | |
| 117 | 118 | Ok(codes.iter().map(AdminCompCodeRow::from_db).collect()) | |
| 118 | 119 | } |
| @@ -18,13 +18,15 @@ use axum::{ | |||
| 18 | 18 | use axum::http::request::Parts; | |
| 19 | 19 | use axum::extract::FromRequestParts; | |
| 20 | 20 | use serde::Deserialize; | |
| 21 | + | use sqlx::PgPool; | |
| 21 | 22 | ||
| 22 | 23 | use crate::{ | |
| 23 | 24 | auth::AdminUser, | |
| 24 | 25 | csrf::{post_csrf, CsrfRouter}, | |
| 25 | 26 | db::{self, UserId}, | |
| 27 | + | email::EmailClient, | |
| 26 | 28 | error::{AppError, Result}, | |
| 27 | - | AppState, | |
| 29 | + | AppState, Integrations, Ops, | |
| 28 | 30 | }; | |
| 29 | 31 | ||
| 30 | 32 | /// `/admin*` routes served WITHOUT the admin gate, by explicit design. | |
| @@ -129,21 +131,22 @@ pub fn admin_routes(state: AppState) -> CsrfRouter<AppState> { | |||
| 129 | 131 | /// Backfill MT communities for all projects that don't have one yet. | |
| 130 | 132 | #[tracing::instrument(skip_all, name = "admin::admin_mt_provision")] | |
| 131 | 133 | async fn admin_mt_provision( | |
| 132 | - | State(state): State<AppState>, | |
| 134 | + | State(db): State<PgPool>, | |
| 135 | + | State(integrations): State<Integrations>, | |
| 133 | 136 | AdminUser(_admin): AdminUser, | |
| 134 | 137 | ) -> Result<Response> { | |
| 135 | - | let Some(ref mt) = state.mt_client else { | |
| 138 | + | let Some(ref mt) = integrations.mt_client else { | |
| 136 | 139 | return Err(AppError::validation("MT integration not configured".to_string())); | |
| 137 | 140 | }; | |
| 138 | 141 | ||
| 139 | - | let projects = db::projects::get_projects_without_mt_community(&state.db).await?; | |
| 142 | + | let projects = db::projects::get_projects_without_mt_community(&db).await?; | |
| 140 | 143 | let total = projects.len(); | |
| 141 | 144 | let mut provisioned = 0u32; | |
| 142 | 145 | let mut failed = 0u32; | |
| 143 | 146 | ||
| 144 | 147 | // Batch-load all project owners in one query instead of N individual lookups | |
| 145 | 148 | let owner_ids: Vec<db::UserId> = projects.iter().map(|p| p.user_id).collect(); | |
| 146 | - | let owners = db::users::get_users_by_ids(&state.db, &owner_ids).await?; | |
| 149 | + | let owners = db::users::get_users_by_ids(&db, &owner_ids).await?; | |
| 147 | 150 | let owner_map: std::collections::HashMap<db::UserId, &db::DbUser> = | |
| 148 | 151 | owners.iter().map(|u| (u.id, u)).collect(); | |
| 149 | 152 | ||
| @@ -166,7 +169,7 @@ async fn admin_mt_provision( | |||
| 166 | 169 | } | |
| 167 | 170 | ||
| 168 | 171 | let mt = mt.clone(); | |
| 169 | - | let pool = state.db.clone(); | |
| 172 | + | let pool = db.clone(); | |
| 170 | 173 | let req = crate::mt_client::CreateCommunityRequest { | |
| 171 | 174 | name: project.title.clone(), | |
| 172 | 175 | slug: project.slug.to_string(), | |
| @@ -220,7 +223,8 @@ pub(super) struct ShutdownNoticeForm { | |||
| 220 | 223 | /// Send a shutdown notice email to all users. | |
| 221 | 224 | #[tracing::instrument(skip_all, name = "admin::admin_shutdown_notice")] | |
| 222 | 225 | async fn admin_shutdown_notice( | |
| 223 | - | State(state): State<AppState>, | |
| 226 | + | State(db): State<PgPool>, | |
| 227 | + | State(email): State<EmailClient>, | |
| 224 | 228 | AdminUser(_admin): AdminUser, | |
| 225 | 229 | Form(form): Form<ShutdownNoticeForm>, | |
| 226 | 230 | ) -> Result<Response> { | |
| @@ -229,14 +233,14 @@ async fn admin_shutdown_notice( | |||
| 229 | 233 | return Err(AppError::validation("Shutdown date is required".to_string())); | |
| 230 | 234 | } | |
| 231 | 235 | ||
| 232 | - | let all_users = db::users::get_all_user_emails(&state.db).await?; | |
| 236 | + | let all_users = db::users::get_all_user_emails(&db).await?; | |
| 233 | 237 | let count = all_users.len(); | |
| 234 | 238 | ||
| 235 | 239 | // Fan out on a background task with a bounded JoinSet (mirrors the broadcast | |
| 236 | 240 | // path). The previous version sent one email at a time, inline, scaling the | |
| 237 | 241 | // request's latency with the entire user base and tying up a handler for the | |
| 238 | 242 | // whole send. Return immediately; the task logs its own totals. | |
| 239 | - | let email_client = state.email.clone(); | |
| 243 | + | let email_client = email.clone(); | |
| 240 | 244 | let shutdown_date = shutdown_date.to_string(); | |
| 241 | 245 | tokio::spawn(async move { | |
| 242 | 246 | let mut set = tokio::task::JoinSet::new(); | |
| @@ -296,10 +300,10 @@ async fn admin_shutdown_notice( | |||
| 296 | 300 | /// `project_founder_pricing.md` for the full plan. | |
| 297 | 301 | #[tracing::instrument(skip_all, name = "admin::admin_close_founder_window")] | |
| 298 | 302 | async fn admin_close_founder_window( | |
| 299 | - | State(state): State<AppState>, | |
| 303 | + | State(db): State<PgPool>, | |
| 300 | 304 | AdminUser(_admin): AdminUser, | |
| 301 | 305 | ) -> Result<Response> { | |
| 302 | - | let locked = db::users::lock_in_founders_with_active_subscriptions(&state.db).await?; | |
| 306 | + | let locked = db::users::lock_in_founders_with_active_subscriptions(&db).await?; | |
| 303 | 307 | tracing::info!(locked = locked, "founder window close: users stamped with founder_locked_at"); | |
| 304 | 308 | Ok(( | |
| 305 | 309 | axum::http::StatusCode::OK, | |
| @@ -317,13 +321,14 @@ async fn admin_close_founder_window( | |||
| 317 | 321 | /// Render the admin metrics dashboard with live Prometheus data. | |
| 318 | 322 | #[tracing::instrument(skip_all, name = "admin::admin_metrics")] | |
| 319 | 323 | async fn admin_metrics( | |
| 320 | - | State(state): State<AppState>, | |
| 324 | + | State(db): State<PgPool>, | |
| 325 | + | State(ops): State<Ops>, | |
| 321 | 326 | session: tower_sessions::Session, | |
| 322 | 327 | AdminUser(user): AdminUser, | |
| 323 | 328 | ) -> Result<impl IntoResponse> { | |
| 324 | 329 | let csrf_token = crate::helpers::get_csrf_token(&session).await; | |
| 325 | 330 | let uptime = { | |
| 326 | - | let d = state.start_instant.elapsed(); | |
| 331 | + | let d = ops.start_instant.elapsed(); | |
| 327 | 332 | let secs = d.as_secs(); | |
| 328 | 333 | let days = secs / 86400; | |
| 329 | 334 | let hours = (secs % 86400) / 3600; | |
| @@ -333,13 +338,13 @@ async fn admin_metrics( | |||
| 333 | 338 | else { format!("{mins}m") } | |
| 334 | 339 | }; | |
| 335 | 340 | ||
| 336 | - | let pool_size = state.db.size(); | |
| 337 | - | let pool_idle = state.db.num_idle() as u32; | |
| 341 | + | let pool_size = db.size(); | |
| 342 | + | let pool_idle = db.num_idle() as u32; | |
| 338 | 343 | let pool_active = pool_size.saturating_sub(pool_idle); | |
| 339 | 344 | ||
| 340 | 345 | // Parse metrics from the Prometheus handle (if available) | |
| 341 | 346 | let (total_requests, error_rate, total_errors, top_routes, error_breakdown) = | |
| 342 | - | if let Some(ref handle) = state.metrics_handle { | |
| 347 | + | if let Some(ref handle) = ops.metrics_handle { | |
| 343 | 348 | let snap = crate::metrics::snapshot(handle); | |
| 344 | 349 | let rate = if snap.total_requests > 0 { | |
| 345 | 350 | snap.total_5xx as f64 / snap.total_requests as f64 * 100.0 | |
| @@ -385,7 +390,7 @@ pub(super) struct FileOverrideForm { | |||
| 385 | 390 | /// POST /api/admin/users/{id}/file-override | |
| 386 | 391 | #[tracing::instrument(skip_all, name = "admin::admin_file_override")] | |
| 387 | 392 | async fn admin_file_override( | |
| 388 | - | State(state): State<AppState>, | |
| 393 | + | State(db): State<PgPool>, | |
| 389 | 394 | AdminUser(_admin): AdminUser, | |
| 390 | 395 | Path(user_id): Path<UserId>, | |
| 391 | 396 | Form(form): Form<FileOverrideForm>, | |
| @@ -397,7 +402,7 @@ async fn admin_file_override( | |||
| 397 | 402 | return Err(AppError::BadRequest("Override must be a positive number of bytes".to_string())); | |
| 398 | 403 | } | |
| 399 | 404 | ||
| 400 | - | db::creator_tiers::set_max_file_override(&state.db, user_id, form.max_file_bytes).await?; | |
| 405 | + | db::creator_tiers::set_max_file_override(&db, user_id, form.max_file_bytes).await?; | |
| 401 | 406 | ||
| 402 | 407 | let msg = match form.max_file_bytes { | |
| 403 | 408 | Some(bytes) => format!("File override set to {}", crate::helpers::format_bytes(bytes)), |
| @@ -6,15 +6,18 @@ use axum::{ | |||
| 6 | 6 | Form, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::Deserialize; | |
| 9 | + | use sqlx::PgPool; | |
| 9 | 10 | ||
| 10 | 11 | use crate::{ | |
| 11 | 12 | auth::AdminUser, | |
| 13 | + | background::BackgroundTx, | |
| 12 | 14 | db::{self, AppealDecision, ItemId, ModerationActionType, ReportId, ReportStatus, UserId}, | |
| 15 | + | email::EmailClient, | |
| 13 | 16 | error::{AppError, Result}, | |
| 14 | - | helpers::{get_csrf_token, spawn_email}, | |
| 17 | + | helpers::get_csrf_token, | |
| 15 | 18 | templates::*, | |
| 16 | 19 | types::*, | |
| 17 | - | AppState, | |
| 20 | + | AppCaches, Billing, Integrations, | |
| 18 | 21 | }; | |
| 19 | 22 | ||
| 20 | 23 | // ── Appeals ── | |
| @@ -22,13 +25,13 @@ use crate::{ | |||
| 22 | 25 | /// Render the admin appeals queue. | |
| 23 | 26 | #[tracing::instrument(skip_all, name = "admin::admin_appeals")] | |
| 24 | 27 | pub(super) async fn admin_appeals( | |
| 25 | - | State(state): State<AppState>, | |
| 28 | + | State(db): State<PgPool>, | |
| 26 | 29 | session: tower_sessions::Session, | |
| 27 | 30 | AdminUser(user): AdminUser, | |
| 28 | 31 | ) -> Result<impl IntoResponse> { | |
| 29 | 32 | let csrf_token = get_csrf_token(&session).await; | |
| 30 | 33 | ||
| 31 | - | let db_users = db::users::get_pending_appeals(&state.db).await?; | |
| 34 | + | let db_users = db::users::get_pending_appeals(&db).await?; | |
| 32 | 35 | let appeals: Vec<AdminAppealRow> = db_users.iter().map(AdminAppealRow::from_db).collect(); | |
| 33 | 36 | ||
| 34 | 37 | Ok(AdminAppealsTemplate { | |
| @@ -47,8 +50,14 @@ pub(super) struct AppealDecisionForm { | |||
| 47 | 50 | ||
| 48 | 51 | /// Decide an appeal (approve or deny) and send notification email. | |
| 49 | 52 | #[tracing::instrument(skip_all, name = "admin::admin_decide_appeal")] | |
| 53 | + | #[allow(clippy::too_many_arguments)] | |
| 50 | 54 | pub(super) async fn admin_decide_appeal( | |
| 51 | - | State(state): State<AppState>, | |
| 55 | + | State(db): State<PgPool>, | |
| 56 | + | State(email): State<EmailClient>, | |
| 57 | + | State(bg): State<BackgroundTx>, | |
| 58 | + | State(caches): State<AppCaches>, | |
| 59 | + | State(payments): State<Billing>, | |
| 60 | + | State(integrations): State<Integrations>, | |
| 52 | 61 | AdminUser(_admin): AdminUser, | |
| 53 | 62 | Path(user_id): Path<UserId>, | |
| 54 | 63 | Form(form): Form<AppealDecisionForm>, | |
| @@ -59,7 +68,7 @@ pub(super) async fn admin_decide_appeal( | |||
| 59 | 68 | } | |
| 60 | 69 | ||
| 61 | 70 | // Get user for email notification | |
| 62 | - | let db_user = db::users::get_user_by_id(&state.db, user_id) | |
| 71 | + | let db_user = db::users::get_user_by_id(&db, user_id) | |
| 63 | 72 | .await? | |
| 64 | 73 | .ok_or(AppError::NotFound)?; | |
| 65 | 74 | ||
| @@ -67,13 +76,13 @@ pub(super) async fn admin_decide_appeal( | |||
| 67 | 76 | // uses): resolve the appeal, resume fan subscriptions on approval, and email | |
| 68 | 77 | // the outcome. Web fans the Stripe resume out on the background queue. | |
| 69 | 78 | super::moderation_service::decide_appeal( | |
| 70 | - | &state.db, | |
| 71 | - | &state.email, | |
| 72 | - | state.stripe.as_ref(), | |
| 79 | + | &db, | |
| 80 | + | &email, | |
| 81 | + | payments.stripe.as_ref(), | |
| 73 | 82 | super::moderation_service::FanoutMode::Background { | |
| 74 | - | bg: &state.bg, | |
| 75 | - | wam: state.wam.clone(), | |
| 76 | - | session_cache: &state.caches.session_cache, | |
| 83 | + | bg: &bg, | |
| 84 | + | wam: integrations.wam.clone(), | |
| 85 | + | session_cache: &caches.session_cache, | |
| 77 | 86 | }, | |
| 78 | 87 | &db_user, | |
| 79 | 88 | form.decision, | |
| @@ -82,7 +91,7 @@ pub(super) async fn admin_decide_appeal( | |||
| 82 | 91 | .await?; | |
| 83 | 92 | ||
| 84 | 93 | // Return updated appeals list | |
| 85 | - | let db_users = db::users::get_pending_appeals(&state.db).await?; | |
| 94 | + | let db_users = db::users::get_pending_appeals(&db).await?; | |
| 86 | 95 | let appeals: Vec<AdminAppealRow> = db_users.iter().map(AdminAppealRow::from_db).collect(); | |
| 87 | 96 | Ok(AdminAppealEntriesTemplate { appeals }) | |
| 88 | 97 | } | |
| @@ -97,7 +106,7 @@ pub(super) struct ReportFilterQuery { | |||
| 97 | 106 | /// Render the admin reports queue. | |
| 98 | 107 | #[tracing::instrument(skip_all, name = "admin::admin_reports")] | |
| 99 | 108 | pub(super) async fn admin_reports( | |
| 100 | - | State(state): State<AppState>, | |
| 109 | + | State(db): State<PgPool>, | |
| 101 | 110 | session: tower_sessions::Session, | |
| 102 | 111 | AdminUser(user): AdminUser, | |
| 103 | 112 | Query(query): Query<ReportFilterQuery>, | |
| @@ -105,14 +114,14 @@ pub(super) async fn admin_reports( | |||
| 105 | 114 | let csrf_token = get_csrf_token(&session).await; | |
| 106 | 115 | let current_filter = query.status.clone().unwrap_or_default(); | |
| 107 | 116 | ||
| 108 | - | let db_stats = db::reports::get_report_stats(&state.db).await?; | |
| 117 | + | let db_stats = db::reports::get_report_stats(&db).await?; | |
| 109 | 118 | let stats = ReportStats { | |
| 110 | 119 | open: db_stats.open as u32, | |
| 111 | 120 | resolved: db_stats.resolved as u32, | |
| 112 | 121 | dismissed: db_stats.dismissed as u32, | |
| 113 | 122 | }; | |
| 114 | 123 | ||
| 115 | - | let db_reports = db::reports::get_admin_reports(&state.db, query.status.as_deref(), 100, 0).await?; | |
| 124 | + | let db_reports = db::reports::get_admin_reports(&db, query.status.as_deref(), 100, 0).await?; | |
| 116 | 125 | let reports: Vec<AdminReportRow> = db_reports.iter().map(AdminReportRow::from_db).collect(); | |
| 117 | 126 | ||
| 118 | 127 | Ok(AdminReportsTemplate { | |
| @@ -128,11 +137,11 @@ pub(super) async fn admin_reports( | |||
| 128 | 137 | /// Return filtered report entries as an HTMX partial. | |
| 129 | 138 | #[tracing::instrument(skip_all, name = "admin::admin_report_entries")] | |
| 130 | 139 | pub(super) async fn admin_report_entries( | |
| 131 | - | State(state): State<AppState>, | |
| 140 | + | State(db): State<PgPool>, | |
| 132 | 141 | AdminUser(_user): AdminUser, | |
| 133 | 142 | Query(query): Query<ReportFilterQuery>, | |
| 134 | 143 | ) -> Result<impl IntoResponse> { | |
| 135 | - | let db_reports = db::reports::get_admin_reports(&state.db, query.status.as_deref(), 100, 0).await?; | |
| 144 | + | let db_reports = db::reports::get_admin_reports(&db, query.status.as_deref(), 100, 0).await?; | |
| 136 | 145 | let reports: Vec<AdminReportRow> = db_reports.iter().map(AdminReportRow::from_db).collect(); | |
| 137 | 146 | ||
| 138 | 147 | Ok(AdminReportEntriesTemplate { reports }) | |
| @@ -148,7 +157,7 @@ pub(super) struct ReportDecisionForm { | |||
| 148 | 157 | /// Resolve or dismiss a report. | |
| 149 | 158 | #[tracing::instrument(skip_all, name = "admin::admin_resolve_report")] | |
| 150 | 159 | pub(super) async fn admin_resolve_report( | |
| 151 | - | State(state): State<AppState>, | |
| 160 | + | State(db): State<PgPool>, | |
| 152 | 161 | admin_user: AdminUser, | |
| 153 | 162 | Path(id): Path<ReportId>, | |
| 154 | 163 | Form(form): Form<ReportDecisionForm>, | |
| @@ -160,7 +169,7 @@ pub(super) async fn admin_resolve_report( | |||
| 160 | 169 | }; | |
| 161 | 170 | ||
| 162 | 171 | db::reports::resolve_report( | |
| 163 | - | &state.db, | |
| 172 | + | &db, | |
| 164 | 173 | id, | |
| 165 | 174 | status, | |
| 166 | 175 | form.admin_notes.trim(), | |
| @@ -170,7 +179,7 @@ pub(super) async fn admin_resolve_report( | |||
| 170 | 179 | tracing::info!(report_id = %id, decision = %form.decision, "admin resolved report"); | |
| 171 | 180 | ||
| 172 | 181 | // Return updated entries (open filter) | |
| 173 | - | let db_reports = db::reports::get_admin_reports(&state.db, Some("open"), 100, 0).await?; | |
| 182 | + | let db_reports = db::reports::get_admin_reports(&db, Some("open"), 100, 0).await?; | |
| 174 | 183 | let reports: Vec<AdminReportRow> = db_reports.iter().map(AdminReportRow::from_db).collect(); | |
| 175 | 184 | ||
| 176 | 185 | Ok(AdminReportEntriesTemplate { reports }) | |
| @@ -188,7 +197,9 @@ pub(super) struct ItemRemovalForm { | |||
| 188 | 197 | /// Sets `removed_by_admin = true`, hides the item, and emails the creator with the reason. | |
| 189 | 198 | #[tracing::instrument(skip_all, name = "admin::admin_remove_item")] | |
| 190 | 199 | pub(super) async fn admin_remove_item( | |
| 191 | - | State(state): State<AppState>, | |
| 200 | + | State(db): State<PgPool>, | |
| 201 | + | State(email): State<EmailClient>, | |
| 202 | + | State(bg): State<BackgroundTx>, | |
| 192 | 203 | admin_user: AdminUser, | |
| 193 | 204 | Path(item_id): Path<ItemId>, | |
| 194 | 205 | Form(form): Form<ItemRemovalForm>, | |
| @@ -198,26 +209,32 @@ pub(super) async fn admin_remove_item( | |||
| 198 | 209 | return Err(AppError::validation("Removal reason is required".to_string())); | |
| 199 | 210 | } | |
| 200 | 211 | ||
| 201 | - | let item = db::items::admin_remove_item(&state.db, item_id, reason).await?; | |
| 212 | + | let item = db::items::admin_remove_item(&db, item_id, reason).await?; | |
| 202 | 213 | ||
| 203 | 214 | // Look up the creator to send notification email | |
| 204 | - | let owner_id = db::items::get_item_owner(&state.db, item_id) | |
| 215 | + | let owner_id = db::items::get_item_owner(&db, item_id) | |
| 205 | 216 | .await? | |
| 206 | 217 | .ok_or(AppError::NotFound)?; | |
| 207 | 218 | ||
| 208 | - | if let Ok(Some(owner)) = db::users::get_user_by_id(&state.db, owner_id).await { | |
| 219 | + | if let Ok(Some(owner)) = db::users::get_user_by_id(&db, owner_id).await { | |
| 209 | 220 | let owner_email = owner.email.clone(); | |
| 210 | 221 | let owner_name = owner.display_name.clone(); | |
| 211 | 222 | let item_title = item.title.clone(); | |
| 212 | 223 | let reason = reason.to_string(); | |
| 213 | - | spawn_email!(state, "content removal notification", |email| { | |
| 214 | - | email.send_content_removal(&owner_email, owner_name.as_deref(), &item_title, &reason) | |
| 224 | + | let email = email.clone(); | |
| 225 | + | bg.spawn("content removal notification", async move { | |
| 226 | + | if let Err(e) = email | |
| 227 | + | .send_content_removal(&owner_email, owner_name.as_deref(), &item_title, &reason) | |
| 228 | + | .await | |
| 229 | + | { | |
| 230 | + | tracing::error!(error = ?e, "failed to send content removal notification"); | |
| 231 | + | } | |
| 215 | 232 | }); | |
| 216 | 233 | } | |
| 217 | 234 | ||
| 218 | 235 | // Record moderation action against the item owner | |
| 219 | 236 | db::moderation::create_action( | |
| 220 | - | &state.db, owner_id, admin_user.admin_id(), ModerationActionType::ContentRemoval, reason, Some(&item_id.to_string()), | |
| 237 | + | &db, owner_id, admin_user.admin_id(), ModerationActionType::ContentRemoval, reason, Some(&item_id.to_string()), | |
| 221 | 238 | ).await?; | |
| 222 | 239 | ||
| 223 | 240 | tracing::info!( | |
| @@ -233,28 +250,36 @@ pub(super) async fn admin_remove_item( | |||
| 233 | 250 | /// Restore a previously admin-removed item (clears removal, creator must re-publish). | |
| 234 | 251 | #[tracing::instrument(skip_all, name = "admin::admin_restore_item")] | |
| 235 | 252 | pub(super) async fn admin_restore_item( | |
| 236 | - | State(state): State<AppState>, | |
| 253 | + | State(db): State<PgPool>, | |
| 254 | + | State(email): State<EmailClient>, | |
| 255 | + | State(bg): State<BackgroundTx>, | |
| 237 | 256 | admin_user: AdminUser, | |
| 238 | 257 | Path(item_id): Path<ItemId>, | |
| 239 | 258 | ) -> Result<impl IntoResponse> { | |
| 240 | - | let item = db::items::admin_restore_item(&state.db, item_id).await?; | |
| 259 | + | let item = db::items::admin_restore_item(&db, item_id).await?; | |
| 241 | 260 | ||
| 242 | 261 | // Notify creator their item was restored | |
| 243 | - | let owner_id = db::items::get_item_owner(&state.db, item_id) | |
| 262 | + | let owner_id = db::items::get_item_owner(&db, item_id) | |
| 244 | 263 | .await? | |
| 245 | 264 | .ok_or(AppError::NotFound)?; | |
| 246 | 265 | ||
| 247 | - | if let Ok(Some(owner)) = db::users::get_user_by_id(&state.db, owner_id).await { | |
| 266 | + | if let Ok(Some(owner)) = db::users::get_user_by_id(&db, owner_id).await { | |
| 248 | 267 | let owner_email = owner.email.clone(); | |
| 249 | 268 | let owner_name = owner.display_name.clone(); | |
| 250 | 269 | let item_title = item.title.clone(); | |
| 251 | - | spawn_email!(state, "content restore notification", |email| { | |
| 252 | - | email.send_content_restored(&owner_email, owner_name.as_deref(), &item_title) | |
| 270 | + | let email = email.clone(); | |
| 271 | + | bg.spawn("content restore notification", async move { | |
| 272 | + | if let Err(e) = email | |
| 273 | + | .send_content_restored(&owner_email, owner_name.as_deref(), &item_title) | |
| 274 | + | .await | |
| 275 | + | { | |
| 276 | + | tracing::error!(error = ?e, "failed to send content restore notification"); | |
| 277 | + | } | |
| 253 | 278 | }); | |
| 254 | 279 | } | |
| 255 | 280 | ||
| 256 | 281 | // Resolve the content_removal moderation action | |
| 257 | - | db::moderation::resolve_content_removal(&state.db, &item_id.to_string()).await?; | |
| 282 | + | db::moderation::resolve_content_removal(&db, &item_id.to_string()).await?; | |
| 258 | 283 | ||
| 259 | 284 | tracing::info!( | |
| 260 | 285 | item_id = %item_id, |
| @@ -1,4 +1,5 @@ | |||
| 1 | 1 | use axum::{extract::State, response::IntoResponse}; | |
| 2 | + | use sqlx::PgPool; | |
| 2 | 3 | use tower_sessions::Session; | |
| 3 | 4 | ||
| 4 | 5 | use crate::{ | |
| @@ -8,18 +9,17 @@ use crate::{ | |||
| 8 | 9 | helpers::get_csrf_token, | |
| 9 | 10 | templates::AdminSignupsTemplate, | |
| 10 | 11 | types::AdminSignupRow, | |
| 11 | - | AppState, | |
| 12 | 12 | }; | |
| 13 | 13 | ||
| 14 | 14 | /// Admin page listing email signups from the landing page. | |
| 15 | 15 | #[tracing::instrument(skip_all, name = "admin::admin_signups")] | |
| 16 | 16 | pub(super) async fn admin_signups( | |
| 17 | - | State(state): State<AppState>, | |
| 17 | + | State(db): State<PgPool>, | |
| 18 | 18 | session: Session, | |
| 19 | 19 | AdminUser(admin): AdminUser, | |
| 20 | 20 | ) -> Result<impl IntoResponse> { | |
| 21 | - | let db_signups = db::email_signups::get_all_email_signups(&state.db).await?; | |
| 22 | - | let total = db::email_signups::count_email_signups(&state.db).await?; | |
| 21 | + | let db_signups = db::email_signups::get_all_email_signups(&db).await?; | |
| 22 | + | let total = db::email_signups::count_email_signups(&db).await?; | |
| 23 | 23 | ||
| 24 | 24 | let signups: Vec<AdminSignupRow> = db_signups | |
| 25 | 25 | .into_iter() |
| @@ -9,16 +9,18 @@ use axum::{ | |||
| 9 | 9 | extract::{Path, State}, | |
| 10 | 10 | response::{IntoResponse, Response}, | |
| 11 | 11 | }; | |
| 12 | + | use sqlx::PgPool; | |
| 12 | 13 | ||
| 13 | 14 | use crate::{ | |
| 14 | 15 | auth::AdminUser, | |
| 16 | + | config::Config, | |
| 15 | 17 | db::{self, scan_admin_actions::AdminAction, FileScanStatus, ItemId, UserId, VersionId}, | |
| 16 | 18 | error::Result, | |
| 17 | 19 | helpers::get_csrf_token, | |
| 18 | 20 | storage::FileType, | |
| 19 | 21 | templates::*, | |
| 20 | 22 | types::*, | |
| 21 | - | AppState, | |
| 23 | + | AppStorage, Scanning, | |
| 22 | 24 | }; | |
| 23 | 25 | ||
| 24 | 26 | const HEALTH_WINDOW_HOURS: i64 = 24; | |
| @@ -28,8 +30,8 @@ const HISTORY_ROW_LIMIT: i64 = 100; | |||
| 28 | 30 | const AUDIT_LOG_ROW_LIMIT: i64 = 500; | |
| 29 | 31 | ||
| 30 | 32 | /// Build the per-layer health-card vec from the DB rollup. | |
| 31 | - | async fn fetch_layer_health(state: &AppState) -> Result<Vec<LayerHealthCard>> { | |
| 32 | - | let rows = db::scanning::layer_health_window(&state.db, HEALTH_WINDOW_HOURS).await?; | |
| 33 | + | async fn fetch_layer_health(db: &PgPool) -> Result<Vec<LayerHealthCard>> { | |
| 34 | + | let rows = db::scanning::layer_health_window(db, HEALTH_WINDOW_HOURS).await?; | |
| 33 | 35 | let mut by_layer: HashMap<String, LayerHealthCard> = HashMap::new(); | |
| 34 | 36 | ||
| 35 | 37 | // The canonical layer list — keep cards visible even if a layer hasn't | |
| @@ -106,14 +108,14 @@ fn relative_age(t: chrono::DateTime<chrono::Utc>) -> String { | |||
| 106 | 108 | /// Render the admin upload review queue. | |
| 107 | 109 | #[tracing::instrument(skip_all, name = "admin::admin_uploads")] | |
| 108 | 110 | pub(super) async fn admin_uploads( | |
| 109 | - | State(state): State<AppState>, | |
| 111 | + | State(db): State<PgPool>, | |
| 110 | 112 | session: tower_sessions::Session, | |
| 111 | 113 | AdminUser(user): AdminUser, | |
| 112 | 114 | ) -> Result<impl IntoResponse> { | |
| 113 | 115 | let csrf_token = get_csrf_token(&session).await; | |
| 114 | 116 | ||
| 115 | - | let held_items = db::scanning::get_held_items(&state.db).await?; | |
| 116 | - | let held_versions = db::scanning::get_held_versions(&state.db).await?; | |
| 117 | + | let held_items = db::scanning::get_held_items(&db).await?; | |
| 118 | + | let held_versions = db::scanning::get_held_versions(&db).await?; | |
| 117 | 119 | // The held queries are capped at 1000 rows each (oldest first). Hitting the | |
| 118 | 120 | // cap is an incident-scale backlog; surface it rather than silently truncate. | |
| 119 | 121 | if held_items.len() >= 1000 || held_versions.len() >= 1000 { | |
| @@ -130,15 +132,15 @@ pub(super) async fn admin_uploads( | |||
| 130 | 132 | held_uploads.sort_by(|a, b| a.held_at.cmp(&b.held_at)); | |
| 131 | 133 | ||
| 132 | 134 | // Attach the last admin action to each held row in a single batch lookup. | |
| 133 | - | attach_last_actions(&state, &mut held_uploads).await; | |
| 135 | + | attach_last_actions(&db, &mut held_uploads).await; | |
| 134 | 136 | ||
| 135 | 137 | let total_held = held_uploads.len(); | |
| 136 | - | let layer_health = fetch_layer_health(&state).await?; | |
| 137 | - | let queue_pending = db::scan_jobs::queued_count(&state.db).await.unwrap_or(0); | |
| 138 | - | let queue_running = db::scan_jobs::running_count(&state.db).await.unwrap_or(0); | |
| 138 | + | let layer_health = fetch_layer_health(&db).await?; | |
| 139 | + | let queue_pending = db::scan_jobs::queued_count(&db).await.unwrap_or(0); | |
| 140 | + | let queue_running = db::scan_jobs::running_count(&db).await.unwrap_or(0); | |
| 139 | 141 | ||
| 140 | 142 | let recent_history: Vec<ScanHistoryDisplay> = db::scanning::recent_history( | |
| 141 | - | &state.db, HISTORY_WINDOW_HOURS, HISTORY_ROW_LIMIT, | |
| 143 | + | &db, HISTORY_WINDOW_HOURS, HISTORY_ROW_LIMIT, | |
| 142 | 144 | ).await | |
| 143 | 145 | .unwrap_or_default() | |
| 144 | 146 | .iter() | |
| @@ -162,7 +164,7 @@ pub(super) async fn admin_uploads( | |||
| 162 | 164 | ||
| 163 | 165 | /// Batch-fetch the latest admin action for the displayed held rows and | |
| 164 | 166 | /// attach each as `last_action` on its row. | |
| 165 | - | async fn attach_last_actions(state: &AppState, rows: &mut [AdminHeldUploadRow]) { | |
| 167 | + | async fn attach_last_actions(db: &PgPool, rows: &mut [AdminHeldUploadRow]) { | |
| 166 | 168 | use std::str::FromStr; | |
| 167 | 169 | let mut version_ids: Vec<uuid::Uuid> = Vec::new(); | |
| 168 | 170 | let mut item_ids: Vec<uuid::Uuid> = Vec::new(); | |
| @@ -175,9 +177,9 @@ async fn attach_last_actions(state: &AppState, rows: &mut [AdminHeldUploadRow]) | |||
| 175 | 177 | item_ids.push(uuid); | |
| 176 | 178 | } | |
| 177 | 179 | } | |
| 178 | - | let version_actions = db::scan_admin_actions::latest_per_version(&state.db, &version_ids) | |
| 180 | + | let version_actions = db::scan_admin_actions::latest_per_version(db, &version_ids) | |
| 179 | 181 | .await.unwrap_or_default(); | |
| 180 | - | let item_actions = db::scan_admin_actions::latest_per_item(&state.db, &item_ids) | |
| 182 | + | let item_actions = db::scan_admin_actions::latest_per_item(db, &item_ids) | |
| 181 | 183 | .await.unwrap_or_default(); | |
| 182 | 184 | ||
| 183 | 185 | for r in rows.iter_mut() { | |
| @@ -212,7 +214,7 @@ pub(super) struct AuditFilters { | |||
| 212 | 214 | /// filters are optional; empty/absent means no constraint on that column. | |
| 213 | 215 | #[tracing::instrument(skip_all, name = "admin::scan_audit")] | |
| 214 | 216 | pub(super) async fn admin_scan_audit( | |
| 215 | - | State(state): State<AppState>, | |
| 217 | + | State(db): State<PgPool>, | |
| 216 | 218 | session: tower_sessions::Session, | |
| 217 | 219 | AdminUser(user): AdminUser, | |
| 218 | 220 | axum::extract::Query(filters): axum::extract::Query<AuditFilters>, | |
| @@ -221,7 +223,7 @@ pub(super) async fn admin_scan_audit( | |||
| 221 | 223 | let action = filters.action.as_deref().filter(|s| !s.is_empty()); | |
| 222 | 224 | let admin = filters.admin.as_deref().filter(|s| !s.is_empty()); | |
| 223 | 225 | let entries: Vec<AdminAuditLogRow> = db::scan_admin_actions::list_filtered( | |
| 224 | - | &state.db, action, admin, filters.since_days, AUDIT_LOG_ROW_LIMIT, | |
| 226 | + | &db, action, admin, filters.since_days, AUDIT_LOG_ROW_LIMIT, | |
| 225 | 227 | ).await | |
| 226 | 228 | .unwrap_or_default() | |
| 227 | 229 | .iter() | |
| @@ -239,9 +241,9 @@ pub(super) async fn admin_scan_audit( | |||
| 239 | 241 | } | |
| 240 | 242 | ||
| 241 | 243 | /// Re-query held uploads and return the entries partial. | |
| 242 | - | pub(super) async fn refresh_held_uploads_partial(state: &AppState) -> Result<Response> { | |
| 243 | - | let held_items = db::scanning::get_held_items(&state.db).await?; | |
| 244 | - | let held_versions = db::scanning::get_held_versions(&state.db).await?; | |
| 244 | + | pub(super) async fn refresh_held_uploads_partial(db: &PgPool) -> Result<Response> { | |
| 245 | + | let held_items = db::scanning::get_held_items(db).await?; | |
| 246 | + | let held_versions = db::scanning::get_held_versions(db).await?; | |
| 245 | 247 | ||
| 246 | 248 | let mut held_uploads: Vec<AdminHeldUploadRow> = Vec::new(); | |
| 247 | 249 | held_uploads.extend(held_items.iter().map(AdminHeldUploadRow::from_held_item)); | |
| @@ -256,74 +258,78 @@ pub(super) async fn refresh_held_uploads_partial(state: &AppState) -> Result<Res | |||
| 256 | 258 | /// Promote a held item upload to Clean. Renames the legacy "approve" verb. | |
| 257 | 259 | #[tracing::instrument(skip_all, name = "admin::promote_item")] | |
| 258 | 260 | pub(super) async fn admin_promote_item( | |
| 259 | - | State(state): State<AppState>, | |
| 261 | + | State(db): State<PgPool>, | |
| 262 | + | State(storage): State<AppStorage>, | |
| 263 | + | State(config): State<Config>, | |
| 260 | 264 | AdminUser(admin): AdminUser, | |
| 261 | 265 | Path(id): Path<ItemId>, | |
| 262 | 266 | ) -> Result<Response> { | |
| 263 | 267 | // Copy each held (staging) file to its content key and mark Clean in one | |
| 264 | 268 | // step — a held file sits unserved at its staging key, so approving must run | |
| 265 | 269 | // the same promote the scan worker's Clean path runs (C1). | |
| 266 | - | crate::routes::storage::commit_promote_item(&state.db, &state.storage, &state.config, id).await?; | |
| 270 | + | crate::routes::storage::commit_promote_item(&db, &storage, &config, id).await?; | |
| 267 | 271 | db::scan_admin_actions::log_item( | |
| 268 | - | &state.db, id, admin.id, AdminAction::Promote, | |
| 272 | + | &db, id, admin.id, AdminAction::Promote, | |
| 269 | 273 | Some("held_for_review"), Some("clean"), | |
| 270 | 274 | None, | |
| 271 | 275 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 272 | 276 | tracing::info!(item_id = %id, admin_id = %admin.id, "item promoted to clean"); | |
| 273 | - | refresh_held_uploads_partial(&state).await | |
| 277 | + | refresh_held_uploads_partial(&db).await | |
| 274 | 278 | } | |
| 275 | 279 | ||
| 276 | 280 | /// Quarantine a held item upload. Renames the legacy "reject" verb. | |
| 277 | 281 | #[tracing::instrument(skip_all, name = "admin::quarantine_item")] | |
| 278 | 282 | pub(super) async fn admin_quarantine_item( | |
| 279 | - | State(state): State<AppState>, | |
| 283 | + | State(db): State<PgPool>, | |
| 280 | 284 | AdminUser(admin): AdminUser, | |
| 281 | 285 | Path(id): Path<ItemId>, | |
| 282 | 286 | ) -> Result<Response> { | |
| 283 | - | db::scanning::update_item_scan_status(&state.db, id, FileScanStatus::Quarantined).await?; | |
| 287 | + | db::scanning::update_item_scan_status(&db, id, FileScanStatus::Quarantined).await?; | |
| 284 | 288 | db::scan_admin_actions::log_item( | |
| 285 | - | &state.db, id, admin.id, AdminAction::Quarantine, | |
| 289 | + | &db, id, admin.id, AdminAction::Quarantine, | |
| 286 | 290 | Some("held_for_review"), Some("quarantined"), | |
| 287 | 291 | None, | |
| 288 | 292 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 289 | 293 | tracing::info!(item_id = %id, admin_id = %admin.id, "item quarantined"); | |
| 290 | - | refresh_held_uploads_partial(&state).await | |
| 294 | + | refresh_held_uploads_partial(&db).await | |
| 291 | 295 | } | |
| 292 | 296 | ||
| 293 | 297 | /// Promote a held version upload to Clean. | |
| 294 | 298 | #[tracing::instrument(skip_all, name = "admin::promote_version")] | |
| 295 | 299 | pub(super) async fn admin_promote_version( | |
| 296 | - | State(state): State<AppState>, | |
| 300 | + | State(db): State<PgPool>, | |
| 301 | + | State(storage): State<AppStorage>, | |
| 302 | + | State(config): State<Config>, | |
| 297 | 303 | AdminUser(admin): AdminUser, | |
| 298 | 304 | Path(id): Path<VersionId>, | |
| 299 | 305 | ) -> Result<Response> { | |
| 300 | 306 | // See admin_promote_item: copy the held (staging) download to its content key | |
| 301 | 307 | // and mark Clean together (C1). | |
| 302 | - | crate::routes::storage::commit_promote_version(&state.db, &state.storage, &state.config, id).await?; | |
| 308 | + | crate::routes::storage::commit_promote_version(&db, &storage, &config, id).await?; | |
| 303 | 309 | db::scan_admin_actions::log_version( | |
| 304 | - | &state.db, id, admin.id, AdminAction::Promote, | |
| 310 | + | &db, id, admin.id, AdminAction::Promote, | |
| 305 | 311 | Some("held_for_review"), Some("clean"), | |
| 306 | 312 | None, | |
| 307 | 313 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 308 | 314 | tracing::info!(version_id = %id, admin_id = %admin.id, "version promoted to clean"); | |
| 309 | - | refresh_held_uploads_partial(&state).await | |
| 315 | + | refresh_held_uploads_partial(&db).await | |
| 310 | 316 | } | |
| 311 | 317 | ||
| 312 | 318 | /// Quarantine a held version upload. | |
| 313 | 319 | #[tracing::instrument(skip_all, name = "admin::quarantine_version")] | |
| 314 | 320 | pub(super) async fn admin_quarantine_version( | |
| 315 | - | State(state): State<AppState>, | |
| 321 | + | State(db): State<PgPool>, | |
| 316 | 322 | AdminUser(admin): AdminUser, | |
| 317 | 323 | Path(id): Path<VersionId>, | |
| 318 | 324 | ) -> Result<Response> { | |
| 319 | - | db::scanning::update_version_scan_status(&state.db, id, FileScanStatus::Quarantined).await?; | |
| 325 | + | db::scanning::update_version_scan_status(&db, id, FileScanStatus::Quarantined).await?; | |
| 320 | 326 | db::scan_admin_actions::log_version( | |
| 321 | - | &state.db, id, admin.id, AdminAction::Quarantine, | |
| 327 | + | &db, id, admin.id, AdminAction::Quarantine, | |
| 322 | 328 | Some("held_for_review"), Some("quarantined"), | |
| 323 | 329 | None, | |
| 324 | 330 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 325 | 331 | tracing::info!(version_id = %id, admin_id = %admin.id, "version quarantined"); | |
| 326 | - | refresh_held_uploads_partial(&state).await | |
| 332 | + | refresh_held_uploads_partial(&db).await | |
| 327 | 333 | } | |
| 328 | 334 | ||
| 329 | 335 | // ─── Rescan ────────────────────────────────────────────────────────────────── | |
| @@ -333,25 +339,27 @@ pub(super) async fn admin_quarantine_version( | |||
| 333 | 339 | /// that a scan is in flight. | |
| 334 | 340 | #[tracing::instrument(skip_all, name = "admin::rescan_version")] | |
| 335 | 341 | pub(super) async fn admin_rescan_version( | |
| 336 | - | State(state): State<AppState>, | |
| 342 | + | State(db): State<PgPool>, | |
| 343 | + | State(scanning): State<Scanning>, | |
| 337 | 344 | AdminUser(admin): AdminUser, | |
| 338 | 345 | Path(id): Path<VersionId>, | |
| 339 | 346 | ) -> Result<Response> { | |
| 340 | - | rescan_version_inner(&state, id, admin.id, AdminAction::Rescan).await?; | |
| 341 | - | refresh_held_uploads_partial(&state).await | |
| 347 | + | rescan_version_inner(&db, scanning.scanner.as_ref(), id, admin.id, AdminAction::Rescan).await?; | |
| 348 | + | refresh_held_uploads_partial(&db).await | |
| 342 | 349 | } | |
| 343 | 350 | ||
| 344 | 351 | async fn rescan_version_inner( | |
| 345 | - | state: &AppState, | |
| 352 | + | db: &PgPool, | |
| 353 | + | scanner: Option<&std::sync::Arc<crate::scanning::ScanPipeline>>, | |
| 346 | 354 | id: VersionId, | |
| 347 | 355 | admin_id: UserId, | |
| 348 | 356 | action: AdminAction, | |
| 349 | 357 | ) -> Result<()> { | |
| 350 | - | let v = db::versions::get_version_by_id(&state.db, id).await? | |
| 358 | + | let v = db::versions::get_version_by_id(db, id).await? | |
| 351 | 359 | .ok_or(crate::error::AppError::NotFound)?; | |
| 352 | - | let item = db::items::get_item_by_id(&state.db, v.item_id).await? | |
| 360 | + | let item = db::items::get_item_by_id(db, v.item_id).await? | |
| 353 | 361 | .ok_or(crate::error::AppError::NotFound)?; | |
| 354 | - | let owner = db::items::get_item_owner(&state.db, item.id).await? | |
| 362 | + | let owner = db::items::get_item_owner(db, item.id).await? | |
| 355 | 363 | .ok_or(crate::error::AppError::NotFound)?; | |
| 356 | 364 | let s3_key = v.s3_key.clone().ok_or(crate::error::AppError::NotFound)?; | |
| 357 | 365 | let size = v.file_size_bytes.unwrap_or(0); | |
| @@ -361,8 +369,8 @@ async fn rescan_version_inner( | |||
| 361 | 369 | // to call scan_jobs::enqueue + update_*_scan_status directly, which made | |
| 362 | 370 | // ordering bugs hard to fence at the type level. | |
| 363 | 371 | crate::routes::storage::commit_rescan( | |
| 364 | - | &state.db, | |
| 365 | - | state.scanner.as_ref(), | |
| 372 | + | db, | |
| 373 | + | scanner, | |
| 366 | 374 | crate::routes::storage::CommitTarget::Version(id), | |
| 367 | 375 | &s3_key, | |
| 368 | 376 | FileType::Download, | |
| @@ -370,7 +378,7 @@ async fn rescan_version_inner( | |||
| 370 | 378 | size, | |
| 371 | 379 | ).await?; | |
| 372 | 380 | db::scan_admin_actions::log_version( | |
| 373 | - | &state.db, id, admin_id, action, | |
| 381 | + | db, id, admin_id, action, | |
| 374 | 382 | Some("held_for_review"), Some("pending"), None, | |
| 375 | 383 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 376 | 384 | tracing::info!(version_id = %id, admin_id = %admin_id, "version re-enqueued for scan"); | |
| @@ -380,23 +388,25 @@ async fn rescan_version_inner( | |||
| 380 | 388 | /// Re-enqueue a single item for scanning. | |
| 381 | 389 | #[tracing::instrument(skip_all, name = "admin::rescan_item")] | |
| 382 | 390 | pub(super) async fn admin_rescan_item( | |
| 383 | - | State(state): State<AppState>, | |
| 391 | + | State(db): State<PgPool>, | |
| 392 | + | State(scanning): State<Scanning>, | |
| 384 | 393 | AdminUser(admin): AdminUser, | |
| 385 | 394 | Path(id): Path<ItemId>, | |
| 386 | 395 | ) -> Result<Response> { | |
| 387 | - | rescan_item_inner(&state, id, admin.id, AdminAction::Rescan).await?; | |
| 388 | - | refresh_held_uploads_partial(&state).await | |
| 396 | + | rescan_item_inner(&db, scanning.scanner.as_ref(), id, admin.id, AdminAction::Rescan).await?; | |
| 397 | + | refresh_held_uploads_partial(&db).await | |
| 389 | 398 | } | |
| 390 | 399 | ||
| 391 | 400 | async fn rescan_item_inner( | |
| 392 | - | state: &AppState, | |
| 401 | + | db: &PgPool, | |
| 402 | + | scanner: Option<&std::sync::Arc<crate::scanning::ScanPipeline>>, | |
| 393 | 403 | id: ItemId, | |
| 394 | 404 | admin_id: UserId, | |
| 395 | 405 | action: AdminAction, | |
| 396 | 406 | ) -> Result<()> { | |
| 397 | - | let item = db::items::get_item_by_id(&state.db, id).await? | |
| 407 | + | let item = db::items::get_item_by_id(db, id).await? | |
| 398 | 408 | .ok_or(crate::error::AppError::NotFound)?; | |
| 399 | - | let owner = db::items::get_item_owner(&state.db, id).await? | |
| 409 | + | let owner = db::items::get_item_owner(db, id).await? | |
| 400 | 410 | .ok_or(crate::error::AppError::NotFound)?; | |
| 401 | 411 | ||
| 402 | 412 | let (s3_key, size, file_type) = if let Some(k) = item.audio_s3_key.clone() { | |
| @@ -408,8 +418,8 @@ async fn rescan_item_inner( | |||
| 408 | 418 | }; | |
| 409 | 419 | ||
| 410 | 420 | crate::routes::storage::commit_rescan( | |
| 411 | - | &state.db, | |
| 412 | - | state.scanner.as_ref(), | |
| 421 | + | db, | |
| 422 | + | scanner, | |
| 413 | 423 | crate::routes::storage::CommitTarget::Item(id), | |
| 414 | 424 | &s3_key, | |
| 415 | 425 | file_type, | |
| @@ -417,7 +427,7 @@ async fn rescan_item_inner( | |||
| 417 | 427 | size, | |
| 418 | 428 | ).await?; | |
| 419 | 429 | db::scan_admin_actions::log_item( | |
| 420 | - | &state.db, id, admin_id, action, | |
| 430 | + | db, id, admin_id, action, | |
| 421 | 431 | Some("held_for_review"), Some("pending"), None, | |
| 422 | 432 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 423 | 433 | tracing::info!(item_id = %id, admin_id = %admin_id, "item re-enqueued for scan"); | |
| @@ -428,7 +438,8 @@ async fn rescan_item_inner( | |||
| 428 | 438 | /// accumulated under a previous broken pipeline configuration. | |
| 429 | 439 | #[tracing::instrument(skip_all, name = "admin::bulk_rescan_held")] | |
| 430 | 440 | pub(super) async fn admin_bulk_rescan_held( | |
| 431 | - | State(state): State<AppState>, | |
| 441 | + | State(db): State<PgPool>, | |
| 442 | + | State(scanning): State<Scanning>, | |
| 432 | 443 | AdminUser(admin): AdminUser, | |
| 433 | 444 | ) -> Result<Response> { | |
| 434 | 445 | // Clear the backlog with bounded concurrency instead of one sequential | |
| @@ -438,26 +449,28 @@ pub(super) async fn admin_bulk_rescan_held( | |||
| 438 | 449 | let mut set: tokio::task::JoinSet<bool> = tokio::task::JoinSet::new(); | |
| 439 | 450 | let mut total = 0usize; | |
| 440 | 451 | ||
| 441 | - | for cand in db::scan_jobs::rescan_candidates_versions(&state.db).await? { | |
| 452 | + | for cand in db::scan_jobs::rescan_candidates_versions(&db).await? { | |
| 442 | 453 | if set.len() >= MAX_INFLIGHT && let Some(Ok(ok)) = set.join_next().await { | |
| 443 | 454 | total += ok as usize; | |
| 444 | 455 | } | |
| 445 | - | let state = state.clone(); | |
| 456 | + | let db = db.clone(); | |
| 457 | + | let scanner = scanning.scanner.clone(); | |
| 446 | 458 | let admin_id = admin.id; | |
| 447 | 459 | set.spawn(async move { | |
| 448 | - | rescan_version_inner(&state, VersionId::from_uuid(cand.version_id), admin_id, AdminAction::BulkRescan) | |
| 460 | + | rescan_version_inner(&db, scanner.as_ref(), VersionId::from_uuid(cand.version_id), admin_id, AdminAction::BulkRescan) | |
| 449 | 461 | .await | |
| 450 | 462 | .is_ok() | |
| 451 | 463 | }); | |
| 452 | 464 | } | |
| 453 | - | for cand in db::scan_jobs::rescan_candidates_items(&state.db).await? { | |
| 465 | + | for cand in db::scan_jobs::rescan_candidates_items(&db).await? { | |
| 454 | 466 | if set.len() >= MAX_INFLIGHT && let Some(Ok(ok)) = set.join_next().await { | |
| 455 | 467 | total += ok as usize; | |
| 456 | 468 | } | |
| 457 | - | let state = state.clone(); | |
| 469 | + | let db = db.clone(); | |
| 470 | + | let scanner = scanning.scanner.clone(); | |
| 458 | 471 | let admin_id = admin.id; | |
| 459 | 472 | set.spawn(async move { | |
| 460 | - | rescan_item_inner(&state, ItemId::from_uuid(cand.item_id), admin_id, AdminAction::BulkRescan) | |
| 473 | + | rescan_item_inner(&db, scanner.as_ref(), ItemId::from_uuid(cand.item_id), admin_id, AdminAction::BulkRescan) | |
| 461 | 474 | .await | |
| 462 | 475 | .is_ok() | |
| 463 | 476 | }); | |
| @@ -468,7 +481,7 @@ pub(super) async fn admin_bulk_rescan_held( | |||
| 468 | 481 | } | |
| 469 | 482 | } | |
| 470 | 483 | tracing::info!(total, admin_id = %admin.id, "bulk rescan of held queue dispatched"); | |
| 471 | - | refresh_held_uploads_partial(&state).await | |
| 484 | + | refresh_held_uploads_partial(&db).await | |
| 472 | 485 | } | |
| 473 | 486 | ||
| 474 | 487 | /// Bulk-promote every currently-held item and version to Clean. Sticky | |
| @@ -481,7 +494,9 @@ pub(super) async fn admin_bulk_rescan_held( | |||
| 481 | 494 | /// record of *why*, so we require it. | |
| 482 | 495 | #[tracing::instrument(skip_all, name = "admin::bulk_promote_held")] | |
| 483 | 496 | pub(super) async fn admin_bulk_promote_held( | |
| 484 | - | State(state): State<AppState>, | |
| 497 | + | State(db): State<PgPool>, | |
| 498 | + | State(storage): State<AppStorage>, | |
| 499 | + | State(config): State<Config>, | |
| 485 | 500 | AdminUser(admin): AdminUser, | |
| 486 | 501 | axum::Form(form): axum::Form<BulkPromoteForm>, | |
| 487 | 502 | ) -> Result<Response> { | |
| @@ -492,8 +507,8 @@ pub(super) async fn admin_bulk_promote_held( | |||
| 492 | 507 | )); | |
| 493 | 508 | } | |
| 494 | 509 | ||
| 495 | - | let held_items = db::scanning::get_held_items(&state.db).await?; | |
| 496 | - | let held_versions = db::scanning::get_held_versions(&state.db).await?; | |
| 510 | + | let held_items = db::scanning::get_held_items(&db).await?; | |
| 511 | + | let held_versions = db::scanning::get_held_versions(&db).await?; | |
| 497 | 512 | ||
| 498 | 513 | // Bounded-concurrency promote (Run #21 Performance MODERATE). The note is | |
| 499 | 514 | // shared across tasks via Arc; MAX_INFLIGHT caps borrowed connections. | |
| @@ -506,17 +521,19 @@ pub(super) async fn admin_bulk_promote_held( | |||
| 506 | 521 | if set.len() >= MAX_INFLIGHT && let Some(Ok(ok)) = set.join_next().await { | |
| 507 | 522 | total += ok as usize; | |
| 508 | 523 | } | |
| 509 | - | let state = state.clone(); | |
| 524 | + | let db = db.clone(); | |
| 525 | + | let storage = storage.clone(); | |
| 526 | + | let config = config.clone(); | |
| 510 | 527 | let note = note.clone(); | |
| 511 | 528 | let admin_id = admin.id; | |
| 512 | 529 | let vid = v.version_id; | |
| 513 | 530 | set.spawn(async move { | |
| 514 | - | if let Err(e) = crate::routes::storage::commit_promote_version(&state.db, &state.storage, &state.config, vid).await { | |
| 531 | + | if let Err(e) = crate::routes::storage::commit_promote_version(&db, &storage, &config, vid).await { | |
| 515 | 532 | tracing::warn!(version_id = %vid, error = ?e, "bulk promote: version promote failed"); | |
| 516 | 533 | false | |
| 517 | 534 | } else { | |
| 518 | 535 | db::scan_admin_actions::log_version( | |
| 519 | - | &state.db, vid, admin_id, AdminAction::BulkPromote, | |
| 536 | + | &db, vid, admin_id, AdminAction::BulkPromote, | |
| 520 | 537 | Some("held_for_review"), Some("clean"), Some(¬e), | |
| 521 | 538 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 522 | 539 | true | |
| @@ -527,17 +544,19 @@ pub(super) async fn admin_bulk_promote_held( | |||
| 527 | 544 | if set.len() >= MAX_INFLIGHT && let Some(Ok(ok)) = set.join_next().await { | |
| 528 | 545 | total += ok as usize; | |
| 529 | 546 | } | |
| 530 | - | let state = state.clone(); | |
| 547 | + | let db = db.clone(); | |
| 548 | + | let storage = storage.clone(); | |
| 549 | + | let config = config.clone(); | |
| 531 | 550 | let note = note.clone(); | |
| 532 | 551 | let admin_id = admin.id; | |
| 533 | 552 | let iid = i.item_id; | |
| 534 | 553 | set.spawn(async move { | |
| 535 | - | if let Err(e) = crate::routes::storage::commit_promote_item(&state.db, &state.storage, &state.config, iid).await { | |
| 554 | + | if let Err(e) = crate::routes::storage::commit_promote_item(&db, &storage, &config, iid).await { | |
| 536 | 555 | tracing::warn!(item_id = %iid, error = ?e, "bulk promote: item promote failed"); | |
| 537 | 556 | false | |
| 538 | 557 | } else { | |
| 539 | 558 | db::scan_admin_actions::log_item( | |
| 540 | - | &state.db, iid, admin_id, AdminAction::BulkPromote, | |
| 559 | + | &db, iid, admin_id, AdminAction::BulkPromote, | |
| 541 | 560 | Some("held_for_review"), Some("clean"), Some(¬e), | |
| 542 | 561 | ).await.unwrap_or_else(|e| tracing::warn!(error = ?e, "failed to record scan admin audit action")); | |
| 543 | 562 | true | |
| @@ -550,7 +569,7 @@ pub(super) async fn admin_bulk_promote_held( | |||
| 550 | 569 | } | |
| 551 | 570 | } | |
| 552 | 571 | tracing::warn!(total, admin_id = %admin.id, note = %note, "bulk promote of held queue executed"); | |
| 553 | - | refresh_held_uploads_partial(&state).await | |
| 572 | + | refresh_held_uploads_partial(&db).await | |
| 554 | 573 | } | |
| 555 | 574 | ||
| 556 | 575 | #[derive(serde::Deserialize)] | |
| @@ -592,16 +611,16 @@ struct ScanPipelineHealth { | |||
| 592 | 611 | /// reaper (300s). | |
| 593 | 612 | #[tracing::instrument(skip_all, name = "admin::scan_health_json")] | |
| 594 | 613 | pub(super) async fn scan_health_json( | |
| 595 | - | State(state): State<AppState>, | |
| 614 | + | State(db): State<PgPool>, | |
| 596 | 615 | ) -> Result<impl IntoResponse> { | |
| 597 | - | let queue_pending = db::scan_jobs::queued_count(&state.db).await.unwrap_or(0); | |
| 598 | - | let queue_running = db::scan_jobs::running_count(&state.db).await.unwrap_or(0); | |
| 599 | - | let queue_stuck = db::scan_jobs::stuck_count(&state.db, 300).await.unwrap_or(0); | |
| 600 | - | let held = db::scanning::held_counts(&state.db).await | |
| 616 | + | let queue_pending = db::scan_jobs::queued_count(&db).await.unwrap_or(0); | |
| 617 | + | let queue_running = db::scan_jobs::running_count(&db).await.unwrap_or(0); | |
| 618 | + | let queue_stuck = db::scan_jobs::stuck_count(&db, 300).await.unwrap_or(0); | |
| 619 | + | let held = db::scanning::held_counts(&db).await | |
| 601 | 620 | .unwrap_or(db::scanning::HeldCounts { held_versions: 0, held_items: 0, held_media: 0 }); | |
| 602 | 621 | let held_total = held.held_versions + held.held_items + held.held_media; | |
| 603 | 622 | ||
| 604 | - | let rows = db::scanning::layer_health_window(&state.db, 1).await.unwrap_or_default(); | |
| 623 | + | let rows = db::scanning::layer_health_window(&db, 1).await.unwrap_or_default(); | |
| 605 | 624 | let now = chrono::Utc::now(); | |
| 606 | 625 | let layers: Vec<LayerHealthJson> = rows.into_iter().map(|r| { | |
| 607 | 626 | let success = r.pass_count + r.skip_count; | |
| @@ -656,10 +675,10 @@ pub(super) async fn scan_health_json( | |||
| 656 | 675 | /// HTMX partial: current pending + running counts. Polled by the dashboard. | |
| 657 | 676 | #[tracing::instrument(skip_all, name = "admin::queue_summary_partial")] | |
| 658 | 677 | pub(super) async fn admin_queue_summary_partial( | |
| 659 | - | State(state): State<AppState>, | |
| 678 | + | State(db): State<PgPool>, | |
| 660 | 679 | AdminUser(_admin): AdminUser, | |
| 661 | 680 | ) -> Result<impl IntoResponse> { | |
| 662 | - | let queue_pending = db::scan_jobs::queued_count(&state.db).await.unwrap_or(0); | |
| 663 | - | let queue_running = db::scan_jobs::running_count(&state.db).await.unwrap_or(0); | |
| 681 | + | let queue_pending = db::scan_jobs::queued_count(&db).await.unwrap_or(0); | |
| 682 | + | let queue_running = db::scan_jobs::running_count(&db).await.unwrap_or(0); | |
| 664 | 683 | Ok(AdminQueueSummaryTemplate { queue_pending, queue_running }) | |
| 665 | 684 | } |
| @@ -6,15 +6,18 @@ use axum::{ | |||
| 6 | 6 | Form, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::Deserialize; | |
| 9 | + | use sqlx::PgPool; | |
| 9 | 10 | ||
| 10 | 11 | use crate::{ | |
| 11 | 12 | auth::AdminUser, | |
| 13 | + | background::BackgroundTx, | |
| 12 | 14 | db::{self, ModerationActionType, UserId}, | |
| 15 | + | email::EmailClient, | |
| 13 | 16 | error::{AppError, Result}, | |
| 14 | - | helpers::{get_csrf_token, spawn_email}, | |
| 17 | + | helpers::get_csrf_token, | |
| 15 | 18 | templates::*, | |
| 16 | 19 | types::*, | |
| 17 | - | AppState, | |
| 20 | + | AppCaches, Billing, Integrations, | |
| 18 | 21 | }; | |
| 19 | 22 | ||
| 20 | 23 | #[derive(Debug, Deserialize)] | |
| @@ -26,7 +29,7 @@ pub(super) struct UserFilterQuery { | |||
| 26 | 29 | /// Render the admin user management page. | |
| 27 | 30 | #[tracing::instrument(skip_all, name = "admin::admin_users")] | |
| 28 | 31 | pub(super) async fn admin_users( | |
| 29 | - | State(state): State<AppState>, | |
| 32 | + | State(db): State<PgPool>, | |
| 30 | 33 | session: tower_sessions::Session, | |
| 31 | 34 | AdminUser(user): AdminUser, | |
| 32 | 35 | Query(query): Query<UserFilterQuery>, | |
| @@ -42,19 +45,19 @@ pub(super) async fn admin_users( | |||
| 42 | 45 | let per_page: i64 = 50; | |
| 43 | 46 | let offset = (page - 1) * per_page; | |
| 44 | 47 | ||
| 45 | - | let (total_users_i64, total_suspended_i64) = db::users::count_users_summary(&state.db).await?; | |
| 48 | + | let (total_users_i64, total_suspended_i64) = db::users::count_users_summary(&db).await?; | |
| 46 | 49 | let total_users = total_users_i64 as usize; | |
| 47 | 50 | let total_suspended = total_suspended_i64 as usize; | |
| 48 | 51 | ||
| 49 | 52 | let total_count = match query.status.as_deref() { | |
| 50 | 53 | Some("suspended") => total_suspended_i64, | |
| 51 | 54 | Some("active") => total_users_i64 - total_suspended_i64, | |
| 52 | - | Some(f @ ("custom_pages" | "pages_locked")) => db::users::count_users(&state.db, Some(f)).await?, | |
| 55 | + | Some(f @ ("custom_pages" | "pages_locked")) => db::users::count_users(&db, Some(f)).await?, | |
| 53 | 56 | _ => total_users_i64, | |
| 54 | 57 | }; | |
| 55 | 58 | let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as i64; | |
| 56 | 59 | ||
| 57 | - | let db_users = db::users::get_all_users(&state.db, query.status.as_deref(), per_page, offset).await?; | |
| 60 | + | let db_users = db::users::get_all_users(&db, query.status.as_deref(), per_page, offset).await?; | |
| 58 | 61 | ||
| 59 | 62 | let users: Vec<AdminUserRow> = db_users.iter().map(AdminUserRow::from_db).collect(); | |
| 60 | 63 | ||
| @@ -74,7 +77,7 @@ pub(super) async fn admin_users( | |||
| 74 | 77 | /// Return filtered user entries as an HTMX partial. | |
| 75 | 78 | #[tracing::instrument(skip_all, name = "admin::admin_user_entries")] | |
| 76 | 79 | pub(super) async fn admin_user_entries( | |
| 77 | - | State(state): State<AppState>, | |
| 80 | + | State(db): State<PgPool>, | |
| 78 | 81 | AdminUser(_user): AdminUser, | |
| 79 | 82 | Query(query): Query<UserFilterQuery>, | |
| 80 | 83 | ) -> Result<impl IntoResponse> { | |
| @@ -87,10 +90,10 @@ pub(super) async fn admin_user_entries( | |||
| 87 | 90 | let per_page: i64 = 50; | |
| 88 | 91 | let offset = (page - 1) * per_page; | |
| 89 | 92 | ||
| 90 | - | let total_count = db::users::count_users(&state.db, query.status.as_deref()).await?; | |
| 93 | + | let total_count = db::users::count_users(&db, query.status.as_deref()).await?; | |
| 91 | 94 | let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as i64; | |
| 92 | 95 | ||
| 93 | - | let db_users = db::users::get_all_users(&state.db, query.status.as_deref(), per_page, offset).await?; | |
| 96 | + | let db_users = db::users::get_all_users(&db, query.status.as_deref(), per_page, offset).await?; | |
| 94 | 97 | let users: Vec<AdminUserRow> = db_users.iter().map(AdminUserRow::from_db).collect(); | |
| 95 | 98 | Ok(AdminUserEntriesTemplate { | |
| 96 | 99 | users, | |
| @@ -109,7 +112,8 @@ pub(super) struct SuspendForm { | |||
| 109 | 112 | /// Records the warning in moderation history and emails the user. | |
| 110 | 113 | #[tracing::instrument(skip_all, name = "admin::admin_warn_user")] | |
| 111 | 114 | pub(super) async fn admin_warn_user( | |
| 112 | - | State(state): State<AppState>, | |
| 115 | + | State(db): State<PgPool>, | |
| 116 | + | State(email): State<EmailClient>, | |
| 113 | 117 | admin_user: AdminUser, | |
| 114 | 118 | Path(id): Path<UserId>, | |
| 115 | 119 | Form(form): Form<SuspendForm>, | |
| @@ -119,15 +123,15 @@ pub(super) async fn admin_warn_user( | |||
| 119 | 123 | return Err(AppError::validation("Reason is required".to_string())); | |
| 120 | 124 | } | |
| 121 | 125 | ||
| 122 | - | let db_user = db::users::get_user_by_id(&state.db, id) | |
| 126 | + | let db_user = db::users::get_user_by_id(&db, id) | |
| 123 | 127 | .await? | |
| 124 | 128 | .ok_or(AppError::NotFound)?; | |
| 125 | 129 | ||
| 126 | 130 | // Record warning in moderation history | |
| 127 | - | db::moderation::create_action(&state.db, id, admin_user.admin_id(), ModerationActionType::Warning, reason, None).await?; | |
| 131 | + | db::moderation::create_action(&db, id, admin_user.admin_id(), ModerationActionType::Warning, reason, None).await?; | |
| 128 | 132 | ||
| 129 | 133 | // Send warning email | |
| 130 | - | if let Err(e) = state.email | |
| 134 | + | if let Err(e) = email | |
| 131 | 135 | .send_policy_warning(&db_user.email, db_user.display_name.as_deref(), reason) | |
| 132 | 136 | .await | |
| 133 | 137 | { | |
| @@ -136,13 +140,19 @@ pub(super) async fn admin_warn_user( | |||
| 136 | 140 | ||
| 137 | 141 | tracing::info!(user_id = %id, admin_id = %admin_user.id(), reason = %reason, "admin sent policy warning"); | |
| 138 | 142 | ||
| 139 | - | refresh_user_entries_partial(&state).await | |
| 143 | + | refresh_user_entries_partial(&db).await | |
| 140 | 144 | } | |
| 141 | 145 | ||
| 142 | 146 | /// Suspend a user account and send notification email. | |
| 143 | 147 | #[tracing::instrument(skip_all, name = "admin::admin_suspend_user")] | |
| 148 | + | #[allow(clippy::too_many_arguments)] | |
| 144 | 149 | pub(super) async fn admin_suspend_user( | |
| 145 | - | State(state): State<AppState>, | |
| 150 | + | State(db): State<PgPool>, | |
| 151 | + | State(email): State<EmailClient>, | |
| 152 | + | State(bg): State<BackgroundTx>, | |
| 153 | + | State(caches): State<AppCaches>, | |
| 154 | + | State(payments): State<Billing>, | |
| 155 | + | State(integrations): State<Integrations>, | |
| 146 | 156 | admin_user: AdminUser, | |
| 147 | 157 | Path(id): Path<UserId>, | |
| 148 | 158 | Form(form): Form<SuspendForm>, | |
| @@ -153,7 +163,7 @@ pub(super) async fn admin_suspend_user( | |||
| 153 | 163 | } | |
| 154 | 164 | ||
| 155 | 165 | // Get user for email notification | |
| 156 | - | let db_user = db::users::get_user_by_id(&state.db, id) | |
| 166 | + | let db_user = db::users::get_user_by_id(&db, id) | |
| 157 | 167 | .await? | |
| 158 | 168 | .ok_or(AppError::NotFound)?; | |
| 159 | 169 | ||
| @@ -162,13 +172,13 @@ pub(super) async fn admin_suspend_user( | |||
| 162 | 172 | // revocation, fan-sub pause, email). Web fans the Stripe calls out on the | |
| 163 | 173 | // background queue and evicts the in-memory session cache. | |
| 164 | 174 | super::moderation_service::suspend_creator( | |
| 165 | - | &state.db, | |
| 166 | - | &state.email, | |
| 167 | - | state.stripe.as_ref(), | |
| 175 | + | &db, | |
| 176 | + | &email, | |
| 177 | + | payments.stripe.as_ref(), | |
| 168 | 178 | super::moderation_service::FanoutMode::Background { | |
| 169 | - | bg: &state.bg, | |
| 170 | - | wam: state.wam.clone(), | |
| 171 | - | session_cache: &state.caches.session_cache, | |
| 179 | + | bg: &bg, | |
| 180 | + | wam: integrations.wam.clone(), | |
| 181 | + | session_cache: &caches.session_cache, | |
| 172 | 182 | }, | |
| 173 | 183 | &db_user, | |
| 174 | 184 | admin_user.admin_id(), | |
| @@ -176,34 +186,38 @@ pub(super) async fn admin_suspend_user( | |||
| 176 | 186 | ) | |
| 177 | 187 | .await?; | |
| 178 | 188 | ||
| 179 | - | refresh_user_entries_partial(&state).await | |
| 189 | + | refresh_user_entries_partial(&db).await | |
| 180 | 190 | } | |
| 181 | 191 | ||
| 182 | 192 | /// Unsuspend a user account (admin override). | |
| 183 | 193 | #[tracing::instrument(skip_all, name = "admin::admin_unsuspend_user")] | |
| 184 | 194 | pub(super) async fn admin_unsuspend_user( | |
| 185 | - | State(state): State<AppState>, | |
| 195 | + | State(db): State<PgPool>, | |
| 196 | + | State(bg): State<BackgroundTx>, | |
| 197 | + | State(caches): State<AppCaches>, | |
| 198 | + | State(payments): State<Billing>, | |
| 199 | + | State(integrations): State<Integrations>, | |
| 186 | 200 | AdminUser(_admin): AdminUser, | |
| 187 | 201 | Path(id): Path<UserId>, | |
| 188 | 202 | ) -> Result<impl IntoResponse> { | |
| 189 | 203 | // Get user for Stripe account ID before unsuspending | |
| 190 | - | let db_user = db::users::get_user_by_id(&state.db, id) | |
| 204 | + | let db_user = db::users::get_user_by_id(&db, id) | |
| 191 | 205 | .await? | |
| 192 | 206 | .ok_or(AppError::NotFound)?; | |
| 193 | 207 | ||
| 194 | 208 | super::moderation_service::unsuspend_creator( | |
| 195 | - | &state.db, | |
| 196 | - | state.stripe.as_ref(), | |
| 209 | + | &db, | |
| 210 | + | payments.stripe.as_ref(), | |
| 197 | 211 | super::moderation_service::FanoutMode::Background { | |
| 198 | - | bg: &state.bg, | |
| 199 | - | wam: state.wam.clone(), | |
| 200 | - | session_cache: &state.caches.session_cache, | |
| 212 | + | bg: &bg, | |
| 213 | + | wam: integrations.wam.clone(), | |
| 214 | + | session_cache: &caches.session_cache, | |
| 201 | 215 | }, | |
| 202 | 216 | &db_user, | |
| 203 | 217 | ) | |
| 204 | 218 | .await?; | |
| 205 | 219 | ||
| 206 | - | refresh_user_entries_partial(&state).await | |
| 220 | + | refresh_user_entries_partial(&db).await | |
| 207 | 221 | } | |
| 208 | 222 | ||
| 209 | 223 | /// Permanently terminate a user account (enforcement ladder step 4). | |
| @@ -213,11 +227,15 @@ pub(super) async fn admin_unsuspend_user( | |||
| 213 | 227 | /// data before the scheduler deletes the account. | |
| 214 | 228 | #[tracing::instrument(skip_all, name = "admin::admin_terminate_user")] | |
| 215 | 229 | pub(super) async fn admin_terminate_user( | |
| 216 | - | State(state): State<AppState>, | |
| 230 | + | State(db): State<PgPool>, | |
| 231 | + | State(email): State<EmailClient>, | |
| 232 | + | State(bg): State<BackgroundTx>, | |
| 233 | + | State(payments): State<Billing>, | |
| 234 | + | State(integrations): State<Integrations>, | |
| 217 | 235 | admin_user: AdminUser, | |
| 218 | 236 | Path(id): Path<UserId>, | |
| 219 | 237 | ) -> Result<impl IntoResponse> { | |
| 220 | - | let db_user = db::users::get_user_by_id(&state.db, id) | |
| 238 | + | let db_user = db::users::get_user_by_id(&db, id) | |
| 221 | 239 | .await? | |
| 222 | 240 | .ok_or(AppError::NotFound)?; | |
| 223 | 241 | ||
| @@ -233,41 +251,47 @@ pub(super) async fn admin_terminate_user( | |||
| 233 | 251 | )); | |
| 234 | 252 | } | |
| 235 | 253 | ||
| 236 | - | db::users::terminate_user(&state.db, id).await?; | |
| 254 | + | db::users::terminate_user(&db, id).await?; | |
| 237 | 255 | ||
| 238 | 256 | // Record moderation action | |
| 239 | 257 | db::moderation::create_action( | |
| 240 | - | &state.db, id, admin_user.admin_id(), ModerationActionType::Termination, | |
| 258 | + | &db, id, admin_user.admin_id(), ModerationActionType::Termination, | |
| 241 | 259 | db_user.suspension_reason.as_deref().unwrap_or("Account terminated"), | |
| 242 | 260 | None, | |
| 243 | 261 | ).await?; | |
| 244 | 262 | ||
| 245 | 263 | // Cancel all fan subscriptions — both active and paused (suspension already paused them) | |
| 246 | - | if let Some(ref stripe) = state.stripe | |
| 264 | + | if let Some(ref stripe) = payments.stripe | |
| 247 | 265 | && let Some(ref account_id) = db_user.stripe_account_id | |
| 248 | 266 | { | |
| 249 | - | let active_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, id).await?; | |
| 250 | - | let paused_subs = db::subscriptions::get_paused_subscriptions_by_creator(&state.db, id).await?; | |
| 267 | + | let active_subs = db::subscriptions::get_active_subscriptions_by_creator(&db, id).await?; | |
| 268 | + | let paused_subs = db::subscriptions::get_paused_subscriptions_by_creator(&db, id).await?; | |
| 251 | 269 | let ids: Vec<String> = active_subs | |
| 252 | 270 | .into_iter() | |
| 253 | 271 | .chain(paused_subs) | |
| 254 | 272 | .map(|s| s.stripe_subscription_id) | |
| 255 | 273 | .collect(); | |
| 256 | 274 | crate::payments::fan_ops::spawn_fan_sub_fanout( | |
| 257 | - | &state.bg, | |
| 275 | + | &bg, | |
| 258 | 276 | std::sync::Arc::clone(stripe), | |
| 259 | 277 | account_id.clone(), | |
| 260 | 278 | ids, | |
| 261 | 279 | crate::payments::fan_ops::FanSubOp::Cancel, | |
| 262 | - | state.wam.clone(), | |
| 280 | + | integrations.wam.clone(), | |
| 263 | 281 | ); | |
| 264 | 282 | } | |
| 265 | 283 | ||
| 266 | 284 | // Send termination email | |
| 267 | 285 | let user_email = db_user.email.clone(); | |
| 268 | 286 | let user_name = db_user.display_name.clone(); | |
| 269 | - | spawn_email!(state, "account termination notification", |email| { | |
| 270 | - | email.send_account_termination(&user_email, user_name.as_deref()) | |
| 287 | + | let email = email.clone(); | |
| 288 | + | bg.spawn("account termination notification", async move { | |
| 289 | + | if let Err(e) = email | |
| 290 | + | .send_account_termination(&user_email, user_name.as_deref()) | |
| 291 | + | .await | |
| 292 | + | { | |
| 293 | + | tracing::error!(error = ?e, "failed to send account termination notification"); | |
| 294 | + | } | |
| 271 | 295 | }); | |
| 272 | 296 | ||
| 273 | 297 | tracing::info!( | |
| @@ -276,33 +300,33 @@ pub(super) async fn admin_terminate_user( | |||
| 276 | 300 | "admin terminated user account (30-day export window started)" | |
| 277 | 301 | ); | |
| 278 | 302 | ||
| 279 | - | refresh_user_entries_partial(&state).await | |
| 303 | + | refresh_user_entries_partial(&db).await | |
| 280 | 304 | } | |
| 281 | 305 | ||
| 282 | 306 | /// Trust a user (uploads auto-publish). | |
| 283 | 307 | #[tracing::instrument(skip_all, name = "admin::admin_trust_user")] | |
| 284 | 308 | pub(super) async fn admin_trust_user( | |
| 285 | - | State(state): State<AppState>, | |
| 309 | + | State(db): State<PgPool>, | |
| 286 | 310 | AdminUser(_admin): AdminUser, | |
| 287 | 311 | Path(id): Path<UserId>, | |
| 288 | 312 | headers: axum::http::HeaderMap, | |
| 289 | 313 | ) -> Result<Response> { | |
| 290 | - | db::users::set_upload_trusted(&state.db, id, true).await?; | |
| 314 | + | db::users::set_upload_trusted(&db, id, true).await?; | |
| 291 | 315 | tracing::info!(user_id = %id, "admin trusted user for uploads"); | |
| 292 | - | refresh_partial_for_target(&state, &headers).await | |
| 316 | + | refresh_partial_for_target(&db, &headers).await | |
| 293 | 317 | } | |
| 294 | 318 | ||
| 295 | 319 | /// Untrust a user (uploads require review). | |
| 296 | 320 | #[tracing::instrument(skip_all, name = "admin::admin_untrust_user")] | |
| 297 | 321 | pub(super) async fn admin_untrust_user( | |
| 298 | - | State(state): State<AppState>, | |
| 322 | + | State(db): State<PgPool>, | |
| 299 | 323 | AdminUser(_admin): AdminUser, | |
| 300 | 324 | Path(id): Path<UserId>, | |
| 301 | 325 | headers: axum::http::HeaderMap, | |
| 302 | 326 | ) -> Result<Response> { | |
| 303 | - | db::users::set_upload_trusted(&state.db, id, false).await?; | |
| 327 | + | db::users::set_upload_trusted(&db, id, false).await?; | |
| 304 | 328 | tracing::info!(user_id = %id, "admin untrusted user for uploads"); | |
| 305 | - | refresh_partial_for_target(&state, &headers).await | |
| 329 | + | refresh_partial_for_target(&db, &headers).await | |
| 306 | 330 | } | |
| 307 | 331 | ||
| 308 | 332 | /// Lock a user's custom pages (moderation kill switch): the editor goes | |
| @@ -310,14 +334,14 @@ pub(super) async fn admin_untrust_user( | |||
| 310 | 334 | /// default. The source is preserved, so unlocking restores it. Reversible. | |
| 311 | 335 | #[tracing::instrument(skip_all, name = "admin::admin_lock_custom_pages")] | |
| 312 | 336 | pub(super) async fn admin_lock_custom_pages( | |
| 313 | - | State(state): State<AppState>, | |
| 337 | + | State(db): State<PgPool>, | |
| 314 | 338 | admin_user: AdminUser, | |
| 315 | 339 | Path(id): Path<UserId>, | |
| 316 | 340 | headers: axum::http::HeaderMap, | |
| 317 | 341 | ) -> Result<Response> { | |
| 318 | - | db::users::set_custom_pages_locked(&state.db, id, true).await?; | |
| 342 | + | db::users::set_custom_pages_locked(&db, id, true).await?; | |
| 319 | 343 | db::moderation::create_action( | |
| 320 | - | &state.db, | |
| 344 | + | &db, | |
| 321 | 345 | id, | |
| 322 | 346 | admin_user.admin_id(), | |
| 323 | 347 | db::ModerationActionType::ContentRemoval, | |
| @@ -326,38 +350,38 @@ pub(super) async fn admin_lock_custom_pages( | |||
| 326 | 350 | ) | |
| 327 | 351 | .await?; | |
| 328 | 352 | tracing::info!(user_id = %id, "admin locked custom pages"); | |
| 329 | - | refresh_partial_for_target(&state, &headers).await | |
| 353 | + | refresh_partial_for_target(&db, &headers).await | |
| 330 | 354 | } | |
| 331 | 355 | ||
| 332 | 356 | /// Unlock a user's custom pages, restoring their preserved custom source. | |
| 333 | 357 | #[tracing::instrument(skip_all, name = "admin::admin_unlock_custom_pages")] | |
| 334 | 358 | pub(super) async fn admin_unlock_custom_pages( | |
| 335 | - | State(state): State<AppState>, | |
| 359 | + | State(db): State<PgPool>, | |
| 336 | 360 | AdminUser(_admin): AdminUser, | |
| 337 | 361 | Path(id): Path<UserId>, | |
| 338 | 362 | headers: axum::http::HeaderMap, | |
| 339 | 363 | ) -> Result<Response> { | |
| 340 | - | db::users::set_custom_pages_locked(&state.db, id, false).await?; | |
| 364 | + | db::users::set_custom_pages_locked(&db, id, false).await?; | |
| 341 | 365 | tracing::info!(user_id = %id, "admin unlocked custom pages"); | |
| 342 | - | refresh_partial_for_target(&state, &headers).await | |
| 366 | + | refresh_partial_for_target(&db, &headers).await | |
| 343 | 367 | } | |
| 344 | 368 | ||
| 345 | 369 | /// Return the right partial based on which page triggered the request. | |
| 346 | - | async fn refresh_partial_for_target(state: &AppState, headers: &axum::http::HeaderMap) -> Result<Response> { | |
| 370 | + | async fn refresh_partial_for_target(db: &PgPool, headers: &axum::http::HeaderMap) -> Result<Response> { | |
| 347 | 371 | let target = headers.get("HX-Target").and_then(|v| v.to_str().ok()).unwrap_or(""); | |
| 348 | 372 | if target == "users-table" { | |
| 349 | - | Ok(refresh_user_entries_partial(state).await?.into_response()) | |
| 373 | + | Ok(refresh_user_entries_partial(db).await?.into_response()) | |
| 350 | 374 | } else { | |
| 351 | - | super::uploads::refresh_held_uploads_partial(state).await | |
| 375 | + | super::uploads::refresh_held_uploads_partial(db).await | |
| 352 | 376 | } | |
| 353 | 377 | } | |
| 354 | 378 | ||
| 355 | 379 | /// Re-query users and return the entries partial (page 1, no filter). | |
| 356 | - | async fn refresh_user_entries_partial(state: &AppState) -> Result<AdminUserEntriesTemplate> { | |
| 380 | + | async fn refresh_user_entries_partial(db: &PgPool) -> Result<AdminUserEntriesTemplate> { | |
| 357 | 381 | let per_page: i64 = 50; | |
| 358 | - | let total_count = db::users::count_users(&state.db, None).await?; | |
| 382 | + | let total_count = db::users::count_users(db, None).await?; | |
| 359 | 383 | let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as i64; | |
| 360 | - | let db_users = db::users::get_all_users(&state.db, None, per_page, 0).await?; | |
| 384 | + | let db_users = db::users::get_all_users(db, None, per_page, 0).await?; | |
| 361 | 385 | let users: Vec<AdminUserRow> = db_users.iter().map(AdminUserRow::from_db).collect(); | |
| 362 | 386 | Ok(AdminUserEntriesTemplate { | |
| 363 | 387 | users, |
| @@ -6,6 +6,7 @@ use axum::{ | |||
| 6 | 6 | Form, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::Deserialize; | |
| 9 | + | use sqlx::PgPool; | |
| 9 | 10 | ||
| 10 | 11 | use crate::{ | |
| 11 | 12 | auth::AdminUser, | |
| @@ -14,7 +15,6 @@ use crate::{ | |||
| 14 | 15 | helpers::get_csrf_token, | |
| 15 | 16 | templates::*, | |
| 16 | 17 | types::*, | |
| 17 | - | AppState, | |
| 18 | 18 | }; | |
| 19 | 19 | ||
| 20 | 20 | #[derive(Debug, Deserialize)] | |
| @@ -25,7 +25,7 @@ pub(super) struct WaitlistFilterQuery { | |||
| 25 | 25 | /// Render the admin waitlist dashboard with stats and entries. | |
| 26 | 26 | #[tracing::instrument(skip_all, name = "admin::admin_waitlist")] | |
| 27 | 27 | pub(super) async fn admin_waitlist( | |
| 28 | - | State(state): State<AppState>, | |
| 28 | + | State(db): State<PgPool>, | |
| 29 | 29 | session: tower_sessions::Session, | |
| 30 | 30 | AdminUser(user): AdminUser, | |
| 31 | 31 | Query(query): Query<WaitlistFilterQuery>, | |
| @@ -34,8 +34,8 @@ pub(super) async fn admin_waitlist( | |||
| 34 | 34 | let csrf_token = get_csrf_token(&session).await; | |
| 35 | 35 | let current_filter = query.status.clone().unwrap_or_default(); | |
| 36 | 36 | ||
| 37 | - | let db_stats = db::waitlist::get_waitlist_stats(&state.db).await?; | |
| 38 | - | let total_creators = db::waitlist::count_active_creators(&state.db).await?; | |
| 37 | + | let db_stats = db::waitlist::get_waitlist_stats(&db).await?; | |
| 38 | + | let total_creators = db::waitlist::count_active_creators(&db).await?; | |
| 39 | 39 | ||
| 40 | 40 | let stats = WaitlistStats { | |
| 41 | 41 | total_pending: db_stats.pending as u32, | |
| @@ -44,7 +44,7 @@ pub(super) async fn admin_waitlist( | |||
| 44 | 44 | total_creators: total_creators as u32, | |
| 45 | 45 | }; | |
| 46 | 46 | ||
| 47 | - | let db_entries = db::waitlist::get_admin_waitlist(&state.db, query.status.as_deref()).await?; | |
| 47 | + | let db_entries = db::waitlist::get_admin_waitlist(&db, query.status.as_deref()).await?; | |
| 48 | 48 | ||
| 49 | 49 | let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect(); | |
| 50 | 50 | ||
| @@ -61,12 +61,12 @@ pub(super) async fn admin_waitlist( | |||
| 61 | 61 | /// Return filtered waitlist entries as an HTMX partial. | |
| 62 | 62 | #[tracing::instrument(skip_all, name = "admin::admin_waitlist_entries")] | |
| 63 | 63 | pub(super) async fn admin_waitlist_entries( | |
| 64 | - | State(state): State<AppState>, | |
| 64 | + | State(db): State<PgPool>, | |
| 65 | 65 | AdminUser(_user): AdminUser, | |
| 66 | 66 | Query(query): Query<WaitlistFilterQuery>, | |
| 67 | 67 | ) -> Result<impl IntoResponse> { | |
| 68 | 68 | ||
| 69 | - | let db_entries = db::waitlist::get_admin_waitlist(&state.db, query.status.as_deref()).await?; | |
| 69 | + | let db_entries = db::waitlist::get_admin_waitlist(&db, query.status.as_deref()).await?; | |
| 70 | 70 | ||
| 71 | 71 | let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect(); | |
| 72 | 72 | ||
| @@ -76,21 +76,21 @@ pub(super) async fn admin_waitlist_entries( | |||
| 76 | 76 | /// Approve a waitlist entry and grant creator access to the user. | |
| 77 | 77 | #[tracing::instrument(skip_all, name = "admin::admin_approve")] | |
| 78 | 78 | pub(super) async fn admin_approve( | |
| 79 | - | State(state): State<AppState>, | |
| 79 | + | State(db): State<PgPool>, | |
| 80 | 80 | AdminUser(_user): AdminUser, | |
| 81 | 81 | Path(id): Path<WaitlistEntryId>, | |
| 82 | 82 | ) -> Result<Response> { | |
| 83 | 83 | ||
| 84 | 84 | // Get entry to find user_id | |
| 85 | - | let entry = db::waitlist::update_waitlist_status(&state.db, id, WaitlistStatus::Approved, Some(SelectionMethod::HandPicked), None).await?; | |
| 85 | + | let entry = db::waitlist::update_waitlist_status(&db, id, WaitlistStatus::Approved, Some(SelectionMethod::HandPicked), None).await?; | |
| 86 | 86 | ||
| 87 | 87 | // Grant creator access | |
| 88 | - | db::waitlist::grant_creator_access(&state.db, entry.user_id).await?; | |
| 88 | + | db::waitlist::grant_creator_access(&db, entry.user_id).await?; | |
| 89 | 89 | ||
| 90 | 90 | tracing::info!(entry_id = %id, user_id = %entry.user_id, "admin approved waitlist entry"); | |
| 91 | 91 | ||
| 92 | 92 | // Return updated entries partial | |
| 93 | - | let db_entries = db::waitlist::get_admin_waitlist(&state.db, Some("pending")).await?; | |
| 93 | + | let db_entries = db::waitlist::get_admin_waitlist(&db, Some("pending")).await?; | |
| 94 | 94 | let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect(); | |
| 95 | 95 | ||
| 96 | 96 | Ok(AdminWaitlistEntriesTemplate { entries }.into_response()) | |
| @@ -99,17 +99,17 @@ pub(super) async fn admin_approve( | |||
| 99 | 99 | /// Flag a waitlist entry as spam. | |
| 100 | 100 | #[tracing::instrument(skip_all, name = "admin::admin_spam")] | |
| 101 | 101 | pub(super) async fn admin_spam( | |
| 102 | - | State(state): State<AppState>, | |
| 102 | + | State(db): State<PgPool>, | |
| 103 | 103 | AdminUser(_user): AdminUser, | |
| 104 | 104 | Path(id): Path<WaitlistEntryId>, | |
| 105 | 105 | ) -> Result<Response> { | |
| 106 | 106 | ||
| 107 | - | db::waitlist::update_waitlist_status(&state.db, id, WaitlistStatus::Spam, None, None).await?; | |
| 107 | + | db::waitlist::update_waitlist_status(&db, id, WaitlistStatus::Spam, None, None).await?; | |
| 108 | 108 | ||
| 109 | 109 | tracing::info!(entry_id = %id, "admin flagged waitlist entry as spam"); | |
| 110 | 110 | ||
| 111 | 111 | // Return updated entries partial | |
| 112 | - | let db_entries = db::waitlist::get_admin_waitlist(&state.db, Some("pending")).await?; | |
| 112 | + | let db_entries = db::waitlist::get_admin_waitlist(&db, Some("pending")).await?; | |
| 113 | 113 | let entries: Vec<AdminWaitlistRow> = db_entries.iter().map(AdminWaitlistRow::from).collect(); | |
| 114 | 114 | ||
| 115 | 115 | Ok(AdminWaitlistEntriesTemplate { entries }.into_response()) | |
| @@ -123,7 +123,7 @@ pub(super) struct LotteryForm { | |||
| 123 | 123 | /// Run a creator lottery: create a wave, select random winners, and grant access. | |
| 124 | 124 | #[tracing::instrument(skip_all, name = "admin::admin_lottery")] | |
| 125 | 125 | pub(super) async fn admin_lottery( | |
| 126 | - | State(state): State<AppState>, | |
| 126 | + | State(db): State<PgPool>, | |
| 127 | 127 | AdminUser(_user): AdminUser, | |
| 128 | 128 | Form(form): Form<LotteryForm>, | |
| 129 | 129 | ) -> Result<Response> { | |
| @@ -133,7 +133,7 @@ pub(super) async fn admin_lottery( | |||
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | 135 | // Use a transaction for atomicity | |
| 136 | - | let mut tx = state.db.begin().await?; | |
| 136 | + | let mut tx = db.begin().await?; | |
| 137 | 137 | ||
| 138 | 138 | // Count hand-picks not yet assigned to a wave | |
| 139 | 139 | let hand_picked_count = db::waitlist::count_unassigned_handpicks(&mut *tx).await?; |