Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate api/users to slices api/users handlers narrowed per-handler: db-only for most; sessions add AppCaches (session_cache); library/broadcast add Config/EmailClient/ BackgroundTx; support adds Integrations(wam); pause_creator adds Billing + BackgroundTx + Integrations. spawn_email! inlined in library. profile:: delete_account keeps State<AppState> (uses AppState::delete_user_account, which couples the DB delete with domain-cache invalidation). No behavior change; profile/session/library/invite/contact tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 00:52 UTC
Commit: 018900b4044582ba5dd41eb5bea1ff168856b4de
Parent: 3ed3e95
10 files changed, +136 insertions, -99 deletions
@@ -7,13 +7,16 @@ use axum::{
7 7 };
8 8 use serde::Deserialize;
9 9
10 + use sqlx::PgPool;
11 +
10 12 use crate::{
11 13 auth::AuthUser,
14 + config::Config,
12 15 constants,
13 16 db,
17 + email::EmailClient,
14 18 error::{AppError, Result},
15 19 templates::FormStatusTemplate,
16 - AppState,
17 20 };
18 21
19 22 /// Form input for broadcasting to followers.
@@ -26,7 +29,9 @@ pub struct BroadcastForm {
26 29 /// Send a plain-text broadcast email to all followers.
27 30 #[tracing::instrument(skip_all, name = "users::broadcast_send")]
28 31 pub(in crate::routes::api) async fn broadcast_send(
29 - State(state): State<AppState>,
32 + State(db): State<PgPool>,
33 + State(config): State<Config>,
34 + State(email): State<EmailClient>,
30 35 AuthUser(user): AuthUser,
31 36 Form(form): Form<BroadcastForm>,
32 37 ) -> Result<Response> {
@@ -59,7 +64,7 @@ pub(in crate::routes::api) async fn broadcast_send(
59 64 }
60 65
61 66 // Rate limit: one broadcast per 24 hours
62 - if !db::users::try_set_broadcast_at(&state.db, user.id).await? {
67 + if !db::users::try_set_broadcast_at(&db, user.id).await? {
63 68 return Ok(Html(FormStatusTemplate {
64 69 success: false,
65 70 message: "You can only send one broadcast per 24 hours".to_string(),
@@ -68,12 +73,12 @@ pub(in crate::routes::api) async fn broadcast_send(
68 73
69 74 // Get follower emails, enforcing the broadcast recipient cap at the type
70 75 // level — `BoundedRecipients::new` is the only way to obtain a sendable list.
71 - let followers = db::follows::get_follower_emails(&state.db, user.id).await?;
76 + let followers = db::follows::get_follower_emails(&db, user.id).await?;
72 77 let recipients = match crate::email::BoundedRecipients::new(followers) {
73 78 Ok(r) => r,
74 79 Err(count) => {
75 80 // Roll back the 24h rate-limit slot so the creator can try again after lifting the cap.
76 - let _ = db::users::clear_broadcast_at(&state.db, user.id).await;
81 + let _ = db::users::clear_broadcast_at(&db, user.id).await;
77 82 return Ok(Html(FormStatusTemplate {
78 83 success: false,
79 84 message: format!(
@@ -92,7 +97,7 @@ pub(in crate::routes::api) async fn broadcast_send(
92 97 }
93 98
94 99 // Get creator name
95 - let db_user = db::users::get_user_by_id(&state.db, user.id)
100 + let db_user = db::users::get_user_by_id(&db, user.id)
96 101 .await?
97 102 .ok_or(AppError::NotFound)?;
98 103 let creator_name = db_user.display_name.as_deref()
@@ -103,9 +108,9 @@ pub(in crate::routes::api) async fn broadcast_send(
103 108 let body = body.to_string();
104 109 let creator_name = creator_name.to_string();
105 110 let creator_id = user.id;
106 - let email_client = state.email.clone();
107 - let host_url = state.config.host_url.clone();
108 - let signing_secret = state.config.signing_secret.clone();
111 + let email_client = email.clone();
112 + let host_url = config.host_url.clone();
113 + let signing_secret = config.signing_secret.clone();
109 114
110 115 let followers = recipients.into_inner();
111 116 tokio::spawn(async move {
@@ -6,20 +6,21 @@ use axum::{
6 6 response::IntoResponse,
7 7 };
8 8
9 + use sqlx::PgPool;
10 +
9 11 use crate::{
10 12 auth::AuthUser,
11 13 db::{self, UserId},
12 14 error::Result,
13 - AppState,
14 15 };
15 16
16 17 /// Revoke contact sharing with a specific creator.
17 18 #[tracing::instrument(skip_all, name = "users::revoke_contact")]
18 19 pub(in crate::routes::api) async fn revoke_contact(
19 - State(state): State<AppState>,
20 + State(db): State<PgPool>,
20 21 AuthUser(user): AuthUser,
21 22 Path(seller_id): Path<UserId>,
22 23 ) -> Result<impl IntoResponse> {
23 - db::transactions::revoke_contact_sharing(&state.db, user.id, seller_id).await?;
24 + db::transactions::revoke_contact_sharing(&db, user.id, seller_id).await?;
24 25 Ok(StatusCode::NO_CONTENT)
25 26 }
@@ -5,19 +5,20 @@ use axum::{
5 5 response::IntoResponse,
6 6 };
7 7
8 + use sqlx::PgPool;
9 +
8 10 use crate::{
9 11 auth::AuthUser,
10 12 constants,
11 13 db,
12 14 error::{AppError, Result},
13 15 templates::AlertTemplate,
14 - AppState,
15 16 };
16 17
17 18 /// Generate a new invite code for the authenticated creator.
18 19 #[tracing::instrument(skip_all, name = "api::create_invite")]
19 20 pub(in crate::routes::api) async fn create_invite(
20 - State(state): State<AppState>,
21 + State(db): State<PgPool>,
21 22 AuthUser(user): AuthUser,
22 23 ) -> Result<impl IntoResponse> {
23 24 user.check_not_sandbox()?;
@@ -26,7 +27,7 @@ pub(in crate::routes::api) async fn create_invite(
26 27 }
27 28
28 29 // Only creators can generate invites
29 - let db_user = db::users::get_user_by_id(&state.db, user.id)
30 + let db_user = db::users::get_user_by_id(&db, user.id)
30 31 .await?
31 32 .ok_or(AppError::NotFound)?;
32 33
@@ -35,7 +36,7 @@ pub(in crate::routes::api) async fn create_invite(
35 36 }
36 37
37 38 // Check limit
38 - let active_count = db::invites::count_active_invites(&state.db, user.id).await?;
39 + let active_count = db::invites::count_active_invites(&db, user.id).await?;
39 40 if active_count >= constants::INVITE_LIMIT_PER_CREATOR {
40 41 return Ok(AlertTemplate::new(
41 42 "error",
@@ -45,7 +46,7 @@ pub(in crate::routes::api) async fn create_invite(
45 46
46 47 // Generate and store
47 48 let code = db::invites::generate_invite_code();
48 - db::invites::create_invite_code(&state.db, user.id, &code).await?;
49 + db::invites::create_invite_code(&db, user.id, &code).await?;
49 50
50 51 let formatted = db::invites::format_invite_code(&code);
51 52 let link = format!("makenot.work/join?invite={}", formatted);
@@ -8,13 +8,17 @@ use axum::{
8 8 };
9 9 use serde::Serialize;
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::AuthUser,
15 + background::BackgroundTx,
16 + config::Config,
13 17 db::{self, ItemId},
18 + email::EmailClient,
14 19 error::{AppError, Result},
15 - helpers::{self, htmx_toast_response, is_htmx_request, spawn_email},
20 + helpers::{self, htmx_toast_response, is_htmx_request},
16 21 templates::{LibraryStatusTemplate, SaveStatusTemplate},
17 - AppState,
18 22 };
19 23
20 24 use super::SuccessMessageResponse;
@@ -30,7 +34,10 @@ struct LibraryActionResponse {
30 34 /// Claim a free item and add it to the user's library.
31 35 #[tracing::instrument(skip_all, name = "users::add_to_library")]
32 36 pub(in crate::routes::api) async fn add_to_library(
33 - State(state): State<AppState>,
37 + State(db): State<PgPool>,
38 + State(config): State<Config>,
39 + State(email): State<EmailClient>,
40 + State(bg): State<BackgroundTx>,
34 41 headers: HeaderMap,
35 42 AuthUser(user): AuthUser,
36 43 Path(item_id): Path<ItemId>,
@@ -39,7 +46,7 @@ pub(in crate::routes::api) async fn add_to_library(
39 46 let is_htmx = is_htmx_request(&headers);
40 47
41 48 // Get the item
42 - let item = db::items::get_item_by_id(&state.db, item_id)
49 + let item = db::items::get_item_by_id(&db, item_id)
43 50 .await?
44 51 .ok_or(AppError::NotFound)?;
45 52
@@ -60,17 +67,17 @@ pub(in crate::routes::api) async fn add_to_library(
60 67 }
61 68
62 69 // Get the project to find the seller
63 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
70 + let project = db::projects::get_project_by_id(&db, item.project_id)
64 71 .await?
65 72 .ok_or(AppError::NotFound)?;
66 73
67 74 // Get the seller's username for transaction record
68 - let seller = db::users::get_user_by_id(&state.db, project.user_id)
75 + let seller = db::users::get_user_by_id(&db, project.user_id)
69 76 .await?
70 77 .ok_or(AppError::NotFound)?;
71 78
72 79 // Claim the free item + increment sales count atomically
73 - let mut tx = state.db.begin().await?;
80 + let mut tx = db.begin().await?;
74 81 let claimed = db::transactions::claim_free_item(
75 82 &mut *tx,
76 83 &db::transactions::ClaimParams {
@@ -93,7 +100,7 @@ pub(in crate::routes::api) async fn add_to_library(
93 100 // Grant access to bundle child items
94 101 if claimed && item.item_type == db::ItemType::Bundle {
95 102 crate::routes::stripe::grant_bundle_items(
96 - &state.db, item_id, user.id, project.user_id, None,
103 + &db, item_id, user.id, project.user_id, None,
97 104 )
98 105 .await;
99 106 }
@@ -102,7 +109,7 @@ pub(in crate::routes::api) async fn add_to_library(
102 109 if claimed && item.enable_license_keys {
103 110 let key_code = helpers::generate_key_code();
104 111 match db::license_keys::create_license_key(
105 - &state.db,
112 + &db,
106 113 item_id,
107 114 user.id,
108 115 None, // free claim — no transaction linked
@@ -122,7 +129,7 @@ pub(in crate::routes::api) async fn add_to_library(
122 129
123 130 // Notify seller of free claim (fire-and-forget)
124 131 if claimed && seller.notify_sale {
125 - let buyer_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten();
132 + let buyer_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
126 133 let buyer_username = buyer_user.as_ref()
127 134 .map(|b| b.username.to_string())
128 135 .unwrap_or_else(|| "Someone".to_string());
@@ -130,17 +137,20 @@ pub(in crate::routes::api) async fn add_to_library(
130 137 let seller_email = seller.email.clone();
131 138 let seller_name = seller.display_name.clone();
132 139 let unsub_url = crate::email::generate_unsubscribe_url(
133 - &state.config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &state.config.signing_secret,
140 + &config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &config.signing_secret,
134 141 );
135 - spawn_email!(state, "sale notification", |email| {
136 - email.send_sale_notification(
142 + let email = email.clone();
143 + bg.spawn("sale notification", async move {
144 + if let Err(e) = email.send_sale_notification(
137 145 &seller_email,
138 146 seller_name.as_deref(),
139 147 &buyer_username,
140 148 &item_title,
141 149 "Free",
142 150 Some(&unsub_url),
143 - )
151 + ).await {
152 + tracing::error!(error = ?e, "failed to send sale notification");
153 + }
144 154 });
145 155 }
146 156
@@ -159,7 +169,7 @@ pub(in crate::routes::api) async fn add_to_library(
159 169 /// Remove a free item from the user's library.
160 170 #[tracing::instrument(skip_all, name = "users::remove_from_library")]
161 171 pub(in crate::routes::api) async fn remove_from_library(
162 - State(state): State<AppState>,
172 + State(db): State<PgPool>,
163 173 headers: HeaderMap,
164 174 AuthUser(user): AuthUser,
165 175 Path(item_id): Path<ItemId>,
@@ -167,11 +177,11 @@ pub(in crate::routes::api) async fn remove_from_library(
167 177 let is_htmx = is_htmx_request(&headers);
168 178
169 179 // Remove the free item from library
170 - let removed = db::transactions::remove_free_item_from_library(&state.db, user.id, item_id).await?;
180 + let removed = db::transactions::remove_free_item_from_library(&db, user.id, item_id).await?;
171 181
172 182 // Decrement denormalized sales_count
173 183 if removed {
174 - db::items::decrement_sales_count(&state.db, item_id).await?;
184 + db::items::decrement_sales_count(&db, item_id).await?;
175 185 }
176 186
177 187 if is_htmx {
@@ -7,12 +7,13 @@ use axum::{
7 7 };
8 8 use serde::Deserialize;
9 9
10 + use sqlx::PgPool;
11 +
10 12 use crate::{
11 13 auth::AuthUser,
12 14 db,
13 15 error::Result,
14 16 templates::SaveStatusTemplate,
15 - AppState,
16 17 };
17 18
18 19 /// Form input for notification preferences (checkbox values: "on" or absent).
@@ -31,7 +32,7 @@ pub struct UpdatePreferencesForm {
31 32 /// Update the authenticated user's notification preferences.
32 33 #[tracing::instrument(skip_all, name = "users::update_preferences")]
33 34 pub(in crate::routes::api) async fn update_preferences(
34 - State(state): State<AppState>,
35 + State(db): State<PgPool>,
35 36 AuthUser(user): AuthUser,
36 37 Form(form): Form<UpdatePreferencesForm>,
37 38 ) -> Result<Response> {
@@ -44,8 +45,8 @@ pub(in crate::routes::api) async fn update_preferences(
44 45 let tips_enabled = form.tips_enabled.as_deref() == Some("on");
45 46 let notify_tip = form.notify_tip.as_deref() == Some("on");
46 47
47 - db::users::update_notification_preferences(&state.db, user.id, notify_sale, notify_follower, notify_release, login_notification_enabled, notify_issues, notify_status).await?;
48 - db::users::update_tip_preferences(&state.db, user.id, tips_enabled, notify_tip).await?;
48 + db::users::update_notification_preferences(&db, user.id, notify_sale, notify_follower, notify_release, login_notification_enabled, notify_issues, notify_status).await?;
49 + db::users::update_tip_preferences(&db, user.id, tips_enabled, notify_tip).await?;
49 50
50 51 Ok(Html(SaveStatusTemplate {
51 52 success: true,
@@ -7,17 +7,20 @@ use axum::{
7 7 Json,
8 8 };
9 9 use serde::{Deserialize, Serialize};
10 + use sqlx::PgPool;
10 11 use tower_sessions::Session;
11 12
12 13 use crate::{
13 14 auth::AuthUser,
15 + background::BackgroundTx,
16 + config::Config,
14 17 db::{self, UserId, Username},
15 - email,
18 + email::{self, EmailClient},
16 19 error::{AppError, Result, ResultExt},
17 20 helpers::is_htmx_request,
18 21 templates::{AlertTemplate, FormStatusTemplate, SaveStatusTemplate},
19 22 validation,
20 - AppState,
23 + AppCaches, AppState, Billing, Integrations,
21 24 };
22 25
23 26 use crate::extractors::ValidatedForm;
@@ -42,7 +45,7 @@ pub struct UpdateProfileRequest {
42 45 /// Update the authenticated user's display name and/or bio.
43 46 #[tracing::instrument(skip_all, name = "users::update_profile")]
44 47 pub(in crate::routes::api) async fn update_profile(
45 - State(state): State<AppState>,
48 + State(db): State<PgPool>,
46 49 headers: HeaderMap,
47 50 AuthUser(user): AuthUser,
48 51 ValidatedForm(req): ValidatedForm<UpdateProfileRequest>,
@@ -73,7 +76,7 @@ pub(in crate::routes::api) async fn update_profile(
73 76 }
74 77
75 78 let updated = db::users::update_user_profile(
76 - &state.db,
79 + &db,
77 80 user.id,
78 81 req.display_name.as_deref(),
79 82 req.bio.as_deref(),
@@ -105,7 +108,7 @@ pub struct UpdateThemeRequest {
105 108 /// Set the authenticated user's public-profile theme.
106 109 #[tracing::instrument(skip_all, name = "users::update_profile_theme")]
107 110 pub(in crate::routes::api) async fn update_profile_theme(
108 - State(state): State<AppState>,
111 + State(db): State<PgPool>,
109 112 headers: HeaderMap,
110 113 AuthUser(user): AuthUser,
111 114 ValidatedForm(req): ValidatedForm<UpdateThemeRequest>,
@@ -113,7 +116,7 @@ pub(in crate::routes::api) async fn update_profile_theme(
113 116 user.check_not_suspended()?;
114 117 let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref())
115 118 .map_err(|id| AppError::validation(format!("Unknown theme: {id}")))?;
116 - db::users::update_user_theme(&state.db, user.id, theme_id.as_deref()).await?;
119 + db::users::update_user_theme(&db, user.id, theme_id.as_deref()).await?;
117 120
118 121 if is_htmx_request(&headers) {
119 122 return Ok(Html(SaveStatusTemplate {
@@ -134,7 +137,8 @@ pub struct UpdatePasswordRequest {
134 137 /// Change the authenticated user's password after verifying the current one.
135 138 #[tracing::instrument(skip_all, name = "users::update_password")]
136 139 pub(in crate::routes::api) async fn update_password(
137 - State(state): State<AppState>,
140 + State(db): State<PgPool>,
141 + State(caches): State<AppCaches>,
138 142 headers: HeaderMap,
139 143 session: Session,
140 144 AuthUser(user): AuthUser,
@@ -144,7 +148,7 @@ pub(in crate::routes::api) async fn update_password(
144 148 let is_htmx = is_htmx_request(&headers);
145 149
146 150 // Get current user with password hash
147 - let db_user = db::users::get_user_by_id(&state.db, user.id)
151 + let db_user = db::users::get_user_by_id(&db, user.id)
148 152 .await?
149 153 .ok_or(AppError::NotFound)?;
150 154
@@ -200,7 +204,7 @@ pub(in crate::routes::api) async fn update_password(
200 204 // by swapping in `delete_all_sessions_for_user`: that would also log the user
201 205 // out of their current web session, and the JWT bump already happened here.
202 206 let new_hash = crate::auth::hash_password_async(req.new_password.clone()).await?;
203 - db::users::update_user_password(&state.db, user.id, &new_hash).await?;
207 + db::users::update_user_password(&db, user.id, &new_hash).await?;
204 208
205 209 // Invalidate all other sessions so stolen/leaked sessions can't survive a password change
206 210 let current_tracking_id = session
@@ -209,7 +213,7 @@ pub(in crate::routes::api) async fn update_password(
209 213 .ok()
210 214 .flatten();
211 215 let revoked_ids = if let Some(current_id) = current_tracking_id {
212 - db::sessions::delete_other_sessions(&state.db, current_id, user.id).await?
216 + db::sessions::delete_other_sessions(&db, current_id, user.id).await?
213 217 } else {
214 218 // Legacy current session: no SESSION_TRACKING_KEY, so it has no
215 219 // `user_sessions` row and the auth extractor never validates it against
@@ -219,10 +223,10 @@ pub(in crate::routes::api) async fn update_password(
219 223 // the *tracked* branch, where it WOULD kill the current row) still
220 224 // holds. Other untracked legacy sessions can't be targeted by id and
221 225 // are left to expire.
222 - db::sessions::delete_all_sessions_for_user(&state.db, user.id).await?
226 + db::sessions::delete_all_sessions_for_user(&db, user.id).await?
223 227 };
224 228 for id in &revoked_ids {
225 - state.caches.session_cache.remove(id);
229 + caches.session_cache.remove(id);
226 230 }
227 231 if !revoked_ids.is_empty() {
228 232 tracing::info!(user_id = %user.id, revoked = revoked_ids.len(), event = "password_change_revoke_sessions", "Revoked other sessions on password change");
@@ -275,11 +279,11 @@ pub(in crate::routes::api) async fn delete_account(
275 279 /// Self-deactivate account (enter limbo state).
276 280 #[tracing::instrument(skip_all, name = "users::deactivate_account")]
277 281 pub(in crate::routes::api) async fn deactivate_account(
278 - State(state): State<AppState>,
282 + State(db): State<PgPool>,
279 283 AuthUser(user): AuthUser,
280 284 ) -> Result<impl IntoResponse> {
281 285 user.check_not_sandbox()?;
282 - db::users::deactivate_user(&state.db, user.id).await?;
286 + db::users::deactivate_user(&db, user.id).await?;
283 287 tracing::info!(user_id = %user.id, "user self-deactivated account");
284 288 Ok(StatusCode::NO_CONTENT)
285 289 }
@@ -287,10 +291,10 @@ pub(in crate::routes::api) async fn deactivate_account(
287 291 /// Reactivate a self-deactivated account.
288 292 #[tracing::instrument(skip_all, name = "users::reactivate_account")]
289 293 pub(in crate::routes::api) async fn reactivate_account(
290 - State(state): State<AppState>,
294 + State(db): State<PgPool>,
291 295 AuthUser(user): AuthUser,
292 296 ) -> Result<impl IntoResponse> {
293 - db::users::reactivate_user(&state.db, user.id).await?;
297 + db::users::reactivate_user(&db, user.id).await?;
294 298 tracing::info!(user_id = %user.id, "user reactivated account");
295 299 Ok(StatusCode::NO_CONTENT)
296 300 }
@@ -300,12 +304,15 @@ pub(in crate::routes::api) async fn reactivate_account(
300 304 /// and blocks new purchases. Content remains hosted indefinitely.
301 305 #[tracing::instrument(skip_all, name = "users::pause_creator")]
302 306 pub(in crate::routes::api) async fn pause_creator(
303 - State(state): State<AppState>,
307 + State(db): State<PgPool>,
308 + State(payments): State<Billing>,
309 + State(bg): State<BackgroundTx>,
310 + State(integrations): State<Integrations>,
304 311 AuthUser(user): AuthUser,
305 312 ) -> Result<impl IntoResponse> {
306 313 user.check_not_sandbox()?;
307 314
308 - let db_user = db::users::get_user_by_id(&state.db, user.id)
315 + let db_user = db::users::get_user_by_id(&db, user.id)
309 316 .await?
310 317 .ok_or(AppError::NotFound)?;
311 318
@@ -322,9 +329,9 @@ pub(in crate::routes::api) async fn pause_creator(
322 329 return Err(AppError::BadRequest("Only creators can pause their account".to_string()));
323 330 }
324 331
325 - if let Some(ref stripe) = state.stripe {
332 + if let Some(ref stripe) = payments.stripe {
326 333 // Cancel the creator's own tier subscription on Stripe (platform-level)
327 - if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_user(&state.db, user.id).await?
334 + if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_user(&db, user.id).await?
328 335 && ct_sub.status == db::SubscriptionStatus::Active
329 336 && let Err(e) = stripe.cancel_platform_subscription(&ct_sub.stripe_subscription_id).await
330 337 {
@@ -335,21 +342,21 @@ pub(in crate::routes::api) async fn pause_creator(
335 342 // account). Fanned out on the background queue so a creator with many
336 343 // fans doesn't tie the request up for N serial Stripe round-trips.
337 344 if let Some(ref stripe_account_id) = db_user.stripe_account_id {
338 - let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, user.id).await?;
345 + let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&db, user.id).await?;
339 346 let ids = fan_subs.into_iter().map(|s| s.stripe_subscription_id).collect();
340 347 crate::payments::fan_ops::spawn_fan_sub_fanout(
341 - &state.bg,
348 + &bg,
342 349 std::sync::Arc::clone(stripe),
343 350 stripe_account_id.clone(),
344 351 ids,
345 352 crate::payments::fan_ops::FanSubOp::CancelAtPeriodEnd(true),
346 - state.wam.clone(),
353 + integrations.wam.clone(),
347 354 );
348 355 }
349 356 }
350 357
351 358 // Set the pause timestamp
352 - db::users::pause_creator(&state.db, user.id).await?;
359 + db::users::pause_creator(&db, user.id).await?;
353 360 tracing::info!(user_id = %user.id, "creator paused account");
354 361
355 362 Ok(StatusCode::NO_CONTENT)
@@ -358,25 +365,27 @@ pub(in crate::routes::api) async fn pause_creator(
358 365 /// Disconnect the authenticated user's Stripe account.
359 366 #[tracing::instrument(skip_all, name = "users::disconnect_stripe")]
360 367 pub(in crate::routes::api) async fn disconnect_stripe(
361 - State(state): State<AppState>,
368 + State(db): State<PgPool>,
362 369 AuthUser(user): AuthUser,
363 370 ) -> Result<impl IntoResponse> {
364 371 user.check_not_suspended()?;
365 - db::users::disconnect_user_stripe(&state.db, user.id).await?;
372 + db::users::disconnect_user_stripe(&db, user.id).await?;
366 373 Ok(StatusCode::NO_CONTENT)
367 374 }
368 375
369 376 /// Resend the email verification link to the authenticated user.
370 377 #[tracing::instrument(skip_all, name = "users::resend_verification")]
371 378 pub(in crate::routes::api) async fn resend_verification(
372 - State(state): State<AppState>,
379 + State(db): State<PgPool>,
380 + State(config): State<Config>,
381 + State(email): State<EmailClient>,
373 382 headers: HeaderMap,
374 383 AuthUser(user): AuthUser,
375 384 ) -> Result<Response> {
376 385 let is_htmx = is_htmx_request(&headers);
377 386
378 387 // Get full user data
379 - let db_user = db::users::get_user_by_id(&state.db, user.id)
388 + let db_user = db::users::get_user_by_id(&db, user.id)
380 389 .await?
381 390 .ok_or(AppError::NotFound)?;
382 391
@@ -393,14 +402,14 @@ pub(in crate::routes::api) async fn resend_verification(
393 402
394 403 // Generate verification URL
395 404 let verify_url = email::generate_verification_url(
396 - &state.config.host_url,
405 + &config.host_url,
397 406 user.id,
398 407 &db_user.email,
399 - &state.config.signing_secret,
408 + &config.signing_secret,
400 409 );
401 410
402 411 // Send verification email
403 - if let Err(e) = state.email
412 + if let Err(e) = email
404 413 .send_verification(&db_user.email, db_user.display_name.as_deref(), &verify_url)
405 414 .await
406 415 {
@@ -432,7 +441,9 @@ pub struct RequestDeletionForm {
432 441 /// Send an account deletion confirmation email after verifying the username.
433 442 #[tracing::instrument(skip_all, name = "users::request_account_deletion")]
434 443 pub(in crate::routes::api) async fn request_account_deletion(
435 - State(state): State<AppState>,
444 + State(db): State<PgPool>,
445 + State(config): State<Config>,
446 + State(email): State<EmailClient>,
436 447 headers: HeaderMap,
437 448 AuthUser(user): AuthUser,
438 449 ValidatedForm(form): ValidatedForm<RequestDeletionForm>,
@@ -441,7 +452,7 @@ pub(in crate::routes::api) async fn request_account_deletion(
441 452 let is_htmx = is_htmx_request(&headers);
442 453
443 454 // Get user from DB
444 - let db_user = db::users::get_user_by_id(&state.db, user.id)
455 + let db_user = db::users::get_user_by_id(&db, user.id)
445 456 .await?
446 457 .ok_or(AppError::NotFound)?;
447 458
@@ -458,14 +469,14 @@ pub(in crate::routes::api) async fn request_account_deletion(
458 469
459 470 // Generate deletion URL
460 471 let delete_url = email::generate_deletion_url(
461 - &state.config.host_url,
472 + &config.host_url,
462 473 user.id,
463 474 &db_user.email,
464 - &state.config.signing_secret,
475 + &config.signing_secret,
465 476 );
466 477
467 478 // Send deletion confirmation email
468 - if let Err(e) = state.email
479 + if let Err(e) = email
469 480 .send_deletion_confirmation(&db_user.email, db_user.display_name.as_deref(), &delete_url)
470 481 .await
471 482 {
@@ -503,7 +514,7 @@ pub struct AppealForm {
503 514 /// Submit an appeal for a suspended account.
504 515 #[tracing::instrument(skip_all, name = "users::submit_appeal")]
505 516 pub(in crate::routes::api) async fn submit_appeal(
506 - State(state): State<AppState>,
517 + State(db): State<PgPool>,
507 518 headers: HeaderMap,
508 519 AuthUser(user): AuthUser,
509 520 ValidatedForm(form): ValidatedForm<AppealForm>,
@@ -519,7 +530,7 @@ pub(in crate::routes::api) async fn submit_appeal(
519 530 }
520 531
521 532 // Reject re-submission if a recent denial exists (within 30 days)
522 - let db_user = db::users::get_user_by_id(&state.db, user.id)
533 + let db_user = db::users::get_user_by_id(&db, user.id)
523 534 .await?
524 535 .ok_or(AppError::NotFound)?;
525 536 if db_user.appeal_decision.as_deref() == Some("denied")
@@ -550,7 +561,7 @@ pub(in crate::routes::api) async fn submit_appeal(
550 561 return Err(AppError::validation("Appeal must be between 1 and 2000 characters".to_string()));
551 562 }
552 563
553 - db::users::submit_appeal(&state.db, user.id, appeal_text).await?;
564 + db::users::submit_appeal(&db, user.id, appeal_text).await?;
554 565
555 566 tracing::info!(user_id = %user.id, "suspension appeal submitted");
556 567
@@ -6,18 +6,21 @@ use axum::{
6 6 };
7 7 use tower_sessions::Session;
8 8
9 + use sqlx::PgPool;
10 +
9 11 use crate::{
10 12 auth::{AuthUser, SESSION_TRACKING_KEY},
11 13 db::{self, UserSessionId},
12 14 error::{AppError, Result},
13 15 templates::UserSessionsPartialTemplate,
14 - AppState,
16 + AppCaches,
15 17 };
16 18
17 19 /// Revoke a single session (sign out another device).
18 20 #[tracing::instrument(skip_all, name = "api::revoke_session")]
19 21 pub(in crate::routes::api) async fn revoke_session(
20 - State(state): State<AppState>,
22 + State(db): State<PgPool>,
23 + State(caches): State<AppCaches>,
21 24 session: Session,
22 25 AuthUser(user): AuthUser,
23 26 Path(session_id): Path<UserSessionId>,
@@ -31,11 +34,11 @@ pub(in crate::routes::api) async fn revoke_session(
31 34 ));
32 35 }
33 36
34 - db::sessions::delete_user_session(&state.db, session_id, user.id).await?;
35 - state.caches.session_cache.remove(&session_id);
37 + db::sessions::delete_user_session(&db, session_id, user.id).await?;
38 + caches.session_cache.remove(&session_id);
36 39
37 40 // Re-render the sessions list
38 - let sessions = db::sessions::get_user_sessions(&state.db, user.id).await?;
41 + let sessions = db::sessions::get_user_sessions(&db, user.id).await?;
39 42 let current_tracking_id = session
40 43 .get::<UserSessionId>(SESSION_TRACKING_KEY)
41 44 .await
@@ -51,7 +54,8 @@ pub(in crate::routes::api) async fn revoke_session(
51 54 /// Revoke all sessions except the current one.
52 55 #[tracing::instrument(skip_all, name = "api::revoke_other_sessions")]
53 56 pub(in crate::routes::api) async fn revoke_other_sessions(
54 - State(state): State<AppState>,
57 + State(db): State<PgPool>,
58 + State(caches): State<AppCaches>,
55 59 session: Session,
56 60 AuthUser(user): AuthUser,
57 61 ) -> Result<impl IntoResponse> {
@@ -62,15 +66,15 @@ pub(in crate::routes::api) async fn revoke_other_sessions(
62 66 .flatten();
63 67
64 68 if let Some(current_id) = current_tracking_id {
65 - let revoked_ids = db::sessions::delete_other_sessions(&state.db, current_id, user.id).await?;
69 + let revoked_ids = db::sessions::delete_other_sessions(&db, current_id, user.id).await?;
66 70 for id in &revoked_ids {
67 - state.caches.session_cache.remove(id);
71 + caches.session_cache.remove(id);
68 72 }
69 73 tracing::info!(user_id = %user.id, revoked = revoked_ids.len(), event = "revoke_other_sessions", "Revoked other sessions");
70 74 }
71 75
72 76 // Re-render the sessions list
73 - let sessions = db::sessions::get_user_sessions(&state.db, user.id).await?;
77 + let sessions = db::sessions::get_user_sessions(&db, user.id).await?;
74 78
75 79 Ok(UserSessionsPartialTemplate {
76 80 sessions,
@@ -7,12 +7,13 @@ use axum::{
7 7 };
8 8 use serde::Deserialize;
9 9
10 + use sqlx::PgPool;
11 +
10 12 use crate::{
11 13 auth::AuthUser,
12 14 db,
13 15 error::Result,
14 16 templates::SaveStatusTemplate,
15 - AppState,
16 17 };
17 18
18 19 #[derive(Debug, Deserialize)]
@@ -23,13 +24,13 @@ pub struct StripeTaxForm {
23 24 /// Toggle Stripe Tax for the authenticated creator.
24 25 #[tracing::instrument(skip_all, name = "users::toggle_stripe_tax")]
25 26 pub(in crate::routes::api) async fn toggle_stripe_tax(
26 - State(state): State<AppState>,
27 + State(db): State<PgPool>,
27 28 AuthUser(user): AuthUser,
28 29 Form(form): Form<StripeTaxForm>,
29 30 ) -> Result<Response> {
30 31 let enabled = form.stripe_tax_enabled.as_deref() == Some("on");
31 32
32 - db::users::update_stripe_tax_enabled(&state.db, user.id, enabled).await?;
33 + db::users::update_stripe_tax_enabled(&db, user.id, enabled).await?;
33 34
34 35 Ok(Html(SaveStatusTemplate {
35 36 success: true,
@@ -9,9 +9,10 @@ use serde::Deserialize;
9 9
10 10 use crate::{
11 11 auth::AuthUser,
12 + email::EmailClient,
12 13 error::Result,
13 14 templates::FormStatusTemplate,
14 - AppState,
15 + Integrations,
15 16 };
16 17
17 18 /// Form input for submitting a support ticket.
@@ -25,7 +26,8 @@ pub struct SupportTicketForm {
25 26 /// Submit a support ticket. Creates a WAM ticket and sends a confirmation email.
26 27 #[tracing::instrument(skip_all, name = "users::submit_support_ticket")]
27 28 pub(in crate::routes::api) async fn submit_support_ticket(
28 - State(state): State<AppState>,
29 + State(email): State<EmailClient>,
30 + State(integrations): State<Integrations>,
29 31 AuthUser(user): AuthUser,
30 32 Form(form): Form<SupportTicketForm>,
31 33 ) -> Result<Response> {
@@ -63,7 +65,7 @@ pub(in crate::routes::api) async fn submit_support_ticket(
63 65 );
64 66
65 67 // Create WAM ticket for triage
66 - if let Some(ref wam) = state.wam {
68 + if let Some(ref wam) = integrations.wam {
67 69 let title = format!("[support] {}", subject);
68 70 wam.create_ticket(
69 71 &title,
@@ -94,7 +96,7 @@ Makenotwork Support"#,
94 96 category,
95 97 message,
96 98 );
97 - if let Err(e) = state.email.send_alert(
99 + if let Err(e) = email.send_alert(
98 100 &user.email,
99 101 &format!("We received your request: {}", subject),
100 102 &confirmation,
@@ -107,7 +109,7 @@ Makenotwork Support"#,
107 109 "New support ticket from {} ({}):\n\nSubject: {}\nCategory: {}\nUser ID: {}\n\n{}",
108 110 user.username, user.email, subject, category, user.id, message,
109 111 );
110 - if let Err(e) = state.email.send_alert(
112 + if let Err(e) = email.send_alert(
111 113 "info@makenot.work",
112 114 &format!("[support] {}", subject),
113 115 &support_body,
@@ -8,6 +8,8 @@ use axum::{
8 8 };
9 9 use serde::Deserialize;
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::AuthUser,
13 15 db,
@@ -15,7 +17,6 @@ use crate::{
15 17 helpers::is_htmx_request,
16 18 templates::AlertTemplate,
17 19 validation,
18 - AppState,
19 20 };
20 21
21 22 /// Form input for applying to the creator waitlist.
@@ -39,7 +40,7 @@ pub struct WaitlistApplyForm {
39 40 /// Submit an application to the creator waitlist.
40 41 #[tracing::instrument(skip_all, name = "users::waitlist_apply")]
41 42 pub(in crate::routes::api) async fn waitlist_apply(
42 - State(state): State<AppState>,
43 + State(db): State<PgPool>,
43 44 headers: HeaderMap,
44 45 AuthUser(user): AuthUser,
45 46 Form(form): Form<WaitlistApplyForm>,
@@ -55,7 +56,7 @@ pub(in crate::routes::api) async fn waitlist_apply(
55 56 }
56 57
57 58 // Check verified email
58 - let db_user = db::users::get_user_by_id(&state.db, user.id)
59 + let db_user = db::users::get_user_by_id(&db, user.id)
59 60 .await?
60 61 .ok_or(AppError::NotFound)?;
61 62
@@ -67,7 +68,7 @@ pub(in crate::routes::api) async fn waitlist_apply(
67 68 }
68 69
69 70 // Check if already applied
70 - if db::waitlist::get_waitlist_entry_by_user(&state.db, user.id).await?.is_some() {
71 + if db::waitlist::get_waitlist_entry_by_user(&db, user.id).await?.is_some() {
71 72 if is_htmx {
72 73 return Ok(AlertTemplate::new("info", "You've already applied.").into_response());
73 74 }
@@ -93,7 +94,7 @@ pub(in crate::routes::api) async fn waitlist_apply(
93 94 }
94 95
95 96 // Create entry
96 - db::waitlist::create_waitlist_entry(&state.db, user.id, &full_pitch).await?;
97 + db::waitlist::create_waitlist_entry(&db, user.id, &full_pitch).await?;
97 98
98 99 tracing::info!(user_id = %user.id, "waitlist application submitted");
99 100