Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate pages/public + finish blog Migrate pages/public/** (content/, health/, landing, discover, docs, feed, sitemap, two_factor, join_wizard, creators) to slices: State<PgPool> + Config/AppStorage/Billing/Integrations/Ops/EmailClient/BackgroundTx and the single-field docs (Arc<DocLoader>) / page_view_tx slices per handler. Narrow shared helpers::fetch_discussion_info to (&Integrations, &Config) and fix all 7 call sites; this unblocks blog::blog_post_page/changelog_post (now State<PgPool>+Integrations+Config, no longer holding AppState). health collect_health narrowed to db+storage+payments+ops+config. spawn_email!/ bg.spawn tasks capture per-slice clones. Holdouts kept on State<AppState> (coupled to the un-migrated custom_domain fallback): item_page/project_page/user_page + their pub(crate) shared renderers, and landing::index (custom_domain::try_handle). No behavior change; public 10 + health 1 + discover 20 + blog 13 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 03:37 UTC
Commit: 10d71a8262f29665b69429d413a6b28ced127cd8
Parent: a575621
15 files changed, +331 insertions, -262 deletions
@@ -3,7 +3,7 @@
3 3
4 4 use tower_sessions::Session;
5 5
6 - use crate::AppState;
6 + use crate::config::Config;
7 7
8 8 /// Get or create a CSRF token for the session, returning `None` on failure.
9 9 ///
@@ -15,7 +15,8 @@ pub async fn get_csrf_token(session: &Session) -> Option<String> {
15 15 /// Fetch MT discussion thread stats (URL + post count) for a linked thread.
16 16 /// Returns (discussion_url, discussion_count) — both None if MT unavailable or no linked thread.
17 17 pub async fn fetch_discussion_info(
18 - state: &AppState,
18 + integrations: &crate::Integrations,
19 + config: &Config,
19 20 mt_thread_id: Option<crate::db::MtThreadId>,
20 21 project_slug: &str,
21 22 category_slug: &str,
@@ -23,10 +24,10 @@ pub async fn fetch_discussion_info(
23 24 let Some(thread_id) = mt_thread_id else {
24 25 return (None, None);
25 26 };
26 - let Some(ref mt) = state.mt_client else {
27 + let Some(ref mt) = integrations.mt_client else {
27 28 return (None, None);
28 29 };
29 - let Some(ref mt_base_url) = state.config.integrations.mt_base_url else {
30 + let Some(ref mt_base_url) = config.integrations.mt_base_url else {
30 31 return (None, None);
31 32 };
32 33
@@ -10,6 +10,7 @@ use tower_sessions::Session;
10 10
11 11 use crate::{
12 12 auth::MaybeUserUnverified,
13 + config::Config,
13 14 constants,
14 15 csrf::CsrfRouter,
15 16 db::{self, Slug},
@@ -17,7 +18,7 @@ use crate::{
17 18 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
18 19 templates::*,
19 20 types::*,
20 - AppState,
21 + AppState, Integrations,
21 22 };
22 23
23 24 /// Register blog page routes. All GET-only; returned as a `CsrfRouter` so the
@@ -68,7 +69,9 @@ async fn project_blog_page(
68 69 /// Public blog post reader page.
69 70 #[tracing::instrument(skip_all, name = "blog_pages::blog_post_page")]
70 71 async fn blog_post_page(
71 - State(state): State<AppState>,
72 + State(db): State<PgPool>,
73 + State(config): State<Config>,
74 + State(integrations): State<Integrations>,
72 75 session: Session,
73 76 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
74 77 Path((slug, post_slug)): Path<(String, String)>,
@@ -77,15 +80,15 @@ async fn blog_post_page(
77 80
78 81 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
79 82 let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?;
80 - let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
83 + let db_project = db::projects::get_public_project_by_slug(&db, &slug)
81 84 .await?
82 85 .ok_or(AppError::NotFound)?;
83 86
84 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
87 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
85 88 .await?
86 89 .ok_or(AppError::NotFound)?;
87 90
88 - let db_post = db::blog_posts::get_blog_post_by_slug(&state.db, db_project.id, &post_slug)
91 + let db_post = db::blog_posts::get_blog_post_by_slug(&db, db_project.id, &post_slug)
89 92 .await?
90 93 .ok_or(AppError::NotFound)?;
91 94
@@ -102,7 +105,7 @@ async fn blog_post_page(
102 105
103 106 let project_slug_str = db_project.slug.to_string();
104 107 let (discussion_url, discussion_count) =
105 - fetch_discussion_info(&state, db_post.mt_thread_id, &project_slug_str, "blog").await;
108 + fetch_discussion_info(&integrations, &config, db_post.mt_thread_id, &project_slug_str, "blog").await;
106 109
107 110 Ok(BlogPostTemplate {
108 111 csrf_token,
@@ -120,7 +123,7 @@ async fn blog_post_page(
120 123 project_title_json,
121 124 project_slug: project_slug_str,
122 125 post_slug: post_slug.to_string(),
123 - host_url: state.config.host_url.clone(),
126 + host_url: config.host_url.clone(),
124 127 project_cover_image_url: db_project.cover_image_url,
125 128 discussion_url,
126 129 discussion_count,
@@ -170,7 +173,9 @@ async fn changelog_index(
170 173 /// Platform changelog post (alias for a "changelog" project blog post).
171 174 #[tracing::instrument(skip_all, name = "blog_pages::changelog_post")]
172 175 async fn changelog_post(
173 - State(state): State<AppState>,
176 + State(db): State<PgPool>,
177 + State(config): State<Config>,
178 + State(integrations): State<Integrations>,
174 179 session: Session,
175 180 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
176 181 Path(post_slug): Path<String>,
@@ -183,15 +188,15 @@ async fn changelog_post(
183 188 // justification.
184 189 let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
185 190 let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?;
186 - let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
191 + let db_project = db::projects::get_public_project_by_slug(&db, &slug)
187 192 .await?
188 193 .ok_or(AppError::NotFound)?;
189 194
190 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
195 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
191 196 .await?
192 197 .ok_or(AppError::NotFound)?;
193 198
194 - let db_post = db::blog_posts::get_blog_post_by_slug(&state.db, db_project.id, &post_slug)
199 + let db_post = db::blog_posts::get_blog_post_by_slug(&db, db_project.id, &post_slug)
195 200 .await?
196 201 .ok_or(AppError::NotFound)?;
197 202
@@ -211,7 +216,7 @@ async fn changelog_post(
211 216
212 217 let project_slug_str = db_project.slug.to_string();
213 218 let (discussion_url, discussion_count) =
214 - fetch_discussion_info(&state, db_post.mt_thread_id, &project_slug_str, "blog").await;
219 + fetch_discussion_info(&integrations, &config, db_post.mt_thread_id, &project_slug_str, "blog").await;
215 220
216 221 Ok(BlogPostTemplate {
217 222 csrf_token,
@@ -230,7 +235,7 @@ async fn changelog_post(
230 235 project_title_json,
231 236 project_slug: project_slug_str,
232 237 post_slug: post_slug.to_string(),
233 - host_url: state.config.host_url.clone(),
238 + host_url: config.host_url.clone(),
234 239 project_cover_image_url: db_project.cover_image_url,
235 240 discussion_url,
236 241 discussion_count,
@@ -1,9 +1,10 @@
1 1 //! Public item detail page handler.
2 2
3 3 use axum::{
4 - extract::{Path, State},
4 + extract::{FromRef, Path, State},
5 5 response::{IntoResponse, Response},
6 6 };
7 + use sqlx::PgPool;
7 8 use tower_sessions::Session;
8 9
9 10 use crate::{
@@ -14,7 +15,7 @@ use crate::{
14 15 pricing,
15 16 templates::*,
16 17 types::*,
17 - AppState,
18 + AppState, AppStorage,
18 19 };
19 20
20 21 /// Render a public item detail page (text reader, audio player, or download).
@@ -66,6 +67,10 @@ pub(crate) async fn render_item_page(
66 67 }
67 68
68 69 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
70 + // render_item_page keeps `&AppState` (it is shared with the custom-domain
71 + // fallback in `custom_domain.rs`), so project the narrowed integration slice
72 + // here for the now-slimmed `fetch_discussion_info`.
73 + let integrations = crate::Integrations::from_ref(state);
69 74 // Store page never renders the full article body — that lives on /l/{id}.
70 75 // Compute a short plain-text excerpt from the raw markdown for the deck.
71 76 let (excerpt, reading_time) = match db_item.content() {
@@ -139,7 +144,7 @@ pub(crate) async fn render_item_page(
139 144 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
140 145 let project_slug_str = db_project.slug.to_string();
141 146 let (discussion_url, discussion_count) =
142 - fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
147 + fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await;
143 148 return Ok(TextReaderTemplate {
144 149 csrf_token: csrf_token.clone(),
145 150 session_user: maybe_user,
@@ -166,7 +171,7 @@ pub(crate) async fn render_item_page(
166 171 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
167 172 let project_slug_str = db_project.slug.to_string();
168 173 let (discussion_url, discussion_count) =
169 - fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
174 + fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await;
170 175 return Ok(AudioPlayerTemplate {
171 176 csrf_token: csrf_token.clone(),
172 177 session_user: maybe_user,
@@ -191,7 +196,7 @@ pub(crate) async fn render_item_page(
191 196 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
192 197 let project_slug_str = db_project.slug.to_string();
193 198 let (discussion_url, discussion_count) =
194 - fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
199 + fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await;
195 200 return Ok(VideoPlayerTemplate {
196 201 csrf_token: csrf_token.clone(),
197 202 session_user: maybe_user,
@@ -213,7 +218,7 @@ pub(crate) async fn render_item_page(
213 218
214 219 let project_slug_str = db_project.slug.to_string();
215 220 let (discussion_url, discussion_count) =
216 - fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
221 + fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await;
217 222
218 223 // Convert bundle child items to view models
219 224 let bundle_item_views: Vec<Item> = bundle_child_items
@@ -309,13 +314,14 @@ struct PlayerSegment {
309 314
310 315 /// Build the segments JSON for the media player. Returns "null" if no insertions.
311 316 pub(super) async fn build_segments_json(
312 - state: &AppState,
317 + db: &PgPool,
318 + storage: &AppStorage,
313 319 item_id: ItemId,
314 320 media_url: &Option<String>,
315 321 db_item: &db::DbItem,
316 322 ) -> String {
317 323 let placements =
318 - match db::content_insertions::list_playable_placements_for_item(&state.db, item_id).await {
324 + match db::content_insertions::list_playable_placements_for_item(db, item_id).await {
319 325 Ok(p) => p,
320 326 Err(_) => return "null".to_string(),
321 327 };
@@ -324,7 +330,7 @@ pub(super) async fn build_segments_json(
324 330 return "null".to_string();
325 331 }
326 332
327 - let s3 = match &state.storage.s3 {
333 + let s3 = match &storage.s3 {
328 334 Some(s3) => s3,
329 335 None => return "null".to_string(),
330 336 };
@@ -7,10 +7,12 @@ use axum::{
7 7 extract::{Path, State},
8 8 response::{IntoResponse, Response},
9 9 };
10 + use sqlx::PgPool;
10 11 use tower_sessions::Session;
11 12
12 13 use crate::{
13 14 auth::MaybeUserVerified,
15 + config::Config,
14 16 constants,
15 17 db::{self, ContentData, ItemId, ItemType},
16 18 error::{AppError, Result},
@@ -18,13 +20,18 @@ use crate::{
18 20 pricing,
19 21 templates::*,
20 22 types::*,
21 - AppState,
23 + AppStorage, Integrations,
22 24 };
23 25
24 26 /// `GET /l/{item_id}`: render the library (consumption) view.
27 + #[allow(clippy::too_many_arguments)]
25 28 #[tracing::instrument(skip_all, name = "content::library_page")]
26 29 pub(in crate::routes::pages::public) async fn library_page(
27 - State(state): State<AppState>,
30 + State(db): State<PgPool>,
31 + State(storage): State<AppStorage>,
32 + State(config): State<Config>,
33 + State(integrations): State<Integrations>,
34 + State(page_view_tx): State<crate::db::page_views::PageViewTx>,
28 35 session: Session,
29 36 headers: axum::http::HeaderMap,
30 37 MaybeUserVerified(maybe_user): MaybeUserVerified,
@@ -33,13 +40,13 @@ pub(in crate::routes::pages::public) async fn library_page(
33 40 let csrf_token = get_csrf_token(&session).await;
34 41 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
35 42
36 - let db_item = db::items::get_item_by_id(&state.db, id)
43 + let db_item = db::items::get_item_by_id(&db, id)
37 44 .await?
38 45 .ok_or(AppError::NotFound)?;
39 - let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
46 + let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
40 47 .await?
41 48 .ok_or(AppError::NotFound)?;
42 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
49 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
43 50 .await?
44 51 .ok_or(AppError::NotFound)?;
45 52 if db_user.is_sandbox {
@@ -59,12 +66,12 @@ pub(in crate::routes::pages::public) async fn library_page(
59 66 // Compute access using the same logic as item_page.
60 67 let item_pricing = pricing::for_item(&db_item);
61 68 let in_library = if let Some(ref user) = maybe_user {
62 - db::transactions::has_purchased_item(&state.db, user.id, db_item.id).await?
69 + db::transactions::has_purchased_item(&db, user.id, db_item.id).await?
63 70 } else {
64 71 false
65 72 };
66 73 let item_sub = if let Some(ref user) = maybe_user {
67 - db::subscriptions::SubscriptionGate::check(&state.db, user.id, db::subscriptions::SubscriptionScope::Item(db_item.id)).await?
74 + db::subscriptions::SubscriptionGate::check(&db, user.id, db::subscriptions::SubscriptionScope::Item(db_item.id)).await?
68 75 } else {
69 76 None
70 77 };
@@ -76,12 +83,12 @@ pub(in crate::routes::pages::public) async fn library_page(
76 83 let mut has_access = item_pricing.can_access(&ctx);
77 84 if !has_access
78 85 && let Some(ref user) = maybe_user
79 - && db::bundles::has_access_via_bundle(&state.db, user.id, db_item.id).await?
86 + && db::bundles::has_access_via_bundle(&db, user.id, db_item.id).await?
80 87 {
81 88 has_access = true;
82 89 }
83 90
84 - let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
91 + let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
85 92 let is_free = item_pricing.is_free();
86 93 let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, has_access);
87 94
@@ -89,11 +96,11 @@ pub(in crate::routes::pages::public) async fn library_page(
89 96 // Render 403 with link back to /i/{id}. For unlisted items, list containing bundles.
90 97 let containing_bundles: Vec<Item> = if !db_item.listed {
91 98 let bundle_ids =
92 - db::bundles::get_bundles_containing_item(&state.db, db_item.id).await?;
99 + db::bundles::get_bundles_containing_item(&db, db_item.id).await?;
93 100 // Batch-fetch the public bundles in one query (Perf-MIN N+1);
94 101 // get_public_items_by_ids already filters to is_public, matching the
95 102 // per-item check the old loop did.
96 - db::items::get_public_items_by_ids(&state.db, &bundle_ids)
103 + db::items::get_public_items_by_ids(&db, &bundle_ids)
97 104 .await?
98 105 .iter()
99 106 .map(|b| {
@@ -113,7 +120,7 @@ pub(in crate::routes::pages::public) async fn library_page(
113 120 session_user: maybe_user,
114 121 item,
115 122 creator_username: db_user.username.to_string(),
116 - host_url: state.config.host_url.clone(),
123 + host_url: config.host_url.clone(),
117 124 containing_bundles,
118 125 is_logged_in,
119 126 },
@@ -127,20 +134,22 @@ pub(in crate::routes::pages::public) async fn library_page(
127 134 .and_then(|v| v.to_str().ok())
128 135 .unwrap_or("");
129 136 if !super::is_bot(ua) {
130 - super::track_view(&state, "item", *db_item.id);
137 + super::track_view(&page_view_tx, "item", *db_item.id);
131 138 }
132 139
133 - let db_versions = db::versions::get_versions_by_item(&state.db, db_item.id).await?;
140 + let db_versions = db::versions::get_versions_by_item(&db, db_item.id).await?;
134 141 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
135 142
136 143 let project_slug_str = db_project.slug.to_string();
137 144 let (discussion_url, discussion_count) =
138 - fetch_discussion_info(&state, db_item.mt_thread_id, &project_slug_str, "items").await;
145 + fetch_discussion_info(&integrations, &config, db_item.mt_thread_id, &project_slug_str, "items").await;
139 146
140 147 // Phase 2: audio items get their own player template.
141 148 if db_item.item_type == ItemType::Audio {
142 149 return render_audio_library(
143 - &state,
150 + &db,
151 + &storage,
152 + &config,
144 153 &db_item,
145 154 &db_user,
146 155 &db_project,
@@ -158,7 +167,7 @@ pub(in crate::routes::pages::public) async fn library_page(
158 167 // Phase 4: text items get their own reader template.
159 168 if db_item.item_type == ItemType::Text {
160 169 return render_text_library(
161 - &state,
170 + &config,
162 171 &db_item,
163 172 &db_user,
164 173 &db_project,
@@ -175,7 +184,9 @@ pub(in crate::routes::pages::public) async fn library_page(
175 184 // Phase 3: video items get their own player template.
176 185 if db_item.item_type == ItemType::Video {
177 186 return render_video_library(
178 - &state,
187 + &db,
188 + &storage,
189 + &config,
179 190 &db_item,
180 191 &db_user,
181 192 &db_project,
@@ -193,7 +204,7 @@ pub(in crate::routes::pages::public) async fn library_page(
193 204 // Phase 1: downloads / bundle / other items render here. Audio, video, and
194 205 // text branches above handle their own templates.
195 206 let bundle_child_items = if db_item.item_type == ItemType::Bundle {
196 - db::bundles::get_bundle_items(&state.db, db_item.id).await?
207 + db::bundles::get_bundle_items(&db, db_item.id).await?
197 208 } else {
198 209 Vec::new()
199 210 };
@@ -205,12 +216,11 @@ pub(in crate::routes::pages::public) async fn library_page(
205 216 })
206 217 .collect();
207 218
208 - let cdn_base = state
209 - .config
219 + let cdn_base = config
210 220 .cdn_base_url
211 221 .as_deref()
212 222 .unwrap_or("https://cdn.makenot.work");
213 - let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?;
223 + let db_sections = db::item_sections::list_by_item(&db, db_item.id).await?;
214 224 let sections: Vec<ItemSection> = db_sections
215 225 .iter()
216 226 .map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base))
@@ -223,7 +233,7 @@ pub(in crate::routes::pages::public) async fn library_page(
223 233 creator_username: db_user.username.to_string(),
224 234 project_title: db_project.title.clone(),
225 235 project_slug: project_slug_str,
226 - host_url: state.config.host_url.clone(),
236 + host_url: config.host_url.clone(),
227 237 versions,
228 238 bundle_items,
229 239 sections,
@@ -236,7 +246,9 @@ pub(in crate::routes::pages::public) async fn library_page(
236 246
237 247 #[allow(clippy::too_many_arguments)]
238 248 async fn render_audio_library(
239 - state: &AppState,
249 + db: &PgPool,
250 + storage: &AppStorage,
251 + config: &Config,
240 252 db_item: &db::DbItem,
241 253 db_user: &db::DbUser,
242 254 db_project: &db::DbProject,
@@ -250,7 +262,7 @@ async fn render_audio_library(
250 262 ) -> Result<Response> {
251 263 let avatar_initials =
252 264 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
253 - let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?;
265 + let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?;
254 266 let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
255 267
256 268 let audio_url = match db_item.content() {
@@ -260,7 +272,7 @@ async fn render_audio_library(
260 272 audio_url,
261 273 ..
262 274 } => {
263 - if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &state.storage.s3) {
275 + if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &storage.s3) {
264 276 let expiry_secs = match duration_seconds {
265 277 Some(duration) => ((duration as u64) * 2)
266 278 .clamp(3600, constants::STREAMING_CACHE_MAX_SECS),
@@ -281,7 +293,7 @@ async fn render_audio_library(
281 293 };
282 294
283 295 let segments_json =
284 - super::item::build_segments_json(state, db_item.id, &audio_url, db_item).await;
296 + super::item::build_segments_json(db, storage, db_item.id, &audio_url, db_item).await;
285 297
286 298 Ok(LibraryAudioTemplate {
287 299 csrf_token,
@@ -296,7 +308,7 @@ async fn render_audio_library(
296 308 chapters,
297 309 segments_json,
298 310 versions,
299 - host_url: state.config.host_url.clone(),
311 + host_url: config.host_url.clone(),
300 312 discussion_url,
301 313 discussion_count,
302 314 is_owner,
@@ -306,7 +318,9 @@ async fn render_audio_library(
306 318
307 319 #[allow(clippy::too_many_arguments)]
308 320 async fn render_video_library(
309 - state: &AppState,
321 + db: &PgPool,
322 + storage: &AppStorage,
323 + config: &Config,
310 324 db_item: &db::DbItem,
311 325 db_user: &db::DbUser,
312 326 db_project: &db::DbProject,
@@ -320,7 +334,7 @@ async fn render_video_library(
320 334 ) -> Result<Response> {
321 335 let avatar_initials =
322 336 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
323 - let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?;
337 + let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?;
324 338 let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
325 339
326 340 let video_url = match db_item.content() {
@@ -329,7 +343,7 @@ async fn render_video_library(
329 343 duration_seconds,
330 344 ..
331 345 } => {
332 - if let (Some(s3_key), Some(s3)) = (&video_s3_key, &state.storage.s3) {
346 + if let (Some(s3_key), Some(s3)) = (&video_s3_key, &storage.s3) {
333 347 let expiry_secs = match duration_seconds {
334 348 Some(duration) => ((duration as u64) * 2)
335 349 .clamp(3600, constants::STREAMING_CACHE_MAX_SECS),
@@ -350,7 +364,7 @@ async fn render_video_library(
350 364 };
351 365
352 366 let segments_json =
353 - super::item::build_segments_json(state, db_item.id, &video_url, db_item).await;
367 + super::item::build_segments_json(db, storage, db_item.id, &video_url, db_item).await;
354 368
355 369 Ok(LibraryVideoTemplate {
356 370 csrf_token,
@@ -365,7 +379,7 @@ async fn render_video_library(
365 379 chapters,
366 380 segments_json,
367 381 versions,
368 - host_url: state.config.host_url.clone(),
382 + host_url: config.host_url.clone(),
369 383 discussion_url,
370 384 discussion_count,
371 385 is_owner,
@@ -375,7 +389,7 @@ async fn render_video_library(
375 389
376 390 #[allow(clippy::too_many_arguments)]
377 391 async fn render_text_library(
378 - state: &AppState,
392 + config: &Config,
379 393 db_item: &db::DbItem,
380 394 db_user: &db::DbUser,
381 395 db_project: &db::DbProject,
@@ -388,8 +402,7 @@ async fn render_text_library(
388 402 ) -> Result<Response> {
389 403 let avatar_initials =
390 404 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
391 - let cdn_base = state
392 - .config
405 + let cdn_base = config
393 406 .cdn_base_url
394 407 .as_deref()
395 408 .unwrap_or("https://cdn.makenot.work");
@@ -417,7 +430,7 @@ async fn render_text_library(
417 430 project_slug: db_project.slug.to_string(),
418 431 body_html,
419 432 reading_time,
420 - host_url: state.config.host_url.clone(),
433 + host_url: config.host_url.clone(),
421 434 discussion_url,
422 435 discussion_count,
423 436 is_owner,
@@ -15,10 +15,12 @@ use axum::{
15 15 response::{IntoResponse, Redirect, Response},
16 16 };
17 17 use serde::Deserialize;
18 + use sqlx::PgPool;
18 19 use tower_sessions::Session;
19 20
20 21 use crate::{
21 22 auth::{MaybeUserVerified, SessionUser},
23 + config::Config,
22 24 db::{self, FollowTargetType, ItemId, Username},
23 25 error::{AppError, Result},
24 26 helpers::get_csrf_token,
@@ -32,8 +34,12 @@ use crate::{
32 34 /// Routes through the bounded `PageViewTx` batcher (single bg flush task,
33 35 /// bulk UPSERT every 500ms) — the prior per-request `tokio::spawn` pattern
34 36 /// saturated the DB pool under any view burst.
35 - pub(crate) fn track_view(state: &crate::AppState, target_type: &'static str, target_id: uuid::Uuid) {
36 - state.page_view_tx.try_record(target_type, target_id);
37 + pub(crate) fn track_view(
38 + page_view_tx: &crate::db::page_views::PageViewTx,
39 + target_type: &'static str,
40 + target_id: uuid::Uuid,
41 + ) {
42 + page_view_tx.try_record(target_type, target_id);
37 43 }
38 44
39 45 /// Returns true if the User-Agent looks like a bot/crawler.
@@ -81,7 +87,7 @@ pub(super) async fn user_page(
81 87 .and_then(|v| v.to_str().ok())
82 88 .unwrap_or("");
83 89 if !is_bot(ua) {
84 - track_view(&state, "user", *db_user.id);
90 + track_view(&state.page_view_tx, "user", *db_user.id);
85 91 }
86 92 Ok(response)
87 93 }
@@ -147,7 +153,7 @@ pub(crate) async fn render_user_profile(
147 153 /// Render the purchase confirmation page with fee breakdown.
148 154 #[tracing::instrument(skip_all, name = "content::purchase_page")]
149 155 pub(super) async fn purchase_page(
150 - State(state): State<AppState>,
156 + State(db): State<PgPool>,
151 157 session: Session,
152 158 MaybeUserVerified(maybe_user): MaybeUserVerified,
153 159 Path(item_id): Path<String>,
@@ -157,15 +163,15 @@ pub(super) async fn purchase_page(
157 163 let is_logged_in = maybe_user.is_some();
158 164 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
159 165
160 - let db_item = db::items::get_item_by_id(&state.db, id)
166 + let db_item = db::items::get_item_by_id(&db, id)
161 167 .await?
162 168 .ok_or(AppError::NotFound)?;
163 169
164 - let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
170 + let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
165 171 .await?
166 172 .ok_or(AppError::NotFound)?;
167 173
168 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
174 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
169 175 .await?
170 176 .ok_or(AppError::NotFound)?;
171 177
@@ -198,7 +204,7 @@ pub(super) async fn purchase_page(
198 204 let stripe_fee = crate::formatting::format_dollars_plain(stripe_fee_cents);
199 205 let creator_receives = crate::formatting::format_dollars_plain(creator_receives_cents);
200 206
201 - let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?;
207 + let purchase_tags = db::tags::get_tags_for_item(&db, id).await?;
202 208 let item = Item::from_db_list(&db_item, &purchase_tags, price_cents == 0, false);
203 209
204 210 let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents);
@@ -206,7 +212,7 @@ pub(super) async fn purchase_page(
206 212 let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min);
207 213
208 214 let pending_started = if let Some(ref u) = maybe_user {
209 - match db::transactions::get_pending_item_purchase(&state.db, u.id, id).await? {
215 + match db::transactions::get_pending_item_purchase(&db, u.id, id).await? {
210 216 Some((_, created_at)) => format_relative_ago(created_at),
211 217 None => String::new(),
212 218 }
@@ -252,7 +258,7 @@ fn format_relative_ago(ts: chrono::DateTime<chrono::Utc>) -> String {
252 258 /// Render a purchase receipt page.
253 259 #[tracing::instrument(skip_all, name = "content::receipt_page")]
254 260 pub(super) async fn receipt_page(
255 - State(state): State<AppState>,
261 + State(db): State<PgPool>,
256 262 session: Session,
257 263 MaybeUserVerified(maybe_user): MaybeUserVerified,
258 264 Path(transaction_id): Path<String>,
@@ -260,7 +266,7 @@ pub(super) async fn receipt_page(
260 266 let csrf_token = get_csrf_token(&session).await;
261 267 let tx_id: db::TransactionId = transaction_id.parse().map_err(|_| AppError::NotFound)?;
262 268
263 - let tx = db::transactions::get_transaction_by_id(&state.db, tx_id)
269 + let tx = db::transactions::get_transaction_by_id(&db, tx_id)
264 270 .await?
265 271 .ok_or(AppError::NotFound)?;
266 272
@@ -312,20 +318,20 @@ pub(super) async fn receipt_page(
312 318 /// Render a public collection page.
313 319 #[tracing::instrument(skip_all, name = "content::collection_page")]
314 320 pub(super) async fn collection_page(
315 - State(state): State<AppState>,
321 + State(db): State<PgPool>,
316 322 session: Session,
317 323 MaybeUserVerified(maybe_user): MaybeUserVerified,
318 324 Path((username, slug)): Path<(String, String)>,
319 325 ) -> Result<impl IntoResponse> {
320 326 let csrf_token = get_csrf_token(&session).await;
321 327 let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
322 - let db_user = db::users::get_user_by_username(&state.db, &username)
328 + let db_user = db::users::get_user_by_username(&db, &username)
323 329 .await?
324 330 .ok_or(AppError::NotFound)?;
325 331
326 332 let slug = db::Slug::new(&slug).map_err(|_| AppError::NotFound)?;
327 333 let collection =
328 - db::collections::get_collection_by_user_and_slug(&state.db, db_user.id, &slug)
334 + db::collections::get_collection_by_user_and_slug(&db, db_user.id, &slug)
329 335 .await?
330 336 .ok_or(AppError::NotFound)?;
331 337
@@ -335,7 +341,7 @@ pub(super) async fn collection_page(
335 341 return Err(AppError::NotFound);
336 342 }
337 343
338 - let db_items = db::collections::get_collection_items(&state.db, collection.id).await?;
344 + let db_items = db::collections::get_collection_items(&db, collection.id).await?;
339 345 let items: Vec<CollectionItem> = db_items.iter().map(CollectionItem::from).collect();
340 346
341 347 let item_count = items.len() as i64;
@@ -363,12 +369,13 @@ pub(super) async fn collection_page(
363 369 /// and social media sharing. Shows item summary + guest checkout button.
364 370 #[tracing::instrument(skip_all, name = "content::buy_page")]
365 371 pub(super) async fn buy_page(
366 - State(state): State<AppState>,
372 + State(db): State<PgPool>,
373 + State(config): State<Config>,
367 374 Path(item_id): Path<String>,
368 375 ) -> Result<impl IntoResponse> {
369 376 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
370 377
371 - let db_item = db::items::get_item_by_id(&state.db, id)
378 + let db_item = db::items::get_item_by_id(&db, id)
372 379 .await?
373 380 .ok_or(AppError::NotFound)?;
374 381
@@ -376,15 +383,15 @@ pub(super) async fn buy_page(
376 383 return Err(AppError::NotFound);
377 384 }
378 385
379 - let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
386 + let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
380 387 .await?
381 388 .ok_or(AppError::NotFound)?;
382 389
383 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
390 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
384 391 .await?
385 392 .ok_or(AppError::NotFound)?;
386 393
387 - let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?;
394 + let purchase_tags = db::tags::get_tags_for_item(&db, id).await?;
388 395 let item = Item::from_db_list(&db_item, &purchase_tags, db_item.price_cents == 0, false);
389 396
390 397 let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents);
@@ -398,6 +405,6 @@ pub(super) async fn buy_page(
398 405 pwyw_enabled: db_item.pwyw_enabled,
399 406 pwyw_min_dollars,
400 407 suggested_price,
401 - host_url: state.config.host_url.clone(),
408 + host_url: config.host_url.clone(),
402 409 })
403 410 }
@@ -36,7 +36,7 @@ pub(in crate::routes::pages::public) async fn project_page(
36 36 .and_then(|v| v.to_str().ok())
37 37 .unwrap_or("");
38 38 if !super::is_bot(ua) {
39 - super::track_view(&state, "project", *db_project.id);
39 + super::track_view(&state.page_view_tx, "project", *db_project.id);
40 40 }
41 41 Ok(response)
42 42 }
@@ -19,7 +19,6 @@ use crate::{
19 19 helpers::get_csrf_token,
20 20 templates::*,
21 21 types::*,
22 - AppState,
23 22 };
24 23
25 24 /// The four viewer-independent discover facet results (item-type, tag, ai-tier, and
@@ -311,7 +310,7 @@ pub struct TagTreeQuery {
311 310 /// Browse the tag hierarchy with breadcrumb navigation.
312 311 #[tracing::instrument(skip_all, name = "discover::tag_tree")]
313 312 pub(super) async fn tag_tree(
314 - State(state): State<AppState>,
313 + State(db): State<PgPool>,
315 314 session: Session,
316 315 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
317 316 Query(query): Query<TagTreeQuery>,
@@ -320,7 +319,7 @@ pub(super) async fn tag_tree(
320 319
321 320 // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre")
322 321 let parent_tag = if let Some(ref slug) = query.parent {
323 - db::tags::get_tag_by_slug(&state.db, slug).await?
322 + db::tags::get_tag_by_slug(&db, slug).await?
324 323 } else {
325 324 None
326 325 };
@@ -328,13 +327,13 @@ pub(super) async fn tag_tree(
328 327 let parent_id = parent_tag.as_ref().map(|t| t.id);
329 328
330 329 // Fetch children at this level
331 - let children = db::tags::get_child_tags(&state.db, parent_id).await?;
330 + let children = db::tags::get_child_tags(&db, parent_id).await?;
332 331
333 332 // Item counts + child counts, both scoped to just this level's children
334 333 // rather than aggregating over every tag in the catalog (fuzz 2026-07-06 C5-2).
335 334 let child_ids: Vec<_> = children.iter().map(|c| c.id).collect();
336 - let tag_counts = db::tags::item_counts_by_tag(&state.db, &child_ids).await?;
337 - let grandchild_counts = db::tags::count_children_by_parents(&state.db, &child_ids).await?;
335 + let tag_counts = db::tags::item_counts_by_tag(&db, &child_ids).await?;
336 + let grandchild_counts = db::tags::count_children_by_parents(&db, &child_ids).await?;
338 337
339 338 let categories: Vec<TagTreeNode> = children.iter().map(|child| {
340 339 TagTreeNode {
@@ -347,7 +346,7 @@ pub(super) async fn tag_tree(
347 346
348 347 // Build breadcrumbs from ancestor chain
349 348 let (breadcrumbs, current_tag) = if let Some(ref pt) = parent_tag {
350 - let ancestors = db::tags::get_tag_ancestors(&state.db, pt.id).await?;
349 + let ancestors = db::tags::get_tag_ancestors(&db, pt.id).await?;
351 350 // ancestors includes the tag itself as the last element.
352 351 // We want all ancestors except the current tag as breadcrumbs,
353 352 // and the current tag as current_tag.
@@ -380,7 +379,7 @@ pub(super) async fn tag_tree(
380 379 /// Render the discover page with filterable, paginated items or projects.
381 380 #[tracing::instrument(skip_all, name = "discover::discover")]
382 381 pub(super) async fn discover(
383 - State(state): State<AppState>,
382 + State(db): State<PgPool>,
384 383 session: Session,
385 384 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
386 385 Query(query): Query<DiscoverQuery>,
@@ -391,7 +390,7 @@ pub(super) async fn discover(
391 390 let tag_filter = f.tag;
392 391 let item_type_filter = f.item_type;
393 392 let has_source_code = f.has_source_code;
394 - let data = fetch_discover_data(&state.db, &query).await?;
393 + let data = fetch_discover_data(&db, &query).await?;
395 394
396 395 // Build type and tag filters (items mode only)
397 396 let category_filter = f.category;
@@ -399,7 +398,7 @@ pub(super) async fn discover(
399 398 // Build category filters (projects mode only)
400 399 let category_filters = if data.mode == "projects" {
401 400 let cat_counts = db::categories::get_category_counts(
402 - &state.db,
401 + &db,
403 402 search_filter,
404 403 )
405 404 .await?;
@@ -433,7 +432,7 @@ pub(super) async fn discover(
433 432 // per-viewer, so it is computed fresh (and only when logged in).
434 433 let viewer_id = maybe_user.as_ref().map(|u| u.id);
435 434 let (type_counts, tag_counts, ai_counts, price_counts) = cached_facets(
436 - &state.db, search_filter, item_type_filter, tag_filter, query.min_price, query.max_price,
435 + &db, search_filter, item_type_filter, tag_filter, query.min_price, query.max_price,
437 436 ).await?;
438 437 // Only the first ~10 tag facets are rendered (see `.take(10)` below), so
439 438 // test follow-membership against just those rather than fetching the
@@ -441,7 +440,7 @@ pub(super) async fn discover(
441 440 let followed_tag_ids = if let Some(uid) = viewer_id {
442 441 let rendered_tag_ids: Vec<_> =
443 442 tag_counts.iter().take(10).map(|tc| tc.tag_id).collect();
444 - db::follows::following_subset(&state.db, uid, &rendered_tag_ids).await?
443 + db::follows::following_subset(&db, uid, &rendered_tag_ids).await?
445 444 } else {
446 445 std::collections::HashSet::new()
447 446 };
@@ -588,11 +587,11 @@ pub(super) async fn discover(
588 587 /// Return discover results as an HTMX partial for filtering and pagination.
589 588 #[tracing::instrument(skip_all, name = "discover::discover_results")]
590 589 pub(super) async fn discover_results(
591 - State(state): State<AppState>,
590 + State(db): State<PgPool>,
592 591 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
593 592 Query(query): Query<DiscoverQuery>,
594 593 ) -> Result<impl IntoResponse> {
595 - let data = fetch_discover_data(&state.db, &query).await?;
594 + let data = fetch_discover_data(&db, &query).await?;
596 595
597 596 Ok(DiscoverResultsTemplate {
598 597 items: data.items,
@@ -626,11 +625,11 @@ pub struct SearchSuggestion {
626 625 /// Return search suggestions (tags, projects, creators) as JSON.
627 626 #[tracing::instrument(skip_all, name = "discover::search_suggestions")]
628 627 pub(super) async fn search_suggestions_handler(
629 - State(state): State<AppState>,
628 + State(db): State<PgPool>,
630 629 Query(query): Query<SuggestionsQuery>,
631 630 ) -> Result<impl IntoResponse> {
632 631 let q = query.q.unwrap_or_default();
633 - let rows = db::discover::search_suggestions(&state.db, &q).await?;
632 + let rows = db::discover::search_suggestions(&db, &q).await?;
634 633 let suggestions: Vec<SearchSuggestion> = rows
635 634 .into_iter()
636 635 .map(|r| SearchSuggestion {
@@ -12,13 +12,12 @@ use crate::{
12 12 error::{AppError, Result},
13 13 helpers::get_csrf_token,
14 14 templates::{DocIndexTemplate, DocSection, DocSectionEntry, DocSubsection, DocTemplate},
15 - AppState,
16 15 };
17 16
18 17 /// GET /docs: index page listing all docs grouped by section.
19 18 #[tracing::instrument(skip_all, name = "docs::docs_index")]
20 19 pub async fn docs_index(
21 - State(state): State<AppState>,
20 + State(docs): State<std::sync::Arc<docengine::DocLoader>>,
22 21 session: Session,
23 22 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
24 23 ) -> Result<impl IntoResponse> {
@@ -26,7 +25,7 @@ pub async fn docs_index(
26 25
27 26 // Group index entries by section, preserving load order.
28 27 let mut sections: Vec<DocSection> = Vec::new();
29 - for entry in state.docs.index() {
28 + for entry in docs.index() {
30 29 let section = sections
31 30 .iter_mut()
32 31 .find(|s| s.name == entry.section);
@@ -89,20 +88,20 @@ pub async fn docs_index(
89 88 /// GET /docs/search.json: full-text search index for client-side filtering.
90 89 #[tracing::instrument(skip_all, name = "docs::docs_search_index")]
91 90 pub async fn docs_search_index(
92 - State(state): State<AppState>,
91 + State(docs): State<std::sync::Arc<docengine::DocLoader>>,
93 92 ) -> Json<Vec<docengine::DocSearchEntry>> {
94 - Json(state.docs.search_index())
93 + Json(docs.search_index())
95 94 }
96 95
97 96 /// GET /docs/{slug}: individual doc page.
98 97 #[tracing::instrument(skip_all, name = "docs::doc_page")]
99 98 pub async fn doc_page(
100 - State(state): State<AppState>,
99 + State(docs): State<std::sync::Arc<docengine::DocLoader>>,
101 100 session: Session,
102 101 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
103 102 Path(slug): Path<String>,
104 103 ) -> Result<impl IntoResponse> {
105 - let page = state.docs.get(&slug).ok_or(AppError::NotFound)?;
104 + let page = docs.get(&slug).ok_or(AppError::NotFound)?;
106 105 let csrf_token = get_csrf_token(&session).await;
107 106
108 107 Ok(DocTemplate {
@@ -3,6 +3,7 @@
3 3 use axum::extract::{Query, State};
4 4 use axum::response::IntoResponse;
5 5 use serde::Deserialize;
6 + use sqlx::PgPool;
6 7 use tower_sessions::Session;
7 8
8 9 use crate::{
@@ -13,7 +14,6 @@ use crate::{
13 14 helpers::get_csrf_token,
14 15 templates::FeedTemplate,
15 16 types::DiscoverItem,
16 - AppState,
17 17 };
18 18
19 19 /// Query parameters for the feed page.
@@ -25,7 +25,7 @@ pub struct FeedQuery {
25 25 /// GET /feed: paginated feed of items from followed users, projects, and tags.
26 26 #[tracing::instrument(skip_all, name = "feed::feed_page")]
27 27 pub(super) async fn feed_page(
28 - State(state): State<AppState>,
28 + State(db): State<PgPool>,
29 29 session: Session,
30 30 AuthUser(user): AuthUser,
31 31 Query(query): Query<FeedQuery>,
@@ -41,11 +41,11 @@ pub(super) async fn feed_page(
41 41 // overflows for a large `?page=` (garbage offset in release, panic in debug).
42 42 let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64;
43 43
44 - let total_items = db::follows::count_followed_feed_items(&state.db, user.id).await? as u32;
44 + let total_items = db::follows::count_followed_feed_items(&db, user.id).await? as u32;
45 45 let total_pages = (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1);
46 46
47 47 let db_items = db::follows::get_followed_feed_items(
48 - &state.db,
48 + &db,
49 49 user.id,
50 50 constants::FEED_PAGE_SIZE as i64,
51 51 offset,
@@ -19,12 +19,15 @@ use axum::response::IntoResponse;
19 19 use axum::Json;
20 20 use tower_sessions::Session;
21 21
22 + use sqlx::PgPool;
23 +
22 24 use crate::{
25 + config::Config,
23 26 db,
24 27 error::Result,
25 28 helpers::get_csrf_token,
26 29 templates::*,
27 - AppState,
30 + AppStorage, Billing, Ops,
28 31 };
29 32
30 33 /// Per-subsystem ceiling for the outbound probes in the admin health fan-out,
@@ -225,7 +228,13 @@ fn format_privacy_jobs(
225 228
226 229 /// Run all health checks and return computed data.
227 230 /// Used by the HTML dashboard; runs live probes (DB, S3, HTTP self-tests).
228 - async fn collect_health(state: &AppState) -> HealthData {
231 + async fn collect_health(
232 + db: &PgPool,
233 + storage: &AppStorage,
234 + payments: &Billing,
235 + ops: &Ops,
236 + config: &Config,
237 + ) -> HealthData {
229 238 use std::time::Instant;
230 239
231 240 let check_start = Instant::now();
@@ -251,25 +260,25 @@ async fn collect_health(state: &AppState) -> HealthData {
251 260 let (db_test_users, db_test_projects, db_test_items, db_test_transactions) = tokio::join!(
252 261 run_test("Count users", || async {
253 262 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
254 - .fetch_one(&state.db)
263 + .fetch_one(db)
255 264 .await
256 265 .is_ok()
257 266 }),
258 267 run_test("Count projects", || async {
259 268 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM projects")
260 - .fetch_one(&state.db)
269 + .fetch_one(db)
261 270 .await
262 271 .is_ok()
263 272 }),
264 273 run_test("Count items", || async {
265 274 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM items")
266 - .fetch_one(&state.db)
275 + .fetch_one(db)
267 276 .await
268 277 .is_ok()
269 278 }),
270 279 run_test("Count transactions", || async {
271 280 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM transactions")
272 - .fetch_one(&state.db)
281 + .fetch_one(db)
273 282 .await
274 283 .is_ok()
275 284 }),
@@ -279,7 +288,7 @@ async fn collect_health(state: &AppState) -> HealthData {
279 288 // On failure the counts render as zeros, but that must not be SILENT — log it
280 289 // so a stats-query failure is visible in the ops logs (the DB probes above are
281 290 // the authoritative liveness signal; a real DB outage turns them red too).
282 - let stats = db::health::get_health_stats(&state.db).await.unwrap_or_else(|e| {
291 + let stats = db::health::get_health_stats(db).await.unwrap_or_else(|e| {
283 292 tracing::error!(error = ?e, "health: DB stats query failed; rendering zero counts (see DB probes for liveness)");
284 293 db::health::DbHealthStats {
285 294 user_count: 0,
@@ -301,8 +310,8 @@ async fn collect_health(state: &AppState) -> HealthData {
301 310 let db_status_class = if db_ok { "status-ok" } else { "status-error" };
302 311
303 312 // Pool info
304 - let pool_max = state.db.size();
305 - let pool_idle = state.db.num_idle();
313 + let pool_max = db.size();
314 + let pool_idle = db.num_idle();
306 315 let pool_active = pool_max.saturating_sub(pool_idle as u32);
307 316
308 317 // S3 storage status with connectivity check. Bound the one outbound network
@@ -310,8 +319,8 @@ async fn collect_health(state: &AppState) -> HealthData {
310 319 // a few seconds rather than stalling the admin dashboard on the client-level
311 320 // 60s operation timeout (audit Run 22). The DB probes are already bounded by
312 321 // the pooled-connection `statement_timeout`, and the PoM fetch has its own.
313 - let storage_configured = state.storage.s3.is_some();
314 - let s3_reachable = if let Some(ref s3) = state.storage.s3 {
322 + let storage_configured = storage.s3.is_some();
323 + let s3_reachable = if let Some(ref s3) = storage.s3 {
315 324 matches!(
316 325 tokio::time::timeout(HEALTH_PROBE_TIMEOUT, s3.check_connectivity()).await,
317 326 Ok(res) if res.is_ok()
@@ -326,18 +335,18 @@ async fn collect_health(state: &AppState) -> HealthData {
326 335 } else {
327 336 ("Not configured", "status-warn")
328 337 };
329 - let (storage_bucket, storage_region) = if let Some(ref storage) = state.config.storage {
338 + let (storage_bucket, storage_region) = if let Some(ref storage) = config.storage {
330 339 (storage.bucket.clone(), storage.region.clone())
331 340 } else {
332 341 (String::new(), String::new())
333 342 };
334 343
335 344 // Stripe status
336 - let stripe_configured = state.stripe.is_some();
345 + let stripe_configured = payments.stripe.is_some();
337 346 let stripe_status = if stripe_configured { "Configured" } else { "Not configured" };
338 347 let stripe_status_class = if stripe_configured { "status-ok" } else { "status-warn" };
339 348 let stripe_mode = if stripe_configured {
340 - if state.config.stripe.as_ref().map(|s| s.secret_key.starts_with("sk_live")).unwrap_or(false) {
349 + if config.stripe.as_ref().map(|s| s.secret_key.starts_with("sk_live")).unwrap_or(false) {
341 350 "Live"
342 351 } else {
343 352 "Test"
@@ -358,19 +367,19 @@ async fn collect_health(state: &AppState) -> HealthData {
358 367 // `active_session_count > 0` check false-alarmed the whole dashboard to
359 368 // "Issues detected" on an idle instance (Run 15). Matches monitor.rs.
360 369 let session_ok = sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM tower_sessions.session)")
361 - .fetch_one(&state.db)
370 + .fetch_one(db)
362 371 .await
363 372 .is_ok();
364 373 let session_status = if session_ok { "Active" } else { "Error" };
365 374 let session_status_class = if session_ok { "status-ok" } else { "status-error" };
366 375
367 376 // SyncKit status
368 - let synckit_configured = state.config.synckit_jwt_secret.is_some();
377 + let synckit_configured = config.synckit_jwt_secret.is_some();
369 378 let synckit_status = if synckit_configured { "Configured" } else { "Not configured" };
370 379 let synckit_status_class = if synckit_configured { "status-ok" } else { "status-warn" };
371 380
372 381 // Security & Monitoring
373 - let admin_configured = state.config.admin_user_id.is_some();
382 + let admin_configured = config.admin_user_id.is_some();
374 383
375 384 // Overall tri-state status
376 385 let overall = if !db_ok || !session_ok {
@@ -383,11 +392,11 @@ async fn collect_health(state: &AppState) -> HealthData {
383 392
384 393 // Environment
385 394 let environment = if cfg!(debug_assertions) { "Development" } else { "Production" };
386 - let host = state.config.host_url.clone();
395 + let host = config.host_url.clone();
387 396
388 397 // Real uptime from AppState
389 - let uptime = format_uptime(state.start_instant.elapsed());
390 - let started_at = state.started_at.format("%Y-%m-%d %H:%M:%S UTC").to_string();
398 + let uptime = format_uptime(ops.start_instant.elapsed());
399 + let started_at = ops.started_at.format("%Y-%m-%d %H:%M:%S UTC").to_string();
391 400
392 401 // Version with git hash
393 402 let version = match option_env!("GIT_HASH") {
@@ -452,17 +461,17 @@ async fn collect_health(state: &AppState) -> HealthData {
452 461
453 462 let alerts_configured = std::env::var("ALERT_EMAIL").is_ok();
454 463
455 - let uptime_24h = db::monitor::get_health_uptime_percent(&state.db, 24)
464 + let uptime_24h = db::monitor::get_health_uptime_percent(db, 24)
456 465 .await
457 466 .unwrap_or(None);
458 - let uptime_7d = db::monitor::get_health_uptime_percent(&state.db, 168)
467 + let uptime_7d = db::monitor::get_health_uptime_percent(db, 168)
459 468 .await
460 469 .unwrap_or(None);
461 - let last_incident = db::monitor::get_last_incident(&state.db)
470 + let last_incident = db::monitor::get_last_incident(db)
462 471 .await
463 472 .unwrap_or(None)
464 473 .map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string());
465 - let recent_snapshots = db::monitor::get_recent_health_history(&state.db, 10)
474 + let recent_snapshots = db::monitor::get_recent_health_history(db, 10)
466 475 .await
467 476 .unwrap_or_default();
468 477
@@ -525,7 +534,7 @@ async fn collect_health(state: &AppState) -> HealthData {
525 534 pom_routes_total,
526 535 pom_routes_ok,
527 536 pom_routes_failed,
528 - privacy_jobs: db::scheduler_jobs::get_job_runs(&state.db).await.unwrap_or_default(),
537 + privacy_jobs: db::scheduler_jobs::get_job_runs(db).await.unwrap_or_default(),
529 538 }
530 539 }
531 540
@@ -545,14 +554,18 @@ async fn collect_health(state: &AppState) -> HealthData {
545 554 /// non-admin branch now reads the same cached snapshot `/api/health` does.
546 555 #[tracing::instrument(skip_all, name = "health::health")]
547 556 pub(super) async fn health(
548 - State(state): State<AppState>,
557 + State(db): State<PgPool>,
558 + State(storage): State<AppStorage>,
559 + State(payments): State<Billing>,
560 + State(ops): State<Ops>,
561 + State(config): State<Config>,
549 562 session: Session,
550 563 crate::auth::MaybeUserVerified(maybe_user): crate::auth::MaybeUserVerified,
551 564 ) -> Result<axum::response::Response> {
552 565 let is_admin = maybe_user.as_ref().is_some_and(|u| u.is_admin);
553 566 if !is_admin {
554 567 // Cheap cached status only — no live fan-out for unauthenticated callers.
555 - let (overall, _db_ok) = cached_health_status(&state).await;
568 + let (overall, _db_ok) = cached_health_status(&db).await;
556 569 // `label()` is a controlled &'static str from OverallStatus — no user input.
557 570 let body = axum::response::Html(format!(
558 571 "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
@@ -565,7 +578,7 @@ pub(super) async fn health(
565 578 return Ok(body.into_response());
566 579 }
567 580
568 - let data = collect_health(&state).await;
581 + let data = collect_health(&db, &storage, &payments, &ops, &config).await;
569 582
570 583 let now = chrono::Utc::now();
571 584
@@ -697,8 +710,8 @@ pub(super) async fn health(
697 710 /// This is the cheap read shared by `GET /api/health` and the anonymous
698 711 /// `/health` HTML page, so both agree on status without either running the live
699 712 /// `collect_health` subsystem fan-out. Returns `(overall, db_ok)`.
700 - async fn cached_health_status(state: &AppState) -> (OverallStatus, bool) {
701 - let latest = db::monitor::get_recent_health_history(&state.db, 1)
713 + async fn cached_health_status(db: &PgPool) -> (OverallStatus, bool) {
714 + let latest = db::monitor::get_recent_health_history(db, 1)
702 715 .await
703 716 .unwrap_or_default();
704 717 if let Some(snap) = latest.first() {
@@ -712,7 +725,7 @@ async fn cached_health_status(state: &AppState) -> (OverallStatus, bool) {
712 725 } else {
713 726 // No monitor data yet — single minimal probe.
714 727 let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1")
715 - .fetch_one(&state.db)
728 + .fetch_one(db)
716 729 .await
717 730 .is_ok();
718 731 let status = if db_ok { OverallStatus::Operational } else { OverallStatus::Error };
@@ -726,11 +739,11 @@ async fn cached_health_status(state: &AppState) -> (OverallStatus, bool) {
726 739 /// running live probes. Returns 200 if operational or degraded, 503 if error.
727 740 #[tracing::instrument(skip_all, name = "health::health_json")]
728 741 pub(super) async fn health_json(
729 - State(state): State<AppState>,
742 + State(db): State<PgPool>,
730 743 ) -> impl IntoResponse {
731 744 // Cached monitor data only — no live probes (fast <10ms as documented),
732 745 // with a single-`SELECT 1` fallback before the first monitor tick.
733 - let (overall, db_ok) = cached_health_status(&state).await;
746 + let (overall, db_ok) = cached_health_status(&db).await;
734 747
735 748 let http_status = if overall == OverallStatus::Error {
736 749 StatusCode::SERVICE_UNAVAILABLE
@@ -11,17 +11,19 @@ use axum::{
11 11 Form,
12 12 };
13 13 use serde::Deserialize;
14 + use sqlx::PgPool;
14 15 use tower_sessions::Session;
15 16
16 17 use crate::{
17 18 auth::{hash_password_async, login_user, track_session, AuthUser, MaybeUserVerified, SessionUser},
19 + background::BackgroundTx,
20 + config::Config,
18 21 db::{self},
19 - email,
22 + email::{self, EmailClient},
20 23 error::{AppError, Result},
21 24 helpers::{get_csrf_token, is_htmx_request},
22 25 routes::pages::dashboard::wizards::build_step_nav,
23 26 templates::*,
24 - AppState,
25 27 };
26 28
27 29 const JOIN_STEPS: &[&str] = &["account", "profile", "complete"];
@@ -68,7 +70,10 @@ pub struct AccountForm {
68 70 /// POST `/join/step/account`: create account and log in, then return step 2.
69 71 #[tracing::instrument(skip_all, name = "join_wizard::account_create")]
70 72 pub async fn step_account_create(
71 - State(state): State<AppState>,
73 + State(db): State<PgPool>,
74 + State(config): State<Config>,
75 + State(mailer): State<EmailClient>,
76 + State(bg): State<BackgroundTx>,
72 77 headers: HeaderMap,
73 78 session: Session,
74 79 Form(form): Form<AccountForm>,
@@ -120,10 +125,10 @@ pub async fn step_account_create(
120 125 };
121 126
122 127 // Check uniqueness
123 - let username_taken = db::users::get_user_by_username(&state.db, &username)
128 + let username_taken = db::users::get_user_by_username(&db, &username)
124 129 .await?
125 130 .is_some();
126 - let email_taken = db::users::get_user_by_email(&state.db, &email)
131 + let email_taken = db::users::get_user_by_email(&db, &email)
127 132 .await?
128 133 .is_some();
129 134 // Username collisions are safe to reveal — usernames are public handles
@@ -140,11 +145,11 @@ pub async fn step_account_create(
140 145 return return_error(Some("username"), "This username is already taken");
141 146 }
142 147 if email_taken {
143 - let login_url = format!("{}/login", state.config.host_url);
144 - let reset_url = format!("{}/forgot-password", state.config.host_url);
145 - let email_client = state.email.clone();
148 + let login_url = format!("{}/login", config.host_url);
149 + let reset_url = format!("{}/forgot-password", config.host_url);
150 + let email_client = mailer.clone();
146 151 let to_email = email.to_string();
147 - state.bg.spawn("account-exists notice", async move {
152 + bg.spawn("account-exists notice", async move {
148 153 if let Err(e) = email_client
149 154 .send_account_exists(&to_email, &login_url, &reset_url)
150 155 .await
@@ -184,7 +189,7 @@ pub async fn step_account_create(
184 189 // surface as a validation error so the user sees a friendly message
185 190 // (with their typed values preserved) instead of a 500.
186 191 let password_hash = hash_password_async(form.password.clone()).await?;
187 - let user = match db::users::create_user(&state.db, &username, &email, &password_hash).await {
192 + let user = match db::users::create_user(&db, &username, &email, &password_hash).await {
188 193 Ok(u) => u,
189 194 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
190 195 if db_err.code().as_deref() == Some("23505") =>
@@ -206,21 +211,21 @@ pub async fn step_account_create(
206 211 if let Some(ref code_raw) = form.invite_code {
207 212 let code = code_raw.replace('-', "").trim().to_uppercase();
208 213 if !code.is_empty()
209 - && let Some(invite) = db::invites::get_valid_invite_code(&state.db, &code).await?
214 + && let Some(invite) = db::invites::get_valid_invite_code(&db, &code).await?
210 215 // Atomic claim: if a concurrent signup redeemed the same code first,
211 216 // this returns false and we skip the invite side-effects (the signup
212 217 // itself already succeeded).
213 - && db::invites::redeem_invite_code(&state.db, invite.id, user.id).await?
218 + && db::invites::redeem_invite_code(&db, invite.id, user.id).await?
214 219 {
215 - db::waitlist::create_invited_waitlist_entry(&state.db, user.id, invite.creator_id)
220 + db::waitlist::create_invited_waitlist_entry(&db, user.id, invite.creator_id)
216 221 .await?;
217 222
218 223 // Fire-and-forget: notify the inviter
219 224 let inviter_id = invite.creator_id;
220 225 let invitee_username = user.username.to_string();
221 - let email_client = state.email.clone();
222 - let db_pool = state.db.clone();
223 - state.bg.spawn("invite-redeemed notification", async move {
226 + let email_client = mailer.clone();
227 + let db_pool = db.clone();
228 + bg.spawn("invite-redeemed notification", async move {
224 229 if let Ok(Some(inviter)) = db::users::get_user_by_id(&db_pool, inviter_id).await {
225 230 let _ = email_client
226 231 .send_invite_redeemed(
@@ -254,19 +259,19 @@ pub async fn step_account_create(
254 259 is_sandbox: false,
255 260 };
256 261 login_user(&session, session_user).await?;
257 - track_session(&session, &state.db, user_id, &headers).await?;
262 + track_session(&session, &db, user_id, &headers).await?;
258 263
259 264 // Send verification + welcome emails (async)
260 265 let verify_url = email::generate_verification_url(
261 - &state.config.host_url,
266 + &config.host_url,
262 267 user_id,
263 268 &user_email,
264 - &state.config.signing_secret,
269 + &config.signing_secret,
265 270 );
266 - let email_client = state.email.clone();
267 - let welcome_host_url = state.config.host_url.clone();
268 - let welcome_db = state.db.clone();
269 - state.bg.spawn("signup verification + welcome emails", async move {
271 + let email_client = mailer.clone();
272 + let welcome_host_url = config.host_url.clone();
273 + let welcome_db = db.clone();
274 + bg.spawn("signup verification + welcome emails", async move {
270 275 if let Err(e) = email_client
271 276 .send_verification(&user_email, user_display_name.as_deref(), &verify_url)
272 277 .await
@@ -295,19 +300,19 @@ pub async fn step_account_create(
295 300 /// GET `/join/step/{step}`: load a step partial (for back navigation).
296 301 #[tracing::instrument(skip_all, name = "join_wizard::step_load")]
297 302 pub async fn step_load(
298 - State(state): State<AppState>,
303 + State(db): State<PgPool>,
299 304 AuthUser(user): AuthUser,
300 305 session: Session,
301 306 Path(step): Path<String>,
302 307 ) -> Result<Response> {
303 308 let csrf_token = get_csrf_token(&session).await;
304 - render_step(&step, &state, user.id, csrf_token).await
309 + render_step(&step, &db, user.id, csrf_token).await
305 310 }
306 311
307 312 /// POST `/join/step/{step}`: save and return next step.
308 313 #[tracing::instrument(skip_all, name = "join_wizard::step_save")]
309 314 pub async fn step_save(
310 - State(state): State<AppState>,
315 + State(db): State<PgPool>,
311 316 AuthUser(user): AuthUser,
312 317 Path(step): Path<String>,
313 318 Form(form_data): Form<std::collections::HashMap<String, String>>,
@@ -320,14 +325,14 @@ pub async fn step_save(
320 325 let has_bio = bio.as_ref().is_some_and(|s: &String| !s.is_empty());
321 326 if has_display_name || has_bio {
322 327 db::users::update_user_profile(
323 - &state.db,
328 + &db,
324 329 user.id,
325 330 display_name.as_ref().filter(|s: &&String| !s.is_empty()).map(|s| s.as_str()),
326 331 bio.as_ref().filter(|s: &&String| !s.is_empty()).map(|s| s.as_str()),
327 332 )
328 333 .await?;
329 334 }
330 - render_step("complete", &state, user.id, None).await
335 + render_step("complete", &db, user.id, None).await
331 336 }
332 337 _ => Err(AppError::NotFound),
333 338 }
@@ -342,7 +347,7 @@ fn render_step_profile() -> Response {
342 347 }
343 348
344 349 /// Render a step partial with the sidebar nav.
345 - async fn render_step(step: &str, state: &AppState, user_id: db::UserId, csrf_token: Option<String>) -> Result<Response> {
350 + async fn render_step(step: &str, db: &PgPool, user_id: db::UserId, csrf_token: Option<String>) -> Result<Response> {
346 351 match step {
347 352 "account" => {
348 353 // Thread a real CSRF token so a back-nav to the account step renders a
@@ -360,10 +365,10 @@ async fn render_step(step: &str, state: &AppState, user_id: db::UserId, csrf_tok
360 365 }
361 366 "profile" => Ok(render_step_profile()),
362 367 "complete" => {
363 - let user = db::users::get_user_by_id(&state.db, user_id)
368 + let user = db::users::get_user_by_id(db, user_id)
364 369 .await?
365 370 .ok_or(AppError::NotFound)?;
366 - let has_invite = db::waitlist::get_waitlist_entry_by_user(&state.db, user_id)
371 + let has_invite = db::waitlist::get_waitlist_entry_by_user(db, user_id)
367 372 .await?
368 373 .is_some();
369 374 Ok(WizardJoinCompleteTemplate {
@@ -6,10 +6,12 @@ use axum::{
6 6 response::{IntoResponse, Redirect, Response},
7 7 };
8 8 use serde::Deserialize;
9 + use sqlx::PgPool;
9 10 use tower_sessions::Session;
10 11
11 12 use crate::{
12 13 auth::{AuthUser, MaybeUserUnverified},
14 + config::Config,
13 15 constants,
14 16 db,
15 17 error::{AppError, Result},
@@ -17,7 +19,7 @@ use crate::{
17 19 routes::custom_domain,
18 20 templates::*,
19 21 types::*,
20 - AppState,
22 + AppState, Billing,
21 23 };
22 24
23 25 /// Render the landing page, or redirect authenticated users to the library.
@@ -114,14 +116,15 @@ pub(super) async fn index(
114 116 /// Render the authenticated user's library with inline purchases tab.
115 117 #[tracing::instrument(skip_all, name = "landing::library")]
116 118 pub(super) async fn library(
117 - State(state): State<AppState>,
119 + State(db): State<PgPool>,
120 + State(config): State<Config>,
118 121 session: Session,
119 122 AuthUser(user): AuthUser,
120 123 ) -> Result<impl IntoResponse> {
121 - let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?;
122 - let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
124 + let purchases = db::transactions::get_user_purchases(&db, user.id).await?;
125 + let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?;
123 126 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
124 - let has_mt_memberships = state.config.integrations.mt_base_url.is_some();
127 + let has_mt_memberships = config.integrations.mt_base_url.is_some();
125 128 Ok(LibraryTemplate {
126 129 csrf_token: get_csrf_token(&session).await,
127 130 session_user: Some(user),
@@ -140,7 +143,7 @@ pub(super) struct CartQuery {
140 143 /// Render the shopping cart page with items grouped by seller.
141 144 #[tracing::instrument(skip_all, name = "landing::cart_page")]
142 145 pub(super) async fn cart_page(
143 - State(state): State<AppState>,
146 + State(db): State<PgPool>,
144 147 session: Session,
145 148 AuthUser(user): AuthUser,
146 149 Query(query): Query<CartQuery>,
@@ -148,7 +151,7 @@ pub(super) async fn cart_page(
148 151 use std::collections::BTreeMap;
149 152 use crate::templates::CartSellerGroup;
150 153
151 - let cart_items = db::cart::get_cart_items(&state.db, user.id).await?;
154 + let cart_items = db::cart::get_cart_items(&db, user.id).await?;
152 155
153 156 // Group by seller
154 157 let mut groups: BTreeMap<String, Vec<db::cart::CartItem>> = BTreeMap::new();
@@ -187,7 +190,7 @@ pub(super) async fn cart_page(
187 190 let total_items: usize = seller_groups.iter().map(|g| g.item_count).sum();
188 191
189 192 // Wishlist suggestions: items in wishlist but not in cart
190 - let wishlist = db::wishlists::get_wishlist(&state.db, user.id).await?;
193 + let wishlist = db::wishlists::get_wishlist(&db, user.id).await?;
191 194 let cart_item_ids: std::collections::HashSet<_> = cart_items.iter().map(|i| i.item_id).collect();
192 195 let wishlist_suggestions: Vec<_> = wishlist
193 196 .into_iter()
@@ -208,11 +211,11 @@ pub(super) async fn cart_page(
208 211 /// HTMX partial: library purchases tab (includes subscriptions).
209 212 #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")]
210 213 pub(super) async fn library_tab_purchases(
211 - State(state): State<AppState>,
214 + State(db): State<PgPool>,
212 215 AuthUser(user): AuthUser,
213 216 ) -> Result<impl IntoResponse> {
214 - let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?;
215 - let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
217 + let purchases = db::transactions::get_user_purchases(&db, user.id).await?;
218 + let db_subs = db::subscriptions::get_user_subscriptions_with_details(&db, user.id).await?;
216 219 let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
217 220 Ok(LibraryPurchasesTabTemplate { purchases, subscriptions })
218 221 }
@@ -220,7 +223,7 @@ pub(super) async fn library_tab_purchases(
220 223 /// HTMX partial: library feed tab.
221 224 #[tracing::instrument(skip_all, name = "landing::library_tab_feed")]
222 225 pub(super) async fn library_tab_feed(
223 - State(state): State<AppState>,
226 + State(db): State<PgPool>,
224 227 AuthUser(user): AuthUser,
225 228 Query(query): Query<super::feed::FeedQuery>,
226 229 ) -> Result<impl IntoResponse> {
@@ -232,11 +235,11 @@ pub(super) async fn library_tab_feed(
232 235 let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000);
233 236 let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64;
234 237
235 - let total_items = db::follows::count_followed_feed_items(&state.db, user.id).await? as u32;
238 + let total_items = db::follows::count_followed_feed_items(&db, user.id).await? as u32;
236 239 let total_pages = (total_items + constants::FEED_PAGE_SIZE - 1) / constants::FEED_PAGE_SIZE.max(1);
237 240
238 241 let db_items = db::follows::get_followed_feed_items(
239 - &state.db,
242 + &db,
240 243 user.id,
241 244 constants::FEED_PAGE_SIZE as i64,
242 245 offset,
@@ -272,12 +275,12 @@ pub(super) async fn library_tab_feed(
272 275 /// HTMX partial: library collections tab (includes wishlists).
273 276 #[tracing::instrument(skip_all, name = "landing::library_tab_collections")]
274 277 pub(super) async fn library_tab_collections(
275 - State(state): State<AppState>,
278 + State(db): State<PgPool>,
276 279 AuthUser(user): AuthUser,
277 280 ) -> Result<impl IntoResponse> {
278 - let db_collections = db::collections::get_collections_by_user(&state.db, user.id).await?;
281 + let db_collections = db::collections::get_collections_by_user(&db, user.id).await?;
279 282 let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
280 - let wishlists = db::wishlists::get_wishlist(&state.db, user.id).await?;
283 + let wishlists = db::wishlists::get_wishlist(&db, user.id).await?;
281 284 Ok(LibraryCollectionsTabTemplate {
282 285 collections,
283 286 username: user.username.to_string(),
@@ -288,17 +291,17 @@ pub(super) async fn library_tab_collections(
288 291 /// HTMX partial: library contacts tab.
289 292 #[tracing::instrument(skip_all, name = "landing::library_tab_contacts")]
290 293 pub(super) async fn library_tab_contacts(
291 - State(state): State<AppState>,
294 + State(db): State<PgPool>,
292 295 AuthUser(user): AuthUser,
293 296 ) -> Result<impl IntoResponse> {
294 - let shared_creators = db::transactions::get_shared_creators(&state.db, user.id).await?;
297 + let shared_creators = db::transactions::get_shared_creators(&db, user.id).await?;
295 298
296 299 // Fetch seller contacts (buyers who shared their email) if user is a creator
297 - let db_user = db::users::get_user_by_id(&state.db, user.id)
300 + let db_user = db::users::get_user_by_id(&db, user.id)
298 301 .await?
299 302 .ok_or(AppError::NotFound)?;
300 303 let db_contacts = if db_user.can_create_projects {
301 - db::transactions::get_seller_contacts(&state.db, user.id).await?
304 + db::transactions::get_seller_contacts(&db, user.id).await?
302 305 } else {
303 306 vec![]
304 307 };
@@ -320,10 +323,10 @@ pub(super) async fn library_tab_contacts(
320 323 /// HTMX partial: library communities tab (Multithreaded forum memberships).
321 324 #[tracing::instrument(skip_all, name = "landing::library_tab_communities")]
322 325 pub(super) async fn library_tab_communities(
323 - State(state): State<AppState>,
326 + State(config): State<Config>,
324 327 AuthUser(user): AuthUser,
325 328 ) -> Result<axum::response::Response> {
326 - let mt_base_url = match state.config.integrations.mt_base_url.as_ref() {
329 + let mt_base_url = match config.integrations.mt_base_url.as_ref() {
327 330 Some(url) => url,
328 331 None => {
329 332 return Ok(LibraryCommunitiesTabTemplate {
@@ -404,11 +407,11 @@ pub(crate) struct LoginQuery {
404 407 /// Render the login page.
405 408 #[tracing::instrument(skip_all, name = "landing::login_page")]
406 409 pub(crate) async fn login_page(
407 - State(state): State<AppState>,
410 + State(config): State<Config>,
408 411 session: Session,
409 412 Query(query): Query<LoginQuery>,
410 413 ) -> impl IntoResponse {
411 - let sso_enabled = state.config.sso.is_some();
414 + let sso_enabled = config.sso.is_some();
412 415 let notice = match query.gate.as_deref() {
413 416 Some("fan_plus_or_creator") => Some(
414 417 "This is the testnot.work preview, open to creators and Fan+ members. Log in to continue."
@@ -428,15 +431,15 @@ pub(crate) async fn login_page(
428 431 /// Render the interactive pricing calculator page.
429 432 #[tracing::instrument(skip_all, name = "landing::pricing_page")]
430 433 pub(super) async fn pricing_page(
431 - State(state): State<AppState>,
434 + State(payments): State<Billing>,
432 435 session: Session,
433 436 ) -> impl IntoResponse {
434 - let comparison = state
437 + let comparison = payments
435 438 .pricing_comparison
436 - .compute(PRICING_DEFAULT_REVENUE, state.tier_prices.basic_std as f64);
439 + .compute(PRICING_DEFAULT_REVENUE, payments.tier_prices.basic_std as f64);
437 440 PricingTemplate {
438 441 csrf_token: get_csrf_token(&session).await,
439 - tier_prices: state.tier_prices.clone(),
442 + tier_prices: payments.tier_prices.clone(),
440 443 comparison,
441 444 }
442 445 }
@@ -458,7 +461,7 @@ pub(super) struct PricingCompareQuery {
458 461 /// Pure computation, no auth, no state change — a GET so no CSRF is needed.
459 462 #[tracing::instrument(skip_all, name = "landing::pricing_compare")]
460 463 pub(super) async fn pricing_compare(
461 - State(state): State<AppState>,
464 + State(payments): State<Billing>,
462 465 Query(q): Query<PricingCompareQuery>,
463 466 ) -> impl IntoResponse {
464 467 let parse = |s: Option<String>| {
@@ -468,9 +471,9 @@ pub(super) async fn pricing_compare(
468 471 let revenue = parse(q.revenue).unwrap_or(0.0).clamp(0.0, 999_999.0);
469 472 let tier = parse(q.tier)
470 473 .filter(|v| *v >= 0.0)
471 - .unwrap_or(state.tier_prices.basic_std as f64);
474 + .unwrap_or(payments.tier_prices.basic_std as f64);
472 475 PricingComparisonPartial {
473 - comparison: state.pricing_comparison.compute(revenue, tier),
476 + comparison: payments.pricing_comparison.compute(revenue, tier),
474 477 }
475 478 }
476 479
@@ -484,18 +487,19 @@ pub(super) async fn pricing_compare(
484 487 /// no caching needed at current load.
485 488 #[tracing::instrument(skip_all, name = "landing::economics_page")]
486 489 pub(super) async fn economics_page(
487 - State(state): State<AppState>,
490 + State(db): State<PgPool>,
491 + State(payments): State<Billing>,
488 492 session: Session,
489 493 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
490 494 ) -> Result<impl IntoResponse> {
491 495 let paying_creators =
492 - crate::db::creator_tiers::count_active_paying(&state.db).await?;
496 + crate::db::creator_tiers::count_active_paying(&db).await?;
493 497 let trialing_or_grace =
494 - crate::db::creator_tiers::count_trialing_or_grace(&state.db).await?;
498 + crate::db::creator_tiers::count_trialing_or_grace(&db).await?;
495 499 Ok(EconomicsTemplate {
496 500 csrf_token: get_csrf_token(&session).await,
497 501 session_user: maybe_user,
498 - runway_config: state.runway_config.clone(),
502 + runway_config: payments.runway_config.clone(),
499 503 paying_creators,
500 504 trialing_or_grace,
501 505 })
@@ -532,14 +536,14 @@ pub(super) async fn checkout_complete() -> impl IntoResponse {
532 536 /// Render the use cases page.
533 537 #[tracing::instrument(skip_all, name = "landing::use_cases_page")]
534 538 pub(super) async fn use_cases_page(
535 - State(state): State<AppState>,
539 + State(payments): State<Billing>,
536 540 session: Session,
537 541 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
538 542 ) -> impl IntoResponse {
539 543 UseCasesTemplate {
540 544 csrf_token: get_csrf_token(&session).await,
541 545 session_user: maybe_user,
542 - tier_prices: state.tier_prices.clone(),
546 + tier_prices: payments.tier_prices.clone(),
543 547 }
544 548 }
545 549
@@ -578,7 +582,7 @@ pub(super) struct FanPlusQuery {
578 582 /// Render the Fan+ subscription page.
579 583 #[tracing::instrument(skip_all, name = "landing::fan_plus_page")]
580 584 pub(super) async fn fan_plus_page(
581 - State(state): State<AppState>,
585 + State(db): State<PgPool>,
582 586 session: Session,
583 587 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
584 588 Query(query): Query<FanPlusQuery>,
@@ -586,7 +590,7 @@ pub(super) async fn fan_plus_page(
586 590 let csrf_token = get_csrf_token(&session).await;
587 591
588 592 let (is_subscribed, period_end) = if let Some(ref user) = maybe_user {
589 - let fan_sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id).await?;
593 + let fan_sub = db::fan_plus::get_fan_plus_by_user(&db, user.id).await?;
590 594 match fan_sub {
591 595 Some(sub) if sub.status == "active" => {
592 596 let end = sub.current_period_end.map(|d| d.format("%B %-d, %Y").to_string());
@@ -16,6 +16,7 @@ use axum::{
16 16 response::{IntoResponse, Redirect},
17 17 routing::get,
18 18 };
19 + use sqlx::PgPool;
19 20 use tower_sessions::Session;
20 21
21 22 use crate::{
@@ -27,7 +28,7 @@ use crate::{
27 28 helpers::get_csrf_token,
28 29 templates::*,
29 30 types::*,
30 - AppState,
31 + AppState, Billing,
31 32 };
32 33
33 34 use tower_governor::GovernorLayer;
@@ -128,15 +129,16 @@ pub fn public_routes() -> CsrfRouter<AppState> {
128 129 /// Render the public creators page showing invite waves and waitlist stats.
129 130 #[tracing::instrument(skip_all, name = "pages::creators_page")]
130 131 async fn creators_page(
131 - State(state): State<AppState>,
132 + State(db): State<PgPool>,
133 + State(payments): State<Billing>,
132 134 session: Session,
133 135 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
134 136 ) -> Result<impl IntoResponse> {
135 137 let csrf_token = get_csrf_token(&session).await;
136 138
137 - let waves = db::waitlist::get_all_waves(&state.db).await?;
138 - let total_creators = db::waitlist::count_active_creators(&state.db).await? as u32;
139 - let waitlist_pending = db::waitlist::count_waitlist_pending(&state.db).await? as u32;
139 + let waves = db::waitlist::get_all_waves(&db).await?;
140 + let total_creators = db::waitlist::count_active_creators(&db).await? as u32;
141 + let waitlist_pending = db::waitlist::count_waitlist_pending(&db).await? as u32;
140 142
141 143 let is_creator = maybe_user.as_ref().map(|u| u.can_create_projects).unwrap_or(false);
142 144
@@ -149,6 +151,6 @@ async fn creators_page(
149 151 total_creators,
150 152 waitlist_pending,
151 153 is_creator,
152 - tier_prices: state.tier_prices.clone(),
154 + tier_prices: payments.tier_prices.clone(),
153 155 })
154 156 }
@@ -9,8 +9,9 @@ use axum::{
9 9 http::header,
10 10 };
11 11 use chrono::{DateTime, Utc};
12 + use sqlx::PgPool;
12 13
13 - use crate::{error::Result, AppState};
14 + use crate::{config::Config, error::Result};
14 15
15 16 /// In-memory cache for the rendered sitemap XML. Crawlers hit this rarely
16 17 /// and the same response is fine for ~10 min; without the cache an attacker
@@ -22,8 +23,8 @@ static SITEMAP_CACHE: OnceLock<Mutex<Option<(Instant, String)>>> = OnceLock::new
22 23 /// `/robots.txt`. Permits indexing the public surface; blocks the dashboard,
23 24 /// API, admin tooling, and authentication paths where indexed URLs would
24 25 /// be noise or actively harmful (login-page leakage, stale checkout URLs).
25 - pub(super) async fn robots_txt(State(state): State<AppState>) -> impl IntoResponse {
26 - let host = &state.config.host_url;
26 + pub(super) async fn robots_txt(State(config): State<Config>) -> impl IntoResponse {
27 + let host = &config.host_url;
27 28 let body = format!(
28 29 "User-agent: *\n\
29 30 Disallow: /dashboard\n\
@@ -55,7 +56,10 @@ pub(super) async fn robots_txt(State(state): State<AppState>) -> impl IntoRespon
55 56 /// profiles and their public items. Capped to keep the response bounded and
56 57 /// cached for SITEMAP_CACHE_TTL to absorb crawler-or-attacker hammering
57 58 /// without firing two large `fetch_all` queries per request.
58 - pub(super) async fn sitemap_xml(State(state): State<AppState>) -> Result<Response> {
59 + pub(super) async fn sitemap_xml(
60 + State(db): State<PgPool>,
61 + State(config): State<Config>,
62 + ) -> Result<Response> {
59 63 let cache = SITEMAP_CACHE.get_or_init(|| Mutex::new(None));
60 64 if let Ok(guard) = cache.lock()
61 65 && let Some((generated_at, cached_xml)) = guard.as_ref()
@@ -68,7 +72,7 @@ pub(super) async fn sitemap_xml(State(state): State<AppState>) -> Result<Respons
68 72 .into_response());
69 73 }
70 74
71 - let host = state.config.host_url.trim_end_matches('/').to_string();
75 + let host = config.host_url.trim_end_matches('/').to_string();
72 76
73 77 // Active creator usernames (anyone with at least one public, listed,
74 78 // non-deleted item under a public project).
@@ -86,7 +90,7 @@ pub(super) async fn sitemap_xml(State(state): State<AppState>) -> Result<Respons
86 90 LIMIT 5000
87 91 "#,
88 92 )
89 - .fetch_all(&state.db)
93 + .fetch_all(&db)
90 94 .await?;
91 95
92 96 // Public items (id + updated_at for <lastmod>).
@@ -103,7 +107,7 @@ pub(super) async fn sitemap_xml(State(state): State<AppState>) -> Result<Respons
103 107 LIMIT 20000
104 108 "#,
105 109 )
106 - .fetch_all(&state.db)
110 + .fetch_all(&db)
107 111 .await?;
108 112
109 113 let mut xml = String::with_capacity(64 * 1024);
@@ -12,13 +12,15 @@ use tower_sessions::{Expiry, Session};
12 12
13 13 use crate::{
14 14 auth::{login_user, track_session, SessionUser},
15 + background::BackgroundTx,
16 + config::Config,
15 17 constants,
16 18 db::{self, UserId},
19 + email::EmailClient,
17 20 error::{AppError, Result, ResultExt},
18 - helpers::{get_csrf_token, is_htmx_request, spawn_email},
21 + helpers::{get_csrf_token, is_htmx_request},
19 22 routes::api::totp::{build_totp, find_matching_step},
20 23 templates::*,
21 - AppState,
22 24 };
23 25
24 26 /// Session key for the pending 2FA user ID.
@@ -68,7 +70,7 @@ async fn pending_2fa_expired(session: &Session) -> bool {
68 70 /// Render the 2FA verification page (GET /auth/2fa).
69 71 #[tracing::instrument(skip_all, name = "two_factor::two_factor_page")]
70 72 pub(super) async fn two_factor_page(
71 - State(state): State<AppState>,
73 + State(db): State<PgPool>,
72 74 session: Session,
73 75 ) -> Result<Response> {
74 76 // Verify the user is in a valid 2FA flow
@@ -79,7 +81,7 @@ pub(super) async fn two_factor_page(
79 81 .ok_or_else(|| AppError::BadRequest("No pending 2FA session".to_string()))?;
80 82
81 83 if pending_2fa_expired(&session).await {
82 - clear_pending_2fa(&session, &state.db).await;
84 + clear_pending_2fa(&session, &db).await;
83 85 return Err(AppError::BadRequest(
84 86 "Your 2FA session expired. Please log in again.".to_string(),
85 87 ));
@@ -92,9 +94,9 @@ pub(super) async fn two_factor_page(
92 94 .await
93 95 .ok()
94 96 .flatten()
95 - && !db::sessions::pending_2fa_session_exists(&state.db, tracking_id, user_id).await?
97 + && !db::sessions::pending_2fa_session_exists(&db, tracking_id, user_id).await?
96 98 {
97 - clear_pending_2fa(&session, &state.db).await;
99 + clear_pending_2fa(&session, &db).await;
98 100 return Err(AppError::BadRequest(
99 101 "Your session was revoked. Please log in again.".to_string(),
100 102 ));
@@ -119,7 +121,10 @@ pub struct VerifyTwoFactorForm {
119 121 /// Verify the TOTP or backup code and complete login (POST /auth/verify-2fa).
120 122 #[tracing::instrument(skip_all, name = "two_factor::verify_two_factor")]
121 123 pub(super) async fn verify_two_factor(
122 - State(state): State<AppState>,
124 + State(db): State<PgPool>,
125 + State(config): State<Config>,
126 + State(email): State<EmailClient>,
127 + State(bg): State<BackgroundTx>,
123 128 headers: HeaderMap,
124 129 session: Session,
125 130 Form(form): Form<VerifyTwoFactorForm>,
@@ -133,7 +138,7 @@ pub(super) async fn verify_two_factor(
133 138 .ok_or_else(|| AppError::BadRequest("No pending 2FA session".to_string()))?;
134 139
135 140 if pending_2fa_expired(&session).await {
136 - clear_pending_2fa(&session, &state.db).await;
141 + clear_pending_2fa(&session, &db).await;
137 142 return Err(AppError::BadRequest(
138 143 "Your 2FA session expired. Please log in again.".to_string(),
139 144 ));
@@ -148,15 +153,15 @@ pub(super) async fn verify_two_factor(
148 153 .await
149 154 .ok()
150 155 .flatten()
151 - && !db::sessions::pending_2fa_session_exists(&state.db, tracking_id, user_id).await?
156 + && !db::sessions::pending_2fa_session_exists(&db, tracking_id, user_id).await?
152 157 {
153 - clear_pending_2fa(&session, &state.db).await;
158 + clear_pending_2fa(&session, &db).await;
154 159 return Err(AppError::BadRequest(
155 160 "Your session was revoked. Please log in again.".to_string(),
156 161 ));
157 162 }
158 163
159 - let user = db::users::get_user_by_id(&state.db, user_id)
164 + let user = db::users::get_user_by_id(&db, user_id)
160 165 .await?
161 166 .ok_or(AppError::Unauthorized)?;
162 167
@@ -165,7 +170,7 @@ pub(super) async fn verify_two_factor(
165 170 if let Some(locked_until) = user.locked_until
166 171 && locked_until > chrono::Utc::now()
167 172 {
168 - clear_pending_2fa(&session, &state.db).await;
173 + clear_pending_2fa(&session, &db).await;
169 174 let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1;
170 175 let csrf_token = get_csrf_token(&session).await;
171 176 return Ok(TwoFactorTemplate {
@@ -189,7 +194,7 @@ pub(super) async fn verify_two_factor(
189 194 // the whole login — that would also skip the backup-code path below and lock
190 195 // the user out entirely. Log it and fall through to backup codes instead.
191 196 if let Some(ref stored_secret) = user.totp_secret {
192 - match crate::crypto::decrypt_totp_secret(stored_secret, &state.config.signing_secret) {
197 + match crate::crypto::decrypt_totp_secret(stored_secret, &config.signing_secret) {
193 198 Ok(secret) => {
194 199 let totp = build_totp(&secret, &user.email)?;
195 200 // Find the actual step that matched (not just wall-clock step) to
@@ -202,7 +207,7 @@ pub(super) async fn verify_two_factor(
202 207 // concurrent submission of the same code that already advanced
203 208 // the step returns false here and falls through to backup
204 209 // codes instead of being accepted twice.
205 - verified = db::totp::set_totp_last_used_step(&state.db, user_id, step).await?;
210 + verified = db::totp::set_totp_last_used_step(&db, user_id, step).await?;
206 211 }
207 212 }
208 213 Err(e) => {
@@ -223,10 +228,10 @@ pub(super) async fn verify_two_factor(
223 228 // `verify_and_consume_backup_code` per row.
224 229 if !verified {
225 230 let legacy_hmac = crate::routes::api::totp::legacy_hmac_backup_code(
226 - &code, &state.config.signing_secret,
231 + &code, &config.signing_secret,
227 232 );
228 233 if db::totp::verify_and_consume_backup_code(
229 - &state.db, user_id, &code, &legacy_hmac,
234 + &db, user_id, &code, &legacy_hmac,
230 235 ).await? {
231 236 verified = true;
232 237 }
@@ -236,7 +241,7 @@ pub(super) async fn verify_two_factor(
236 241 // Track failed 2FA attempts toward account lockout (same counter as
237 242 // failed password attempts — prevents brute-forcing 6-digit TOTP codes)
238 243 db::auth::increment_failed_login(
239 - &state.db,
244 + &db,
240 245 user_id,
241 246 constants::MAX_LOGIN_ATTEMPTS,
242 247 constants::LOCKOUT_MINUTES,
@@ -244,13 +249,13 @@ pub(super) async fn verify_two_factor(
244 249 .await?;
245 250
246 251 // Check if this attempt triggered a lockout
247 - let user_after = db::users::get_user_by_id(&state.db, user_id).await?;
252 + let user_after = db::users::get_user_by_id(&db, user_id).await?;
248 253 if let Some(ref u) = user_after
249 254 && let Some(locked_until) = u.locked_until
250 255 && locked_until > chrono::Utc::now()
251 256 {
252 257 // Clear the 2FA flow — account is now locked
253 - clear_pending_2fa(&session, &state.db).await;
258 + clear_pending_2fa(&session, &db).await;
254 259 let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1;
255 260 let csrf_token = get_csrf_token(&session).await;
256 261 return Ok(TwoFactorTemplate {
@@ -274,7 +279,7 @@ pub(super) async fn verify_two_factor(
274 279 }
275 280
276 281 // Successful 2FA — reset failed login counter
277 - db::auth::reset_failed_login(&state.db, user_id).await?;
282 + db::auth::reset_failed_login(&db, user_id).await?;
278 283
279 284 // Retrieve stored notification info
280 285 let notify_enabled: bool = session
@@ -303,23 +308,23 @@ pub(super) async fn verify_two_factor(
303 308 .unwrap_or(false);
304 309
305 310 // Clear pending 2FA state
306 - clear_pending_2fa(&session, &state.db).await;
311 + clear_pending_2fa(&session, &db).await;
307 312
308 313 // Complete login
309 - let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await;
314 + let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await;
310 315
311 316 login_user(&session, session_user).await?;
312 317 if !remember {
313 318 session.set_expiry(Some(Expiry::OnSessionEnd));
314 319 }
315 - track_session(&session, &state.db, user_id, &headers).await?;
320 + track_session(&session, &db, user_id, &headers).await?;
316 321 tracing::info!(user_id = %user_id, event = "login_2fa_success", "User completed 2FA login");
317 322
318 323 // Send login notification (same as in auth.rs)
319 324 if notify_enabled
320 325 && let Some(email_addr) = notify_email
321 326 {
322 - let session_count = match db::sessions::count_user_sessions(&state.db, user_id).await {
327 + let session_count = match db::sessions::count_user_sessions(&db, user_id).await {
323 328 Ok(n) => n,
324 329 Err(e) => { tracing::warn!("Failed to count sessions for login notification: {e}"); 0 }
325 330 };
@@ -330,16 +335,22 @@ pub(super) async fn verify_two_factor(
330 335 .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::<String>());
331 336 let ip = crate::helpers::extract_client_ip(&headers);
332 337 let unsub_url = crate::email::generate_unsubscribe_url(
333 - &state.config.host_url, user_id, crate::email::UnsubscribeAction::Login, &user_id.to_string(), &state.config.signing_secret,
338 + &config.host_url, user_id, crate::email::UnsubscribeAction::Login, &user_id.to_string(), &config.signing_secret,
334 339 );
335 - spawn_email!(state, "login notification", |email| {
336 - email.send_new_login_notification(
337 - &email_addr,
338 - notify_name.as_deref(),
339 - user_agent.as_deref(),
340 - ip.as_deref(),
341 - Some(&unsub_url),
342 - )
340 + let email_client = email.clone();
341 + bg.spawn("login notification", async move {
342 + if let Err(e) = email_client
343 + .send_new_login_notification(
344 + &email_addr,
345 + notify_name.as_deref(),
346 + user_agent.as_deref(),
347 + ip.as_deref(),
348 + Some(&unsub_url),
349 + )
350 + .await
351 + {
352 + tracing::error!(error = ?e, "failed to send login notification");
353 + }
343 354 });
344 355 }
345 356 }