Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate custom_domain + content renderers Migrate the coupled cluster that blocked the public-page holdouts. Narrow the three shared pub(crate) renderers: - content::render_user_profile / render_project_page: (&PgPool, &Config, ...) - content::render_item_page: (&PgPool, &Integrations, &Config, ...) and their callers: item_page/project_page/user_page (add page_view_tx for track_view + Integrations for item), custom_domain try_handle/fallback/wrappers (AppCaches domain_cache + db + Integrations + Config), and landing::index (+ Billing for tier_prices). This eliminates the item/project/user-page and landing holdouts. Routes tree is now down to 7 legitimate State<AppState> holdouts (scheduler helpers x4, AppState::delete_user_account x2, require_admin_layer gate x1). No behavior change; item_page 1 + project_page 4 + custom_domain 14 + public 10 + landing 3 + profile 4 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 12:56 UTC
Commit: f6a7b231c92be93b15038aac0b370c58f1f01746
Parent: 6f4354c
5 files changed, +131 insertions, -102 deletions
@@ -9,14 +9,16 @@ use axum::{
9 9 http::{HeaderMap, StatusCode, Uri},
10 10 response::{IntoResponse, Response},
11 11 };
12 + use sqlx::PgPool;
12 13 use tower_sessions::Session;
13 14
14 15 use crate::{
15 16 auth::{MaybeUserVerified, SessionUser},
17 + config::Config,
16 18 db::{self, Slug},
17 19 error::{AppError, Result},
18 20 helpers::get_csrf_token,
19 - AppState,
21 + AppCaches, Integrations,
20 22 };
21 23
22 24 use super::pages::public::content;
@@ -50,8 +52,12 @@ fn is_mnw_domain(host: &str) -> bool {
50 52 ///
51 53 /// Called from named route handlers (e.g. `landing::index` for `/`) where the
52 54 /// fallback handler wouldn't fire because the route is already matched.
55 + #[allow(clippy::too_many_arguments)]
53 56 pub async fn try_handle(
54 - state: &AppState,
57 + db: &PgPool,
58 + caches: &AppCaches,
59 + integrations: &Integrations,
60 + config: &Config,
55 61 headers: &HeaderMap,
56 62 path: &str,
57 63 session: &Session,
@@ -63,7 +69,7 @@ pub async fn try_handle(
63 69 return None;
64 70 }
65 71
66 - let user_id = state.caches.domain_cache.get(&host).map(|e| *e.value())?;
72 + let user_id = caches.domain_cache.get(&host).map(|e| *e.value())?;
67 73
68 74 let segments: Vec<&str> = path
69 75 .trim_start_matches('/')
@@ -72,10 +78,12 @@ pub async fn try_handle(
72 78 .collect();
73 79
74 80 let result = match segments.as_slice() {
75 - [] => render_user_profile(state, user_id, session, maybe_user).await,
76 - [project_slug] => render_project(state, user_id, project_slug, session, maybe_user).await,
81 + [] => render_user_profile(db, config, user_id, session, maybe_user).await,
82 + [project_slug] => {
83 + render_project(db, config, user_id, project_slug, session, maybe_user).await
84 + }
77 85 [project_slug, item_slug] => {
78 - render_item(state, user_id, project_slug, item_slug, session, maybe_user).await
86 + render_item(db, integrations, config, user_id, project_slug, item_slug, session, maybe_user).await
79 87 }
80 88 _ => return Some(StatusCode::NOT_FOUND.into_response()),
81 89 };
@@ -103,8 +111,12 @@ pub async fn try_handle(
103 111 ///
104 112 /// MNW domains get a standard 404.
105 113 #[tracing::instrument(skip_all, name = "custom_domain::fallback")]
114 + #[allow(clippy::too_many_arguments)]
106 115 pub async fn custom_domain_fallback(
107 - State(state): State<AppState>,
116 + State(db): State<PgPool>,
117 + State(caches): State<AppCaches>,
118 + State(integrations): State<Integrations>,
119 + State(config): State<Config>,
108 120 headers: HeaderMap,
109 121 uri: Uri,
110 122 session: Session,
@@ -120,7 +132,7 @@ pub async fn custom_domain_fallback(
120 132 }
121 133
122 134 // Look up custom domain
123 - let user_id = match state.caches.domain_cache.get(&host) {
135 + let user_id = match caches.domain_cache.get(&host) {
124 136 Some(entry) => *entry.value(),
125 137 None => return StatusCode::NOT_FOUND.into_response(),
126 138 };
@@ -130,12 +142,12 @@ pub async fn custom_domain_fallback(
130 142 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
131 143
132 144 let result = match segments.as_slice() {
133 - [] => render_user_profile(&state, user_id, &session, &maybe_user).await,
145 + [] => render_user_profile(&db, &config, user_id, &session, &maybe_user).await,
134 146 [project_slug] => {
135 - render_project(&state, user_id, project_slug, &session, &maybe_user).await
147 + render_project(&db, &config, user_id, project_slug, &session, &maybe_user).await
136 148 }
137 149 [project_slug, item_slug] => {
138 - render_item(&state, user_id, project_slug, item_slug, &session, &maybe_user).await
150 + render_item(&db, &integrations, &config, user_id, project_slug, item_slug, &session, &maybe_user).await
139 151 }
140 152 _ => return StatusCode::NOT_FOUND.into_response(),
141 153 };
@@ -154,21 +166,23 @@ pub async fn custom_domain_fallback(
154 166
155 167 /// Render a user profile for a custom domain.
156 168 async fn render_user_profile(
157 - state: &AppState,
169 + db: &PgPool,
170 + config: &Config,
158 171 user_id: db::UserId,
159 172 session: &Session,
160 173 maybe_user: &Option<SessionUser>,
161 174 ) -> Result<Response> {
162 175 let csrf_token = get_csrf_token(session).await;
163 - let db_user = db::users::get_user_by_id(&state.db, user_id)
176 + let db_user = db::users::get_user_by_id(db, user_id)
164 177 .await?
165 178 .ok_or(AppError::NotFound)?;
166 - content::render_user_profile(state, &db_user, csrf_token, maybe_user.clone()).await
179 + content::render_user_profile(db, config, &db_user, csrf_token, maybe_user.clone()).await
167 180 }
168 181
169 182 /// Render a project page for a custom domain (scoped to user_id + slug).
170 183 async fn render_project(
171 - state: &AppState,
184 + db: &PgPool,
185 + config: &Config,
172 186 user_id: db::UserId,
173 187 project_slug: &str,
174 188 session: &Session,
@@ -177,15 +191,18 @@ async fn render_project(
177 191 let csrf_token = get_csrf_token(session).await;
178 192 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
179 193 let db_project =
180 - db::projects::get_public_project_by_user_and_slug(&state.db, user_id, &slug)
194 + db::projects::get_public_project_by_user_and_slug(db, user_id, &slug)
181 195 .await?
182 196 .ok_or(AppError::NotFound)?;
183 - content::render_project_page(state, &db_project, csrf_token, maybe_user.clone()).await
197 + content::render_project_page(db, config, &db_project, csrf_token, maybe_user.clone()).await
184 198 }
185 199
186 200 /// Render an item page for a custom domain (scoped to user_id + project slug + item slug).
201 + #[allow(clippy::too_many_arguments)]
187 202 async fn render_item(
188 - state: &AppState,
203 + db: &PgPool,
204 + integrations: &Integrations,
205 + config: &Config,
189 206 user_id: db::UserId,
190 207 project_slug: &str,
191 208 item_slug: &str,
@@ -195,16 +212,16 @@ async fn render_item(
195 212 let csrf_token = get_csrf_token(session).await;
196 213 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
197 214 let db_project =
198 - db::projects::get_public_project_by_user_and_slug(&state.db, user_id, &slug)
215 + db::projects::get_public_project_by_user_and_slug(db, user_id, &slug)
199 216 .await?
200 217 .ok_or(AppError::NotFound)?;
201 - let db_item = db::items::get_item_by_project_and_slug(&state.db, db_project.id, item_slug)
218 + let db_item = db::items::get_item_by_project_and_slug(db, db_project.id, item_slug)
202 219 .await?
203 220 .ok_or(AppError::NotFound)?;
204 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
221 + let db_user = db::users::get_user_by_id(db, db_project.user_id)
205 222 .await?
206 223 .ok_or(AppError::NotFound)?;
207 - content::render_item_page(state, &db_item, &db_project, &db_user, csrf_token, maybe_user.clone()).await
224 + content::render_item_page(db, integrations, config, &db_item, &db_project, &db_user, csrf_token, maybe_user.clone()).await
208 225 }
209 226
210 227 #[cfg(test)]
@@ -1,7 +1,7 @@
1 1 //! Public item detail page handler.
2 2
3 3 use axum::{
4 - extract::{FromRef, Path, State},
4 + extract::{Path, State},
5 5 response::{IntoResponse, Response},
6 6 };
7 7 use sqlx::PgPool;
@@ -9,32 +9,35 @@ use tower_sessions::Session;
9 9
10 10 use crate::{
11 11 auth::{MaybeUserVerified, SessionUser},
12 + config::Config,
12 13 db::{self, ContentData, ItemId, ItemType},
13 14 error::{AppError, Result},
14 15 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
15 16 pricing,
16 17 templates::*,
17 18 types::*,
18 - AppState, AppStorage,
19 + AppStorage, Integrations,
19 20 };
20 21
21 22 /// Render a public item detail page (text reader, audio player, or download).
22 23 #[tracing::instrument(skip_all, name = "content::item_page")]
23 24 pub(in crate::routes::pages::public) async fn item_page(
24 - State(state): State<AppState>,
25 + State(db): State<PgPool>,
26 + State(integrations): State<Integrations>,
27 + State(config): State<Config>,
25 28 session: Session,
26 29 MaybeUserVerified(maybe_user): MaybeUserVerified,
27 30 Path(item_id): Path<String>,
28 31 ) -> Result<Response> {
29 32 let csrf_token = get_csrf_token(&session).await;
30 33 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
31 - let db_item = db::items::get_item_by_id(&state.db, id)
34 + let db_item = db::items::get_item_by_id(&db, id)
32 35 .await?
33 36 .ok_or(AppError::NotFound)?;
34 - let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
37 + let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
35 38 .await?
36 39 .ok_or(AppError::NotFound)?;
37 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
40 + let db_user = db::users::get_user_by_id(&db, db_project.user_id)
38 41 .await?
39 42 .ok_or(AppError::NotFound)?;
40 43 if db_user.is_sandbox {
@@ -42,12 +45,15 @@ pub(in crate::routes::pages::public) async fn item_page(
42 45 }
43 46 // View tracking moved to /l/{id} — consumption is the meaningful signal,
44 47 // not store-page traffic.
45 - render_item_page(&state, &db_item, &db_project, &db_user, csrf_token, maybe_user).await
48 + render_item_page(&db, &integrations, &config, &db_item, &db_project, &db_user, csrf_token, maybe_user).await
46 49 }
47 50
48 51 /// Shared item page renderer, used by both named routes and custom domain fallback.
52 + #[allow(clippy::too_many_arguments)]
49 53 pub(crate) async fn render_item_page(
50 - state: &AppState,
54 + db: &PgPool,
55 + integrations: &Integrations,
56 + config: &Config,
51 57 db_item: &db::DbItem,
52 58 db_project: &db::DbProject,
53 59 db_user: &db::DbUser,
@@ -66,11 +72,7 @@ pub(crate) async fn render_item_page(
66 72 return Err(AppError::NotFound);
67 73 }
68 74
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);
75 + let cdn_base = config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
74 76 // Store page never renders the full article body — that lives on /l/{id}.
75 77 // Compute a short plain-text excerpt from the raw markdown for the deck.
76 78 let (excerpt, reading_time) = match db_item.content() {
@@ -87,12 +89,12 @@ pub(crate) async fn render_item_page(
87 89
88 90 let item_pricing = pricing::for_item(db_item);
89 91 let in_library = if let Some(ref user) = maybe_user {
90 - db::transactions::has_purchased_item(&state.db, user.id, db_item.id).await?
92 + db::transactions::has_purchased_item(db, user.id, db_item.id).await?
91 93 } else {
92 94 false
93 95 };
94 96 let item_sub = if let Some(ref user) = maybe_user {
95 - db::subscriptions::SubscriptionGate::check(&state.db, user.id, db::subscriptions::SubscriptionScope::Item(db_item.id)).await?
97 + db::subscriptions::SubscriptionGate::check(db, user.id, db::subscriptions::SubscriptionScope::Item(db_item.id)).await?
96 98 } else {
97 99 None
98 100 };
@@ -107,29 +109,29 @@ pub(crate) async fn render_item_page(
107 109 // Bundle access: user may have purchased a bundle containing this item
108 110 if !has_access
109 111 && let Some(ref user) = maybe_user
110 - && db::bundles::has_access_via_bundle(&state.db, user.id, db_item.id).await?
112 + && db::bundles::has_access_via_bundle(db, user.id, db_item.id).await?
111 113 {
112 114 has_access = true;
113 115 }
114 116
115 117 // For unlisted items, load the bundles that contain them (for "Available in" display)
116 118 let containing_bundle_ids = if !db_item.listed {
117 - db::bundles::get_bundles_containing_item(&state.db, db_item.id).await?
119 + db::bundles::get_bundles_containing_item(db, db_item.id).await?
118 120 } else {
119 121 vec![]
120 122 };
121 123 // Single batched query instead of one get_item_by_id per bundle id (N+1).
122 124 let containing_bundles: Vec<db::DbItem> =
123 - db::items::get_public_items_by_ids(&state.db, &containing_bundle_ids).await?;
125 + db::items::get_public_items_by_ids(db, &containing_bundle_ids).await?;
124 126
125 127 // For bundle-type items, load the child items
126 128 let bundle_child_items = if db_item.item_type == ItemType::Bundle {
127 - db::bundles::get_bundle_items(&state.db, db_item.id).await?
129 + db::bundles::get_bundle_items(db, db_item.id).await?
128 130 } else {
129 131 vec![]
130 132 };
131 133
132 - let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
134 + let item_tags = db::tags::get_tags_for_item(db, db_item.id).await?;
133 135 let item = Item::from_db_detail(
134 136 db_item,
135 137 &item_tags,
@@ -144,7 +146,7 @@ pub(crate) async fn render_item_page(
144 146 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
145 147 let project_slug_str = db_project.slug.to_string();
146 148 let (discussion_url, discussion_count) =
147 - fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await;
149 + fetch_discussion_info(integrations, config, db_item.mt_thread_id, &project_slug_str, "items").await;
148 150 return Ok(TextReaderTemplate {
149 151 csrf_token: csrf_token.clone(),
150 152 session_user: maybe_user,
@@ -159,7 +161,7 @@ pub(crate) async fn render_item_page(
159 161 has_access,
160 162 reading_time,
161 163 excerpt,
162 - host_url: state.config.host_url.clone(),
164 + host_url: config.host_url.clone(),
163 165 discussion_url,
164 166 discussion_count,
165 167 }
@@ -171,7 +173,7 @@ pub(crate) async fn render_item_page(
171 173 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
172 174 let project_slug_str = db_project.slug.to_string();
173 175 let (discussion_url, discussion_count) =
174 - fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await;
176 + fetch_discussion_info(integrations, config, db_item.mt_thread_id, &project_slug_str, "items").await;
175 177 return Ok(AudioPlayerTemplate {
176 178 csrf_token: csrf_token.clone(),
177 179 session_user: maybe_user,
@@ -184,7 +186,7 @@ pub(crate) async fn render_item_page(
184 186 is_free,
185 187 in_library,
186 188 has_access,
187 - host_url: state.config.host_url.clone(),
189 + host_url: config.host_url.clone(),
188 190 discussion_url,
189 191 discussion_count,
190 192 }
@@ -196,7 +198,7 @@ pub(crate) async fn render_item_page(
196 198 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
197 199 let project_slug_str = db_project.slug.to_string();
198 200 let (discussion_url, discussion_count) =
199 - fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await;
201 + fetch_discussion_info(integrations, config, db_item.mt_thread_id, &project_slug_str, "items").await;
200 202 return Ok(VideoPlayerTemplate {
201 203 csrf_token: csrf_token.clone(),
202 204 session_user: maybe_user,
@@ -209,7 +211,7 @@ pub(crate) async fn render_item_page(
209 211 is_free,
210 212 in_library,
211 213 has_access,
212 - host_url: state.config.host_url.clone(),
214 + host_url: config.host_url.clone(),
213 215 discussion_url,
214 216 discussion_count,
215 217 }
@@ -218,7 +220,7 @@ pub(crate) async fn render_item_page(
218 220
219 221 let project_slug_str = db_project.slug.to_string();
220 222 let (discussion_url, discussion_count) =
221 - fetch_discussion_info(&integrations, &state.config, db_item.mt_thread_id, &project_slug_str, "items").await;
223 + fetch_discussion_info(integrations, config, db_item.mt_thread_id, &project_slug_str, "items").await;
222 224
223 225 // Convert bundle child items to view models
224 226 let bundle_item_views: Vec<Item> = bundle_child_items
@@ -238,14 +240,14 @@ pub(crate) async fn render_item_page(
238 240 })
239 241 .collect();
240 242
241 - let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?;
243 + let db_sections = db::item_sections::list_by_item(db, db_item.id).await?;
242 244 let sections: Vec<ItemSection> = db_sections.iter().map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base)).collect();
243 245
244 246 // Wishlist / cart / collection-count for the viewer, collapsed into one query
245 247 // (was three sequential round-trips). Display-only; a failure falls back to
246 248 // the empty default, matching the prior per-call error tolerance.
247 249 let viewer_flags = if let Some(ref user) = maybe_user {
248 - db::items::get_viewer_item_flags(&state.db, user.id, db_item.id)
250 + db::items::get_viewer_item_flags(db, user.id, db_item.id)
249 251 .await
250 252 .unwrap_or_default()
251 253 } else {
@@ -259,7 +261,7 @@ pub(crate) async fn render_item_page(
259 261 // creator-optional; fall back to a title-based description rather than an
260 262 // empty string (the carousel relies on alt for screen-reader parity, and
261 263 // CarouselFrame::new debug-asserts non-empty, so build the struct directly).
262 - let gallery = db::gallery_images::list_for_item(&state.db, db_item.id)
264 + let gallery = db::gallery_images::list_for_item(db, db_item.id)
263 265 .await
264 266 .unwrap_or_default()
265 267 .into_iter()
@@ -281,7 +283,7 @@ pub(crate) async fn render_item_page(
281 283 creator_username: db_user.username.to_string(),
282 284 project_title: db_project.title.clone(),
283 285 project_slug: project_slug_str,
284 - host_url: state.config.host_url.clone(),
286 + host_url: config.host_url.clone(),
285 287 project_cover_image_url: db_project.cover_image_url.clone(),
286 288 discussion_url,
287 289 discussion_count,
@@ -26,7 +26,6 @@ use crate::{
26 26 helpers::get_csrf_token,
27 27 templates::*,
28 28 types::*,
29 - AppState,
30 29 };
31 30
32 31 /// Fire-and-forget page view recording. Never blocks the response.
@@ -67,7 +66,9 @@ pub struct PurchaseQuery {
67 66 /// Render a public user profile page with projects and custom links.
68 67 #[tracing::instrument(skip_all, name = "content::user_page")]
69 68 pub(super) async fn user_page(
70 - State(state): State<AppState>,
69 + State(db): State<PgPool>,
70 + State(config): State<Config>,
71 + State(page_view_tx): State<crate::db::page_views::PageViewTx>,
71 72 session: Session,
72 73 headers: axum::http::HeaderMap,
73 74 MaybeUserVerified(maybe_user): MaybeUserVerified,
@@ -75,49 +76,50 @@ pub(super) async fn user_page(
75 76 ) -> Result<Response> {
76 77 let csrf_token = get_csrf_token(&session).await;
77 78 let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
78 - let db_user = db::users::get_user_by_username(&state.db, &username)
79 + let db_user = db::users::get_user_by_username(&db, &username)
79 80 .await?
80 81 .ok_or(AppError::NotFound)?;
81 82 // Sandbox accounts are not publicly visible
82 83 if db_user.is_sandbox {
83 84 return Err(AppError::NotFound);
84 85 }
85 - let response = render_user_profile(&state, &db_user, csrf_token, maybe_user).await?;
86 + let response = render_user_profile(&db, &config, &db_user, csrf_token, maybe_user).await?;
86 87 let ua = headers.get(axum::http::header::USER_AGENT)
87 88 .and_then(|v| v.to_str().ok())
88 89 .unwrap_or("");
89 90 if !is_bot(ua) {
90 - track_view(&state.page_view_tx, "user", *db_user.id);
91 + track_view(&page_view_tx, "user", *db_user.id);
91 92 }
92 93 Ok(response)
93 94 }
94 95
95 96 /// Shared user profile renderer, used by both named routes and custom domain fallback.
96 97 pub(crate) async fn render_user_profile(
97 - state: &AppState,
98 + db: &PgPool,
99 + config: &Config,
98 100 db_user: &db::DbUser,
99 101 csrf_token: Option<String>,
100 102 maybe_user: Option<SessionUser>,
101 103 ) -> Result<Response> {
102 104 let db_projects =
103 - db::projects::get_public_projects_with_item_counts(&state.db, db_user.id).await?;
104 - let db_links = db::custom_links::get_custom_links_by_user(&state.db, db_user.id).await?;
105 + db::projects::get_public_projects_with_item_counts(db, db_user.id).await?;
106 + let db_links = db::custom_links::get_custom_links_by_user(db, db_user.id).await?;
105 107
106 108 let user = User::from(db_user);
107 109 let projects: Vec<Project> = db_projects.iter().map(Project::from).collect();
108 110 let custom_links: Vec<CustomLink> = db_links.iter().map(CustomLink::from).collect();
109 111
110 112 let db_collections =
111 - db::collections::get_public_collections_by_user(&state.db, db_user.id).await?;
113 + db::collections::get_public_collections_by_user(db, db_user.id).await?;
112 114 let public_collections: Vec<Collection> =
113 115 db_collections.iter().map(Collection::from).collect();
114 116
115 117 let follower_count =
116 - db::follows::get_follower_count(&state.db, FollowTargetType::User, db_user.id.into())
118 + db::follows::get_follower_count(db, FollowTargetType::User, db_user.id.into())
117 119 .await?;
118 120 let is_following = if let Some(ref viewer) = maybe_user {
119 121 db::follows::is_following(
120 - &state.db,
122 + db,
121 123 viewer.id,
122 124 FollowTargetType::User,
123 125 db_user.id.into(),
@@ -144,7 +146,7 @@ pub(crate) async fn render_user_profile(
144 146 is_own_profile,
145 147 is_following,
146 148 follower_count,
147 - host_url: state.config.host_url.clone(),
149 + host_url: config.host_url.clone(),
148 150 theme_css: crate::theming::theme_css(db_user.theme_id.as_deref()),
149 151 }
150 152 .into_response())
@@ -4,23 +4,26 @@ use axum::{
4 4 extract::{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::{
10 11 auth::{MaybeUserVerified, SessionUser},
12 + config::Config,
11 13 db::{self, FollowTargetType, ItemId, ItemType, Slug},
12 14 error::{AppError, Result},
13 15 helpers::get_csrf_token,
14 16 pricing,
15 17 templates::*,
16 18 types::*,
17 - AppState,
18 19 };
19 20
20 21 /// Render a public project page with its published items.
21 22 #[tracing::instrument(skip_all, name = "content::project_page", fields(%slug))]
22 23 pub(in crate::routes::pages::public) async fn project_page(
23 - State(state): State<AppState>,
24 + State(db): State<PgPool>,
25 + State(config): State<Config>,
26 + State(page_view_tx): State<crate::db::page_views::PageViewTx>,
24 27 session: Session,
25 28 headers: axum::http::HeaderMap,
26 29 MaybeUserVerified(maybe_user): MaybeUserVerified,
@@ -28,15 +31,15 @@ pub(in crate::routes::pages::public) async fn project_page(
28 31 ) -> Result<Response> {
29 32 let csrf_token = get_csrf_token(&session).await;
30 33 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
31 - let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
34 + let db_project = db::projects::get_public_project_by_slug(&db, &slug)
32 35 .await?
33 36 .ok_or(AppError::NotFound)?;
34 - let response = render_project_page(&state, &db_project, csrf_token, maybe_user).await?;
37 + let response = render_project_page(&db, &config, &db_project, csrf_token, maybe_user).await?;
35 38 let ua = headers.get(axum::http::header::USER_AGENT)
36 39 .and_then(|v| v.to_str().ok())
37 40 .unwrap_or("");
38 41 if !super::is_bot(ua) {
39 - super::track_view(&state.page_view_tx, "project", *db_project.id);
42 + super::track_view(&page_view_tx, "project", *db_project.id);
40 43 }
41 44 Ok(response)
42 45 }
@@ -48,12 +51,13 @@ pub(in crate::routes::pages::public) async fn project_page(
48 51 fields(project_id = %db_project.id, project_slug = %db_project.slug, viewer_id = ?maybe_user.as_ref().map(|u| u.id))
49 52 )]
50 53 pub(crate) async fn render_project_page(
51 - state: &AppState,
54 + db: &PgPool,
55 + config: &Config,
52 56 db_project: &db::DbProject,
53 57 csrf_token: Option<String>,
54 58 maybe_user: Option<SessionUser>,
55 59 ) -> Result<Response> {
56 - let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
60 + let db_user = db::users::get_user_by_id(db, db_project.user_id)
57 61 .await?
58 62 .ok_or(AppError::NotFound)?;
59 63
@@ -61,7 +65,7 @@ pub(crate) async fn render_project_page(
61 65 let project_pricing = pricing::for_project(db_project);
62 66 if !project_pricing.is_free() {
63 67 let project_ctx = pricing::build_project_access_context(
64 - &state.db,
68 + db,
65 69 maybe_user.as_ref().map(|u| u.id),
66 70 db_project.id,
67 71 db_project.user_id,
@@ -80,7 +84,7 @@ pub(crate) async fn render_project_page(
80 84 "project paywall gate: showing paywall"
81 85 );
82 86 let db_tiers =
83 - db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?;
87 + db::subscriptions::get_active_tiers_by_project(db, db_project.id).await?;
84 88 let subscription_tiers: Vec<SubscriptionTier> =
85 89 db_tiers.iter().map(SubscriptionTier::from).collect();
86 90 let project = Project::from_db(db_project, 0);
@@ -92,13 +96,13 @@ pub(crate) async fn render_project_page(
92 96 price_display: project_pricing.price_display(),
93 97 checkout_type: project_pricing.checkout_type(),
94 98 subscription_tiers,
95 - host_url: state.config.host_url.clone(),
99 + host_url: config.host_url.clone(),
96 100 }
97 101 .into_response());
98 102 }
99 103 }
100 104
101 - let db_items = db::items::get_public_items_by_project(&state.db, db_project.id).await?;
105 + let db_items = db::items::get_public_items_by_project(db, db_project.id).await?;
102 106
103 107 let is_creator = maybe_user
104 108 .as_ref()
@@ -111,7 +115,7 @@ pub(crate) async fn render_project_page(
111 115 let purchased_item_ids: std::collections::HashSet<ItemId> = if let Some(ref user) = maybe_user
112 116 {
113 117 let item_ids: Vec<ItemId> = db_items.iter().map(|i| i.id).collect();
114 - db::transactions::purchased_subset(&state.db, user.id, &item_ids).await?
118 + db::transactions::purchased_subset(db, user.id, &item_ids).await?
115 119 } else {
116 120 std::collections::HashSet::new()
117 121 };
@@ -119,13 +123,13 @@ pub(crate) async fn render_project_page(
119 123 // Per-item subscription proofs for this user, so each item's AccessContext
120 124 // gets its own gate witness rather than a bare membership bool.
121 125 let subscribed_gates = if let Some(ref user) = maybe_user {
122 - db::subscriptions::SubscriptionGate::subscribed_item_gates(&state.db, user.id).await?
126 + db::subscriptions::SubscriptionGate::subscribed_item_gates(db, user.id).await?
123 127 } else {
124 128 std::collections::HashMap::new()
125 129 };
126 130
127 131 let has_subscription = if let Some(ref user) = maybe_user {
128 - db::subscriptions::has_access(&state.db, user.id, db::subscriptions::SubscriptionScope::Project(db_project.id))
132 + db::subscriptions::has_access(db, user.id, db::subscriptions::SubscriptionScope::Project(db_project.id))
129 133 .await?
130 134 } else {
131 135 false
@@ -134,7 +138,7 @@ pub(crate) async fn render_project_page(
134 138 let project = Project::from_db(db_project, db_items.len() as u32);
135 139
136 140 let item_ids: Vec<ItemId> = db_items.iter().map(|i| i.id).collect();
137 - let tags_map = db::tags::get_tags_for_items(&state.db, &item_ids).await?;
141 + let tags_map = db::tags::get_tags_for_items(db, &item_ids).await?;
138 142
139 143 // Batch child-counts for all bundles on the page in one query (was one
140 144 // COUNT per bundle inside the loop below).
@@ -146,7 +150,7 @@ pub(crate) async fn render_project_page(
146 150 let bundle_counts = if bundle_ids.is_empty() {
147 151 std::collections::HashMap::new()
148 152 } else {
149 - db::bundles::get_bundle_item_counts(&state.db, &bundle_ids).await?
153 + db::bundles::get_bundle_item_counts(db, &bundle_ids).await?
150 154 };
151 155
152 156 let mut items: Vec<Item> = Vec::with_capacity(db_items.len());
@@ -168,14 +172,14 @@ pub(crate) async fn render_project_page(
168 172 }
169 173
170 174 let follower_count = db::follows::get_follower_count(
171 - &state.db,
175 + db,
172 176 FollowTargetType::Project,
173 177 db_project.id.into(),
174 178 )
175 179 .await?;
176 180 let is_following = if let Some(ref viewer) = maybe_user {
177 181 db::follows::is_following(
178 - &state.db,
182 + db,
179 183 viewer.id,
180 184 FollowTargetType::Project,
181 185 db_project.id.into(),
@@ -186,12 +190,12 @@ pub(crate) async fn render_project_page(
186 190 };
187 191
188 192 let db_tiers =
189 - db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?;
193 + db::subscriptions::get_active_tiers_by_project(db, db_project.id).await?;
190 194 let subscription_tiers: Vec<SubscriptionTier> =
191 195 db_tiers.iter().map(SubscriptionTier::from).collect();
192 196
193 - let git_repos = if state.config.build.git_repos_path.is_some() {
194 - let linked = db::git_repos::get_repos_by_project(&state.db, db_project.id)
197 + let git_repos = if config.build.git_repos_path.is_some() {
198 + let linked = db::git_repos::get_repos_by_project(db, db_project.id)
195 199 .await
196 200 .unwrap_or_else(|e| {
197 201 tracing::warn!(project_id = %db_project.id, error = %e, "linked git repos lookup failed; omitting repo links");
@@ -208,11 +212,10 @@ pub(crate) async fn render_project_page(
208 212 Vec::new()
209 213 };
210 214
211 - let has_blog_posts = db::blog_posts::has_published_posts(&state.db, db_project.id).await?;
215 + let has_blog_posts = db::blog_posts::has_published_posts(db, db_project.id).await?;
212 216
213 217 let community_url = if db_project.mt_community_id.is_some() {
214 - state
215 - .config
218 + config
216 219 .integrations.mt_base_url
217 220 .as_ref()
218 221 .map(|base| format!("{}/p/{}", base, db_project.slug))
@@ -224,8 +227,8 @@ pub(crate) async fn render_project_page(
224 227 .as_ref()
225 228 .is_some_and(|u| u.id == db_project.user_id);
226 229
227 - let db_sections = db::project_sections::list_by_project(&state.db, db_project.id).await?;
228 - let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
230 + let db_sections = db::project_sections::list_by_project(db, db_project.id).await?;
231 + let cdn_base = config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
229 232 let sections: Vec<ProjectSection> = db_sections
230 233 .iter()
231 234 .map(|s| ProjectSection::from_db(s, db_project.user_id, cdn_base))
@@ -234,7 +237,7 @@ pub(crate) async fn render_project_page(
234 237 // Ordered gallery → carousel frames (additive to the cover image). Alt is
235 238 // creator-optional; fall back to a title-based description (CarouselFrame::new
236 239 // debug-asserts non-empty alt, so build the struct directly).
237 - let gallery = db::gallery_images::list_for_project(&state.db, db_project.id)
240 + let gallery = db::gallery_images::list_for_project(db, db_project.id)
238 241 .await
239 242 .unwrap_or_else(|e| {
240 243 tracing::warn!(project_id = %db_project.id, error = %e, "gallery image lookup failed; rendering without carousel");
@@ -263,7 +266,7 @@ pub(crate) async fn render_project_page(
263 266 follower_count,
264 267 subscription_tiers,
265 268 has_subscription,
266 - host_url: state.config.host_url.clone(),
269 + host_url: config.host_url.clone(),
267 270 git_repos,
268 271 has_blog_posts,
269 272 community_url,
@@ -19,7 +19,7 @@ use crate::{
19 19 routes::custom_domain,
20 20 templates::*,
21 21 types::*,
22 - AppState, Billing,
22 + AppCaches, Billing, Integrations,
23 23 };
24 24
25 25 /// Render the landing page, or redirect authenticated users to the library.
@@ -28,15 +28,20 @@ use crate::{
28 28 /// profile instead (the fallback handler only catches paths that don't match
29 29 /// any named route, so `/` needs to be handled here).
30 30 #[tracing::instrument(skip_all, name = "landing::index")]
31 + #[allow(clippy::too_many_arguments)]
31 32 pub(super) async fn index(
32 - State(state): State<AppState>,
33 + State(db): State<PgPool>,
34 + State(caches): State<AppCaches>,
35 + State(integrations): State<Integrations>,
36 + State(config): State<Config>,
37 + State(billing): State<Billing>,
33 38 headers: HeaderMap,
34 39 session: Session,
35 40 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
36 41 ) -> Result<Response> {
37 42 // Check for custom domain — delegate to the custom domain handler
38 43 if let Some(response) =
39 - custom_domain::try_handle(&state, &headers, "/", &session, &maybe_user).await
44 + custom_domain::try_handle(&db, &caches, &integrations, &config, &headers, "/", &session, &maybe_user).await
40 45 {
41 46 return Ok(response);
42 47 }
@@ -44,15 +49,15 @@ pub(super) async fn index(
44 49 match maybe_user {
45 50 Some(_) => Ok(Redirect::to("/library").into_response()),
46 51 None => {
47 - let total_creators = db::waitlist::count_active_creators(&state.db).await? as u32;
48 - let total_items = db::items::count_public_listed(&state.db).await?;
52 + let total_creators = db::waitlist::count_active_creators(&db).await? as u32;
53 + let total_items = db::items::count_public_listed(&db).await?;
49 54
50 55 // "Last shipped" velocity line: most recent published, landing-
51 56 // flagged post on the changelog project. Read once per render; the
52 57 // line is suppressed entirely when nothing qualifies (no
53 58 // placeholder), matching the runway disclosure's no-fabrication rule.
54 59 let last_shipped = db::blog_posts::get_landing_changelog_post(
55 - &state.db,
60 + &db,
56 61 constants::CHANGELOG_PROJECT_SLUG,
57 62 )
58 63 .await?
@@ -67,7 +72,7 @@ pub(super) async fn index(
67 72 // Surface remaining founder slots only when close enough to feel
68 73 // scarce. 200 is "last chunk" — enough warning to convert, not so
69 74 // early that the number stays prominent for months.
70 - let founder_window_open = state.config.creator_pricing.founder_window_open;
75 + let founder_window_open = config.creator_pricing.founder_window_open;
71 76 const FOUNDER_CAP: u32 = 1_000;
72 77 const URGENCY_THRESHOLD: u32 = 200;
73 78 let founder_slots_remaining = if founder_window_open && total_creators >= FOUNDER_CAP.saturating_sub(URGENCY_THRESHOLD) {
@@ -100,12 +105,12 @@ pub(super) async fn index(
100 105
101 106 Ok(IndexTemplate {
102 107 csrf_token: get_csrf_token(&session).await,
103 - host_url: state.config.host_url.clone(),
108 + host_url: config.host_url.clone(),
104 109 total_creators,
105 110 total_items: total_items as u32,
106 111 founder_window_open,
107 112 founder_slots_remaining,
108 - tier_prices: state.tier_prices.clone(),
113 + tier_prices: billing.tier_prices.clone(),
109 114 landing_carousel,
110 115 last_shipped,
111 116 }.into_response())