max / makenotwork
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 { |