Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate api/internal to slices All api/internal handlers narrowed: mostly State<PgPool>; content/cli_features add Config/EmailClient; cli_features domain handlers add AppCaches (domain_cache); git restart handlers use Ops (restart_at); uploads add AppStorage + Scanning (commit_upload call updated to the narrowed helper). No handler needed to keep AppState. No behavior change; internal 2 + cli 6 + content 30 + domain 15 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 01:03 UTC
Commit: e1b71ae1be199aaf85392098c2a74ba94d513f66
Parent: 018900b
6 files changed, +205 insertions, -183 deletions
@@ -7,11 +7,15 @@ use axum::response::IntoResponse;
7 7 use axum::Json;
8 8 use serde::{Deserialize, Serialize};
9 9
10 + use sqlx::PgPool;
11 +
10 12 use crate::auth::ServiceAuth;
13 + use crate::config::Config;
11 14 use crate::constants;
12 15 use crate::db::{self, CollectionId, ItemId, ProjectId, Slug};
16 + use crate::email::EmailClient;
13 17 use crate::error::{AppError, Result, ResultExt};
14 - use crate::AppState;
18 + use crate::AppCaches;
15 19
16 20 /// User ID query parameter shared by all internal endpoints.
17 21 #[derive(Deserialize)]
@@ -37,23 +41,23 @@ struct TagView {
37 41 /// GET /api/internal/creator/items/{id}/tags?user_id=...
38 42 #[tracing::instrument(skip_all, name = "internal::list_item_tags")]
39 43 pub(super) async fn list_item_tags(
40 - State(state): State<AppState>,
44 + State(db): State<PgPool>,
41 45 actor: InternalActor,
42 46 _auth: ServiceAuth,
43 47 axum::extract::Path(item_id): axum::extract::Path<ItemId>,
44 48 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
45 49 ) -> Result<impl IntoResponse> {
46 - let item = db::items::get_item_by_id(&state.db, item_id)
50 + let item = db::items::get_item_by_id(&db, item_id)
47 51 .await?
48 52 .ok_or(AppError::NotFound)?;
49 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
53 + let project = db::projects::get_project_by_id(&db, item.project_id)
50 54 .await?
51 55 .ok_or(AppError::NotFound)?;
52 56 if project.user_id != actor.user_id() {
53 57 return Err(AppError::Forbidden);
54 58 }
55 59
56 - let tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
60 + let tags = db::tags::get_tags_for_item(&db, item_id).await?;
57 61 let views: Vec<TagView> = tags
58 62 .iter()
59 63 .map(|t| TagView {
@@ -70,15 +74,15 @@ pub(super) async fn list_item_tags(
70 74 /// POST /api/internal/creator/items/tags
71 75 #[tracing::instrument(skip_all, name = "internal::add_item_tag")]
72 76 pub(super) async fn add_item_tag(
73 - State(state): State<AppState>,
77 + State(db): State<PgPool>,
74 78 actor: InternalActor,
75 79 _auth: ServiceAuth,
76 80 Json(req): Json<TagItemRequest>,
77 81 ) -> Result<impl IntoResponse> {
78 - let item = db::items::get_item_by_id(&state.db, req.item_id)
82 + let item = db::items::get_item_by_id(&db, req.item_id)
79 83 .await?
80 84 .ok_or(AppError::NotFound)?;
81 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
85 + let project = db::projects::get_project_by_id(&db, item.project_id)
82 86 .await?
83 87 .ok_or(AppError::NotFound)?;
84 88 if project.user_id != actor.user_id() {
@@ -89,11 +93,11 @@ pub(super) async fn add_item_tag(
89 93 .map(db::TagId::from)
90 94 .map_err(|_| AppError::BadRequest("Invalid tag ID".to_string()))?;
91 95
92 - let _tag = db::tags::get_tag_by_id(&state.db, tag_id)
96 + let _tag = db::tags::get_tag_by_id(&db, tag_id)
93 97 .await?
94 98 .ok_or_else(|| AppError::validation("Tag not found".to_string()))?;
95 99
96 - db::tags::add_tag_to_item(&state.db, req.item_id, tag_id, false).await?;
100 + db::tags::add_tag_to_item(&db, req.item_id, tag_id, false).await?;
97 101
98 102 Ok(Json(serde_json::json!({"success": true})))
99 103 }
@@ -101,15 +105,15 @@ pub(super) async fn add_item_tag(
101 105 /// POST /api/internal/creator/items/tags/remove
102 106 #[tracing::instrument(skip_all, name = "internal::remove_item_tag")]
103 107 pub(super) async fn remove_item_tag(
104 - State(state): State<AppState>,
108 + State(db): State<PgPool>,
105 109 actor: InternalActor,
106 110 _auth: ServiceAuth,
107 111 Json(req): Json<TagItemRequest>,
108 112 ) -> Result<impl IntoResponse> {
109 - let item = db::items::get_item_by_id(&state.db, req.item_id)
113 + let item = db::items::get_item_by_id(&db, req.item_id)
110 114 .await?
111 115 .ok_or(AppError::NotFound)?;
112 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
116 + let project = db::projects::get_project_by_id(&db, item.project_id)
113 117 .await?
114 118 .ok_or(AppError::NotFound)?;
115 119 if project.user_id != actor.user_id() {
@@ -120,7 +124,7 @@ pub(super) async fn remove_item_tag(
120 124 .map(db::TagId::from)
121 125 .map_err(|_| AppError::BadRequest("Invalid tag ID".to_string()))?;
122 126
123 - db::tags::remove_tag_from_item(&state.db, req.item_id, tag_id).await?;
127 + db::tags::remove_tag_from_item(&db, req.item_id, tag_id).await?;
124 128
125 129 Ok(Json(serde_json::json!({"success": true})))
126 130 }
@@ -133,11 +137,11 @@ pub(super) struct TagSearchQuery {
133 137 /// GET /api/internal/tags/search?q=...
134 138 #[tracing::instrument(skip_all, name = "internal::search_tags")]
135 139 pub(super) async fn search_tags(
136 - State(state): State<AppState>,
140 + State(db): State<PgPool>,
137 141 _auth: ServiceAuth,
138 142 axum::extract::Query(q): axum::extract::Query<TagSearchQuery>,
139 143 ) -> Result<impl IntoResponse> {
140 - let tags = db::tags::search_tags(&state.db, &q.q, 20).await?;
144 + let tags = db::tags::search_tags(&db, &q.q, 20).await?;
141 145 let views: Vec<TagView> = tags
142 146 .iter()
143 147 .map(|t| TagView {
@@ -161,7 +165,9 @@ pub(super) struct BroadcastRequest {
161 165 /// POST /api/internal/creator/broadcast
162 166 #[tracing::instrument(skip_all, name = "internal::send_broadcast")]
163 167 pub(super) async fn send_broadcast(
164 - State(state): State<AppState>,
168 + State(db): State<PgPool>,
169 + State(config): State<Config>,
170 + State(email): State<EmailClient>,
165 171 actor: InternalActor,
166 172 _auth: ServiceAuth,
167 173 Json(req): Json<BroadcastRequest>,
@@ -173,7 +179,7 @@ pub(super) async fn send_broadcast(
173 179 return Err(AppError::validation("Body must be 1-5000 characters".to_string()));
174 180 }
175 181
176 - let db_user = db::users::get_user_by_id(&state.db, actor.user_id())
182 + let db_user = db::users::get_user_by_id(&db, actor.user_id())
177 183 .await?
178 184 .ok_or(AppError::NotFound)?;
179 185
@@ -181,7 +187,7 @@ pub(super) async fn send_broadcast(
181 187 return Err(AppError::Forbidden);
182 188 }
183 189
184 - let set = db::users::try_set_broadcast_at(&state.db, actor.user_id()).await?;
190 + let set = db::users::try_set_broadcast_at(&db, actor.user_id()).await?;
185 191 if !set {
186 192 return Err(AppError::validation("You can only send one broadcast per 24 hours".to_string()));
187 193 }
@@ -189,12 +195,12 @@ pub(super) async fn send_broadcast(
189 195 // Enforce the broadcast recipient cap at the type level — the same
190 196 // `BoundedRecipients` seal the public twin (routes/api/users/broadcast.rs)
191 197 // uses, so the two handlers can no longer drift on the cap check.
192 - let followers = db::follows::get_follower_emails(&state.db, actor.user_id()).await?;
198 + let followers = db::follows::get_follower_emails(&db, actor.user_id()).await?;
193 199 let recipients = match crate::email::BoundedRecipients::new(followers) {
194 200 Ok(r) => r,
195 201 Err(count) => {
196 202 // Roll back the 24h rate-limit slot so the creator can retry once the cap is lifted.
197 - let _ = db::users::clear_broadcast_at(&state.db, actor.user_id()).await;
203 + let _ = db::users::clear_broadcast_at(&db, actor.user_id()).await;
198 204 return Err(AppError::validation(format!(
199 205 "Broadcast would reach {count} followers, above the per-send limit of 10,000. \
200 206 Email info@makenot.work to lift the cap for your account."
@@ -208,12 +214,12 @@ pub(super) async fn send_broadcast(
208 214 let creator_name = db_user.display_name.as_deref()
209 215 .unwrap_or(&db_user.username)
210 216 .to_string();
211 - let host_url = state.config.host_url.clone();
212 - let signing_secret = state.config.signing_secret.clone();
217 + let host_url = config.host_url.clone();
218 + let signing_secret = config.signing_secret.clone();
213 219 let creator_id = actor.user_id();
214 220 let subject = req.subject.clone();
215 221 let body = req.body.clone();
216 - let email_client = state.email.clone();
222 + let email_client = email.clone();
217 223
218 224 tokio::spawn(async move {
219 225 let mut set = tokio::task::JoinSet::new();
@@ -274,20 +280,20 @@ struct TierView {
274 280 /// GET /api/internal/creator/projects/{id}/tiers?user_id=...
275 281 #[tracing::instrument(skip_all, name = "internal::list_tiers")]
276 282 pub(super) async fn list_tiers(
277 - State(state): State<AppState>,
283 + State(db): State<PgPool>,
278 284 actor: InternalActor,
279 285 _auth: ServiceAuth,
280 286 axum::extract::Path(project_id): axum::extract::Path<ProjectId>,
281 287 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
282 288 ) -> Result<impl IntoResponse> {
283 - let project = db::projects::get_project_by_id(&state.db, project_id)
289 + let project = db::projects::get_project_by_id(&db, project_id)
284 290 .await?
285 291 .ok_or(AppError::NotFound)?;
286 292 if project.user_id != actor.user_id() {
287 293 return Err(AppError::Forbidden);
288 294 }
289 295
290 - let tiers = db::subscriptions::get_all_tiers_by_project(&state.db, project_id).await?;
296 + let tiers = db::subscriptions::get_all_tiers_by_project(&db, project_id).await?;
291 297 let views: Vec<TierView> = tiers
292 298 .iter()
293 299 .map(|t| TierView {
@@ -325,12 +331,12 @@ struct CollectionView {
325 331 /// GET /api/internal/creator/collections?user_id=...
326 332 #[tracing::instrument(skip_all, name = "internal::list_collections")]
327 333 pub(super) async fn list_collections(
328 - State(state): State<AppState>,
334 + State(db): State<PgPool>,
329 335 actor: InternalActor,
330 336 _auth: ServiceAuth,
331 337 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
332 338 ) -> Result<impl IntoResponse> {
333 - let collections = db::collections::get_collections_by_user(&state.db, actor.user_id()).await?;
339 + let collections = db::collections::get_collections_by_user(&db, actor.user_id()).await?;
334 340 let views: Vec<CollectionView> = collections
335 341 .iter()
336 342 .map(|c| CollectionView {
@@ -349,7 +355,7 @@ pub(super) async fn list_collections(
349 355 /// POST /api/internal/creator/collections
350 356 #[tracing::instrument(skip_all, name = "internal::create_collection")]
351 357 pub(super) async fn create_collection(
352 - State(state): State<AppState>,
358 + State(db): State<PgPool>,
353 359 actor: InternalActor,
354 360 _auth: ServiceAuth,
355 361 Json(req): Json<CreateCollectionRequest>,
@@ -358,7 +364,7 @@ pub(super) async fn create_collection(
358 364 .map_err(|e| AppError::validation(e.to_string()))?;
359 365
360 366 let collection = db::collections::create_collection(
361 - &state.db,
367 + &db,
362 368 actor.user_id(),
363 369 &slug,
364 370 &req.title,
@@ -376,20 +382,20 @@ pub(super) async fn create_collection(
376 382 /// DELETE /api/internal/creator/collections/{id}?user_id=...
377 383 #[tracing::instrument(skip_all, name = "internal::delete_collection")]
378 384 pub(super) async fn delete_collection(
379 - State(state): State<AppState>,
385 + State(db): State<PgPool>,
380 386 actor: InternalActor,
381 387 _auth: ServiceAuth,
382 388 axum::extract::Path(collection_id): axum::extract::Path<CollectionId>,
383 389 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
384 390 ) -> Result<impl IntoResponse> {
385 - let collection = db::collections::get_collection_by_id(&state.db, collection_id)
391 + let collection = db::collections::get_collection_by_id(&db, collection_id)
386 392 .await?
387 393 .ok_or(AppError::NotFound)?;
388 394 if collection.user_id != actor.user_id() {
389 395 return Err(AppError::Forbidden);
390 396 }
391 397
392 - db::collections::delete_collection(&state.db, collection_id, actor.user_id()).await?;
398 + db::collections::delete_collection(&db, collection_id, actor.user_id()).await?;
393 399
394 400 Ok(axum::http::StatusCode::NO_CONTENT)
395 401 }
@@ -404,12 +410,12 @@ pub(super) struct AddDomainRequest {
404 410 /// GET /api/internal/creator/domain?user_id=...
405 411 #[tracing::instrument(skip_all, name = "internal::get_domain")]
406 412 pub(super) async fn get_domain(
407 - State(state): State<AppState>,
413 + State(db): State<PgPool>,
408 414 actor: InternalActor,
409 415 _auth: ServiceAuth,
410 416 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
411 417 ) -> Result<impl IntoResponse> {
412 - let domain = db::custom_domains::get_custom_domain_by_user(&state.db, actor.user_id()).await?;
418 + let domain = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id()).await?;
413 419 match domain {
414 420 Some(d) => Ok(Json(serde_json::json!({
415 421 "id": d.id.to_string(),
@@ -424,7 +430,7 @@ pub(super) async fn get_domain(
424 430 /// POST /api/internal/creator/domain
425 431 #[tracing::instrument(skip_all, name = "internal::add_domain")]
426 432 pub(super) async fn add_domain(
427 - State(state): State<AppState>,
433 + State(db): State<PgPool>,
428 434 actor: InternalActor,
429 435 _auth: ServiceAuth,
430 436 Json(req): Json<AddDomainRequest>,
@@ -438,7 +444,7 @@ pub(super) async fn add_domain(
438 444 let token = generate_verification_token();
439 445 // Map the global `UNIQUE(domain)` violation to a clean 409, matching the web
440 446 // path — a domain another user already holds must not surface as a raw 500.
441 - let record = db::custom_domains::create_custom_domain(&state.db, actor.user_id(), &domain, &token)
447 + let record = db::custom_domains::create_custom_domain(&db, actor.user_id(), &domain, &token)
442 448 .await
443 449 .map_err(|e| crate::helpers::map_unique_violation(e, "That domain is already registered"))?;
444 450
@@ -454,12 +460,13 @@ pub(super) async fn add_domain(
454 460 /// POST /api/internal/creator/domain/verify?user_id=...
455 461 #[tracing::instrument(skip_all, name = "internal::verify_domain")]
456 462 pub(super) async fn verify_domain(
457 - State(state): State<AppState>,
463 + State(db): State<PgPool>,
464 + State(caches): State<AppCaches>,
458 465 actor: InternalActor,
459 466 _auth: ServiceAuth,
460 467 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
461 468 ) -> Result<impl IntoResponse> {
462 - let record = db::custom_domains::get_custom_domain_by_user(&state.db, actor.user_id())
469 + let record = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id())
463 470 .await?
464 471 .ok_or(AppError::NotFound)?;
465 472
@@ -497,8 +504,8 @@ pub(super) async fn verify_domain(
497 504 .unwrap_or(false);
498 505
499 506 if verified {
500 - db::custom_domains::mark_domain_verified(&state.db, record.id).await?;
501 - state.caches.domain_cache.insert(record.domain.clone(), actor.user_id());
507 + db::custom_domains::mark_domain_verified(&db, record.id).await?;
508 + caches.domain_cache.insert(record.domain.clone(), actor.user_id());
502 509 Ok(Json(serde_json::json!({"verified": true, "message": "Domain verified"})))
503 510 } else {
504 511 Ok(Json(serde_json::json!({"verified": false, "message": format!("TXT record not found. Add _mnw-verify.{} = {}", record.domain, record.verification_token)})))
@@ -508,17 +515,18 @@ pub(super) async fn verify_domain(
508 515 /// DELETE /api/internal/creator/domain?user_id=...
509 516 #[tracing::instrument(skip_all, name = "internal::remove_domain")]
510 517 pub(super) async fn remove_domain(
511 - State(state): State<AppState>,
518 + State(db): State<PgPool>,
519 + State(caches): State<AppCaches>,
512 520 actor: InternalActor,
513 521 _auth: ServiceAuth,
514 522 axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
515 523 ) -> Result<impl IntoResponse> {
516 - let record = db::custom_domains::get_custom_domain_by_user(&state.db, actor.user_id())
524 + let record = db::custom_domains::get_custom_domain_by_user(&db, actor.user_id())
517 525 .await?
518 526 .ok_or(AppError::NotFound)?;
519 527
520 - db::custom_domains::delete_custom_domain(&state.db, record.id, actor.user_id()).await?;
521 - state.caches.domain_cache.remove(&record.domain);
528 + db::custom_domains::delete_custom_domain(&db, record.id, actor.user_id()).await?;
529 + caches.domain_cache.remove(&record.domain);
522 530
523 531 Ok(axum::http::StatusCode::NO_CONTENT)
524 532 }
@@ -577,13 +585,13 @@ struct CreateProjectResponse {
577 585 /// POST /api/internal/creator/projects
578 586 #[tracing::instrument(skip_all, name = "internal::create_project")]
579 587 pub(super) async fn create_project(
580 - State(state): State<AppState>,
588 + State(db): State<PgPool>,
581 589 actor: InternalActor,
582 590 _auth: ServiceAuth,
583 591 Json(req): Json<CreateProjectRequest>,
584 592 ) -> Result<impl IntoResponse> {
585 593 // Verify user can create projects
586 - let user = db::users::get_user_by_id(&state.db, actor.user_id())
594 + let user = db::users::get_user_by_id(&db, actor.user_id())
587 595 .await?
588 596 .ok_or(AppError::NotFound)?;
589 597
@@ -599,7 +607,7 @@ pub(super) async fn create_project(
599 607 let slug = Slug::from_trusted(slug_from_title(&req.title));
600 608
601 609 let project = db::projects::create_project(
602 - &state.db,
610 + &db,
603 611 actor.user_id(),
604 612 &slug,
605 613 &req.title,
@@ -8,8 +8,11 @@ use axum::{
8 8 use serde::{Deserialize, Serialize};
9 9 use crate::auth::InternalActor;
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::ServiceAuth,
15 + config::Config,
13 16 db::{
14 17 self, BlogPostId, CodePurpose, DiscountType, ItemId, KeyCode, LicenseKeyId, PromoCodeId,
15 18 ProjectId, Slug,
@@ -17,7 +20,6 @@ use crate::{
17 20 error::{AppError, Result},
18 21 helpers,
19 22 validation,
20 - AppState,
21 23 };
22 24
23 25 // ── Shared query types ──
@@ -66,20 +68,20 @@ impl BlogPostResponse {
66 68 /// List blog posts for a project.
67 69 #[tracing::instrument(skip_all, name = "internal::list_blog_posts")]
68 70 pub(super) async fn list_blog_posts(
69 - State(state): State<AppState>,
71 + State(db): State<PgPool>,
70 72 actor: InternalActor,
71 73 _auth: ServiceAuth,
72 74 Path(project_id): Path<ProjectId>,
73 75 Query(_query): Query<ProjectUserQuery>,
74 76 ) -> Result<impl IntoResponse> {
75 - let project = db::projects::get_project_by_id(&state.db, project_id)
77 + let project = db::projects::get_project_by_id(&db, project_id)
76 78 .await?
77 79 .ok_or(AppError::NotFound)?;
78 80 if project.user_id != actor.user_id() {
79 81 return Err(AppError::Forbidden);
80 82 }
81 83
82 - let posts = db::blog_posts::get_blog_posts_by_project(&state.db, project_id).await?;
84 + let posts = db::blog_posts::get_blog_posts_by_project(&db, project_id).await?;
83 85 let data: Vec<BlogPostResponse> = posts.iter().map(BlogPostResponse::from_db).collect();
84 86
85 87 Ok(Json(data))
@@ -103,12 +105,13 @@ pub(super) struct CreateBlogPostRequest {
103 105 /// Create a new blog post in a project.
104 106 #[tracing::instrument(skip_all, name = "internal::create_blog_post")]
105 107 pub(super) async fn create_blog_post(
106 - State(state): State<AppState>,
108 + State(db): State<PgPool>,
109 + State(config): State<Config>,
107 110 actor: InternalActor,
108 111 _auth: ServiceAuth,
109 112 Json(req): Json<CreateBlogPostRequest>,
110 113 ) -> Result<impl IntoResponse> {
111 - let project = db::projects::get_project_by_id(&state.db, req.project_id)
114 + let project = db::projects::get_project_by_id(&db, req.project_id)
112 115 .await?
113 116 .ok_or(AppError::NotFound)?;
114 117 if project.user_id != actor.user_id() {
@@ -122,7 +125,7 @@ pub(super) async fn create_blog_post(
122 125
123 126 let base = helpers::slugify(&req.title).to_string();
124 127
125 - let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
128 + let cdn_base = config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
126 129 let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, actor.user_id(), cdn_base);
127 130
128 131 // If publish_at is set, create as draft and then set the schedule
@@ -131,7 +134,7 @@ pub(super) async fn create_blog_post(
131 134 // Auto-suffix on a UNIQUE(project_id, slug) collision and retry. The prior
132 135 // code pre-checked existence but did NOT retry the insert, so a concurrent
133 136 // create racing the same slug surfaced a raw 500 (ultra-fuzz Run #1 UX D1).
134 - let pool = &state.db;
137 + let pool = &db;
135 138 let (project_id, user_id) = (req.project_id, actor.user_id());
136 139 let (title_s, body_md_s, body_html_s) =
137 140 (req.title.as_str(), req.body_markdown.as_str(), body_html.as_str());
@@ -154,7 +157,7 @@ pub(super) async fn create_blog_post(
154 157 return Err(AppError::validation("publish_at must be in the future".to_string()));
155 158 }
156 159 db::blog_posts::update_blog_post(
157 - &state.db,
160 + &db,
158 161 post.id,
159 162 &post.title,
160 163 &post.slug,
@@ -180,24 +183,24 @@ pub(super) async fn create_blog_post(
180 183 /// Delete a blog post.
181 184 #[tracing::instrument(skip_all, name = "internal::delete_blog_post")]
182 185 pub(super) async fn delete_blog_post(
183 - State(state): State<AppState>,
186 + State(db): State<PgPool>,
184 187 actor: InternalActor,
185 188 _auth: ServiceAuth,
186 189 Path(post_id): Path<BlogPostId>,
187 190 Query(_query): Query<ItemUserQuery>,
188 191 ) -> Result<impl IntoResponse> {
189 - let post = db::blog_posts::get_blog_post_by_id(&state.db, post_id)
192 + let post = db::blog_posts::get_blog_post_by_id(&db, post_id)
190 193 .await?
191 194 .ok_or(AppError::NotFound)?;
192 195
193 - let project = db::projects::get_project_by_id(&state.db, post.project_id)
196 + let project = db::projects::get_project_by_id(&db, post.project_id)
194 197 .await?
195 198 .ok_or(AppError::NotFound)?;
196 199 if project.user_id != actor.user_id() {
197 200 return Err(AppError::Forbidden);
198 201 }
199 202
200 - db::blog_posts::delete_blog_post(&state.db, post_id, actor.user_id()).await?;
203 + db::blog_posts::delete_blog_post(&db, post_id, actor.user_id()).await?;
201 204
202 205 tracing::info!(user = %actor.user_id(), post = %post_id, "blog post deleted via CLI");
203 206
@@ -225,12 +228,12 @@ struct PromoCodeResponse {
225 228 /// List all promo codes for a creator.
226 229 #[tracing::instrument(skip_all, name = "internal::list_promo_codes")]
227 230 pub(super) async fn list_promo_codes(
228 - State(state): State<AppState>,
231 + State(db): State<PgPool>,
229 232 actor: InternalActor,
230 233 _auth: ServiceAuth,
231 234 Query(_query): Query<UserIdQuery>,
232 235 ) -> Result<impl IntoResponse> {
233 - let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, actor.user_id()).await?;
236 + let codes = db::promo_codes::get_promo_codes_by_creator(&db, actor.user_id()).await?;
234 237 let data: Vec<PromoCodeResponse> = codes
235 238 .into_iter()
236 239 .map(|c| PromoCodeResponse {
@@ -274,7 +277,7 @@ fn default_code_purpose() -> CodePurpose {
274 277 /// Create a new promo code.
275 278 #[tracing::instrument(skip_all, name = "internal::create_promo_code")]
276 279 pub(super) async fn create_promo_code(
277 - State(state): State<AppState>,
280 + State(db): State<PgPool>,
278 281 actor: InternalActor,
279 282 _auth: ServiceAuth,
280 283 Json(req): Json<CreatePromoCodeRequest>,
@@ -289,7 +292,7 @@ pub(super) async fn create_promo_code(
289 292
290 293 // Verify item ownership if scoped to an item
291 294 if let Some(item_id) = req.item_id {
292 - let owner = db::items::get_item_owner(&state.db, item_id)
295 + let owner = db::items::get_item_owner(&db, item_id)
293 296 .await?
294 297 .ok_or(AppError::NotFound)?;
295 298 if owner != actor.user_id() {
@@ -299,7 +302,7 @@ pub(super) async fn create_promo_code(
299 302
300 303 // Verify project ownership if scoped to a project
301 304 if let Some(project_id) = req.project_id {
302 - let project = db::projects::get_project_by_id(&state.db, project_id)
305 + let project = db::projects::get_project_by_id(&db, project_id)
303 306 .await?
304 307 .ok_or(AppError::NotFound)?;
305 308 if project.user_id != actor.user_id() {
@@ -308,7 +311,7 @@ pub(super) async fn create_promo_code(
308 311 }
309 312
310 313 let code = db::promo_codes::create_promo_code(
311 - &state.db,
314 + &db,
312 315 actor.user_id(),
313 316 &req.code,
314 317 req.code_purpose,
@@ -346,20 +349,20 @@ pub(super) async fn create_promo_code(
346 349 /// Delete a promo code.
347 350 #[tracing::instrument(skip_all, name = "internal::delete_promo_code")]
348 351 pub(super) async fn delete_promo_code(
349 - State(state): State<AppState>,
352 + State(db): State<PgPool>,
350 353 actor: InternalActor,
351 354 _auth: ServiceAuth,
352 355 Path(code_id): Path<PromoCodeId>,
353 356 Query(_query): Query<UserIdQuery>,
354 357 ) -> Result<impl IntoResponse> {
355 - let code = db::promo_codes::get_promo_code_by_id(&state.db, code_id)
358 + let code = db::promo_codes::get_promo_code_by_id(&db, code_id)
356 359 .await?
357 360 .ok_or(AppError::NotFound)?;
358 361 if code.creator_id != actor.user_id() {
359 362 return Err(AppError::Forbidden);
360 363 }
361 364
362 - db::promo_codes::delete_promo_code(&state.db, code_id).await?;
365 + db::promo_codes::delete_promo_code(&db, code_id).await?;
363 366
364 367 tracing::info!(user = %actor.user_id(), code = %code.code, "promo code deleted via CLI");
365 368
@@ -383,20 +386,20 @@ struct LicenseKeyResponse {
383 386 /// List license keys for an item.
384 387 #[tracing::instrument(skip_all, name = "internal::list_license_keys")]
385 388 pub(super) async fn list_license_keys(
386 - State(state): State<AppState>,
389 + State(db): State<PgPool>,
387 390 actor: InternalActor,
388 391 _auth: ServiceAuth,
389 392 Path(item_id): Path<ItemId>,
390 393 Query(_query): Query<ItemUserQuery>,
391 394 ) -> Result<impl IntoResponse> {
392 - let owner = db::items::get_item_owner(&state.db, item_id)
395 + let owner = db::items::get_item_owner(&db, item_id)
393 396 .await?
394 397 .ok_or(AppError::NotFound)?;
395 398 if owner != actor.user_id() {
396 399 return Err(AppError::Forbidden);
397 400 }
398 401
399 - let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?;
402 + let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?;
400 403 let data: Vec<LicenseKeyResponse> = keys
401 404 .into_iter()
402 405 .map(|k| LicenseKeyResponse {
@@ -421,17 +424,17 @@ pub(super) struct GenerateKeyRequest {
421 424 /// Generate a new license key for an item.
422 425 #[tracing::instrument(skip_all, name = "internal::generate_license_key")]
423 426 pub(super) async fn generate_license_key(
424 - State(state): State<AppState>,
427 + State(db): State<PgPool>,
425 428 actor: InternalActor,
426 429 _auth: ServiceAuth,
427 430 Path(item_id): Path<ItemId>,
428 431 Json(_req): Json<GenerateKeyRequest>,
429 432 ) -> Result<impl IntoResponse> {
430 - let item = db::items::get_item_by_id(&state.db, item_id)
433 + let item = db::items::get_item_by_id(&db, item_id)
431 434 .await?
432 435 .ok_or(AppError::NotFound)?;
433 436
434 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
437 + let project = db::projects::get_project_by_id(&db, item.project_id)
435 438 .await?
436 439 .ok_or(AppError::NotFound)?;
437 440 if project.user_id != actor.user_id() {
@@ -439,7 +442,7 @@ pub(super) async fn generate_license_key(
439 442 }
440 443
441 444 // Enforce cap
442 - let count = db::license_keys::count_keys_by_item(&state.db, item_id).await?;
445 + let count = db::license_keys::count_keys_by_item(&db, item_id).await?;
443 446 if count >= 1000 {
444 447 return Err(AppError::BadRequest("Maximum of 1000 keys per item".to_string()));
445 448 }
@@ -448,7 +451,7 @@ pub(super) async fn generate_license_key(
448 451 let max_activations = item.default_max_activations;
449 452
450 453 let key = db::license_keys::create_license_key(
451 - &state.db,
454 + &db,
452 455 item_id,
453 456 actor.user_id(),
454 457 None, // transaction_id
@@ -478,25 +481,25 @@ pub(super) struct RevokeKeyRequest {
478 481 /// Revoke a license key.
479 482 #[tracing::instrument(skip_all, name = "internal::revoke_license_key")]
480 483 pub(super) async fn revoke_license_key(
481 - State(state): State<AppState>,
484 + State(db): State<PgPool>,
482 485 actor: InternalActor,
483 486 _auth: ServiceAuth,
484 487 Path(key_id): Path<LicenseKeyId>,
485 488 Json(_req): Json<RevokeKeyRequest>,
486 489 ) -> Result<impl IntoResponse> {
487 - let key = db::license_keys::get_license_key_by_id_unchecked(&state.db, key_id)
490 + let key = db::license_keys::get_license_key_by_id_unchecked(&db, key_id)
488 491 .await?
489 492 .ok_or(AppError::NotFound)?;
490 493
491 494 // Verify ownership through item -> project
492 - let owner = db::items::get_item_owner(&state.db, key.item_id)
495 + let owner = db::items::get_item_owner(&db, key.item_id)
493 496 .await?
494 497 .ok_or(AppError::NotFound)?;
495 498 if owner != actor.user_id() {
496 499 return Err(AppError::Forbidden);
497 500 }
498 501
499 - db::license_keys::revoke_license_key(&state.db, key_id).await?;
502 + db::license_keys::revoke_license_key(&db, key_id).await?;
500 503
501 504 tracing::info!(user = %actor.user_id(), key = %key_id, "license key revoked via CLI");
502 505
@@ -8,12 +8,13 @@ use axum::{
8 8 use serde::{Deserialize, Serialize};
9 9 use crate::auth::InternalActor;
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::ServiceAuth,
13 15 db::{self, ItemId, ItemType, ProjectId, ProjectType, TransactionId},
14 16 error::{AppError, Result},
15 17 helpers,
16 - AppState,
17 18 };
18 19
19 20 // ── Creator projects ──
@@ -38,13 +39,13 @@ struct CreatorProject {
38 39 /// List all projects for a creator with item counts and revenue.
39 40 #[tracing::instrument(skip_all, name = "internal::creator_projects")]
40 41 pub(super) async fn creator_projects(
41 - State(state): State<AppState>,
42 + State(db): State<PgPool>,
42 43 actor: InternalActor,
43 44 _auth: ServiceAuth,
44 45 Query(_query): Query<UserIdQuery>,
45 46 ) -> Result<impl IntoResponse> {
46 - let projects = db::projects::get_projects_by_user(&state.db, actor.user_id()).await?;
47 - let revenue = db::transactions::get_revenue_by_user_projects(&state.db, actor.user_id()).await?;
47 + let projects = db::projects::get_projects_by_user(&db, actor.user_id()).await?;
48 + let revenue = db::transactions::get_revenue_by_user_projects(&db, actor.user_id()).await?;
48 49
49 50 // Build revenue lookup: project_id -> cents
50 51 let revenue_map: std::collections::HashMap<ProjectId, i64> = revenue
@@ -53,7 +54,7 @@ pub(super) async fn creator_projects(
53 54 .collect();
54 55
55 56 // Count items per project in a single query
56 - let item_counts = db::items::count_items_by_user_projects(&state.db, actor.user_id()).await?;
57 + let item_counts = db::items::count_items_by_user_projects(&db, actor.user_id()).await?;
57 58 let count_map: std::collections::HashMap<ProjectId, i64> = item_counts.into_iter().collect();
58 59
59 60 let data: Vec<CreatorProject> = projects
@@ -89,21 +90,21 @@ struct CreatorItem {
89 90 /// List items in a project (verifies ownership).
90 91 #[tracing::instrument(skip_all, name = "internal::creator_project_items")]
91 92 pub(super) async fn creator_project_items(
92 - State(state): State<AppState>,
93 + State(db): State<PgPool>,
93 94 actor: InternalActor,
94 95 _auth: ServiceAuth,
95 96 Path(project_id): Path<ProjectId>,
96 97 Query(_query): Query<UserIdQuery>,
97 98 ) -> Result<impl IntoResponse> {
98 99 // Verify ownership
99 - let project = db::projects::get_project_by_id(&state.db, project_id)
100 + let project = db::projects::get_project_by_id(&db, project_id)
100 101 .await?
101 102 .ok_or(AppError::NotFound)?;
102 103 if project.user_id != actor.user_id() {
103 104 return Err(AppError::Forbidden);
104 105 }
105 106
106 - let items = db::items::get_items_by_project(&state.db, project_id).await?;
107 + let items = db::items::get_items_by_project(&db, project_id).await?;
107 108
108 109 let data: Vec<CreatorItem> = items
109 110 .into_iter()
@@ -150,7 +151,7 @@ struct CreatorStats {
150 151 /// Period comparison stats for the creator dashboard.
151 152 #[tracing::instrument(skip_all, name = "internal::creator_stats")]
152 153 pub(super) async fn creator_stats(
153 - State(state): State<AppState>,
154 + State(db): State<PgPool>,
154 155 actor: InternalActor,
155 156 _auth: ServiceAuth,
156 157 Query(query): Query<StatsQuery>,
@@ -161,10 +162,10 @@ pub(super) async fn creator_stats(
161 162 .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
162 163
163 164 let comparison =
164 - db::analytics::get_period_comparison(&state.db, actor.user_id(), None, None, &range).await?;
165 + db::analytics::get_period_comparison(&db, actor.user_id(), None, None, &range).await?;
165 166
166 - let total_projects = db::projects::count_projects_by_user(&state.db, actor.user_id()).await?;
167 - let total_items = db::items::count_items_by_user(&state.db, actor.user_id()).await?;
167 + let total_projects = db::projects::count_projects_by_user(&db, actor.user_id()).await?;
168 + let total_items = db::items::count_items_by_user(&db, actor.user_id()).await?;
168 169
169 170 Ok(Json(CreatorStats {
170 171 current_revenue_cents: comparison.current_revenue_cents.as_i64(),
@@ -211,7 +212,7 @@ struct AnalyticsResponse {
211 212 /// Revenue timeseries, period comparison, and top projects.
212 213 #[tracing::instrument(skip_all, name = "internal::creator_analytics")]
213 214 pub(super) async fn creator_analytics(
214 - State(state): State<AppState>,
215 + State(db): State<PgPool>,
215 216 actor: InternalActor,
216 217 _auth: ServiceAuth,
217 218 Query(query): Query<StatsQuery>,
@@ -222,12 +223,12 @@ pub(super) async fn creator_analytics(
222 223 .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
223 224
224 225 let buckets =
225 - db::analytics::get_revenue_timeseries(&state.db, actor.user_id(), None, None, &range)
226 + db::analytics::get_revenue_timeseries(&db, actor.user_id(), None, None, &range)
226 227 .await?;
227 228 let comparison =
228 - db::analytics::get_period_comparison(&state.db, actor.user_id(), None, None, &range).await?;
229 + db::analytics::get_period_comparison(&db, actor.user_id(), None, None, &range).await?;
229 230 let revenue =
230 - db::transactions::get_revenue_by_user_projects(&state.db, actor.user_id()).await?;
231 + db::transactions::get_revenue_by_user_projects(&db, actor.user_id()).await?;
231 232
232 233 let top_projects: Vec<ProjectRevenueSummary> = revenue
233 234 .into_iter()
@@ -274,13 +275,13 @@ struct TransactionResponse {
274 275 /// Recent seller transactions (up to 100).
275 276 #[tracing::instrument(skip_all, name = "internal::creator_transactions")]
276 277 pub(super) async fn creator_transactions(
277 - State(state): State<AppState>,
278 + State(db): State<PgPool>,
278 279 actor: InternalActor,
279 280 _auth: ServiceAuth,
280 281 Query(_query): Query<UserIdQuery>,
281 282 ) -> Result<impl IntoResponse> {
282 283 let txs =
283 - db::transactions::get_transactions_by_seller(&state.db, actor.user_id(), Some(100)).await?;
284 + db::transactions::get_transactions_by_seller(&db, actor.user_id(), Some(100)).await?;
284 285
285 286 let data: Vec<TransactionResponse> = txs
286 287 .into_iter()
@@ -304,7 +305,7 @@ pub(super) async fn creator_transactions(
304 305 /// Returns sales data as a CSV string.
305 306 #[tracing::instrument(skip_all, name = "internal::export_sales")]
306 307 pub(super) async fn export_sales(
307 - State(state): State<AppState>,
308 + State(db): State<PgPool>,
308 309 actor: InternalActor,
309 310 _auth: ServiceAuth,
310 311 Query(_query): Query<UserIdQuery>,
@@ -321,7 +322,7 @@ pub(super) async fn export_sales(
321 322 let mut total = 0usize;
322 323 loop {
323 324 let rows = db::transactions::get_seller_transactions_for_export_page(
324 - &state.db,
325 + &db,
325 326 actor.user_id(),
326 327 BATCH,
327 328 offset,
@@ -9,11 +9,14 @@ use serde::{Deserialize, Serialize};
9 9 use crate::auth::InternalActor;
10 10 use std::sync::atomic::Ordering;
11 11
12 + use sqlx::PgPool;
13 +
12 14 use crate::{
13 15 auth::ServiceAuth,
16 + config::Config,
14 17 db::{self, CreatorTier, UserId, Username, Visibility},
15 18 error::{AppError, Result},
16 - AppState,
19 + Ops,
17 20 };
18 21
19 22 // ── SSH key lookup ──
@@ -42,11 +45,12 @@ struct SshKeyLookupResponse {
42 45 /// Look up a user by SSH key fingerprint. Returns user info if found, 404 if not.
43 46 #[tracing::instrument(skip_all, name = "internal::ssh_key_lookup")]
44 47 pub(super) async fn ssh_key_lookup(
45 - State(state): State<AppState>,
48 + State(db): State<PgPool>,
49 + State(config): State<Config>,
46 50 _auth: ServiceAuth,
47 51 Query(query): Query<SshKeyLookupQuery>,
48 52 ) -> Result<impl IntoResponse> {
49 - let user = db::ssh_keys::lookup_user_by_fingerprint(&state.db, &query.fingerprint)
53 + let user = db::ssh_keys::lookup_user_by_fingerprint(&db, &query.fingerprint)
50 54 .await?
51 55 .ok_or(AppError::NotFound)?;
52 56
@@ -54,7 +58,7 @@ pub(super) async fn ssh_key_lookup(
54 58 // session's internal calls. TTL comfortably exceeds any SSH session.
55 59 let expiry = chrono::Utc::now().timestamp() + crate::constants::INTERNAL_ACTOR_TTL_SECS;
56 60 let actor_token =
57 - crate::crypto::mint_internal_actor_token(user.user_id, expiry, &state.config.signing_secret);
61 + crate::crypto::mint_internal_actor_token(user.user_id, expiry, &config.signing_secret);
58 62
59 63 Ok(Json(SshKeyLookupResponse {
60 64 user_id: user.user_id,
@@ -86,12 +90,12 @@ struct SshKeyResponse {
86 90 /// List registered SSH keys for a user.
87 91 #[tracing::instrument(skip_all, name = "internal::list_ssh_keys")]
88 92 pub(super) async fn list_ssh_keys(
89 - State(state): State<AppState>,
93 + State(db): State<PgPool>,
90 94 actor: InternalActor,
91 95 _auth: ServiceAuth,
92 96 Query(_query): Query<UserIdQuery>,
93 97 ) -> Result<impl IntoResponse> {
94 - let keys = db::ssh_keys::list_keys_by_user(&state.db, actor.user_id()).await?;
98 + let keys = db::ssh_keys::list_keys_by_user(&db, actor.user_id()).await?;
95 99 let data: Vec<SshKeyResponse> = keys
96 100 .into_iter()
97 101 .map(|k| SshKeyResponse {
@@ -126,13 +130,13 @@ struct GitAuthorizeResponse {
126 130 /// Auto-creates bare repos on first push if the authenticated user owns the namespace.
127 131 #[tracing::instrument(skip_all, name = "internal::git_authorize")]
128 132 pub(super) async fn git_authorize(
129 - State(state): State<AppState>,
133 + State(db): State<PgPool>,
134 + State(config): State<Config>,
130 135 actor: InternalActor,
131 136 _auth: ServiceAuth,
132 137 Json(req): Json<GitAuthorizeRequest>,
133 138 ) -> Result<impl IntoResponse> {
134 - let git_root = state
135 - .config
139 + let git_root = config
136 140 .build.git_repos_path
137 141 .as_deref()
138 142 .ok_or_else(|| AppError::ServiceUnavailable("Git hosting is not configured".to_string()))?;
@@ -143,12 +147,12 @@ pub(super) async fn git_authorize(
143 147 // unvalidated owner defeats it (a malformed owner simply can't name a real
144 148 // user, so it maps to the same NotFound).
145 149 let owner = Username::new(&req.owner).map_err(|_| AppError::NotFound)?;
146 - let owner_user = db::users::get_user_by_username(&state.db, &owner)
150 + let owner_user = db::users::get_user_by_username(&db, &owner)
147 151 .await?
148 152 .ok_or(AppError::NotFound)?;
149 153
150 154 let repo = match db::git_repos::get_repo_by_user_and_name(
151 - &state.db,
155 + &db,
152 156 owner_user.id,
153 157 &req.repo_name,
154 158 )
@@ -167,11 +171,11 @@ pub(super) async fn git_authorize(
167 171 // Concurrent double-push can race two auto-registers; on the loser's
168 172 // unique violation, re-resolve instead of 500ing the git client — the
169 173 // same pattern the smart-HTTP path uses (ultra-fuzz Run 12 Storage).
170 - match db::git_repos::create_repo(&state.db, owner_user.id, &req.repo_name).await {
174 + match db::git_repos::create_repo(&db, owner_user.id, &req.repo_name).await {
171 175 Ok(r) => r,
172 176 Err(e) => {
173 177 tracing::debug!(owner = %req.owner, repo = %req.repo_name, error = ?e, "auto-register failed, retrying lookup");
174 - db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, &req.repo_name)
178 + db::git_repos::get_repo_by_user_and_name(&db, owner_user.id, &req.repo_name)
175 179 .await?
176 180 .ok_or(AppError::NotFound)?
177 181 }
@@ -225,7 +229,7 @@ pub(super) struct RestartWarningRequest {
225 229 /// `{"seconds": 0}` cancels any pending warning.
226 230 #[tracing::instrument(skip_all, name = "internal::set_restart_warning")]
227 231 pub(super) async fn set_restart_warning(
228 - State(state): State<AppState>,
232 + State(ops): State<Ops>,
229 233 _auth: ServiceAuth,
230 234 Json(req): Json<RestartWarningRequest>,
231 235 ) -> Result<impl IntoResponse> {
@@ -234,7 +238,7 @@ pub(super) async fn set_restart_warning(
234 238 } else {
235 239 0
236 240 };
237 - state.restart_at.store(ts, Ordering::Relaxed);
241 + ops.restart_at.store(ts, Ordering::Relaxed);
238 242 tracing::info!(restart_at = ts, seconds = req.seconds, "restart warning set");
239 243 Ok(axum::http::StatusCode::NO_CONTENT)
240 244 }
@@ -244,9 +248,9 @@ pub(super) async fn set_restart_warning(
244 248 /// Public, unauthenticated. Returns the pending restart timestamp (or null).
245 249 /// Single atomic load, no DB, no session.
246 250 pub(in crate::routes::api) async fn restart_status(
247 - State(state): State<AppState>,
251 + State(ops): State<Ops>,
248 252 ) -> impl IntoResponse {
249 - let ts = state.restart_at.load(Ordering::Relaxed);
253 + let ts = ops.restart_at.load(Ordering::Relaxed);
250 254 let restart_at = if ts > 0 { Some(ts) } else { None };
251 255 Json(serde_json::json!({ "restart_at": restart_at }))
252 256 }
@@ -8,12 +8,13 @@ use axum::{
8 8 use serde::{Deserialize, Serialize};
9 9 use crate::auth::InternalActor;
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::ServiceAuth,
13 15 db::{self, AiTier, ItemId, ItemType, PriceCents, ProjectId},
14 16 error::{AppError, Result},
15 17 validation,
16 - AppState,
17 18 };
18 19
19 20 // ── Shared types ──
@@ -97,7 +98,7 @@ struct CreateItemResponse {
97 98 /// Create a new item in a project. Used by the CLI upload pipeline.
98 99 #[tracing::instrument(skip_all, name = "internal::create_item")]
99 100 pub(super) async fn create_item(
100 - State(state): State<AppState>,
101 + State(db): State<PgPool>,
101 102 actor: InternalActor,
102 103 _auth: ServiceAuth,
103 104 Json(req): Json<CreateItemRequest>,
@@ -112,7 +113,7 @@ pub(super) async fn create_item(
112 113 .map_err(|_| AppError::BadRequest(format!("Invalid item type: {}", req.item_type)))?;
113 114
114 115 // Verify project ownership
115 - let project = db::projects::get_project_by_id(&state.db, req.project_id)
116 + let project = db::projects::get_project_by_id(&db, req.project_id)
116 117 .await?
117 118 .ok_or(AppError::NotFound)?;
118 119 if project.user_id != actor.user_id() {
@@ -121,7 +122,7 @@ pub(super) async fn create_item(
121 122
122 123 let ai_tier = req.ai_tier.unwrap_or(AiTier::Handmade);
123 124 let item = db::items::create_item(
124 - &state.db,
125 + &db,
125 126 req.project_id,
126 127 &req.title,
127 128 None,
@@ -151,18 +152,18 @@ pub(super) async fn create_item(
151 152 /// Get full item detail. Verifies ownership through the project.
152 153 #[tracing::instrument(skip_all, name = "internal::get_item")]
153 154 pub(super) async fn get_item(
154 - State(state): State<AppState>,
155 + State(db): State<PgPool>,
155 156 actor: InternalActor,
156 157 _auth: ServiceAuth,
157 158 Path(item_id): Path<ItemId>,
158 159 Query(_query): Query<ItemUserQuery>,
159 160 ) -> Result<impl IntoResponse> {
160 - let item = db::items::get_item_by_id(&state.db, item_id)
161 + let item = db::items::get_item_by_id(&db, item_id)
161 162 .await?
162 163 .ok_or(AppError::NotFound)?;
163 164
164 165 // Verify ownership through project
165 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
166 + let project = db::projects::get_project_by_id(&db, item.project_id)
166 167 .await?
167 168 .ok_or(AppError::NotFound)?;
168 169 if project.user_id != actor.user_id() {
@@ -199,17 +200,17 @@ pub(super) struct UpdateItemRequest {
199 200 /// Partial update of item fields. Only provided fields are changed.
200 201 #[tracing::instrument(skip_all, name = "internal::update_item")]
201 202 pub(super) async fn update_item(
202 - State(state): State<AppState>,
203 + State(db): State<PgPool>,
203 204 actor: InternalActor,
204 205 _auth: ServiceAuth,
205 206 Path(item_id): Path<ItemId>,
206 207 Json(req): Json<UpdateItemRequest>,
207 208 ) -> Result<impl IntoResponse> {
208 - let item = db::items::get_item_by_id(&state.db, item_id)
209 + let item = db::items::get_item_by_id(&db, item_id)
209 210 .await?
210 211 .ok_or(AppError::NotFound)?;
211 212
212 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
213 + let project = db::projects::get_project_by_id(&db, item.project_id)
213 214 .await?
214 215 .ok_or(AppError::NotFound)?;
215 216 if project.user_id != actor.user_id() {
@@ -233,7 +234,7 @@ pub(super) async fn update_item(
233 234 };
234 235
235 236 let updated = db::items::update_item(
236 - &state.db,
237 + &db,
237 238 item_id,
238 239 actor.user_id(),
239 240 req.title.as_deref(),
@@ -262,17 +263,17 @@ pub(super) async fn update_item(
262 263 /// Permanently delete an item. Verifies ownership.
263 264 #[tracing::instrument(skip_all, name = "internal::delete_item")]
264 265 pub(super) async fn delete_item(
265 - State(state): State<AppState>,
266 + State(db): State<PgPool>,
266 267 actor: InternalActor,
267 268 _auth: ServiceAuth,
268 269 Path(item_id): Path<ItemId>,
269 270 Query(_query): Query<ItemUserQuery>,
270 271 ) -> Result<impl IntoResponse> {
271 - let item = db::items::get_item_by_id(&state.db, item_id)
272 + let item = db::items::get_item_by_id(&db, item_id)
272 273 .await?
273 274 .ok_or(AppError::NotFound)?;
274 275
275 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
276 + let project = db::projects::get_project_by_id(&db, item.project_id)
276 277 .await?
277 278 .ok_or(AppError::NotFound)?;
278 279 if project.user_id != actor.user_id() {
@@ -280,17 +281,17 @@ pub(super) async fn delete_item(
280 281 }
281 282
282 283 // Decrement storage for any S3 files
283 - let file_sizes = db::items::get_item_file_sizes(&state.db, item_id).await?;
284 - let version_size = db::versions::sum_file_sizes_for_item(&state.db, item_id).await?;
284 + let file_sizes = db::items::get_item_file_sizes(&db, item_id).await?;
285 + let version_size = db::versions::sum_file_sizes_for_item(&db, item_id).await?;
285 286 let total_bytes = file_sizes.audio_file_size_bytes.unwrap_or(0)
286 287 + file_sizes.cover_file_size_bytes.unwrap_or(0)
287 288 + file_sizes.video_file_size_bytes.unwrap_or(0)
288 289 + version_size;
289 290 if total_bytes > 0 {
290 - db::creator_tiers::decrement_storage_used(&state.db, actor.user_id(), total_bytes).await?;
291 + db::creator_tiers::decrement_storage_used(&db, actor.user_id(), total_bytes).await?;
291 292 }
292 293
293 - db::items::delete_item(&state.db, item_id, actor.user_id()).await?;
294 + db::items::delete_item(&db, item_id, actor.user_id()).await?;
294 295
295 296 tracing::info!(user = %actor.user_id(), item = %item_id, "item deleted via CLI");
296 297
@@ -308,17 +309,17 @@ pub(super) struct PublishRequest {
308 309 /// Set is_public=true on an item.
309 310 #[tracing::instrument(skip_all, name = "internal::publish_item")]
310 311 pub(super) async fn publish_item(
311 - State(state): State<AppState>,
312 + State(db): State<PgPool>,
312 313 actor: InternalActor,
313 314 _auth: ServiceAuth,
314 315 Path(item_id): Path<ItemId>,
315 316 Json(_req): Json<PublishRequest>,
316 317 ) -> Result<impl IntoResponse> {
317 - let item = db::items::get_item_by_id(&state.db, item_id)
318 + let item = db::items::get_item_by_id(&db, item_id)
318 319 .await?
319 320 .ok_or(AppError::NotFound)?;
320 321
321 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
322 + let project = db::projects::get_project_by_id(&db, item.project_id)
322 323 .await?
323 324 .ok_or(AppError::NotFound)?;
324 325 if project.user_id != actor.user_id() {
@@ -326,7 +327,7 @@ pub(super) async fn publish_item(
326 327 }
327 328
328 329 let updated = db::items::update_item(
329 - &state.db,
330 + &db,
330 331 item_id,
331 332 actor.user_id(),
332 333 None, None, None, None,
@@ -336,7 +337,7 @@ pub(super) async fn publish_item(
336 337 )
337 338 .await?;
338 339
339 - if let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await {
340 + if let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await {
340 341 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after publish");
341 342 }
342 343 tracing::info!(user = %actor.user_id(), item = %item_id, "item published via CLI");
@@ -349,17 +350,17 @@ pub(super) async fn publish_item(
349 350 /// Set is_public=false on an item.
350 351 #[tracing::instrument(skip_all, name = "internal::unpublish_item")]
351 352 pub(super) async fn unpublish_item(
352 - State(state): State<AppState>,
353 + State(db): State<PgPool>,
353 354 actor: InternalActor,
354 355 _auth: ServiceAuth,
355 356 Path(item_id): Path<ItemId>,
356 357 Json(_req): Json<PublishRequest>,
357 358 ) -> Result<impl IntoResponse> {
358 - let item = db::items::get_item_by_id(&state.db, item_id)
359 + let item = db::items::get_item_by_id(&db, item_id)
359 360 .await?
360 361 .ok_or(AppError::NotFound)?;
361 362
362 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
363 + let project = db::projects::get_project_by_id(&db, item.project_id)
363 364 .await?
364 365 .ok_or(AppError::NotFound)?;
365 366 if project.user_id != actor.user_id() {
@@ -367,7 +368,7 @@ pub(super) async fn unpublish_item(
367 368 }
368 369
369 370 let updated = db::items::update_item(
370 - &state.db,
371 + &db,
371 372 item_id,
372 373 actor.user_id(),
373 374 None, None, None, None,
@@ -377,7 +378,7 @@ pub(super) async fn unpublish_item(
377 378 )
378 379 .await?;
379 380
380 - if let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await {
381 + if let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await {
381 382 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after unpublish");
382 383 }
383 384 tracing::info!(user = %actor.user_id(), item = %item_id, "item unpublished via CLI");
@@ -404,24 +405,24 @@ struct VersionResponse {
404 405 /// List versions for an item (newest first).
405 406 #[tracing::instrument(skip_all, name = "internal::item_versions")]
406 407 pub(super) async fn item_versions(
407 - State(state): State<AppState>,
408 + State(db): State<PgPool>,
408 409 actor: InternalActor,
409 410 _auth: ServiceAuth,
410 411 Path(item_id): Path<ItemId>,
411 412 Query(_query): Query<ItemUserQuery>,
412 413 ) -> Result<impl IntoResponse> {
413 - let item = db::items::get_item_by_id(&state.db, item_id)
414 + let item = db::items::get_item_by_id(&db, item_id)
414 415 .await?
415 416 .ok_or(AppError::NotFound)?;
416 417
417 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
418 + let project = db::projects::get_project_by_id(&db, item.project_id)
418 419 .await?
419 420 .ok_or(AppError::NotFound)?;
420 421 if project.user_id != actor.user_id() {
421 422 return Err(AppError::Forbidden);
422 423 }
423 424
424 - let versions = db::versions::get_versions_by_item(&state.db, item_id).await?;
425 + let versions = db::versions::get_versions_by_item(&db, item_id).await?;
425 426
426 427 let data: Vec<VersionResponse> = versions
427 428 .into_iter()
@@ -9,12 +9,14 @@ use serde::{Deserialize, Serialize};
9 9 use crate::auth::InternalActor;
10 10 use std::str::FromStr;
11 11
12 + use sqlx::PgPool;
13 +
12 14 use crate::{
13 15 auth::ServiceAuth,
14 16 db::{self, ItemId},
15 17 error::{AppError, Result},
16 18 storage::{FileType, S3Client, CACHE_CONTROL_IMMUTABLE},
17 - AppState,
19 + AppStorage, Scanning,
18 20 };
19 21
20 22 // ── Presign upload (for CLI upload pipeline) ──
@@ -41,12 +43,13 @@ struct InternalPresignResponse {
41 43 /// Generate a presigned S3 upload URL. Used by the CLI upload pipeline.
42 44 #[tracing::instrument(skip_all, name = "internal::presign_upload")]
43 45 pub(super) async fn presign_upload(
44 - State(state): State<AppState>,
46 + State(db): State<PgPool>,
47 + State(storage): State<AppStorage>,
45 48 actor: InternalActor,
46 49 _auth: ServiceAuth,
47 50 Json(req): Json<InternalPresignRequest>,
48 51 ) -> Result<impl IntoResponse> {
49 - let s3 = state.require_s3()?;
52 + let s3 = storage.require_s3()?;
50 53
51 54 let file_type = FileType::from_str(&req.file_type)
52 55 .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
@@ -55,7 +58,7 @@ pub(super) async fn presign_upload(
55 58 S3Client::validate_extension(file_type, &req.file_name)?;
56 59
57 60 // Verify user owns the item
58 - let owner = db::items::get_item_owner(&state.db, req.item_id)
61 + let owner = db::items::get_item_owner(&db, req.item_id)
59 62 .await?
60 63 .ok_or(AppError::NotFound)?;
61 64 if owner != actor.user_id() {
@@ -63,14 +66,14 @@ pub(super) async fn presign_upload(
63 66 }
64 67
65 68 // Early quota check
66 - db::creator_tiers::check_presign_allowed(&state.db, actor.user_id(), file_type).await?;
69 + db::creator_tiers::check_presign_allowed(&db, actor.user_id(), file_type).await?;
67 70
68 71 // Staging key (unserved); the scan worker promotes it to the content key on a
69 72 // Clean verdict. See routes/storage/uploads.rs for the C1 rationale.
70 73 let s3_key = S3Client::generate_staging_key(&req.file_name);
71 74
72 75 // Track the pending upload so the reaper can clean it up if never confirmed
73 - db::pending_uploads::record_pending_upload(&state.db, actor.user_id(), &s3_key, "main").await?;
76 + db::pending_uploads::record_pending_upload(&db, actor.user_id(), &s3_key, "main").await?;
74 77
75 78 let expires_in = 3600;
76 79 let upload_url = s3
@@ -110,18 +113,20 @@ struct InternalConfirmResponse {
110 113 /// Confirm a completed S3 upload: verify, scan, update DB. Used by the CLI upload pipeline.
111 114 #[tracing::instrument(skip_all, name = "internal::confirm_upload")]
112 115 pub(super) async fn confirm_upload(
113 - State(state): State<AppState>,
116 + State(db): State<PgPool>,
117 + State(storage): State<AppStorage>,
118 + State(scanning): State<Scanning>,
114 119 actor: InternalActor,
115 120 _auth: ServiceAuth,
116 121 Json(req): Json<InternalConfirmRequest>,
117 122 ) -> Result<impl IntoResponse> {
118 - let s3 = state.require_s3()?;
123 + let s3 = storage.require_s3()?;
119 124
120 125 let file_type = FileType::from_str(&req.file_type)
121 126 .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
122 127
123 128 // Verify user owns the item
124 - let owner = db::items::get_item_owner(&state.db, req.item_id)
129 + let owner = db::items::get_item_owner(&db, req.item_id)
125 130 .await?
126 131 .ok_or(AppError::NotFound)?;
127 132 if owner != actor.user_id() {
@@ -146,7 +151,7 @@ pub(super) async fn confirm_upload(
146 151 // repeating the side effects (ultra-fuzz Run 12 Storage: confirm idempotency).
147 152 let already_committed = match file_type {
148 153 FileType::Audio | FileType::Video => {
149 - match db::items::get_item_by_id(&state.db, req.item_id).await? {
154 + match db::items::get_item_by_id(&db, req.item_id).await? {
150 155 Some(item) if file_type == FileType::Audio => {
151 156 item.audio_s3_key.as_deref() == Some(req.s3_key.as_str())
152 157 }
@@ -154,7 +159,7 @@ pub(super) async fn confirm_upload(
154 159 None => false,
155 160 }
156 161 }
157 - FileType::Download => db::versions::get_versions_by_item(&state.db, req.item_id)
162 + FileType::Download => db::versions::get_versions_by_item(&db, req.item_id)
158 163 .await?
159 164 .iter()
160 165 .any(|v| v.s3_key.as_deref() == Some(req.s3_key.as_str())),
@@ -173,7 +178,7 @@ pub(super) async fn confirm_upload(
173 178 // replay short-circuit — which consumed the pending row on the first confirm —
174 179 // and before any reject path that enqueues the object for deletion, so an
175 180 // unowned (at most another user's in-flight) staging object is never touched.
176 - if !db::pending_uploads::is_owned(&state.db, actor.user_id(), &req.s3_key, "main").await? {
181 + if !db::pending_uploads::is_owned(&db, actor.user_id(), &req.s3_key, "main").await? {
177 182 return Err(AppError::BadRequest("Invalid upload key".to_string()));
178 183 }
179 184
@@ -182,7 +187,7 @@ pub(super) async fn confirm_upload(
182 187 AppError::BadRequest("Could not determine file size. Please try uploading again.".to_string())
183 188 })?;
184 189 if file_size_bytes as u64 > file_type.max_size() {
185 - crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await;
190 + crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await;
186 191 return Err(AppError::BadRequest(format!(
187 192 "File exceeds maximum size of {} MB",
188 193 file_type.max_size() / (1024 * 1024)
@@ -191,7 +196,7 @@ pub(super) async fn confirm_upload(
191 196
192 197 // Enforce tier-based limits
193 198 let max_storage = match db::creator_tiers::check_upload_allowed(
194 - &state.db,
199 + &db,
195 200 actor.user_id(),
196 201 file_type,
197 202 file_size_bytes,
@@ -200,7 +205,7 @@ pub(super) async fn confirm_upload(
200 205 {
201 206 Ok(max) => max,
202 207 Err(e) => {
203 - crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await;
208 + crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await;
204 209 return Err(e);
205 210 }
206 211 };
@@ -208,7 +213,7 @@ pub(super) async fn confirm_upload(
208 213 // Reject unsupported file types BEFORE any side effect — same ordering rule
209 214 // as the web upload handlers (see routes/storage/mod.rs::commit_upload).
210 215 if !matches!(file_type, FileType::Audio | FileType::Download | FileType::Video) {
211 - crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await;
216 + crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await;
212 217 return Err(AppError::BadRequest(
213 218 "CLI upload only supports audio, video, and download file types".to_string(),
214 219 ));
@@ -216,25 +221,25 @@ pub(super) async fn confirm_upload(
216 221
217 222 // Increment storage BEFORE writing the DB record (if quota exceeded, the
218 223 // S3 object is cleaned up and no DB record is created).
219 - if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, actor.user_id(), file_size_bytes, max_storage).await {
220 - crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await;
224 + if let Err(e) = db::creator_tiers::try_increment_storage(&db, actor.user_id(), file_size_bytes, max_storage).await {
225 + crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await;
221 226 return Err(e);
222 227 }
223 228
224 - db::pending_uploads::remove_pending_upload(&state.db, actor.user_id(), &req.s3_key, "main").await?;
229 + db::pending_uploads::remove_pending_upload(&db, actor.user_id(), &req.s3_key, "main").await?;
225 230
226 231 // Update the database with S3 key and file size.
227 232 let file_name = req.s3_key.rsplit('/').next().map(|s| s.to_string());
228 233 let commit_target = match file_type {
229 234 FileType::Audio => {
230 - db::items::update_item_audio_s3_key(&state.db, req.item_id, actor.user_id(), &req.s3_key).await?;
231 - db::items::update_item_audio_file_size(&state.db, req.item_id, actor.user_id(), file_size_bytes)
235 + db::items::update_item_audio_s3_key(&db, req.item_id, actor.user_id(), &req.s3_key).await?;
236 + db::items::update_item_audio_file_size(&db, req.item_id, actor.user_id(), file_size_bytes)
232 237 .await?;
233 238 crate::routes::storage::CommitTarget::Item(req.item_id)
234 239 }
235 240 FileType::Download => {
236 241 let version = db::versions::create_version(
237 - &state.db,
242 + &db,
238 243 req.item_id,
239 244 "1.0",
240 245 None,
@@ -247,8 +252,8 @@ pub(super) async fn confirm_upload(
247 252 crate::routes::storage::CommitTarget::Version(version.id)
248 253 }
249 254 FileType::Video => {
250 - db::items::update_item_video_s3_key(&state.db, req.item_id, actor.user_id(), &req.s3_key).await?;
251 - db::items::update_item_video_file_size(&state.db, req.item_id, actor.user_id(), file_size_bytes)
255 + db::items::update_item_video_s3_key(&db, req.item_id, actor.user_id(), &req.s3_key).await?;
256 + db::items::update_item_video_file_size(&db, req.item_id, actor.user_id(), file_size_bytes)
252 257 .await?;
253 258 crate::routes::storage::CommitTarget::Item(req.item_id)
254 259 }
@@ -258,8 +263,8 @@ pub(super) async fn confirm_upload(
258 263 // Scan enqueue + scan_status flip AFTER the DB writes commit — chronic
259 264 // ordering invariant enforced via the shared commit_upload helper.
260 265 let _status = crate::routes::storage::commit_upload(
261 - &state.db,
262 - state.scanner.as_ref(),
266 + &db,
267 + scanning.scanner.as_ref(),
263 268 commit_target,
264 269 &req.s3_key,
265 270 file_type,
@@ -268,8 +273,8 @@ pub(super) async fn confirm_upload(
268 273 ).await?;
269 274
270 275 // Bump project cache
271 - if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await?
272 - && let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await
276 + if let Some(item) = db::items::get_item_by_id(&db, req.item_id).await?
277 + && let Err(e) = db::projects::bump_cache_generation(&db, item.project_id).await
273 278 {
274 279 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after upload");
275 280 }
@@ -304,15 +309,15 @@ struct StorageInfoResponse {
304 309 /// Get storage usage and limits for a creator.
305 310 #[tracing::instrument(skip_all, name = "internal::creator_storage")]
306 311 pub(super) async fn creator_storage(
307 - State(state): State<AppState>,
312 + State(db): State<PgPool>,
308 313 actor: InternalActor,
309 314 _auth: ServiceAuth,
310 315 Query(_query): Query<UserIdQuery>,
311 316 ) -> Result<impl IntoResponse> {
312 - let used = db::creator_tiers::get_storage_used(&state.db, actor.user_id()).await?;
317 + let used = db::creator_tiers::get_storage_used(&db, actor.user_id()).await?;
313 318
314 319 // Resolve effective tier
315 - let tier = db::creator_tiers::get_active_creator_tier(&state.db, actor.user_id()).await?;
320 + let tier = db::creator_tiers::get_active_creator_tier(&db, actor.user_id()).await?;
316 321
317 322 let (max_storage, allows_uploads) = match tier {
318 323 Some(t) => (t.max_storage_bytes(), t.allows_file_uploads()),