Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate pages/dashboard to slices Migrate all pages/dashboard/** handlers (dashboard, custom_page, tabs/, tabs/user/, wizards/, wizards/item/) to slices. Mostly State<PgPool>(+Config); tabs/user creator/payments add Billing (tier_prices/stripe); wizards/project save_preview adds Integrations (mt_client). custom_page + item/project-tab helpers narrowed to &PgPool/&Config. MT-provision spawn clones mt+db slices. Router factories + pure build_step_nav/build_chart_bars untouched. Holdout: wizards/item save_preview + its caller step_save keep State<AppState> (call &AppState scheduler helpers send_release_announcements/spawn_mt_thread). No behavior change; dashboard 8 + wizard 23 + custom_page 16 + tab 9 + creator 71 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 03:03 UTC
Commit: a575621b95075d6362165f5e7c70ca73b7043c24
Parent: 4a79642
13 files changed, +408 insertions, -368 deletions
@@ -26,12 +26,13 @@ use uuid::Uuid;
26 26
27 27 use crate::{
28 28 auth::AuthUser,
29 + config::Config,
29 30 custom_pages::{self, RejectionKind},
30 31 db::{self, custom_pages::{KIND_PROJECT, KIND_USER}, Slug},
31 32 error::{AppError, Result},
32 33 helpers::get_csrf_token,
33 - AppState,
34 34 };
35 + use sqlx::PgPool;
35 36
36 37 /// App-side size caps, matching the DB `octet_length` backstops.
37 38 const MAX_HTML: usize = 16 * 1024;
@@ -93,87 +94,98 @@ struct Target {
93 94
94 95 #[tracing::instrument(skip_all, name = "custom_page::user_editor")]
95 96 pub async fn user_editor(
96 - State(state): State<AppState>,
97 + State(db): State<PgPool>,
98 + State(config): State<Config>,
97 99 AuthUser(user): AuthUser,
98 100 session: Session,
99 101 ) -> Result<Response> {
100 - let target = user_target(&state, user.id, user.username.as_ref()).await?;
101 - render_editor(&state, &session, target).await
102 + let target = user_target(&db, &config, user.id, user.username.as_ref()).await?;
103 + render_editor(&db, &config, &session, target).await
102 104 }
103 105
104 106 #[tracing::instrument(skip_all, name = "custom_page::user_save")]
105 107 pub async fn user_save(
106 - State(state): State<AppState>,
108 + State(db): State<PgPool>,
109 + State(config): State<Config>,
107 110 AuthUser(user): AuthUser,
108 111 Form(form): Form<CustomPageForm>,
109 112 ) -> Result<Response> {
110 - let target = user_target(&state, user.id, user.username.as_ref()).await?;
111 - save(&state, target, form).await
113 + let target = user_target(&db, &config, user.id, user.username.as_ref()).await?;
114 + save(&db, &config, target, form).await
112 115 }
113 116
114 117 #[tracing::instrument(skip_all, name = "custom_page::user_autosave")]
115 118 pub async fn user_autosave(
116 - State(state): State<AppState>,
119 + State(db): State<PgPool>,
120 + State(config): State<Config>,
117 121 AuthUser(user): AuthUser,
118 122 Form(form): Form<CustomPageForm>,
119 123 ) -> Result<Response> {
120 - let target = user_target(&state, user.id, user.username.as_ref()).await?;
121 - autosave(&state, target, form).await
124 + let target = user_target(&db, &config, user.id, user.username.as_ref()).await?;
125 + autosave(&db, &config, target, form).await
122 126 }
123 127
124 128 #[tracing::instrument(skip_all, name = "custom_page::user_reset")]
125 - pub async fn user_reset(State(state): State<AppState>, AuthUser(user): AuthUser) -> Result<Response> {
126 - let target = user_target(&state, user.id, user.username.as_ref()).await?;
127 - reset(&state, target).await
129 + pub async fn user_reset(
130 + State(db): State<PgPool>,
131 + State(config): State<Config>,
132 + AuthUser(user): AuthUser,
133 + ) -> Result<Response> {
134 + let target = user_target(&db, &config, user.id, user.username.as_ref()).await?;
135 + reset(&db, target).await
128 136 }
129 137
130 138 #[tracing::instrument(skip_all, name = "custom_page::project_editor")]
131 139 pub async fn project_editor(
132 - State(state): State<AppState>,
140 + State(db): State<PgPool>,
141 + State(config): State<Config>,
133 142 AuthUser(user): AuthUser,
134 143 session: Session,
135 144 Path(slug): Path<String>,
136 145 ) -> Result<Response> {
137 - let target = project_target(&state, user.id, user.username.as_ref(), &slug).await?;
138 - render_editor(&state, &session, target).await
146 + let target = project_target(&db, &config, user.id, user.username.as_ref(), &slug).await?;
147 + render_editor(&db, &config, &session, target).await
139 148 }
140 149
141 150 #[tracing::instrument(skip_all, name = "custom_page::project_save")]
142 151 pub async fn project_save(
143 - State(state): State<AppState>,
152 + State(db): State<PgPool>,
153 + State(config): State<Config>,
144 154 AuthUser(user): AuthUser,
145 155 Path(slug): Path<String>,
146 156 Form(form): Form<CustomPageForm>,
147 157 ) -> Result<Response> {
148 - let target = project_target(&state, user.id, user.username.as_ref(), &slug).await?;
149 - save(&state, target, form).await
158 + let target = project_target(&db, &config, user.id, user.username.as_ref(), &slug).await?;
159 + save(&db, &config, target, form).await
150 160 }
151 161
152 162 #[tracing::instrument(skip_all, name = "custom_page::project_autosave")]
153 163 pub async fn project_autosave(
154 - State(state): State<AppState>,
164 + State(db): State<PgPool>,
165 + State(config): State<Config>,
155 166 AuthUser(user): AuthUser,
156 167 Path(slug): Path<String>,
157 168 Form(form): Form<CustomPageForm>,
158 169 ) -> Result<Response> {
159 - let target = project_target(&state, user.id, user.username.as_ref(), &slug).await?;
160 - autosave(&state, target, form).await
170 + let target = project_target(&db, &config, user.id, user.username.as_ref(), &slug).await?;
171 + autosave(&db, &config, target, form).await
161 172 }
162 173
163 174 #[tracing::instrument(skip_all, name = "custom_page::project_reset")]
164 175 pub async fn project_reset(
165 - State(state): State<AppState>,
176 + State(db): State<PgPool>,
177 + State(config): State<Config>,
166 178 AuthUser(user): AuthUser,
167 179 Path(slug): Path<String>,
168 180 ) -> Result<Response> {
169 - let target = project_target(&state, user.id, user.username.as_ref(), &slug).await?;
170 - reset(&state, target).await
181 + let target = project_target(&db, &config, user.id, user.username.as_ref(), &slug).await?;
182 + reset(&db, target).await
171 183 }
172 184
173 185 // ── Target resolution ────────────────────────────────────────────────────────
174 186
175 - async fn user_target(state: &AppState, user_id: db::UserId, handle: &str) -> Result<Target> {
176 - let user = db::users::get_user_by_id(&state.db, user_id)
187 + async fn user_target(db: &PgPool, config: &Config, user_id: db::UserId, handle: &str) -> Result<Target> {
188 + let user = db::users::get_user_by_id(db, user_id)
177 189 .await?
178 190 .ok_or(AppError::NotFound)?;
179 191 Ok(Target {
@@ -182,21 +194,21 @@ async fn user_target(state: &AppState, user_id: db::UserId, handle: &str) -> Res
182 194 page_id: *user.id.as_uuid(),
183 195 heading: "Profile page".to_string(),
184 196 base_path: "/dashboard/custom-page".to_string(),
185 - live_url: format!("{}/{}", user_pages_origin(state), handle),
197 + live_url: format!("{}/{}", user_pages_origin(config), handle),
186 198 owner_locked: user.custom_pages_locked,
187 199 current_html: user.custom_html,
188 200 current_css: user.custom_css,
189 201 })
190 202 }
191 203
192 - async fn project_target(state: &AppState, user_id: db::UserId, handle: &str, slug: &str) -> Result<Target> {
204 + async fn project_target(db: &PgPool, config: &Config, user_id: db::UserId, handle: &str, slug: &str) -> Result<Target> {
193 205 let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?;
194 206 // Owner-scoped lookup: a non-owner (or unknown slug) gets NotFound here.
195 - let project = db::projects::get_project_by_user_and_slug(&state.db, user_id, &slug)
207 + let project = db::projects::get_project_by_user_and_slug(db, user_id, &slug)
196 208 .await?
197 209 .ok_or(AppError::NotFound)?;
198 210 // The kill switch is per-creator; a locked owner can't edit any of their pages.
199 - let owner_locked = db::users::get_user_by_id(&state.db, user_id)
211 + let owner_locked = db::users::get_user_by_id(db, user_id)
200 212 .await?
201 213 .map(|u| u.custom_pages_locked)
202 214 .unwrap_or(false);
@@ -208,17 +220,17 @@ async fn project_target(state: &AppState, user_id: db::UserId, handle: &str, slu
208 220 current_html: project.custom_html,
209 221 current_css: project.custom_css,
210 222 base_path: format!("/dashboard/project/{}/custom-page", project.slug),
211 - live_url: format!("{}/{}/{}", user_pages_origin(state), handle, project.slug),
223 + live_url: format!("{}/{}/{}", user_pages_origin(config), handle, project.slug),
212 224 owner_locked,
213 225 })
214 226 }
215 227
216 228 // ── Shared flows ─────────────────────────────────────────────────────────────
217 229
218 - async fn render_editor(state: &AppState, session: &Session, target: Target) -> Result<Response> {
230 + async fn render_editor(db: &PgPool, config: &Config, session: &Session, target: Target) -> Result<Response> {
219 231 // Resume an in-progress draft, or seed one from the live source.
220 232 let draft = db::custom_pages::get_or_create_draft(
221 - &state.db,
233 + db,
222 234 target.owner_id,
223 235 target.page_kind,
224 236 target.page_id,
@@ -227,8 +239,8 @@ async fn render_editor(state: &AppState, session: &Session, target: Target) -> R
227 239 )
228 240 .await?;
229 241
230 - let rejections = sanitize_rejections(state, &target, &draft.custom_html, &draft.custom_css);
231 - let preview_url = format!("{}/preview/{}", user_pages_origin(state), draft.id);
242 + let rejections = sanitize_rejections(config, &target, &draft.custom_html, &draft.custom_css);
243 + let preview_url = format!("{}/preview/{}", user_pages_origin(config), draft.id);
232 244 let csrf_token = get_csrf_token(session).await;
233 245
234 246 EditorTemplate {
@@ -247,7 +259,7 @@ async fn render_editor(state: &AppState, session: &Session, target: Target) -> R
247 259 .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed")))
248 260 }
249 261
250 - async fn save(state: &AppState, target: Target, form: CustomPageForm) -> Result<Response> {
262 + async fn save(db: &PgPool, config: &Config, target: Target, form: CustomPageForm) -> Result<Response> {
251 263 if target.owner_locked {
252 264 return Ok(status_html(false, "Custom pages are locked by moderation."));
253 265 }
@@ -256,7 +268,7 @@ async fn save(state: &AppState, target: Target, form: CustomPageForm) -> Result<
256 268 }
257 269
258 270 // Count what the sanitizer strips from the published page (by kind).
259 - if let Some(policy) = state.config.custom_pages_policy() {
271 + if let Some(policy) = config.custom_pages_policy() {
260 272 let (_h, _c, rejections) =
261 273 custom_pages::sanitize_page(&form.custom_html, &form.custom_css, &target.page_id.to_string(), &policy);
262 274 for r in &rejections {
@@ -267,7 +279,7 @@ async fn save(state: &AppState, target: Target, form: CustomPageForm) -> Result<
267 279 // Publish the page and clear its draft in one transaction: a crash between
268 280 // the two previously left the page published but the stale draft alive, so
269 281 // the editor reopened showing pre-save content over the saved page.
270 - let mut tx = state.db.begin().await?;
282 + let mut tx = db.begin().await?;
271 283 match target.page_kind {
272 284 KIND_USER => {
273 285 db::users::update_user_custom_page(&mut *tx, target.owner_id, &form.custom_html, &form.custom_css).await?;
@@ -290,7 +302,7 @@ async fn save(state: &AppState, target: Target, form: CustomPageForm) -> Result<
290 302 Ok(status_html(true, "Saved and published."))
291 303 }
292 304
293 - async fn autosave(state: &AppState, target: Target, form: CustomPageForm) -> Result<Response> {
305 + async fn autosave(db: &PgPool, config: &Config, target: Target, form: CustomPageForm) -> Result<Response> {
294 306 if target.owner_locked {
295 307 let panel = BlockedPanelTemplate {
296 308 rejections: vec![RejectionView {
@@ -320,7 +332,7 @@ async fn autosave(state: &AppState, target: Target, form: CustomPageForm) -> Res
320 332 }
321 333
322 334 let draft = db::custom_pages::upsert_draft(
323 - &state.db,
335 + db,
324 336 target.owner_id,
325 337 target.page_kind,
326 338 target.page_id,
@@ -329,13 +341,13 @@ async fn autosave(state: &AppState, target: Target, form: CustomPageForm) -> Res
329 341 )
330 342 .await?;
331 343
332 - let rejections = sanitize_rejections(state, &target, &draft.custom_html, &draft.custom_css);
344 + let rejections = sanitize_rejections(config, &target, &draft.custom_html, &draft.custom_css);
333 345 let panel = BlockedPanelTemplate { rejections }
334 346 .render()
335 347 .map_err(|_| AppError::Internal(anyhow::anyhow!("template render failed")))?;
336 348
337 349 // Out-of-band swap forces the preview iframe to reload the fresh draft.
338 - let preview_url = format!("{}/preview/{}", user_pages_origin(state), draft.id);
350 + let preview_url = format!("{}/preview/{}", user_pages_origin(config), draft.id);
339 351 let bust = chrono::Utc::now().timestamp_millis();
340 352 let oob = format!(
341 353 "<iframe id=\"cp-preview\" class=\"cp-preview\" title=\"Live preview\" \
@@ -345,31 +357,31 @@ async fn autosave(state: &AppState, target: Target, form: CustomPageForm) -> Res
345 357 Ok(Html(format!("{panel}{oob}")).into_response())
346 358 }
347 359
348 - async fn reset(state: &AppState, target: Target) -> Result<Response> {
360 + async fn reset(db: &PgPool, target: Target) -> Result<Response> {
349 361 if target.owner_locked {
350 362 return Ok(Redirect::to(&target.base_path).into_response());
351 363 }
352 364 match target.page_kind {
353 - KIND_USER => db::users::reset_user_custom_page(&state.db, target.owner_id).await?,
365 + KIND_USER => db::users::reset_user_custom_page(db, target.owner_id).await?,
354 366 _ => {
355 367 db::projects::reset_project_custom_page(
356 - &state.db,
368 + db,
357 369 db::ProjectId::from_uuid(target.page_id),
358 370 target.owner_id,
359 371 )
360 372 .await?
361 373 }
362 374 }
363 - db::custom_pages::delete_draft(&state.db, target.owner_id, target.page_kind, target.page_id).await?;
375 + db::custom_pages::delete_draft(db, target.owner_id, target.page_kind, target.page_id).await?;
364 376 Ok(Redirect::to(&target.base_path).into_response())
365 377 }
366 378
367 379 // ── Helpers ──────────────────────────────────────────────────────────────────
368 380
369 381 /// Scheme + user-pages host, e.g. `https://u.makenot.work`.
370 - fn user_pages_origin(state: &AppState) -> String {
371 - let scheme = if state.config.host_url.starts_with("https") { "https" } else { "http" };
372 - format!("{scheme}://{}", state.config.user_pages_host)
382 + fn user_pages_origin(config: &Config) -> String {
383 + let scheme = if config.host_url.starts_with("https") { "https" } else { "http" };
384 + format!("{scheme}://{}", config.user_pages_host)
373 385 }
374 386
375 387 fn oversize_message(form: &CustomPageForm) -> Option<String> {
@@ -382,8 +394,8 @@ fn oversize_message(form: &CustomPageForm) -> Option<String> {
382 394 }
383 395 }
384 396
385 - fn sanitize_rejections(state: &AppState, target: &Target, html: &str, css: &str) -> Vec<RejectionView> {
386 - let Some(policy) = state.config.custom_pages_policy() else {
397 + fn sanitize_rejections(config: &Config, target: &Target, html: &str, css: &str) -> Vec<RejectionView> {
398 + let Some(policy) = config.custom_pages_policy() else {
387 399 return Vec::new();
388 400 };
389 401 let (_html, _css, rejections) = custom_pages::sanitize_page(html, css, &target.page_id.to_string(), &policy);
@@ -12,23 +12,23 @@ use crate::{
12 12 helpers::get_csrf_token,
13 13 templates::*,
14 14 types::*,
15 - AppState,
16 15 };
16 + use sqlx::PgPool;
17 17
18 18 /// Render an inline edit row for an item in the project content table.
19 19 #[tracing::instrument(skip_all, name = "dashboard_forms::item_edit_row")]
20 20 pub(super) async fn item_edit_row(
21 - State(state): State<AppState>,
21 + State(db): State<PgPool>,
22 22 AuthUser(session_user): AuthUser,
23 23 Path(id): Path<String>,
24 24 ) -> Result<impl IntoResponse> {
25 25 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
26 26
27 - let db_item = db::items::get_item_by_id(&state.db, item_id)
27 + let db_item = db::items::get_item_by_id(&db, item_id)
28 28 .await?
29 29 .ok_or(AppError::NotFound)?;
30 30
31 - let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
31 + let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
32 32 .await?
33 33 .ok_or(AppError::NotFound)?;
34 34
@@ -44,18 +44,18 @@ pub(super) async fn item_edit_row(
44 44 /// Render the data export portal page.
45 45 #[tracing::instrument(skip_all, name = "dashboard_forms::export_portal")]
46 46 pub(super) async fn export_portal(
47 - State(state): State<AppState>,
47 + State(db): State<PgPool>,
48 48 session: Session,
49 49 AuthUser(session_user): AuthUser,
50 50 ) -> Result<impl IntoResponse> {
51 51 let csrf_token = get_csrf_token(&session).await;
52 52
53 53 // Check if user has any S3 content (items, versions, insertions)
54 - let items = db::items::get_items_by_user(&state.db, session_user.id).await?;
54 + let items = db::items::get_items_by_user(&db, session_user.id).await?;
55 55 let has_item_content = items.iter().any(|i| i.has_s3_content());
56 56
57 57 // Sum known file sizes from versions and insertions
58 - let known_size = db::creator_tiers::get_user_content_size(&state.db, session_user.id).await?;
58 + let known_size = db::creator_tiers::get_user_content_size(&db, session_user.id).await?;
59 59 let has_content = has_item_content || known_size > 0;
60 60
61 61 let content_size = if !has_content {
@@ -79,13 +79,13 @@ pub(super) async fn export_portal(
79 79 /// Render the data import portal page.
80 80 #[tracing::instrument(skip_all, name = "dashboard_forms::import_portal")]
81 81 pub(super) async fn import_portal(
82 - State(state): State<AppState>,
82 + State(db): State<PgPool>,
83 83 session: Session,
84 84 AuthUser(session_user): AuthUser,
85 85 ) -> Result<impl IntoResponse> {
86 86 let csrf_token = get_csrf_token(&session).await;
87 87
88 - let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?;
88 + let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?;
89 89 let projects: Vec<crate::templates::ImportProjectOption> = db_projects
90 90 .into_iter()
91 91 .map(|p| crate::templates::ImportProjectOption {
@@ -94,7 +94,7 @@ pub(super) async fn import_portal(
94 94 })
95 95 .collect();
96 96
97 - let db_jobs = db::imports::list_import_jobs(&state.db, session_user.id).await?;
97 + let db_jobs = db::imports::list_import_jobs(&db, session_user.id).await?;
98 98 let jobs: Vec<crate::templates::ImportJobRow> = db_jobs
99 99 .into_iter()
100 100 .map(|j| crate::templates::ImportJobRow {
@@ -117,7 +117,6 @@ pub(super) async fn import_portal(
117 117 /// Render the account deletion confirmation page.
118 118 #[tracing::instrument(skip_all, name = "dashboard_forms::delete_account_page")]
119 119 pub(super) async fn delete_account_page(
120 - State(_state): State<AppState>,
121 120 session: Session,
122 121 AuthUser(session_user): AuthUser,
123 122 ) -> Result<impl IntoResponse> {
@@ -139,14 +138,14 @@ pub(super) struct BlogEditorQuery {
139 138 /// Render the blog post editor page (create new or edit existing).
140 139 #[tracing::instrument(skip_all, name = "dashboard_forms::blog_editor")]
141 140 pub(super) async fn blog_editor(
142 - State(state): State<AppState>,
141 + State(db): State<PgPool>,
143 142 session: Session,
144 143 AuthUser(session_user): AuthUser,
145 144 Path(slug): Path<String>,
146 145 Query(query): Query<BlogEditorQuery>,
147 146 ) -> Result<impl IntoResponse> {
148 147 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
149 - let db_project = db::projects::get_project_by_user_and_slug(&state.db, session_user.id, &slug_val)
148 + let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug_val)
150 149 .await?
151 150 .ok_or(AppError::NotFound)?;
152 151
@@ -159,7 +158,7 @@ pub(super) async fn blog_editor(
159 158 // If editing an existing post, load it
160 159 if let Some(post_id_str) = &query.post {
161 160 let post_id: BlogPostId = post_id_str.parse().map_err(|_| AppError::NotFound)?;
162 - let post = db::blog_posts::get_blog_post_by_id(&state.db, post_id)
161 + let post = db::blog_posts::get_blog_post_by_id(&db, post_id)
163 162 .await?
164 163 .ok_or(AppError::NotFound)?;
165 164
@@ -8,14 +8,15 @@ use tower_sessions::Session;
8 8
9 9 use crate::{
10 10 auth::AuthUser,
11 + config::Config,
11 12 constants::DASHBOARD_TRANSACTION_LIMIT,
12 13 db::{self, analytics::TimeRange, ItemId, Slug},
13 14 error::{AppError, Result, ResultExt},
14 15 helpers::{self, get_csrf_token},
15 16 templates::*,
16 17 types::*,
17 - AppState,
18 18 };
19 + use sqlx::PgPool;
19 20
20 21 use super::AnalyticsQuery;
21 22
@@ -66,7 +67,8 @@ fn build_onboarding_checklist(
66 67 /// Render the main user dashboard with projects and transactions.
67 68 #[tracing::instrument(skip_all, name = "dashboard::dashboard")]
68 69 pub(super) async fn dashboard(
69 - State(state): State<AppState>,
70 + State(db): State<PgPool>,
71 + State(config): State<Config>,
70 72 session: Session,
71 73 AuthUser(session_user): AuthUser,
72 74 ) -> Result<impl IntoResponse> {
@@ -76,10 +78,10 @@ pub(super) async fn dashboard(
76 78 // series so the dashboard pays one round-trip's latency, not four (Run 11
77 79 // Perf MOD tail; revisits Run 10 M6 now that the heavy per-render work is gone).
78 80 let (db_user, db_projects, incoming_txs, outgoing_txs) = tokio::try_join!(
79 - db::users::get_user_by_id(&state.db, session_user.id),
80 - db::projects::get_projects_by_user(&state.db, session_user.id),
81 - db::transactions::get_transactions_by_seller(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)),
82 - db::transactions::get_transactions_by_buyer(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)),
81 + db::users::get_user_by_id(&db, session_user.id),
82 + db::projects::get_projects_by_user(&db, session_user.id),
83 + db::transactions::get_transactions_by_seller(&db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)),
84 + db::transactions::get_transactions_by_buyer(&db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)),
83 85 )?;
84 86 let db_user = db_user.ok_or(AppError::NotFound)?;
85 87
@@ -100,7 +102,7 @@ pub(super) async fn dashboard(
100 102 let stripe_done = user.stripe_connected;
101 103 let projects_done = !db_projects.is_empty();
102 104 let publish_done = if projects_done {
103 - db::items::has_public_item_by_user(&state.db, session_user.id).await?
105 + db::items::has_public_item_by_user(&db, session_user.id).await?
104 106 } else {
105 107 false
106 108 };
@@ -122,7 +124,7 @@ pub(super) async fn dashboard(
122 124 // call sites): show the tab whenever MT is configured; the tab lazy-loads the
123 125 // user's actual memberships on open. Previously this blocked every dashboard
124 126 // render on a synchronous 3s-timeout MT round-trip (ultra-fuzz Run 10 Perf S6).
125 - let has_mt_memberships = state.config.integrations.mt_base_url.is_some();
127 + let has_mt_memberships = config.integrations.mt_base_url.is_some();
126 128
127 129 let suspended = db_user.is_suspended();
128 130 let suspension_reason = db_user.suspension_reason.clone();
@@ -157,14 +159,15 @@ pub(super) async fn dashboard(
157 159 password_warning,
158 160 deactivated: db_user.is_deactivated(),
159 161 creator_paused: db_user.is_creator_paused(),
160 - git_enabled: state.config.build.git_repos_path.is_some(),
162 + git_enabled: config.build.git_repos_path.is_some(),
161 163 })
162 164 }
163 165
164 166 /// Render the dashboard view for a single owned project.
165 167 #[tracing::instrument(skip_all, name = "dashboard::dashboard_project")]
166 168 pub(super) async fn dashboard_project(
167 - State(state): State<AppState>,
169 + State(db): State<PgPool>,
170 + State(config): State<Config>,
168 171 session: Session,
169 172 AuthUser(session_user): AuthUser,
170 173 Path(slug): Path<String>,
@@ -172,12 +175,12 @@ pub(super) async fn dashboard_project(
172 175 let csrf_token = get_csrf_token(&session).await;
173 176 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
174 177
175 - let db_project = db::projects::get_project_by_user_and_slug(&state.db, session_user.id, &slug)
178 + let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug)
176 179 .await?
177 180 .ok_or(AppError::NotFound)?;
178 181
179 - let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
180 - let (revenue_cents, sales_count) = db::transactions::get_revenue_by_project(&state.db, db_project.id).await?;
182 + let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
183 + let (revenue_cents, sales_count) = db::transactions::get_revenue_by_project(&db, db_project.id).await?;
181 184
182 185 let project = Project::from_db(&db_project, db_items.len() as u32);
183 186
@@ -208,14 +211,14 @@ pub(super) async fn dashboard_project(
208 211 .map(|(i, item)| ContentItem::from_db(item, (i + 1) as u32))
209 212 .collect();
210 213
211 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
214 + let db_user = db::users::get_user_by_id(&db, session_user.id)
212 215 .await?
213 216 .ok_or(AppError::NotFound)?;
214 217
215 218 let has_blog = db_project.features.iter().any(|f| f == "blog");
216 219 let synckit_enabled = db_project.features.iter().any(|f| f == "cloud_sync");
217 220
218 - let git_enabled = state.config.build.git_repos_path.is_some();
221 + let git_enabled = config.build.git_repos_path.is_some();
219 222
220 223 Ok(DashboardProjectTemplate {
221 224 csrf_token,
@@ -234,7 +237,7 @@ pub(super) async fn dashboard_project(
234 237 /// Render the dashboard shell for a single owned item (tabs loaded via HTMX).
235 238 #[tracing::instrument(skip_all, name = "dashboard::dashboard_item")]
236 239 pub(super) async fn dashboard_item(
237 - State(state): State<AppState>,
240 + State(db): State<PgPool>,
238 241 session: Session,
239 242 AuthUser(session_user): AuthUser,
240 243 Path(id): Path<String>,
@@ -243,11 +246,11 @@ pub(super) async fn dashboard_item(
243 246
244 247 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
245 248
246 - let db_item = db::items::get_item_by_id(&state.db, item_id)
249 + let db_item = db::items::get_item_by_id(&db, item_id)
247 250 .await?
248 251 .ok_or(AppError::NotFound)?;
249 252
250 - let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
253 + let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
251 254 .await?
252 255 .ok_or(AppError::NotFound)?;
253 256
@@ -257,7 +260,7 @@ pub(super) async fn dashboard_item(
257 260 }
258 261
259 262 let is_free = db_item.price_cents == 0;
260 - let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
263 + let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
261 264 let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, true);
262 265
263 266 Ok(DashboardItemTemplate {
@@ -272,18 +275,18 @@ pub(super) async fn dashboard_item(
272 275 /// Render the HTMX partial for item analytics (stats + revenue chart).
273 276 #[tracing::instrument(skip_all, name = "dashboard::dashboard_item_analytics")]
274 277 pub(super) async fn dashboard_item_analytics(
275 - State(state): State<AppState>,
278 + State(db): State<PgPool>,
276 279 AuthUser(session_user): AuthUser,
277 280 Path(id): Path<String>,
278 281 Query(query): Query<AnalyticsQuery>,
279 282 ) -> Result<impl IntoResponse> {
280 283 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
281 284
282 - let db_item = db::items::get_item_by_id(&state.db, item_id)
285 + let db_item = db::items::get_item_by_id(&db, item_id)
283 286 .await?
284 287 .ok_or(AppError::NotFound)?;
285 288
286 - let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
289 + let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
287 290 .await?
288 291 .ok_or(AppError::NotFound)?;
289 292
@@ -298,7 +301,7 @@ pub(super) async fn dashboard_item_analytics(
298 301 .unwrap_or(TimeRange::Days30);
299 302
300 303 let buckets = db::analytics::get_revenue_timeseries(
301 - &state.db,
304 + &db,
302 305 session_user.id,
303 306 None,
304 307 Some(item_id),
@@ -307,7 +310,7 @@ pub(super) async fn dashboard_item_analytics(
307 310 .await?;
308 311
309 312 let comparison = db::analytics::get_period_comparison(
310 - &state.db,
313 + &db,
311 314 session_user.id,
312 315 None,
313 316 Some(item_id),
@@ -319,7 +322,7 @@ pub(super) async fn dashboard_item_analytics(
319 322
320 323 let revenue_str = comparison.current_revenue_cents.format_revenue();
321 324
322 - let db_versions = db::versions::get_versions_by_item(&state.db, item_id).await?;
325 + let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
323 326 let total_downloads: i32 = db_versions.iter().map(|v| v.download_count).sum();
324 327
325 328 let stats = vec![
@@ -370,22 +373,22 @@ pub(super) async fn dismiss_onboarding(
370 373 /// Restore the onboarding checklist after it was dismissed.
371 374 #[tracing::instrument(skip_all, name = "dashboard::restore_onboarding")]
372 375 pub(super) async fn restore_onboarding(
373 - State(state): State<AppState>,
376 + State(db): State<PgPool>,
374 377 session: Session,
375 378 AuthUser(session_user): AuthUser,
376 379 ) -> Result<impl IntoResponse> {
377 380 session.remove::<bool>(ONBOARDING_DISMISSED_KEY).await.ok();
378 381
379 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
382 + let db_user = db::users::get_user_by_id(&db, session_user.id)
380 383 .await?
381 384 .ok_or(AppError::NotFound)?;
382 - let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?;
385 + let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?;
383 386
384 387 let profile_done = db_user.display_name.as_ref().is_some_and(|n| !n.is_empty());
385 388 let stripe_done = db_user.stripe_account_id.is_some();
386 389 let projects_done = !db_projects.is_empty();
387 390 let publish_done = if projects_done {
388 - db::items::has_public_item_by_user(&state.db, session_user.id).await?
391 + db::items::has_public_item_by_user(&db, session_user.id).await?
389 392 } else {
390 393 false
391 394 };
@@ -8,13 +8,14 @@ use std::collections::{HashMap, HashSet};
8 8
9 9 use crate::{
10 10 auth::AuthUser,
11 + config::Config,
11 12 db::{self, analytics::TimeRange, ItemId, Slug},
12 13 error::{AppError, Result},
13 14 helpers,
14 15 templates::*,
15 16 types::*,
16 - AppState,
17 17 };
18 + use sqlx::PgPool;
18 19
19 20 use super::AnalyticsQuery;
20 21
@@ -74,13 +75,13 @@ fn build_content_items_with_bundles(
74 75 /// Returns `Ok(Err(304))` if the client's cached version is fresh,
75 76 /// or `Ok(Ok((project, generation)))` if rendering is needed.
76 77 async fn resolve_project_etag(
77 - state: &AppState,
78 + db: &PgPool,
78 79 user_id: db::UserId,
79 80 slug: &str,
80 81 headers: &HeaderMap,
81 82 ) -> Result<std::result::Result<(db::DbProject, i64), axum::response::Response>> {
82 83 let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?;
83 - let db_project = db::projects::get_project_by_user_and_slug(&state.db, user_id, &slug)
84 + let db_project = db::projects::get_project_by_user_and_slug(db, user_id, &slug)
84 85 .await?
85 86 .ok_or(AppError::NotFound)?;
86 87
@@ -94,18 +95,18 @@ async fn resolve_project_etag(
94 95 /// Render the HTMX partial for the project overview tab with stats.
95 96 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_overview")]
96 97 pub(super) async fn project_tab_overview(
97 - State(state): State<AppState>,
98 + State(db): State<PgPool>,
98 99 AuthUser(session_user): AuthUser,
99 100 headers: HeaderMap,
100 101 Path(slug): Path<String>,
101 102 ) -> Result<axum::response::Response> {
102 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
103 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
103 104 Ok(pair) => pair,
104 105 Err(not_modified) => return Ok(not_modified),
105 106 };
106 107
107 - let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
108 - let (revenue_cents, sales_count) = db::transactions::get_revenue_by_project(&state.db, db_project.id).await?;
108 + let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
109 + let (revenue_cents, sales_count) = db::transactions::get_revenue_by_project(&db, db_project.id).await?;
109 110
110 111 let revenue_str = format!("${}.{:02}", revenue_cents / 100, revenue_cents % 100);
111 112
@@ -130,7 +131,7 @@ pub(super) async fn project_tab_overview(
130 131 },
131 132 ];
132 133
133 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
134 + let db_user = db::users::get_user_by_id(&db, session_user.id)
134 135 .await?
135 136 .ok_or(AppError::NotFound)?;
136 137
@@ -149,20 +150,20 @@ pub(super) async fn project_tab_overview(
149 150 /// Render the HTMX partial for the project content/items tab.
150 151 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_content")]
151 152 pub(super) async fn project_tab_content(
152 - State(state): State<AppState>,
153 + State(db): State<PgPool>,
153 154 AuthUser(session_user): AuthUser,
154 155 headers: HeaderMap,
155 156 Path(slug): Path<String>,
156 157 ) -> Result<axum::response::Response> {
157 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
158 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
158 159 Ok(pair) => pair,
159 160 Err(not_modified) => return Ok(not_modified),
160 161 };
161 162
162 - let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
163 - let bundle_map = db::bundles::get_project_bundle_map(&state.db, db_project.id).await?;
164 - let db_deleted = db::items::get_deleted_items_by_project(&state.db, db_project.id).await?;
165 - let db_posts = db::blog_posts::get_blog_posts_by_project(&state.db, db_project.id).await?;
163 + let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
164 + let bundle_map = db::bundles::get_project_bundle_map(&db, db_project.id).await?;
165 + let db_deleted = db::items::get_deleted_items_by_project(&db, db_project.id).await?;
166 + let db_posts = db::blog_posts::get_blog_posts_by_project(&db, db_project.id).await?;
166 167
167 168 let items = build_content_items_with_bundles(&db_items, &bundle_map);
168 169 let deleted_items: Vec<crate::templates::DeletedItemRow> = db_deleted
@@ -202,13 +203,13 @@ pub(super) async fn project_tab_content(
202 203 /// Analytics data changes constantly, so no ETag caching.
203 204 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_analytics")]
204 205 pub(super) async fn project_tab_analytics(
205 - State(state): State<AppState>,
206 + State(db): State<PgPool>,
206 207 AuthUser(session_user): AuthUser,
207 208 Path(slug): Path<String>,
208 209 Query(query): Query<AnalyticsQuery>,
209 210 ) -> Result<impl IntoResponse> {
210 211 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
211 - let db_project = db::projects::get_project_by_user_and_slug(&state.db, session_user.id, &slug)
212 + let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug)
212 213 .await?
213 214 .ok_or(AppError::NotFound)?;
214 215
@@ -219,7 +220,7 @@ pub(super) async fn project_tab_analytics(
219 220 .unwrap_or(TimeRange::Days30);
220 221
221 222 let buckets = db::analytics::get_revenue_timeseries(
222 - &state.db,
223 + &db,
223 224 session_user.id,
224 225 Some(db_project.id),
225 226 None,
@@ -228,7 +229,7 @@ pub(super) async fn project_tab_analytics(
228 229 .await?;
229 230
230 231 let comparison = db::analytics::get_period_comparison(
231 - &state.db,
232 + &db,
232 233 session_user.id,
233 234 Some(db_project.id),
234 235 None,
@@ -246,7 +247,7 @@ pub(super) async fn project_tab_analytics(
246 247
247 248 // Page view stats for this project
248 249 let (current_views, prev_views) = db::page_views::get_view_period_comparison(
249 - &state.db,
250 + &db,
250 251 session_user.id,
251 252 Some(db_project.id),
252 253 &range,
@@ -294,7 +295,7 @@ pub(super) async fn project_tab_analytics(
294 295 });
295 296 }
296 297
297 - let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
298 + let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
298 299 let items: Vec<ContentItem> = db_items
299 300 .iter()
300 301 .enumerate()
@@ -313,20 +314,20 @@ pub(super) async fn project_tab_analytics(
313 314 /// Render the HTMX partial for the project settings tab.
314 315 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_settings")]
315 316 pub(super) async fn project_tab_settings(
316 - State(state): State<AppState>,
317 + State(db): State<PgPool>,
317 318 AuthUser(session_user): AuthUser,
318 319 headers: HeaderMap,
319 320 Path(slug): Path<String>,
320 321 ) -> Result<axum::response::Response> {
321 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
322 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
322 323 Ok(pair) => pair,
323 324 Err(not_modified) => return Ok(not_modified),
324 325 };
325 326
326 - let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
327 + let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
327 328
328 329 let project = Project::from_db(&db_project, db_items.len() as u32);
329 - let category_name = db::categories::get_project_category_name(&state.db, db_project.id)
330 + let category_name = db::categories::get_project_category_name(&db, db_project.id)
330 331 .await?
331 332 .unwrap_or_default();
332 333
@@ -334,7 +335,7 @@ pub(super) async fn project_tab_settings(
334 335
335 336 let features = db_project.features.clone();
336 337 let project_features = db::ProjectFeature::all();
337 - let sections = db::project_sections::list_by_project(&state.db, db_project.id).await?;
338 + let sections = db::project_sections::list_by_project(&db, db_project.id).await?;
338 339
339 340 let pricing_model = db_project.pricing_model.to_string();
340 341 let price_dollars = if db_project.price_cents > 0 {
@@ -357,24 +358,24 @@ pub(super) async fn project_tab_settings(
357 358 /// Render the HTMX partial for the project subscriptions tab (tier management).
358 359 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_subscriptions")]
359 360 pub(super) async fn project_tab_subscriptions(
360 - State(state): State<AppState>,
361 + State(db): State<PgPool>,
361 362 AuthUser(session_user): AuthUser,
362 363 headers: HeaderMap,
363 364 Path(slug): Path<String>,
364 365 ) -> Result<axum::response::Response> {
365 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
366 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
366 367 Ok(pair) => pair,
367 368 Err(not_modified) => return Ok(not_modified),
368 369 };
369 370
370 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
371 + let db_user = db::users::get_user_by_id(&db, session_user.id)
371 372 .await?
372 373 .ok_or(AppError::NotFound)?;
373 374
374 - let db_tiers = db::subscriptions::get_all_tiers_by_project(&state.db, db_project.id).await?;
375 + let db_tiers = db::subscriptions::get_all_tiers_by_project(&db, db_project.id).await?;
375 376 let tiers: Vec<SubscriptionTier> = db_tiers.iter().map(SubscriptionTier::from).collect();
376 377
377 - let subscriber_count = db::subscriptions::get_project_subscriber_count(&state.db, db_project.id).await?;
378 + let subscriber_count = db::subscriptions::get_project_subscriber_count(&db, db_project.id).await?;
378 379
379 380 Ok(helpers::with_etag(generation, ProjectSubscriptionsTabTemplate {
380 381 project_id: db_project.id.to_string(),
@@ -388,17 +389,17 @@ pub(super) async fn project_tab_subscriptions(
388 389 /// Render the HTMX partial for the project blog tab.
389 390 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_blog")]
390 391 pub(super) async fn project_tab_blog(
391 - State(state): State<AppState>,
392 + State(db): State<PgPool>,
392 393 AuthUser(session_user): AuthUser,
393 394 headers: HeaderMap,
394 395 Path(slug): Path<String>,
395 396 ) -> Result<axum::response::Response> {
396 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
397 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
397 398 Ok(pair) => pair,
398 399 Err(not_modified) => return Ok(not_modified),
399 400 };
400 401
401 - let db_posts = db::blog_posts::get_blog_posts_by_project(&state.db, db_project.id).await?;
402 + let db_posts = db::blog_posts::get_blog_posts_by_project(&db, db_project.id).await?;
402 403
403 404 let posts: Vec<BlogPostDashboardRow> = db_posts
404 405 .into_iter()
@@ -423,18 +424,18 @@ pub(super) async fn project_tab_blog(
423 424 /// Render the HTMX partial for the project promotions tab (promo codes).
424 425 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_promotions")]
425 426 pub(super) async fn project_tab_promotions(
426 - State(state): State<AppState>,
427 + State(db): State<PgPool>,
427 428 AuthUser(session_user): AuthUser,
428 429 headers: HeaderMap,
429 430 Path(slug): Path<String>,
430 431 ) -> Result<axum::response::Response> {
431 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
432 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
432 433 Ok(pair) => pair,
433 434 Err(not_modified) => return Ok(not_modified),
434 435 };
435 436
436 - let codes = db::promo_codes::get_promo_codes_by_project(&state.db, db_project.id).await?;
437 - let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
437 + let codes = db::promo_codes::get_promo_codes_by_project(&db, db_project.id).await?;
438 + let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
438 439
439 440 let items: Vec<ContentItem> = db_items
440 441 .iter()
@@ -453,27 +454,28 @@ pub(super) async fn project_tab_promotions(
453 454 /// Render the HTMX partial for the project code tab (git repos).
454 455 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_code")]
455 456 pub(super) async fn project_tab_code(
456 - State(state): State<AppState>,
457 + State(db): State<PgPool>,
458 + State(config): State<Config>,
457 459 AuthUser(session_user): AuthUser,
458 460 headers: HeaderMap,
459 461 Path(slug): Path<String>,
460 462 ) -> Result<axum::response::Response> {
461 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
463 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
462 464 Ok(pair) => pair,
463 465 Err(not_modified) => return Ok(not_modified),
464 466 };
465 467
466 - let git_enabled = state.config.build.git_repos_path.is_some();
468 + let git_enabled = config.build.git_repos_path.is_some();
467 469 // Degrade to an empty list on error, but LOG it — a silent `unwrap_or_default`
468 470 // renders the Code tab as "no repos" and makes a DB outage look like empty
469 471 // state with no signal (audit Run 13 Obs).
470 - let db_linked_repos = db::git_repos::get_repos_by_project(&state.db, db_project.id)
472 + let db_linked_repos = db::git_repos::get_repos_by_project(&db, db_project.id)
471 473 .await
472 474 .unwrap_or_else(|e| {
473 475 tracing::warn!(error = ?e, project_id = %db_project.id, "code tab: failed to load linked repos; rendering as none");
474 476 Vec::new()
475 477 });
476 - let all_repos = db::git_repos::get_repos_by_user(&state.db, session_user.id)
478 + let all_repos = db::git_repos::get_repos_by_user(&db, session_user.id)
477 479 .await
478 480 .unwrap_or_else(|e| {
479 481 tracing::warn!(error = ?e, user_id = %session_user.id, "code tab: failed to load user repos; rendering as none");
@@ -486,7 +488,7 @@ pub(super) async fn project_tab_code(
486 488 let repo_ids: Vec<_> = db_linked_repos.iter().map(|r| r.id).collect();
487 489 let mut collabs_by_repo: std::collections::HashMap<_, Vec<RepoCollaboratorView>> =
488 490 std::collections::HashMap::new();
489 - for c in db::repo_collaborators::list_collaborators_for_repos(&state.db, &repo_ids)
491 + for c in db::repo_collaborators::list_collaborators_for_repos(&db, &repo_ids)
490 492 .await
491 493 .unwrap_or_else(|e| {
492 494 tracing::warn!(error = ?e, "code tab: failed to load repo collaborators; rendering none");
@@ -508,7 +510,7 @@ pub(super) async fn project_tab_code(
508 510 })
509 511 .collect();
510 512
511 - let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
513 + let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
512 514 let project = Project::from_db(&db_project, db_items.len() as u32);
513 515
514 516 Ok(helpers::with_etag(generation, ProjectCodeTabTemplate {
@@ -525,10 +527,10 @@ pub(super) async fn project_tab_code(
525 527 /// mapping was copy-pasted verbatim in both (audit Run 17 Architecture).
526 528 #[tracing::instrument(skip_all, name = "project_tabs::load_project_members")]
527 529 async fn load_project_members(
528 - state: &AppState,
530 + db: &PgPool,
529 531 project_id: db::ProjectId,
530 532 ) -> Result<(Vec<ProjectMemberRow>, i64)> {
531 - let db_members = db::project_members::get_project_members(&state.db, project_id).await?;
533 + let db_members = db::project_members::get_project_members(db, project_id).await?;
532 534 let members: Vec<ProjectMemberRow> = db_members
533 535 .iter()
534 536 .map(|m| ProjectMemberRow {
@@ -542,24 +544,24 @@ async fn load_project_members(
542 544 added_at: m.added_at.format("%Y-%m-%d").to_string(),
543 545 })
544 546 .collect();
545 - let total_member_split = db::project_members::get_total_split_percent(&state.db, project_id).await?;
547 + let total_member_split = db::project_members::get_total_split_percent(db, project_id).await?;
546 548 Ok((members, 100 - total_member_split))
547 549 }
548 550
549 551 /// Render the HTMX partial for the project members tab.
550 552 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_members")]
551 553 pub(super) async fn project_tab_members(
552 - State(state): State<AppState>,
554 + State(db): State<PgPool>,
553 555 AuthUser(session_user): AuthUser,
554 556 headers: HeaderMap,
555 557 Path(slug): Path<String>,
556 558 ) -> Result<axum::response::Response> {
557 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
559 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
558 560 Ok(pair) => pair,
559 561 Err(not_modified) => return Ok(not_modified),
560 562 };
561 563
562 - let (members, owner_split) = load_project_members(&state, db_project.id).await?;
564 + let (members, owner_split) = load_project_members(&db, db_project.id).await?;
563 565
564 566 Ok(helpers::with_etag(generation, ProjectMembersTabTemplate {
565 567 project_id: db_project.id.to_string(),
@@ -572,28 +574,28 @@ pub(super) async fn project_tab_members(
572 574 /// Combined monetization tab: tiers, promo codes, and team splits.
573 575 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_monetization")]
574 576 pub(super) async fn project_tab_monetization(
575 - State(state): State<AppState>,
577 + State(db): State<PgPool>,
576 578 AuthUser(session_user): AuthUser,
577 579 headers: HeaderMap,
578 580 Path(slug): Path<String>,
579 581 ) -> Result<axum::response::Response> {
580 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
582 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
581 583 Ok(pair) => pair,
582 584 Err(not_modified) => return Ok(not_modified),
583 585 };
584 586
585 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
587 + let db_user = db::users::get_user_by_id(&db, session_user.id)
586 588 .await?
587 589 .ok_or(AppError::NotFound)?;
588 590
589 591 // Tiers
590 - let db_tiers = db::subscriptions::get_all_tiers_by_project(&state.db, db_project.id).await?;
592 + let db_tiers = db::subscriptions::get_all_tiers_by_project(&db, db_project.id).await?;
591 593 let tiers: Vec<SubscriptionTier> = db_tiers.iter().map(SubscriptionTier::from).collect();
592 - let subscriber_count = db::subscriptions::get_project_subscriber_count(&state.db, db_project.id).await?;
594 + let subscriber_count = db::subscriptions::get_project_subscriber_count(&db, db_project.id).await?;
593 595
594 596 // Promo codes
595 - let codes = db::promo_codes::get_promo_codes_by_project(&state.db, db_project.id).await?;
596 - let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
597 + let codes = db::promo_codes::get_promo_codes_by_project(&db, db_project.id).await?;
598 + let db_items = db::items::get_items_by_project(&db, db_project.id).await?;
597 599 let items: Vec<ContentItem> = db_items
598 600 .iter()
599 601 .enumerate()
@@ -601,7 +603,7 @@ pub(super) async fn project_tab_monetization(
601 603 .collect();
602 604
603 605 // Members
604 - let (members, owner_split) = load_project_members(&state, db_project.id).await?;
606 + let (members, owner_split) = load_project_members(&db, db_project.id).await?;
605 607
606 608 Ok(helpers::with_etag(generation, ProjectMonetizationTabTemplate {
607 609 project_id: db_project.id.to_string(),
@@ -619,33 +621,33 @@ pub(super) async fn project_tab_monetization(
619 621 /// Render the HTMX partial for SyncKit apps linked to a project.
620 622 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_synckit")]
621 623 pub(super) async fn project_tab_synckit(
622 - State(state): State<AppState>,
624 + State(db): State<PgPool>,
623 625 AuthUser(session_user): AuthUser,
624 626 headers: HeaderMap,
625 627 Path(slug): Path<String>,
626 628 ) -> Result<axum::response::Response> {
627 - let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
629 + let (db_project, generation) = match resolve_project_etag(&db, session_user.id, &slug, &headers).await? {
628 630 Ok(pair) => pair,
629 631 Err(not_modified) => return Ok(not_modified),
630 632 };
631 633
632 634 let db_apps =
633 - db::synckit::get_sync_apps_by_project(&state.db, db_project.id).await?;
635 + db::synckit::get_sync_apps_by_project(&db, db_project.id).await?;
634 636
635 637 let stats_batch =
636 - db::synckit::get_sync_app_stats_batch(&state.db, session_user.id).await?;
638 + db::synckit::get_sync_app_stats_batch(&db, session_user.id).await?;
637 639 let stats_map: std::collections::HashMap<_, _> = stats_batch
638 640 .into_iter()
639 641 .map(|(id, devices, logs)| (id, (devices, logs)))
640 642 .collect();
641 643
642 644 let billing_batch =
643 - db::synckit_billing::get_apps_with_billing_by_project(&state.db, db_project.id).await?;
645 + db::synckit_billing::get_apps_with_billing_by_project(&db, db_project.id).await?;
644 646 let billing_map: std::collections::HashMap<_, _> = billing_batch
645 647 .into_iter()
646 648 .map(|b| (b.id, b))
647 649 .collect();
648 - let top_keys_map = crate::types::build_top_keys_map(&state.db, &billing_map).await?;
650 + let top_keys_map = crate::types::build_top_keys_map(&db, &billing_map).await?;
649 651
650 652 let mut apps = Vec::with_capacity(db_apps.len());
651 653 for app in &db_apps {
@@ -5,24 +5,25 @@ use axum::response::IntoResponse;
5 5
6 6 use crate::{
7 7 auth::AuthUser,
8 + config::Config,
8 9 db::{self, ItemId},
9 10 error::{AppError, Result},
10 11 templates::*,
11 12 types::*,
12 - AppState,
13 13 };
14 + use sqlx::PgPool;
14 15
15 16 /// Resolve an item by ID, verify ownership, and return the item + project.
16 17 async fn resolve_owned_item(
17 - state: &AppState,
18 + db: &PgPool,
18 19 user_id: db::UserId,
19 20 id: &str,
20 21 ) -> Result<(db::DbItem, db::DbProject)> {
21 22 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
22 - let db_item = db::items::get_item_by_id(&state.db, item_id)
23 + let db_item = db::items::get_item_by_id(db, item_id)
23 24 .await?
24 25 .ok_or(AppError::NotFound)?;
25 - let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
26 + let db_project = db::projects::get_project_by_id(db, db_item.project_id)
26 27 .await?
27 28 .ok_or(AppError::NotFound)?;
28 29 if db_project.user_id != user_id {
@@ -40,13 +41,13 @@ fn build_item_view(db_item: &db::DbItem, item_tags: &[db::DbItemTag]) -> Item {
40 41 /// Item overview tab: quick actions + analytics (lazy-loaded).
41 42 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_overview")]
42 43 pub(in crate::routes::pages::dashboard) async fn item_tab_overview(
43 - State(state): State<AppState>,
44 + State(db): State<PgPool>,
44 45 AuthUser(session_user): AuthUser,
45 46 Path(id): Path<String>,
46 47 ) -> Result<impl IntoResponse> {
47 48 let (db_item, _db_project) =
48 - resolve_owned_item(&state, session_user.id, &id).await?;
49 - let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
49 + resolve_owned_item(&db, session_user.id, &id).await?;
50 + let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
50 51 let item = build_item_view(&db_item, &item_tags);
51 52 Ok(ItemOverviewTabTemplate { item })
52 53 }
@@ -54,18 +55,18 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_overview(
54 55 /// Item details tab: name, description, tags, content editor, bundle contents.
55 56 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_details")]
56 57 pub(in crate::routes::pages::dashboard) async fn item_tab_details(
57 - State(state): State<AppState>,
58 + State(db): State<PgPool>,
58 59 AuthUser(session_user): AuthUser,
59 60 Path(id): Path<String>,
60 61 ) -> Result<impl IntoResponse> {
61 62 let (db_item, db_project) =
62 - resolve_owned_item(&state, session_user.id, &id).await?;
63 + resolve_owned_item(&db, session_user.id, &id).await?;
63 64 let item_id = db_item.id;
64 - let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
65 + let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
65 66 let item = build_item_view(&db_item, &item_tags);
66 67
67 68 let (bundle_items, bundleable_items) = if db_item.item_type == db::ItemType::Bundle {
68 - let children = db::bundles::get_bundle_items(&state.db, item_id).await?;
69 + let children = db::bundles::get_bundle_items(&db, item_id).await?;
69 70 let bundle_child_ids: Vec<db::ItemId> = children.iter().map(|c| c.id).collect();
70 71 let child_items: Vec<Item> = children
71 72 .iter()
@@ -75,7 +76,7 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_details(
75 76 })
76 77 .collect();
77 78 let available = db::bundles::get_bundleable_items(
78 - &state.db,
79 + &db,
79 80 db_project.id,
80 81 Some(item_id),
81 82 )
@@ -93,7 +94,7 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_details(
93 94 (vec![], vec![])
94 95 };
95 96
96 - let db_sections = db::item_sections::list_by_item(&state.db, item_id).await?;
97 + let db_sections = db::item_sections::list_by_item(&db, item_id).await?;
97 98 let sections: Vec<crate::types::ItemSection> =
98 99 db_sections.iter().map(crate::types::ItemSection::from).collect();
99 100
@@ -108,18 +109,18 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_details(
108 109 /// Item pricing tab: PWYW settings, license keys, promo codes.
109 110 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_pricing")]
110 111 pub(in crate::routes::pages::dashboard) async fn item_tab_pricing(
111 - State(state): State<AppState>,
112 + State(db): State<PgPool>,
112 113 AuthUser(session_user): AuthUser,
113 114 Path(id): Path<String>,
114 115 ) -> Result<impl IntoResponse> {
115 116 let (db_item, _db_project) =
116 - resolve_owned_item(&state, session_user.id, &id).await?;
117 + resolve_owned_item(&db, session_user.id, &id).await?;
117 118 let item_id = db_item.id;
118 - let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
119 + let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
119 120 let item = build_item_view(&db_item, &item_tags);
120 121
121 122 let db_license_keys = if db_item.enable_license_keys {
122 - db::license_keys::get_license_keys_by_item(&state.db, item_id).await?
123 + db::license_keys::get_license_keys_by_item(&db, item_id).await?
123 124 } else {
124 125 vec![]
125 126 };
@@ -127,7 +128,7 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_pricing(
127 128 db_license_keys.into_iter().map(LicenseKeyRow::from).collect();
128 129
129 130 let db_promo_codes =
130 - db::promo_codes::get_promo_codes_by_item(&state.db, item_id).await?;
131 + db::promo_codes::get_promo_codes_by_item(&db, item_id).await?;
131 132 let promo_codes: Vec<PromoCodeRow> =
132 133 db_promo_codes.into_iter().map(PromoCodeRow::from).collect();
133 134
@@ -142,17 +143,17 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_pricing(
142 143 /// Item files tab: version upload + download table.
143 144 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_files")]
144 145 pub(in crate::routes::pages::dashboard) async fn item_tab_files(
145 - State(state): State<AppState>,
146 + State(db): State<PgPool>,
146 147 AuthUser(session_user): AuthUser,
147 148 Path(id): Path<String>,
148 149 ) -> Result<impl IntoResponse> {
149 150 let (db_item, _db_project) =
150 - resolve_owned_item(&state, session_user.id, &id).await?;
151 + resolve_owned_item(&db, session_user.id, &id).await?;
151 152 let item_id = db_item.id;
152 - let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
153 + let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
153 154 let item = build_item_view(&db_item, &item_tags);
154 155
155 - let db_versions = db::versions::get_versions_by_item(&state.db, item_id).await?;
156 + let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
156 157 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
157 158
158 159 Ok(ItemFilesTabTemplate { item, versions })
@@ -161,19 +162,20 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_files(
161 162 /// Item embed tab: copy-paste embed codes.
162 163 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_embed")]
163 164 pub(in crate::routes::pages::dashboard) async fn item_tab_embed(
164 - State(state): State<AppState>,
165 + State(db): State<PgPool>,
166 + State(config): State<Config>,
165 167 AuthUser(session_user): AuthUser,
166 168 Path(id): Path<String>,
167 169 ) -> Result<impl IntoResponse> {
168 170 let (db_item, _db_project) =
169 - resolve_owned_item(&state, session_user.id, &id).await?;
170 - let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
171 + resolve_owned_item(&db, session_user.id, &id).await?;
172 + let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
171 173 let is_audio = db_item.audio_s3_key.is_some();
172 174 let item = build_item_view(&db_item, &item_tags);
173 175
174 176 Ok(ItemEmbedTabTemplate {
175 177 item,
176 - host_url: state.config.host_url.clone(),
178 + host_url: config.host_url.clone(),
177 179 is_audio,
178 180 })
179 181 }
@@ -181,17 +183,17 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_embed(
181 183 /// Item sales tab: transaction history with refund buttons.
182 184 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_sales")]
183 185 pub(in crate::routes::pages::dashboard) async fn item_tab_sales(
184 - State(state): State<AppState>,
186 + State(db): State<PgPool>,
185 187 AuthUser(session_user): AuthUser,
186 188 Path(id): Path<String>,
187 189 ) -> Result<impl IntoResponse> {
188 190 let (db_item, _db_project) =
189 - resolve_owned_item(&state, session_user.id, &id).await?;
191 + resolve_owned_item(&db, session_user.id, &id).await?;
190 192 let item_id = db_item.id;
191 - let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
193 + let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
192 194 let item = build_item_view(&db_item, &item_tags);
193 195
194 - let sales = db::transactions::get_sales_by_item(&state.db, item_id, session_user.id).await?;
196 + let sales = db::transactions::get_sales_by_item(&db, item_id, session_user.id).await?;
195 197 let rows: Vec<SaleRow> = sales.iter().map(|tx| {
196 198 let buyer_display = tx.guest_email.clone()
197 199 .or_else(|| tx.buyer_id.map(|_| "Registered user".to_string()))
@@ -6,6 +6,7 @@ use tower_sessions::Session;
6 6
7 7 use crate::{
8 8 auth::AuthUser,
9 + config::Config,
9 10 constants,
10 11 db::{self, analytics::TimeRange},
11 12 error::{AppError, Result},
@@ -13,35 +14,38 @@ use crate::{
13 14 helpers,
14 15 templates::*,
15 16 types::*,
16 - AppState,
17 + Billing,
17 18 };
19 + use sqlx::PgPool;
18 20
19 21 use super::super::super::AnalyticsQuery;
20 22
21 23 /// Render the HTMX partial for the dashboard creator/waitlist tab.
22 24 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_creator")]
23 25 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_creator(
24 - State(state): State<AppState>,
26 + State(db): State<PgPool>,
27 + State(config): State<Config>,
28 + State(payments): State<Billing>,
25 29 session: Session,
26 30 AuthUser(session_user): AuthUser,
27 31 ) -> Result<impl IntoResponse> {
28 32 let csrf_token = get_csrf_token(&session).await;
29 33
30 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
34 + let db_user = db::users::get_user_by_id(&db, session_user.id)
31 35 .await?
32 36 .ok_or(AppError::NotFound)?;
33 37
34 38 let waitlist_entry =
35 - db::waitlist::get_waitlist_entry_by_user(&state.db, session_user.id).await?;
39 + db::waitlist::get_waitlist_entry_by_user(&db, session_user.id).await?;
36 40 let entry = waitlist_entry.as_ref().map(WaitlistEntry::from);
37 41
38 42 // Load invite data for creators when invites are enabled
39 43 let (active_invite_count, invite_codes) =
40 44 if db_user.can_create_projects && constants::INVITES_ENABLED {
41 45 let count =
42 - db::invites::count_active_invites(&state.db, session_user.id).await?;
46 + db::invites::count_active_invites(&db, session_user.id).await?;
43 47 let codes =
44 - db::invites::get_invites_by_creator(&state.db, session_user.id).await?;
48 + db::invites::get_invites_by_creator(&db, session_user.id).await?;
45 49 let display_codes: Vec<InviteCodeDisplay> =
46 50 codes.iter().map(InviteCodeDisplay::from).collect();
47 51 (count, display_codes)
@@ -51,9 +55,9 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_creator(
51 55
52 56 // Load wave/stats data for non-creator informational section
53 57 let (total_creators, waitlist_pending, waves) = if !db_user.can_create_projects {
54 - let db_waves = db::waitlist::get_all_waves(&state.db).await?;
55 - let creators = db::waitlist::count_active_creators(&state.db).await? as u32;
56 - let pending = db::waitlist::count_waitlist_pending(&state.db).await? as u32;
58 + let db_waves = db::waitlist::get_all_waves(&db).await?;
59 + let creators = db::waitlist::count_active_creators(&db).await? as u32;
60 + let pending = db::waitlist::count_waitlist_pending(&db).await? as u32;
57 61 let wave_stats: Vec<WaveStats> =
58 62 db_waves.iter().map(WaveStats::from).collect();
59 63 (creators, pending, wave_stats)
@@ -62,14 +66,14 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_creator(
62 66 };
63 67
64 68 let follower_count = if db_user.can_create_projects {
65 - db::follows::get_broadcast_follower_count(&state.db, session_user.id).await?
69 + db::follows::get_broadcast_follower_count(&db, session_user.id).await?
66 70 } else {
67 71 0
68 72 };
69 73
70 74 // Load creator tier subscription data
71 75 let creator_sub =
72 - db::creator_tiers::get_creator_sub_by_user(&state.db, session_user.id).await?;
76 + db::creator_tiers::get_creator_sub_by_user(&db, session_user.id).await?;
73 77 let (creator_tier_label, creator_period_end, creator_sub_status) = match &creator_sub {
74 78 Some(sub) => {
75 79 let label = Some(sub.tier.label().to_string());
@@ -95,9 +99,9 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_creator(
95 99 storage_pct,
96 100 ) = if db_user.can_create_projects {
97 101 let breakdown =
98 - db::creator_tiers::get_storage_breakdown(&state.db, session_user.id).await?;
102 + db::creator_tiers::get_storage_breakdown(&db, session_user.id).await?;
99 103 let effective_tier =
100 - db::creator_tiers::get_active_creator_tier(&state.db, session_user.id)
104 + db::creator_tiers::get_active_creator_tier(&db, session_user.id)
101 105 .await?
102 106 .unwrap_or(crate::db::CreatorTier::SmallFiles); // default for display
103 107 let max = effective_tier.max_storage_bytes();
@@ -149,7 +153,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_creator(
149 153 creator_tier_label,
150 154 creator_period_end,
151 155 creator_sub_status,
152 - creator_tiers_configured: !state.config.creator_pricing.tier_prices.is_empty(),
156 + creator_tiers_configured: !config.creator_pricing.tier_prices.is_empty(),
153 157 storage_audio,
154 158 storage_covers,
155 159 storage_downloads,
@@ -160,17 +164,17 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_creator(
160 164 storage_total,
161 165 storage_max,
162 166 storage_pct,
163 - founder_window_open: state.config.creator_pricing.founder_window_open,
167 + founder_window_open: config.creator_pricing.founder_window_open,
164 168 is_founder: db_user.is_founder,
165 169 is_founder_locked: db_user.is_founder_locked(),
166 - tier_cards: state.tier_prices.cards(),
170 + tier_cards: payments.tier_prices.cards(),
167 171 })
168 172 }
169 173
170 174 /// Render the HTMX partial for the user-level analytics tab (aggregated across all projects).
171 175 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_analytics")]
172 176 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_analytics(
173 - State(state): State<AppState>,
177 + State(db): State<PgPool>,
174 178 AuthUser(session_user): AuthUser,
175 179 Query(query): Query<AnalyticsQuery>,
176 180 ) -> Result<impl IntoResponse> {
@@ -181,7 +185,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_analytics(
181 185 .unwrap_or(TimeRange::Days30);
182 186
183 187 let buckets = db::analytics::get_revenue_timeseries(
184 - &state.db,
188 + &db,
185 189 session_user.id,
186 190 None,
187 191 None,
@@ -190,7 +194,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_analytics(
190 194 .await?;
191 195
192 196 let comparison = db::analytics::get_period_comparison(
193 - &state.db,
197 + &db,
194 198 session_user.id,
195 199 None,
196 200 None,
@@ -208,7 +212,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_analytics(
208 212
209 213 // Page view stats
210 214 let (current_views, prev_views) =
211 - db::page_views::get_view_period_comparison(&state.db, session_user.id, None, &range)
215 + db::page_views::get_view_period_comparison(&db, session_user.id, None, &range)
212 216 .await?;
213 217 let view_change = db::analytics::pct_change(current_views, prev_views);
214 218
@@ -262,10 +266,10 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_analytics(
262 266
263 267 // Build per-project revenue + views breakdown for comparison table
264 268 let project_data =
265 - db::transactions::get_revenue_by_user_projects_in_range(&state.db, session_user.id, &range)
269 + db::transactions::get_revenue_by_user_projects_in_range(&db, session_user.id, &range)
266 270 .await?;
267 271 let project_views =
268 - db::page_views::get_views_by_seller_projects(&state.db, session_user.id, &range).await?;
272 + db::page_views::get_views_by_seller_projects(&db, session_user.id, &range).await?;
269 273
270 274 // Merge revenue + views by project ID
271 275 let max_rev = project_data.iter().map(|(_, _, r, _)| *r).max().unwrap_or(1).max(1);
@@ -295,7 +299,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_analytics(
295 299
296 300 // Top projects list (all-time, for the simple list below the chart)
297 301 let project_revenues =
298 - db::transactions::get_revenue_by_user_projects(&state.db, session_user.id).await?;
302 + db::transactions::get_revenue_by_user_projects(&db, session_user.id).await?;
299 303 let top_projects: Vec<ProjectRevenue> = project_revenues
300 304 .into_iter()
301 305 .map(|(_pid, title, rev_cents)| ProjectRevenue {
@@ -5,22 +5,22 @@ use axum::response::IntoResponse;
5 5
6 6 use crate::{
7 7 auth::AuthUser,
8 + config::Config,
8 9 db,
9 10 error::{AppError, Result},
10 11 helpers,
11 12 templates::*,
12 13 types::*,
13 - AppState,
14 14 };
15 + use sqlx::PgPool;
15 16
16 17 /// Render the HTMX partial for the Forums tab (Multithreaded community memberships).
17 18 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_forums")]
18 19 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_forums(
19 - State(state): State<AppState>,
20 + State(config): State<Config>,
20 21 AuthUser(session_user): AuthUser,
21 22 ) -> Result<impl IntoResponse> {
22 - let mt_base_url = state
23 - .config
23 + let mt_base_url = config
24 24 .integrations.mt_base_url
25 25 .as_ref()
26 26 .ok_or(AppError::NotFound)?;
@@ -86,17 +86,17 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_forums(
86 86 /// Render the HTMX partial for the SyncKit tab.
87 87 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_synckit")]
88 88 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_synckit(
89 - State(state): State<AppState>,
89 + State(db): State<PgPool>,
90 90 AuthUser(session_user): AuthUser,
91 91 ) -> Result<impl IntoResponse> {
92 92 let db_apps =
93 - db::synckit::get_sync_apps_by_creator(&state.db, session_user.id).await?;
93 + db::synckit::get_sync_apps_by_creator(&db, session_user.id).await?;
94 94 let db_projects =
95 - db::projects::get_projects_by_user(&state.db, session_user.id).await?;
95 + db::projects::get_projects_by_user(&db, session_user.id).await?;
96 96
97 97 // Batch-fetch stats and item titles (no N+1)
98 98 let stats_batch =
99 - db::synckit::get_sync_app_stats_batch(&state.db, session_user.id).await?;
99 + db::synckit::get_sync_app_stats_batch(&db, session_user.id).await?;
100 100 let stats_map: std::collections::HashMap<_, _> = stats_batch
101 101 .into_iter()
102 102 .map(|(id, devices, logs)| (id, (devices, logs)))
@@ -105,12 +105,12 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_synckit(
105 105 let item_ids: Vec<db::ItemId> =
106 106 db_apps.iter().filter_map(|a| a.item_id).collect();
107 107 let item_titles_batch =
108 - db::items::get_item_titles_batch(&state.db, &item_ids).await?;
108 + db::items::get_item_titles_batch(&db, &item_ids).await?;
109 109 let item_title_map: std::collections::HashMap<_, _> =
110 110 item_titles_batch.into_iter().collect();
111 111
112 112 let billing_batch =
113 - db::synckit_billing::get_apps_with_billing_by_creator(&state.db, session_user.id).await?;
113 + db::synckit_billing::get_apps_with_billing_by_creator(&db, session_user.id).await?;
114 114 let billing_map: std::collections::HashMap<_, _> = billing_batch
115 115 .into_iter()
116 116 .map(|b| (b.id, b))
@@ -118,7 +118,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_synckit(
118 118
119 119 // For per_key-mode apps, fetch the most-loaded keys to render mini-gauges
120 120 // under the app-aggregate gauge. One batched query covers every app.
121 - let top_keys_map = build_top_keys_map(&state.db, &billing_map).await?;
121 + let top_keys_map = build_top_keys_map(&db, &billing_map).await?;
122 122
123 123 let mut apps = Vec::with_capacity(db_apps.len());
124 124 for app in db_apps {
@@ -172,18 +172,18 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_synckit(
172 172 /// Render the HTMX partial for the dashboard media tab.
173 173 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_media")]
174 174 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_media(
175 - State(state): State<AppState>,
175 + State(db): State<PgPool>,
176 + State(config): State<Config>,
176 177 AuthUser(session_user): AuthUser,
177 178 ) -> Result<impl IntoResponse> {
178 - let cdn_base = state
179 - .config
179 + let cdn_base = config
180 180 .cdn_base_url
181 181 .as_deref()
182 182 .unwrap_or("https://cdn.makenot.work");
183 183
184 184 let db_files =
185 - db::media_files::list_by_user_folder(&state.db, session_user.id, None).await?;
186 - let folders = db::media_files::list_folders(&state.db, session_user.id).await?;
185 + db::media_files::list_by_user_folder(&db, session_user.id, None).await?;
186 + let folders = db::media_files::list_folders(&db, session_user.id).await?;
187 187
188 188 let files: Vec<MediaFileRow> = db_files
189 189 .into_iter()
@@ -211,7 +211,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_media(
211 211 .collect();
212 212
213 213 let breakdown =
214 - db::creator_tiers::get_storage_breakdown(&state.db, session_user.id).await?;
214 + db::creator_tiers::get_storage_breakdown(&db, session_user.id).await?;
215 215 let storage_display = helpers::format_bytes(breakdown.media_bytes);
216 216
217 217 Ok(UserMediaTabTemplate {
@@ -22,27 +22,29 @@ use tower_sessions::Session;
22 22
23 23 use crate::{
24 24 auth::{AuthUser, SESSION_TRACKING_KEY},
25 + config::Config,
25 26 db,
26 27 error::{AppError, Result},
27 28 helpers,
28 29 templates::*,
29 30 types::*,
30 - AppState,
31 31 };
32 + use sqlx::PgPool;
32 33
33 34 /// Render the HTMX partial for the dashboard settings meta-tab.
34 35 /// Includes profile content inline; other sections loaded via HTMX sub-nav.
35 36 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_settings")]
36 37 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_settings(
37 - State(state): State<AppState>,
38 + State(db): State<PgPool>,
39 + State(config): State<Config>,
38 40 AuthUser(session_user): AuthUser,
39 41 ) -> Result<impl IntoResponse> {
40 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
42 + let db_user = db::users::get_user_by_id(&db, session_user.id)
41 43 .await?
42 44 .ok_or(AppError::NotFound)?;
43 45
44 46 let db_links =
45 - db::custom_links::get_custom_links_by_user(&state.db, session_user.id).await?;
47 + db::custom_links::get_custom_links_by_user(&db, session_user.id).await?;
46 48
47 49 let user = User::from(&db_user);
48 50
@@ -56,14 +58,14 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_settings(
56 58 .collect();
57 59
58 60 let feed_url = helpers::generate_feed_url(
59 - &state.config.host_url,
61 + &config.host_url,
60 62 session_user.id,
61 63 db_user.feed_key_version,
62 - &state.config.signing_secret,
64 + &config.signing_secret,
63 65 );
64 66
65 67 let custom_domain =
66 - db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
68 + db::custom_domains::get_custom_domain_by_user(&db, session_user.id)
67 69 .await?
68 70 .map(|d| {
69 71 let instructions = if d.verified {
@@ -84,8 +86,8 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_settings(
84 86 });
85 87
86 88 let has_media = session_user.can_create_projects;
87 - let git_enabled = state.config.build.git_repos_path.is_some();
88 - let has_mt_memberships = state.config.integrations.mt_base_url.is_some();
89 + let git_enabled = config.build.git_repos_path.is_some();
90 + let has_mt_memberships = config.integrations.mt_base_url.is_some();
89 91
90 92 Ok(UserSettingsTabTemplate {
91 93 user,
@@ -102,24 +104,26 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_settings(
102 104
103 105 /// Legacy route; redirects to the profile tab.
104 106 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_details(
105 - state: State<AppState>,
107 + db: State<PgPool>,
108 + config: State<Config>,
106 109 session_user: AuthUser,
107 110 ) -> Result<impl IntoResponse> {
108 - dashboard_tab_profile(state, session_user).await
111 + dashboard_tab_profile(db, config, session_user).await
109 112 }
110 113
111 114 /// Render the HTMX partial for the dashboard profile tab.
112 115 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_profile")]
113 116 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_profile(
114 - State(state): State<AppState>,
117 + State(db): State<PgPool>,
118 + State(config): State<Config>,
115 119 AuthUser(session_user): AuthUser,
116 120 ) -> Result<impl IntoResponse> {
117 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
121 + let db_user = db::users::get_user_by_id(&db, session_user.id)
118 122 .await?
119 123 .ok_or(AppError::NotFound)?;
120 124
121 125 let db_links =
122 - db::custom_links::get_custom_links_by_user(&state.db, session_user.id).await?;
126 + db::custom_links::get_custom_links_by_user(&db, session_user.id).await?;
123 127
124 128 let user = User::from(&db_user);
125 129
@@ -133,14 +137,14 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_profile(
133 137 .collect();
134 138
135 139 let feed_url = helpers::generate_feed_url(
136 - &state.config.host_url,
140 + &config.host_url,
137 141 session_user.id,
138 142 db_user.feed_key_version,
139 - &state.config.signing_secret,
143 + &config.signing_secret,
140 144 );
141 145
142 146 let custom_domain =
143 - db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
147 + db::custom_domains::get_custom_domain_by_user(&db, session_user.id)
144 148 .await?
145 149 .map(|d| {
146 150 let instructions = if d.verified {
@@ -177,15 +181,16 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_profile(
177 181 /// had already shared stops verifying immediately.
178 182 #[tracing::instrument(skip_all, name = "dashboard_tabs::regenerate_feed_url")]
179 183 pub(in crate::routes::pages::dashboard) async fn regenerate_feed_url(
180 - State(state): State<AppState>,
184 + State(db): State<PgPool>,
185 + State(config): State<Config>,
181 186 AuthUser(session_user): AuthUser,
182 187 ) -> Result<impl IntoResponse> {
183 - let version = db::users::bump_feed_key_version(&state.db, session_user.id).await?;
188 + let version = db::users::bump_feed_key_version(&db, session_user.id).await?;
184 189 let feed_url = helpers::generate_feed_url(
185 - &state.config.host_url,
190 + &config.host_url,
186 191 session_user.id,
187 192 version,
188 - &state.config.signing_secret,
193 + &config.signing_secret,
189 194 );
190 195
191 196 // host_url is config (https origin), the id is a UUID, version an integer,
@@ -206,18 +211,18 @@ pub(in crate::routes::pages::dashboard) async fn regenerate_feed_url(
206 211 /// Render the HTMX partial for the dashboard account tab.
207 212 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_account")]
208 213 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account(
209 - State(state): State<AppState>,
214 + State(db): State<PgPool>,
210 215 session: Session,
211 216 AuthUser(session_user): AuthUser,
212 217 ) -> Result<impl IntoResponse> {
213 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
218 + let db_user = db::users::get_user_by_id(&db, session_user.id)
214 219 .await?
215 220 .ok_or(AppError::NotFound)?;
216 221
217 222 let user = User::from(&db_user);
218 223
219 224 let sessions =
220 - db::sessions::get_user_sessions(&state.db, session_user.id).await?;
225 + db::sessions::get_user_sessions(&db, session_user.id).await?;
221 226 let current_session_id = session
222 227 .get::<db::UserSessionId>(SESSION_TRACKING_KEY)
223 228 .await
@@ -225,8 +230,8 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account(
225 230 .flatten();
226 231
227 232 // Fetch moderation actions for "Account Status" section
228 - let active_actions = db::moderation::get_active_actions(&state.db, session_user.id).await?;
229 - let all_actions = db::moderation::get_history(&state.db, session_user.id).await?;
233 + let active_actions = db::moderation::get_active_actions(&db, session_user.id).await?;
234 + let all_actions = db::moderation::get_history(&db, session_user.id).await?;
230 235
231 236 let moderation_active: Vec<ModerationActionView> = active_actions
232 237 .iter()
@@ -249,7 +254,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account(
249 254 })
250 255 .collect();
251 256
252 - let fan_plus = db::fan_plus::get_fan_plus_by_user(&state.db, session_user.id)
257 + let fan_plus = db::fan_plus::get_fan_plus_by_user(&db, session_user.id)
253 258 .await?
254 259 .filter(|sub| matches!(sub.status, db::SubscriptionStatus::Active | db::SubscriptionStatus::PastDue))
255 260 .map(|sub| crate::templates::FanPlusPaneView {
@@ -276,18 +281,18 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account(
276 281 /// Render the HTMX partial for the dashboard projects tab.
277 282 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_projects")]
278 283 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_projects(
279 - State(state): State<AppState>,
284 + State(db): State<PgPool>,
280 285 AuthUser(session_user): AuthUser,
281 286 headers: HeaderMap,
282 287 ) -> Result<axum::response::Response> {
283 288 let generation =
284 - db::users::get_cache_generation(&state.db, session_user.id).await?;
289 + db::users::get_cache_generation(&db, session_user.id).await?;
285 290 if let Some(not_modified) = helpers::check_etag(&headers, generation) {
286 291 return Ok(not_modified);
287 292 }
288 293
289 294 let db_projects =
290 - db::projects::get_projects_by_user(&state.db, session_user.id).await?;
295 + db::projects::get_projects_by_user(&db, session_user.id).await?;
291 296
292 297 let projects: Vec<ProjectCard> =
293 298 db_projects.iter().map(ProjectCard::from_db).collect();
@@ -11,29 +11,30 @@ use crate::{
11 11 helpers,
12 12 templates::*,
13 13 types::*,
14 - AppState,
14 + Billing,
15 15 };
16 + use sqlx::PgPool;
16 17
17 18 /// Render the HTMX partial for the dashboard payments tab.
18 19 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payments")]
19 20 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
20 - State(state): State<AppState>,
21 + State(db): State<PgPool>,
21 22 AuthUser(session_user): AuthUser,
22 23 ) -> Result<impl IntoResponse> {
23 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
24 + let db_user = db::users::get_user_by_id(&db, session_user.id)
24 25 .await?
25 26 .ok_or(crate::error::AppError::NotFound)?;
26 27
27 28 let user = User::from(&db_user);
28 29
29 30 let incoming_txs = db::transactions::get_transactions_by_seller(
30 - &state.db,
31 + &db,
31 32 session_user.id,
32 33 Some(DASHBOARD_TRANSACTION_LIMIT),
33 34 )
34 35 .await?;
35 36 let outgoing_txs = db::transactions::get_transactions_by_buyer(
36 - &state.db,
37 + &db,
37 38 session_user.id,
38 39 Some(DASHBOARD_TRANSACTION_LIMIT),
39 40 )
@@ -41,9 +42,9 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
41 42 let transactions = super::super::super::collect_transactions(incoming_txs, outgoing_txs);
42 43
43 44 // Tips
44 - let db_tips = db::tips::get_tips_received(&state.db, session_user.id, 20, 0).await?;
45 - let tips_total_cents = db::tips::total_tips_received(&state.db, session_user.id).await?;
46 - let tips_count = db::tips::count_tips_received(&state.db, session_user.id).await?;
45 + let db_tips = db::tips::get_tips_received(&db, session_user.id, 20, 0).await?;
46 + let tips_total_cents = db::tips::total_tips_received(&db, session_user.id).await?;
47 + let tips_count = db::tips::count_tips_received(&db, session_user.id).await?;
47 48 let tips_received: Vec<TipReceived> = db_tips
48 49 .iter()
49 50 .map(|t| TipReceived {
@@ -56,9 +57,9 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
56 57 .collect();
57 58
58 59 // Revenue splits
59 - let splits_incoming_cents = db::project_members::total_split_revenue(&state.db, session_user.id).await?;
60 - let splits_incoming_count = db::project_members::count_splits_for_recipient(&state.db, session_user.id).await?;
61 - let splits_outgoing_cents = db::project_members::total_split_obligations(&state.db, session_user.id).await?;
60 + let splits_incoming_cents = db::project_members::total_split_revenue(&db, session_user.id).await?;
61 + let splits_incoming_count = db::project_members::count_splits_for_recipient(&db, session_user.id).await?;
62 + let splits_outgoing_cents = db::project_members::total_split_obligations(&db, session_user.id).await?;
62 63
63 64 Ok(UserPaymentsTabTemplate {
64 65 user,
@@ -78,15 +79,16 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
78 79 /// instantly instead of blocking on the Stripe balance round-trip (Run 11 Perf SER-2).
79 80 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payout_summary")]
80 81 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payout_summary(
81 - State(state): State<AppState>,
82 + State(db): State<PgPool>,
83 + State(payments): State<Billing>,
82 84 AuthUser(session_user): AuthUser,
83 85 ) -> Result<impl IntoResponse> {
84 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
86 + let db_user = db::users::get_user_by_id(&db, session_user.id)
85 87 .await?
86 88 .ok_or(crate::error::AppError::NotFound)?;
87 89 let user = User::from(&db_user);
88 90
89 - let payout_summary = match (&state.stripe, &user.stripe_account_id) {
91 + let payout_summary = match (&payments.stripe, &user.stripe_account_id) {
90 92 (Some(stripe), Some(account_id)) => match stripe.get_balance(account_id).await {
91 93 Ok(balance) => Some(PayoutSummary {
92 94 available: helpers::format_revenue(balance.available_cents),
@@ -109,10 +111,10 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payout_summary(
109 111 /// Render the HTMX partial for the buyer contacts section (lazy-loaded in Payments tab).
110 112 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_contacts")]
111 113 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_contacts(
112 - State(state): State<AppState>,
114 + State(db): State<PgPool>,
113 115 AuthUser(session_user): AuthUser,
114 116 ) -> Result<impl IntoResponse> {
115 - let contacts = db::transactions::get_seller_contacts(&state.db, session_user.id).await?;
117 + let contacts = db::transactions::get_seller_contacts(&db, session_user.id).await?;
116 118 let contact_views: Vec<BuyerContact> = contacts
117 119 .into_iter()
118 120 .map(|c| BuyerContact {
@@ -129,7 +131,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_contacts(
129 131 /// Render the HTMX partial for the filtered transactions table.
130 132 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_transactions")]
131 133 pub(in crate::routes::pages::dashboard) async fn dashboard_transactions(
132 - State(state): State<AppState>,
134 + State(db): State<PgPool>,
133 135 AuthUser(session_user): AuthUser,
134 136 Query(query): Query<super::super::super::TransactionQuery>,
135 137 ) -> Result<impl IntoResponse> {
@@ -137,7 +139,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_transactions(
137 139 let transactions = match query.r#type.as_deref() {
138 140 Some("incoming") => {
139 141 let txs = db::transactions::get_transactions_by_seller(
140 - &state.db,
142 + &db,
141 143 session_user.id,
142 144 Some(DASHBOARD_TRANSACTION_LIMIT),
143 145 )
@@ -146,7 +148,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_transactions(
146 148 }
147 149 Some("outgoing") => {
148 150 let txs = db::transactions::get_transactions_by_buyer(
149 - &state.db,
151 + &db,
150 152 session_user.id,
151 153 Some(DASHBOARD_TRANSACTION_LIMIT),
152 154 )
@@ -155,13 +157,13 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_transactions(
155 157 }
156 158 _ => {
157 159 let incoming = db::transactions::get_transactions_by_seller(
158 - &state.db,
160 + &db,
159 161 session_user.id,
160 162 Some(DASHBOARD_TRANSACTION_LIMIT),
161 163 )
162 164 .await?;
163 165 let outgoing = db::transactions::get_transactions_by_buyer(
164 - &state.db,
166 + &db,
165 167 session_user.id,
166 168 Some(DASHBOARD_TRANSACTION_LIMIT),
167 169 )
@@ -22,6 +22,7 @@ use crate::{
22 22 templates::*,
23 23 AppState,
24 24 };
25 + use sqlx::PgPool;
25 26
26 27 use super::build_step_nav;
27 28
@@ -66,18 +67,18 @@ pub(super) const ITEM_LABELS: &[&str] = &[
66 67
67 68 /// Verify the user owns the project + item for wizard steps 2-6.
68 69 async fn verify_item_wizard_access(
69 - state: &AppState,
70 + db: &PgPool,
70 71 user: &crate::auth::SessionUser,
71 72 project_slug: &str,
72 73 item_id_str: &str,
73 74 ) -> Result<(db::DbProject, db::DbItem)> {
74 75 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
75 - let project = db::projects::get_project_by_user_and_slug(&state.db, user.id, &slug)
76 + let project = db::projects::get_project_by_user_and_slug(db, user.id, &slug)
76 77 .await?
77 78 .ok_or(AppError::NotFound)?;
78 79
79 80 let item_id: ItemId = item_id_str.parse().map_err(|_| AppError::NotFound)?;
80 - let item = db::items::get_item_by_id(&state.db, item_id)
81 + let item = db::items::get_item_by_id(db, item_id)
81 82 .await?
82 83 .ok_or(AppError::NotFound)?;
83 84
@@ -99,13 +100,13 @@ async fn verify_item_wizard_access(
99 100 /// and the user lands on the details step.
100 101 #[tracing::instrument(skip_all, name = "wizard::item_page")]
101 102 pub async fn wizard_page(
102 - State(state): State<AppState>,
103 + State(db): State<PgPool>,
103 104 session: Session,
104 105 AuthUser(user): AuthUser,
105 106 Path(slug): Path<String>,
106 107 ) -> Result<Response> {
107 108 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
108 - let project = db::projects::get_project_by_user_and_slug(&state.db, user.id, &slug_val)
109 + let project = db::projects::get_project_by_user_and_slug(&db, user.id, &slug_val)
109 110 .await?
110 111 .ok_or(AppError::NotFound)?;
111 112
@@ -121,11 +122,11 @@ pub async fn wizard_page(
121 122 // Reuse an existing untitled draft for this project+type rather than
122 123 // minting a fresh "Untitled" row on every GET (a prefetch or re-visit
123 124 // otherwise piles up orphan drafts — this is a side-effecting GET).
124 - let item = match db::items::find_untitled_wizard_draft(&state.db, project.id, item_type).await? {
125 + let item = match db::items::find_untitled_wizard_draft(&db, project.id, item_type).await? {
125 126 Some(existing) => existing,
126 127 None => {
127 128 db::items::create_item(
128 - &state.db,
129 + &db,
129 130 project.id,
130 131 "Untitled",
131 132 None,
@@ -170,7 +171,7 @@ pub struct TypeForm {
170 171 /// POST /dashboard/project/{slug}/new-item/step/type: create item, return step 2.
171 172 #[tracing::instrument(skip_all, name = "wizard::item_type_create")]
172 173 pub async fn step_type_create(
173 - State(state): State<AppState>,
174 + State(db): State<PgPool>,
174 175 session: Session,
175 176 AuthUser(user): AuthUser,
176 177 Path(slug): Path<String>,
@@ -180,14 +181,14 @@ pub async fn step_type_create(
180 181 // Surface validation errors as an inline toast (HX-Reswap: none) instead of
181 182 // swapping the full-page 422 into #wizard-step and wiping the selection
182 183 // (ultra-fuzz Run 10 UX S2).
183 - match step_type_create_inner(state, session, user, slug, form).await {
184 + match step_type_create_inner(db, session, user, slug, form).await {
184 185 Ok(resp) => Ok(resp),
185 186 Err(e) => super::wizard_validation_toast(&headers, e),
186 187 }
187 188 }
188 189
189 190 async fn step_type_create_inner(
190 - state: AppState,
191 + db: PgPool,
191 192 session: Session,
192 193 user: crate::auth::SessionUser,
193 194 slug: String,
@@ -196,7 +197,7 @@ async fn step_type_create_inner(
196 197 user.check_not_suspended()?;
197 198
198 199 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
199 - let project = db::projects::get_project_by_user_and_slug(&state.db, user.id, &slug_val)
200 + let project = db::projects::get_project_by_user_and_slug(&db, user.id, &slug_val)
200 201 .await?
201 202 .ok_or(AppError::NotFound)?;
202 203
@@ -215,7 +216,7 @@ async fn step_type_create_inner(
215 216 }
216 217
217 218 let item = db::items::create_item(
218 - &state.db,
219 + &db,
219 220 project.id,
220 221 "Untitled",
221 222 None,
@@ -227,7 +228,7 @@ async fn step_type_create_inner(
227 228 .await?;
228 229
229 230 // Return step 2 (basics) partial
230 - render::render_step(&state, &session, &user, &project, &item, "basics").await
231 + render::render_step(&db, &session, &user, &project, &item, "basics").await
231 232 }
232 233
233 234 // =============================================================================
@@ -237,13 +238,13 @@ async fn step_type_create_inner(
237 238 /// GET /dashboard/project/{slug}/new-item/{id}/step/{step}
238 239 #[tracing::instrument(skip_all, name = "wizard::item_step_load")]
239 240 pub async fn step_load(
240 - State(state): State<AppState>,
241 + State(db): State<PgPool>,
241 242 session: Session,
242 243 AuthUser(user): AuthUser,
243 244 Path((slug, id, step)): Path<(String, String, String)>,
244 245 ) -> Result<Response> {
245 - let (project, item) = verify_item_wizard_access(&state, &user, &slug, &id).await?;
246 - render::render_step(&state, &session, &user, &project, &item, &step).await
246 + let (project, item) = verify_item_wizard_access(&db, &user, &slug, &id).await?;
247 + render::render_step(&db, &session, &user, &project, &item, &step).await
247 248 }
248 249
249 250 // =============================================================================
@@ -261,14 +262,18 @@ pub async fn step_save(
261 262 Form(form): Form<HashMap<String, String>>,
262 263 ) -> Result<Response> {
263 264 user.check_not_suspended()?;
264 - let (project, item) = verify_item_wizard_access(&state, &user, &slug, &id).await?;
265 + let (project, item) = verify_item_wizard_access(&state.db, &user, &slug, &id).await?;
265 266
266 267 let save_result = match step.as_str() {
267 - "type" => save::save_type(&state, &project, &item, &form, user.id).await,
268 - "basics" => save::save_basics(&state, &item, &form, user.id).await,
269 - "content" => save::save_content(&state, &item, &form, user.id).await,
268 + "type" => save::save_type(&state.db, &project, &item, &form, user.id).await,
269 + "basics" => save::save_basics(&state.db, &item, &form, user.id).await,
270 + "content" => save::save_content(&state.db, &item, &form, user.id).await,
270 271 "sections" => Ok(()), // Sections managed via HTMX API; pass-through
271 - "pricing" => save::save_pricing(&state, &item, &form, user.id).await,
272 + "pricing" => save::save_pricing(&state.db, &item, &form, user.id).await,
273 + // save_preview stays on the full &AppState: it fans out to the
274 + // crate::scheduler helpers (send_release_announcements /
275 + // spawn_mt_thread_for_item), which are shared &AppState entry points
276 + // outside this scope. Mirrors crud::update_item.
272 277 "preview" => return save::save_preview(&state, &user, &project, &item, &form).await,
273 278 _ => return Err(AppError::NotFound),
274 279 };
@@ -299,5 +304,5 @@ pub async fn step_save(
299 304 .ok_or(AppError::NotFound)?;
300 305
301 306 let next = super::next_step(ITEM_STEPS, &step).ok_or(AppError::NotFound)?;
302 - render::render_step(&state, &session, &user, &project, &item, next).await
307 + render::render_step(&state.db, &session, &user, &project, &item, next).await
303 308 }
@@ -8,13 +8,13 @@ use crate::{
8 8 error::{AppError, Result},
9 9 helpers::get_csrf_token,
10 10 templates::*,
11 - AppState,
12 11 };
12 + use sqlx::PgPool;
13 13
14 14 use super::{build_step_nav, format_price_display, ITEM_LABELS, ITEM_STEPS};
15 15
16 16 pub(super) async fn render_step(
17 - state: &AppState,
17 + db: &PgPool,
18 18 session: &Session,
19 19 _user: &crate::auth::SessionUser,
20 20 project: &db::DbProject,
@@ -69,9 +69,9 @@ pub(super) async fn render_step(
69 69 let (bundleable_items, selected_bundle_ids, unlisted_ids) =
70 70 if item.item_type == ItemType::Bundle {
71 71 let bundleable =
72 - db::bundles::get_bundleable_items(&state.db, project.id, Some(item.id))
72 + db::bundles::get_bundleable_items(db, project.id, Some(item.id))
73 73 .await?;
74 - let current = db::bundles::get_bundle_items(&state.db, item.id).await?;
74 + let current = db::bundles::get_bundle_items(db, item.id).await?;
75 75 let selected: Vec<String> =
76 76 current.iter().map(|i| i.id.to_string()).collect();
77 77 let unlisted: Vec<String> = current
@@ -108,7 +108,7 @@ pub(super) async fn render_step(
108 108 }
109 109
110 110 "sections" => {
111 - let db_sections = db::item_sections::list_by_item(&state.db, item.id).await?;
111 + let db_sections = db::item_sections::list_by_item(db, item.id).await?;
112 112 let sections: Vec<crate::types::ItemSection> =
113 113 db_sections.iter().map(crate::types::ItemSection::from).collect();
114 114 Ok(WizardItemSectionsTemplate {
@@ -153,7 +153,7 @@ pub(super) async fn render_step(
153 153 }
154 154
155 155 "preview" => {
156 - let tags = db::tags::get_tags_for_item(&state.db, item.id).await?;
156 + let tags = db::tags::get_tags_for_item(db, item.id).await?;
157 157 let tag_names: Vec<String> = tags.iter().map(|t| t.tag_name.clone()).collect();
158 158
159 159 Ok(WizardItemPreviewTemplate {
@@ -170,7 +170,7 @@ pub(super) async fn render_step(
170 170 || item.audio_s3_key.is_some()
171 171 || item.video_s3_key.is_some()
172 172 || (item.item_type == ItemType::Bundle
173 - && db::bundles::get_bundle_item_count(&state.db, item.id).await? > 0),
173 + && db::bundles::get_bundle_item_count(db, item.id).await? > 0),
174 174 is_public: item.is_public,
175 175 }
176 176 .into_response())
@@ -11,10 +11,11 @@ use crate::{
11 11 validation,
12 12 AppState,
13 13 };
14 + use sqlx::PgPool;
14 15
15 16 /// Update the item type when going back to step 1 and re-submitting.
16 17 pub(super) async fn save_type(
17 - state: &AppState,
18 + db: &PgPool,
18 19 project: &db::DbProject,
19 20 item: &db::DbItem,
20 21 form: &HashMap<String, String>,
@@ -36,7 +37,7 @@ pub(super) async fn save_type(
36 37 }
37 38
38 39 db::items::update_item(
39 - &state.db,
40 + db,
40 41 item.id,
41 42 user_id,
42 43 None,
@@ -55,7 +56,7 @@ pub(super) async fn save_type(
55 56 }
56 57
57 58 pub(super) async fn save_basics(
58 - state: &AppState,
59 + db: &PgPool,
59 60 item: &db::DbItem,
60 61 form: &HashMap<String, String>,
61 62 user_id: UserId,
@@ -71,7 +72,7 @@ pub(super) async fn save_basics(
71 72 }
72 73
73 74 db::items::update_item(
74 - &state.db,
75 + db,
75 76 item.id,
76 77 user_id,
77 78 Some(title),
@@ -99,7 +100,7 @@ pub(super) async fn save_basics(
99 100 }
100 101
101 102 pub(super) async fn save_content(
102 - state: &AppState,
103 + db: &PgPool,
103 104 item: &db::DbItem,
104 105 form: &HashMap<String, String>,
105 106 user_id: UserId,
@@ -107,7 +108,7 @@ pub(super) async fn save_content(
107 108 if item.item_type == ItemType::Text {
108 109 // Text items: save body directly
109 110 if let Some(body) = form.get("body") {
110 - db::items::update_item_text(&state.db, item.id, user_id, body).await?;
111 + db::items::update_item_text(db, item.id, user_id, body).await?;
111 112 }
112 113 } else if item.item_type == ItemType::Bundle {
113 114 // Bundle items: parse selected item IDs and unlisted flags
@@ -132,16 +133,16 @@ pub(super) async fn save_content(
132 133 .unwrap_or_default();
133 134
134 135 // Set bundle contents (replaces all existing)
135 - db::bundles::set_bundle_items(&state.db, item.id, &bundle_ids, user_id).await?;
136 + db::bundles::set_bundle_items(db, item.id, &bundle_ids, user_id).await?;
136 137
137 138 // Update listed status for all bundleable items in this project
138 139 let all_bundleable =
139 - db::bundles::get_bundleable_items(&state.db, item.project_id, Some(item.id)).await?;
140 + db::bundles::get_bundleable_items(db, item.project_id, Some(item.id)).await?;
140 141 for bi in &all_bundleable {
141 142 let should_be_unlisted = unlisted_ids.contains(&bi.id);
142 143 if bi.listed == should_be_unlisted {
143 144 // listed=true but should be unlisted, or listed=false but shouldn't be
144 - db::bundles::set_item_listed(&state.db, bi.id, !should_be_unlisted, user_id).await?;
145 + db::bundles::set_item_listed(db, bi.id, !should_be_unlisted, user_id).await?;
145 146 }
146 147 }
147 148 }
@@ -150,7 +151,7 @@ pub(super) async fn save_content(
150 151 }
151 152
152 153 pub(super) async fn save_pricing(
153 - state: &AppState,
154 + db: &PgPool,
154 155 item: &db::DbItem,
155 156 form: &HashMap<String, String>,
156 157 user_id: UserId,
@@ -167,7 +168,7 @@ pub(super) async fn save_pricing(
167 168 match pricing_model {
168 169 "free" => {
169 170 db::items::update_item(
170 - &state.db,
171 + db,
171 172 item.id,
172 173 user_id,
173 174 None,
@@ -187,7 +188,7 @@ pub(super) async fn save_pricing(
187 188 let price_cents = parse_dollars_to_cents("Price", form.get("price").map(String::as_str))?;
188 189 let price = PriceCents::new(price_cents)?;
189 190 db::items::update_item(
190 - &state.db,
191 + db,
191 192 item.id,
192 193 user_id,
193 194 None,
@@ -214,7 +215,7 @@ pub(super) async fn save_pricing(
214 215 let suggested = PriceCents::new(suggested_cents)?;
215 216 let min = PriceCents::new(min_cents)?;
216 217 db::items::update_item(
217 - &state.db,
218 + db,
218 219 item.id,
219 220 user_id,
220 221 None,
@@ -13,14 +13,16 @@ use tower_sessions::Session;
13 13
14 14 use crate::{
15 15 auth::AuthUser,
16 + config::Config,
16 17 db::{self, Slug},
17 18 error::{AppError, Result},
18 19 helpers::get_csrf_token,
19 20 pricing::{self, parse_dollars_to_cents},
20 21 templates::*,
21 22 validation,
22 - AppState,
23 + Integrations,
23 24 };
25 + use sqlx::PgPool;
24 26
25 27 use crate::extractors::ValidatedHtmlForm;
26 28 use super::build_step_nav;
@@ -45,12 +47,12 @@ const PROJECT_LABELS: &[&str] = &[
45 47
46 48 /// Verify the current user owns this project (for wizard steps 2-5).
47 49 async fn verify_wizard_access(
48 - state: &AppState,
50 + db: &PgPool,
49 51 user: &crate::auth::SessionUser,
50 52 slug: &str,
51 53 ) -> Result<db::DbProject> {
52 54 let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?;
53 - let project = db::projects::get_project_by_user_and_slug(&state.db, user.id, &slug)
55 + let project = db::projects::get_project_by_user_and_slug(db, user.id, &slug)
54 56 .await?
55 57 .ok_or(AppError::NotFound)?;
56 58 Ok(project)
@@ -63,7 +65,6 @@ async fn verify_wizard_access(
63 65 /// Render the full wizard page with step 1 (basics) inline.
64 66 #[tracing::instrument(skip_all, name = "wizard::project_page")]
65 67 pub async fn wizard_page(
66 - State(_state): State<AppState>,
67 68 session: Session,
68 69 AuthUser(user): AuthUser,
69 70 ) -> Result<impl IntoResponse> {
@@ -102,7 +103,7 @@ pub struct BasicsForm {
102 103 /// POST /dashboard/new-project/step/basics: create project, return step 2.
103 104 #[tracing::instrument(skip_all, name = "wizard::project_basics_create")]
104 105 pub async fn step_basics_create(
105 - State(state): State<AppState>,
106 + State(db): State<PgPool>,
106 107 session: Session,
107 108 AuthUser(user): AuthUser,
108 109 headers: HeaderMap,
@@ -112,14 +113,14 @@ pub async fn step_basics_create(
112 113 // swapping the full-page 422 into #wizard-step and wiping typed input — the
113 114 // guard the step_save handlers carry but this create handler had dropped
114 115 // (ultra-fuzz Run 10 UX S2).
115 - match step_basics_create_inner(state, session, user, form).await {
116 + match step_basics_create_inner(db, session, user, form).await {
116 117 Ok(resp) => Ok(resp),
117 118 Err(e) => super::wizard_validation_toast(&headers, e),
118 119 }
119 120 }
120 121
121 122 async fn step_basics_create_inner(
122 - state: AppState,
123 + db: PgPool,
123 124 session: Session,
124 125 user: crate::auth::SessionUser,
125 126 form: BasicsForm,
@@ -138,7 +139,7 @@ async fn step_basics_create_inner(
138 139 let category_id = if let Some(ref cat) = form.category {
139 140 let trimmed = cat.trim();
140 141 if !trimmed.is_empty() {
141 - let cat = db::categories::get_or_create_category(&state.db, trimmed).await?;
142 + let cat = db::categories::get_or_create_category(&db, trimmed).await?;
142 143 Some(cat.id)
143 144 } else {
144 145 None
@@ -154,7 +155,7 @@ async fn step_basics_create_inner(
154 155 }
155 156
156 157 let project = db::projects::create_project(
157 - &state.db,
158 + &db,
158 159 user.id,
159 160 &form.slug,
160 161 &form.title,
@@ -164,7 +165,7 @@ async fn step_basics_create_inner(
164 165 .await?;
165 166
166 167 if let Some(cat_id) = category_id {
167 - db::projects::set_project_category(&state.db, project.id, user.id, Some(cat_id)).await?;
168 + db::projects::set_project_category(&db, project.id, user.id, Some(cat_id)).await?;
168 169 }
169 170
170 171 // Save AI tier
@@ -177,15 +178,15 @@ async fn step_basics_create_inner(
177 178 .map_err(|_| AppError::validation("Invalid AI tier".to_string()))?,
178 179 };
179 180 let ai_disclosure = if ai_tier == db::AiTier::Assisted { form.ai_disclosure.as_deref() } else { None };
180 - db::projects::update_project_ai_tier(&state.db, project.id, user.id, ai_tier, ai_disclosure).await?;
181 + db::projects::update_project_ai_tier(&db, project.id, user.id, ai_tier, ai_disclosure).await?;
181 182
182 183 // Create default mailing lists (non-blocking)
183 - if let Err(e) = db::mailing_lists::create_default_lists(&state.db, project.id, &form.title).await {
184 + if let Err(e) = db::mailing_lists::create_default_lists(&db, project.id, &form.title).await {
184 185 tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists");
185 186 }
186 187
187 188 // Return step 2 (appearance) partial
188 - render_step(&state, &session, &user, &project, "appearance").await
189 + render_step(&db, &session, &user, &project, "appearance").await
189 190 }
190 191
191 192 // =============================================================================
@@ -195,13 +196,13 @@ async fn step_basics_create_inner(
195 196 /// GET /dashboard/new-project/{slug}/step/{step}
196 197 #[tracing::instrument(skip_all, name = "wizard::project_step_load")]
197 198 pub async fn step_load(
198 - State(state): State<AppState>,
199 + State(db): State<PgPool>,
199 200 session: Session,
200 201 AuthUser(user): AuthUser,
201 202 Path((slug, step)): Path<(String, String)>,
202 203 ) -> Result<Response> {
203 - let project = verify_wizard_access(&state, &user, &slug).await?;
204 - render_step(&state, &session, &user, &project, &step).await
204 + let project = verify_wizard_access(&db, &user, &slug).await?;
205 + render_step(&db, &session, &user, &project, &step).await
205 206 }
206 207
207 208 // =============================================================================
@@ -210,8 +211,11 @@ pub async fn step_load(
210 211
211 212 /// POST /dashboard/new-project/{slug}/step/{step}
212 213 #[tracing::instrument(skip_all, name = "wizard::project_step_save")]
214 + #[allow(clippy::too_many_arguments)]
213 215 pub async fn step_save(
214 - State(state): State<AppState>,
216 + State(db): State<PgPool>,
217 + State(config): State<Config>,
218 + State(integrations): State<Integrations>,
215 219 session: Session,
216 220 AuthUser(user): AuthUser,
217 221 Path((slug, step)): Path<(String, String)>,
@@ -219,14 +223,14 @@ pub async fn step_save(
219 223 Form(form): Form<HashMap<String, String>>,
220 224 ) -> Result<Response> {
221 225 user.check_not_suspended()?;
222 - let project = verify_wizard_access(&state, &user, &slug).await?;
226 + let project = verify_wizard_access(&db, &user, &slug).await?;
223 227
224 228 let save_result = match step.as_str() {
225 - "basics" => save_basics(&state, &user, &project, &form).await,
226 - "appearance" => save_appearance(&state, &project, &form).await,
227 - "monetization" => save_monetization(&state, &user, &project, &form).await,
228 - "first-content" => save_first_content(&state, &project, &form).await,
229 - "preview" => return save_preview(&state, &user, &project, &form).await,
229 + "basics" => save_basics(&db, &user, &project, &form).await,
230 + "appearance" => save_appearance(&db, &config, &project, &form).await,
231 + "monetization" => save_monetization(&db, &user, &project, &form).await,
232 + "first-content" => save_first_content(&project, &form).await,
233 + "preview" => return save_preview(&db, &integrations, &user, &project, &form).await,
230 234 _ => return Err(AppError::NotFound),
231 235 };
232 236
@@ -250,7 +254,7 @@ pub async fn step_save(
250 254 }
251 255
252 256 let next = super::next_step(PROJECT_STEPS, &step).ok_or(AppError::NotFound)?;
253 - render_step(&state, &session, &user, &project, next).await
257 + render_step(&db, &session, &user, &project, next).await
254 258 }
255 259
256 260 // =============================================================================
@@ -258,7 +262,7 @@ pub async fn step_save(
258 262 // =============================================================================
259 263
260 264 async fn save_basics(
261 - state: &AppState,
265 + db: &PgPool,
262 266 user: &crate::auth::SessionUser,
263 267 project: &db::DbProject,
264 268 form: &HashMap<String, String>,
@@ -275,12 +279,13 @@ async fn save_basics(
275 279 } else {
276 280 None
277 281 };
278 - db::projects::update_project_ai_tier(&state.db, project.id, user.id, ai_tier, ai_disclosure).await?;
282 + db::projects::update_project_ai_tier(db, project.id, user.id, ai_tier, ai_disclosure).await?;
279 283 Ok(())
280 284 }
281 285
282 286 async fn save_appearance(
283 - state: &AppState,
287 + db: &PgPool,
288 + config: &Config,
284 289 project: &db::DbProject,
285 290 form: &HashMap<String, String>,
286 291 ) -> Result<()> {
@@ -299,7 +304,7 @@ async fn save_appearance(
299 304 // `cdn_base` prefix — otherwise `https://cdn.makenot.work.attacker.com/x`
300 305 // would slip past a `cdn_base = "https://cdn.makenot.work"` check
301 306 // (host-prefix confusion). Canonical URLs are `{cdn_base}/{s3_key}`.
302 - if let Some(cdn_base) = state.config.cdn_base_url.as_deref()
307 + if let Some(cdn_base) = config.cdn_base_url.as_deref()
303 308 && !image_url.starts_with(&format!("{}/", cdn_base.trim_end_matches('/')))
304 309 {
305 310 return Err(AppError::validation("Invalid cover image URL"));
@@ -311,19 +316,19 @@ async fn save_appearance(
311 316 // recovers the key; bucket/endpoint (path-style fallback) aren't needed.
312 317 let cover_s3_key = crate::storage::extract_s3_key_from_url(
313 318 image_url,
314 - state.config.cdn_base_url.as_deref(),
319 + config.cdn_base_url.as_deref(),
315 320 None,
316 321 None,
317 322 );
318 323 db::projects::update_project_image_url(
319 - &state.db, project.id, project.user_id, image_url, cover_s3_key.as_deref(),
324 + db, project.id, project.user_id, image_url, cover_s3_key.as_deref(),
320 325 ).await?;
321 326 }
322 327 Ok(())
323 328 }
324 329
325 330 async fn save_monetization(
326 - state: &AppState,
331 + db: &PgPool,
327 332 _user: &crate::auth::SessionUser,
328 333 project: &db::DbProject,
329 334 form: &HashMap<String, String>,
@@ -386,11 +391,11 @@ async fn save_monetization(
386 391 }
387 392
388 393 // All inputs validated — now perform the writes.
389 - db::projects::update_project_pricing(&state.db, project.id, project.user_id, pricing_kind, price_cents, pwyw_min_cents)
394 + db::projects::update_project_pricing(db, project.id, project.user_id, pricing_kind, price_cents, pwyw_min_cents)
390 395 .await?;
391 396 for (name, description, price_cents) in &tiers {
392 397 db::subscriptions::create_subscription_tier(
393 - &state.db,
398 + db,
394 399 project.id,
395 400 name,
396 401 description.as_deref(),
@@ -402,7 +407,6 @@ async fn save_monetization(
402 407 }
403 408
404 409 async fn save_first_content(
405 - _state: &AppState,
406 410 _project: &db::DbProject,
407 411 _form: &HashMap<String, String>,
408 412 ) -> Result<()> {
@@ -412,7 +416,8 @@ async fn save_first_content(
412 416 }
413 417
414 418 async fn save_preview(
415 - state: &AppState,
419 + db: &PgPool,
420 + integrations: &Integrations,
416 421 user: &crate::auth::SessionUser,
417 422 project: &db::DbProject,
418 423 form: &HashMap<String, String>,
@@ -421,7 +426,7 @@ async fn save_preview(
421 426
422 427 if action == "publish" {
423 428 db::projects::update_project(
424 - &state.db,
429 + db,
425 430 project.id,
426 431 user.id,
427 432 None, // title
@@ -433,10 +438,10 @@ async fn save_preview(
433 438
434 439 // Fire-and-forget: provision a paired MT community
435 440 if project.mt_community_id.is_none()
436 - && let Some(ref mt) = state.mt_client
441 + && let Some(ref mt) = integrations.mt_client
437 442 {
438 443 let mt = mt.clone();
439 - let db = state.db.clone();
444 + let db = db.clone();
440 445 let project_id = project.id;
441 446 let slug = project.slug.to_string();
442 447 let title = project.title.clone();
@@ -486,7 +491,7 @@ async fn save_preview(
486 491 // =============================================================================
487 492
488 493 async fn render_step(
489 - state: &AppState,
494 + db: &PgPool,
490 495 session: &Session,
491 496 user: &crate::auth::SessionUser,
492 497 project: &db::DbProject,
@@ -505,7 +510,7 @@ async fn render_step(
505 510 title: project.title.clone(),
506 511 features: project.features.clone(),
507 512 description: project.description.clone().unwrap_or_default(),
508 - category_name: db::categories::get_project_category_name(&state.db, project.id)
513 + category_name: db::categories::get_project_category_name(db, project.id)
509 514 .await?
510 515 .unwrap_or_default(),
511 516 ai_tier: project.ai_tier.to_string(),
@@ -525,9 +530,9 @@ async fn render_step(
525 530 }
526 531 "monetization" => {
527 532 let tiers =
528 - db::subscriptions::get_all_tiers_by_project(&state.db, project.id).await?;
533 + db::subscriptions::get_all_tiers_by_project(db, project.id).await?;
529 534 let stripe_connected = {
530 - let db_user = db::users::get_user_by_id(&state.db, user.id)
535 + let db_user = db::users::get_user_by_id(db, user.id)
531 536 .await?
532 537 .ok_or(AppError::NotFound)?;
533 538 db_user.stripe_onboarding_complete && db_user.stripe_charges_enabled
@@ -569,7 +574,7 @@ async fn render_step(
569 574 .into_response())
570 575 }
571 576 "first-content" => {
572 - let items = db::items::get_items_by_project(&state.db, project.id).await?;
577 + let items = db::items::get_items_by_project(db, project.id).await?;
573 578 Ok(WizardProjectFirstContentTemplate {
574 579 nav,
575 580 slug,
@@ -578,11 +583,11 @@ async fn render_step(
578 583 .into_response())
579 584 }
580 585 "preview" => {
581 - let items = db::items::get_items_by_project(&state.db, project.id).await?;
586 + let items = db::items::get_items_by_project(db, project.id).await?;
582 587 let tiers =
583 - db::subscriptions::get_all_tiers_by_project(&state.db, project.id).await?;
588 + db::subscriptions::get_all_tiers_by_project(db, project.id).await?;
584 589 let category_name =
585 - db::categories::get_project_category_name(&state.db, project.id).await?;
590 + db::categories::get_project_category_name(db, project.id).await?;
586 591 let project_pricing = pricing::for_project(project);
587 592
588 593 Ok(WizardProjectPreviewTemplate {