Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate remaining api top-level + exports Migrate all remaining top-level api/*.rs and api/exports/** handlers to slices. Most are State<PgPool>; multi-slice handlers per body: - subscriptions create_tier: Billing; totp/license_verify/blog reads: Config - domains: AppCaches (domain_cache) + AppLimiters (caddy_ask_semaphore) - content_insertions confirm: AppStorage + Scanning (commit_upload narrowed) - projects: Integrations(mt)/Config/AppStorage; guest_checkout: db+bg+config+ email+storage+Billing+Integrations; exports: bg+email+storage; passkeys: Arc<Webauthn> slice In-file helpers (render_list, verify_collection_ownership, list_inner, validate_target, resolve_key_status, verify_repo_ownership) narrowed to &PgPool. spawn_email!/state.clone spawns replaced with per-slice clones. blog::create_blog_post/update_blog_post kept on State<AppState> (call the un-migrated &AppState scheduler helpers send_blog_post_announcements / spawn_mt_thread_for_blog_post). No behavior change; 14 api test filters pass (project 81, license 24, cart 19, guest 8, export 12, domain 15, collection 21, follow 12, promo 37, passkey 10, totp 8, insertion 18, import 14, blog 13). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 01:41 UTC
Commit: ea98deee4ad3060f8e9c2b0a0b66512fd8997bc3
Parent: e1b71ae
25 files changed, +597 insertions, -523 deletions
@@ -7,6 +7,8 @@ use axum::{
7 7 };
8 8 use serde::{Deserialize, Serialize};
9 9
10 + use sqlx::PgPool;
11 +
10 12 use crate::{
11 13 auth::AuthUser,
12 14 db::{self, BlogPostId, ProjectId, Slug},
@@ -116,11 +118,11 @@ fn blog_post_response(post: &db::DbBlogPost) -> BlogPostResponse {
116 118 /// Get a single blog post for editing.
117 119 #[tracing::instrument(skip_all, name = "blog::get_blog_post")]
118 120 pub(super) async fn get_blog_post(
119 - State(state): State<AppState>,
121 + State(db): State<PgPool>,
120 122 AuthUser(user): AuthUser,
121 123 Path(blog_post_id): Path<BlogPostId>,
122 124 ) -> Result<impl IntoResponse> {
123 - let post = verify_blog_post_ownership(&state.db, blog_post_id, user.id).await?;
125 + let post = verify_blog_post_ownership(&db, blog_post_id, user.id).await?;
124 126 Ok(Json(blog_post_edit_response(&post)))
125 127 }
126 128
@@ -257,15 +259,15 @@ pub(super) async fn update_blog_post(
257 259 /// Delete a blog post.
258 260 #[tracing::instrument(skip_all, name = "blog::delete_blog_post")]
259 261 pub(super) async fn delete_blog_post(
260 - State(state): State<AppState>,
262 + State(db): State<PgPool>,
261 263 AuthUser(user): AuthUser,
262 264 Path(id): Path<BlogPostId>,
263 265 ) -> Result<impl IntoResponse> {
264 266 user.check_not_suspended()?;
265 - let post = verify_blog_post_ownership(&state.db, id, user.id).await?;
267 + let post = verify_blog_post_ownership(&db, id, user.id).await?;
266 268
267 - db::blog_posts::delete_blog_post(&state.db, id, user.id).await?;
268 - db::projects::bump_cache_generation(&state.db, post.project_id).await?;
269 + db::blog_posts::delete_blog_post(&db, id, user.id).await?;
270 + db::projects::bump_cache_generation(&db, post.project_id).await?;
269 271
270 272 Ok(htmx_toast_response("Blog post deleted", "success"))
271 273 }
@@ -273,10 +275,10 @@ pub(super) async fn delete_blog_post(
273 275 /// List published blog posts for a project.
274 276 #[tracing::instrument(skip_all, name = "blog::list_blog_posts")]
275 277 pub(super) async fn list_blog_posts(
276 - State(state): State<AppState>,
278 + State(db): State<PgPool>,
277 279 Path(project_id): Path<ProjectId>,
278 280 ) -> Result<impl IntoResponse> {
279 - let posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, project_id).await?;
281 + let posts = db::blog_posts::get_published_blog_posts_by_project(&db, project_id).await?;
280 282
281 283 let data: Vec<BlogPostResponse> = posts.iter().map(blog_post_response).collect();
282 284
@@ -4,22 +4,23 @@ use axum::extract::{Path, State};
4 4 use axum::response::IntoResponse;
5 5 use axum::Json;
6 6
7 + use sqlx::PgPool;
8 +
7 9 use crate::{
8 10 auth::AuthUser,
9 11 db::{self, ItemId},
10 12 error::{AppError, Result},
11 - AppState,
12 13 };
13 14
14 15 /// Toggle an item's cart status. Returns the new state.
15 16 #[tracing::instrument(skip_all, name = "cart::toggle")]
16 17 pub(super) async fn toggle_cart(
17 - State(state): State<AppState>,
18 + State(db): State<PgPool>,
18 19 AuthUser(user): AuthUser,
19 20 Path(item_id): Path<ItemId>,
20 21 ) -> Result<impl IntoResponse> {
21 22 // Single-query pre-flight: item existence, visibility, ownership, purchase, cart status
22 - let pf = db::cart::toggle_cart_preflight(&state.db, user.id, item_id)
23 + let pf = db::cart::toggle_cart_preflight(&db, user.id, item_id)
23 24 .await?
24 25 .ok_or(AppError::NotFound)?;
25 26
@@ -46,9 +47,9 @@ pub(super) async fn toggle_cart(
46 47 }
47 48
48 49 if pf.in_cart {
49 - db::cart::remove_from_cart(&state.db, user.id, item_id).await?;
50 + db::cart::remove_from_cart(&db, user.id, item_id).await?;
50 51 } else {
51 - db::cart::add_to_cart(&state.db, user.id, item_id).await?;
52 + db::cart::add_to_cart(&db, user.id, item_id).await?;
52 53 }
53 54
54 55 Ok(Json(serde_json::json!({ "in_cart": !pf.in_cart })))
@@ -57,24 +58,24 @@ pub(super) async fn toggle_cart(
57 58 /// Remove an item from the cart explicitly. Returns 204.
58 59 #[tracing::instrument(skip_all, name = "cart::remove")]
59 60 pub(super) async fn remove_from_cart(
60 - State(state): State<AppState>,
61 + State(db): State<PgPool>,
61 62 AuthUser(user): AuthUser,
62 63 Path(item_id): Path<ItemId>,
63 64 ) -> Result<impl IntoResponse> {
64 - db::cart::remove_from_cart(&state.db, user.id, item_id).await?;
65 + db::cart::remove_from_cart(&db, user.id, item_id).await?;
65 66 Ok(axum::http::StatusCode::NO_CONTENT)
66 67 }
67 68
68 69 /// Update the PWYW amount for a cart item.
69 70 #[tracing::instrument(skip_all, name = "cart::update_amount")]
70 71 pub(super) async fn update_cart_amount(
71 - State(state): State<AppState>,
72 + State(db): State<PgPool>,
72 73 AuthUser(user): AuthUser,
73 74 Path(item_id): Path<ItemId>,
74 75 Json(body): Json<UpdateCartAmountRequest>,
75 76 ) -> Result<impl IntoResponse> {
76 77 // Verify item exists and is PWYW
77 - let item = db::items::get_item_by_id(&state.db, item_id)
78 + let item = db::items::get_item_by_id(&db, item_id)
78 79 .await?
79 80 .ok_or(AppError::NotFound)?;
80 81
@@ -102,7 +103,7 @@ pub(super) async fn update_cart_amount(
102 103 }
103 104
104 105 let updated = db::cart::update_cart_amount(
105 - &state.db,
106 + &db,
106 107 user.id,
107 108 item_id,
108 109 Some(body.amount_cents),
@@ -124,9 +125,9 @@ pub(super) struct UpdateCartAmountRequest {
124 125 /// Get the number of items in the cart (for nav badge).
125 126 #[tracing::instrument(skip_all, name = "cart::count")]
126 127 pub(super) async fn cart_count(
127 - State(state): State<AppState>,
128 + State(db): State<PgPool>,
128 129 AuthUser(user): AuthUser,
129 130 ) -> Result<impl IntoResponse> {
130 - let count = db::cart::get_cart_count(&state.db, user.id).await?;
131 + let count = db::cart::get_cart_count(&db, user.id).await?;
131 132 Ok(Json(serde_json::json!({ "count": count })))
132 133 }
@@ -7,11 +7,12 @@ use axum::{
7 7 };
8 8 use serde::{Deserialize, Serialize};
9 9
10 + use sqlx::PgPool;
11 +
10 12 use crate::{
11 13 auth::AuthUser,
12 14 db,
13 15 error::Result,
14 - AppState,
15 16 };
16 17
17 18 /// Typeahead search query.
@@ -30,7 +31,7 @@ pub struct CategorySearchResult {
30 31 /// Search categories for typeahead suggestions, returns JSON.
31 32 #[tracing::instrument(skip_all, name = "categories::search_categories")]
32 33 pub(super) async fn search_categories(
33 - State(state): State<AppState>,
34 + State(db): State<PgPool>,
34 35 axum::extract::Query(query): axum::extract::Query<CategorySearchQuery>,
35 36 ) -> Result<impl IntoResponse> {
36 37 let q = query.q.unwrap_or_default();
@@ -38,7 +39,7 @@ pub(super) async fn search_categories(
38 39 return Ok(Json(Vec::<CategorySearchResult>::new()));
39 40 }
40 41
41 - let categories = db::categories::search_categories(&state.db, &q, 10).await?;
42 + let categories = db::categories::search_categories(&db, &q, 10).await?;
42 43 let results: Vec<CategorySearchResult> = categories
43 44 .into_iter()
44 45 .map(|c| CategorySearchResult {
@@ -59,7 +60,7 @@ pub struct CreateCategoryRequest {
59 60 /// Create a new category (or return existing). Requires auth.
60 61 #[tracing::instrument(skip_all, name = "categories::create_category")]
61 62 pub(super) async fn create_category(
62 - State(state): State<AppState>,
63 + State(db): State<PgPool>,
63 64 AuthUser(_user): AuthUser,
64 65 Json(req): Json<CreateCategoryRequest>,
65 66 ) -> Result<impl IntoResponse> {
@@ -70,7 +71,7 @@ pub(super) async fn create_category(
70 71 ));
71 72 }
72 73
73 - let category = db::categories::get_or_create_category(&state.db, name).await?;
74 + let category = db::categories::get_or_create_category(&db, name).await?;
74 75
75 76 Ok(Json(CategorySearchResult {
76 77 name: category.name,
@@ -8,13 +8,14 @@ use axum::{
8 8 };
9 9 use serde::{Deserialize, Serialize};
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::AuthUser,
13 15 constants,
14 16 db::{self, CollectionId, ItemId, Slug},
15 17 error::{AppError, Result},
16 18 validation,
17 - AppState,
18 19 };
19 20
20 21 // ── Request / response types ──
@@ -61,11 +62,11 @@ pub struct CollectionForItemEntry {
61 62
62 63 /// Fetch a collection and verify the authenticated user owns it.
63 64 async fn verify_collection_ownership(
64 - state: &AppState,
65 + db: &PgPool,
65 66 collection_id: CollectionId,
66 67 user_id: db::UserId,
67 68 ) -> Result<db::DbCollection> {
68 - let collection = db::collections::get_collection_by_id(&state.db, collection_id)
69 + let collection = db::collections::get_collection_by_id(db, collection_id)
69 70 .await?
70 71 .ok_or(AppError::NotFound)?;
71 72
@@ -81,7 +82,7 @@ async fn verify_collection_ownership(
81 82 /// Create a new collection.
82 83 #[tracing::instrument(skip_all, name = "collections::create")]
83 84 pub(super) async fn create_collection(
84 - State(state): State<AppState>,
85 + State(db): State<PgPool>,
85 86 AuthUser(user): AuthUser,
86 87 Json(req): Json<CreateCollectionRequest>,
87 88 ) -> Result<impl IntoResponse> {
@@ -98,7 +99,7 @@ pub(super) async fn create_collection(
98 99 let slug = Slug::new(slug_str)?;
99 100
100 101 // Enforce per-user limit
101 - let count = db::collections::count_collections_by_user(&state.db, user.id).await?;
102 + let count = db::collections::count_collections_by_user(&db, user.id).await?;
102 103 if count >= constants::MAX_COLLECTIONS_PER_USER {
103 104 return Err(AppError::validation(format!(
104 105 "You can create up to {} collections",
@@ -109,7 +110,7 @@ pub(super) async fn create_collection(
109 110 // `create_collection` maps the per-user slug unique-violation to a clean
110 111 // validation error (the seal lives in the db layer), so callers just `?`.
111 112 let collection = db::collections::create_collection(
112 - &state.db,
113 + &db,
113 114 user.id,
114 115 &slug,
115 116 title,
@@ -133,13 +134,13 @@ pub(super) async fn create_collection(
133 134 /// Update a collection's title, description, and visibility.
134 135 #[tracing::instrument(skip_all, name = "collections::update")]
135 136 pub(super) async fn update_collection(
136 - State(state): State<AppState>,
137 + State(db): State<PgPool>,
137 138 AuthUser(user): AuthUser,
138 139 Path(id): Path<CollectionId>,
139 140 Json(req): Json<UpdateCollectionRequest>,
140 141 ) -> Result<impl IntoResponse> {
141 142 user.check_not_suspended()?;
142 - verify_collection_ownership(&state, id, user.id).await?;
143 + verify_collection_ownership(&db, id, user.id).await?;
143 144
144 145 let title = req.title.trim();
145 146 let description = req.description.as_deref().map(str::trim).filter(|s| !s.is_empty());
@@ -150,7 +151,7 @@ pub(super) async fn update_collection(
150 151 }
151 152
152 153 let collection = db::collections::update_collection(
153 - &state.db,
154 + &db,
154 155 id,
155 156 title,
156 157 description,
@@ -170,14 +171,14 @@ pub(super) async fn update_collection(
170 171 /// Delete a collection.
171 172 #[tracing::instrument(skip_all, name = "collections::delete")]
172 173 pub(super) async fn delete_collection(
173 - State(state): State<AppState>,
174 + State(db): State<PgPool>,
174 175 AuthUser(user): AuthUser,
175 176 Path(id): Path<CollectionId>,
176 177 ) -> Result<impl IntoResponse> {
177 178 user.check_not_suspended()?;
178 - verify_collection_ownership(&state, id, user.id).await?;
179 + verify_collection_ownership(&db, id, user.id).await?;
179 180
180 - db::collections::delete_collection(&state.db, id, user.id).await?;
181 + db::collections::delete_collection(&db, id, user.id).await?;
181 182
182 183 Ok(StatusCode::NO_CONTENT)
183 184 }
@@ -185,15 +186,15 @@ pub(super) async fn delete_collection(
185 186 /// Add an item to a collection.
186 187 #[tracing::instrument(skip_all, name = "collections::add_item")]
187 188 pub(super) async fn add_item(
188 - State(state): State<AppState>,
189 + State(db): State<PgPool>,
189 190 AuthUser(user): AuthUser,
190 191 Path((collection_id, item_id)): Path<(CollectionId, ItemId)>,
191 192 ) -> Result<impl IntoResponse> {
192 193 user.check_not_suspended()?;
193 - verify_collection_ownership(&state, collection_id, user.id).await?;
194 + verify_collection_ownership(&db, collection_id, user.id).await?;
194 195
195 196 // Item must exist and be public
196 - let item = db::items::get_item_by_id(&state.db, item_id)
197 + let item = db::items::get_item_by_id(&db, item_id)
197 198 .await?
198 199 .ok_or(AppError::NotFound)?;
199 200 if !item.is_public {
@@ -203,7 +204,7 @@ pub(super) async fn add_item(
203 204 }
204 205
205 206 // Enforce per-collection limit
206 - let count = db::collections::count_collection_items(&state.db, collection_id).await?;
207 + let count = db::collections::count_collection_items(&db, collection_id).await?;
207 208 if count >= constants::MAX_ITEMS_PER_COLLECTION {
208 209 return Err(AppError::validation(format!(
209 210 "A collection can hold up to {} items",
@@ -211,7 +212,7 @@ pub(super) async fn add_item(
211 212 )));
212 213 }
213 214
214 - db::collections::add_item_to_collection(&state.db, collection_id, item_id).await?;
215 + db::collections::add_item_to_collection(&db, collection_id, item_id).await?;
215 216
216 217 Ok(StatusCode::NO_CONTENT)
217 218 }
@@ -219,14 +220,14 @@ pub(super) async fn add_item(
219 220 /// Remove an item from a collection.
220 221 #[tracing::instrument(skip_all, name = "collections::remove_item")]
221 222 pub(super) async fn remove_item(
222 - State(state): State<AppState>,
223 + State(db): State<PgPool>,
223 224 AuthUser(user): AuthUser,
224 225 Path((collection_id, item_id)): Path<(CollectionId, ItemId)>,
225 226 ) -> Result<impl IntoResponse> {
226 227 user.check_not_suspended()?;
227 - verify_collection_ownership(&state, collection_id, user.id).await?;
228 + verify_collection_ownership(&db, collection_id, user.id).await?;
228 229
229 - db::collections::remove_item_from_collection(&state.db, collection_id, item_id).await?;
230 + db::collections::remove_item_from_collection(&db, collection_id, item_id).await?;
230 231
231 232 Ok(StatusCode::NO_CONTENT)
232 233 }
@@ -234,15 +235,15 @@ pub(super) async fn remove_item(
234 235 /// Reorder items in a collection.
235 236 #[tracing::instrument(skip_all, name = "collections::reorder_items")]
236 237 pub(super) async fn reorder_items(
237 - State(state): State<AppState>,
238 + State(db): State<PgPool>,
238 239 AuthUser(user): AuthUser,
239 240 Path(collection_id): Path<CollectionId>,
240 241 Json(req): Json<ReorderItemsRequest>,
241 242 ) -> Result<impl IntoResponse> {
242 243 user.check_not_suspended()?;
243 - verify_collection_ownership(&state, collection_id, user.id).await?;
244 + verify_collection_ownership(&db, collection_id, user.id).await?;
244 245
245 - db::collections::reorder_collection_items(&state.db, collection_id, &req.item_ids).await?;
246 + db::collections::reorder_collection_items(&db, collection_id, &req.item_ids).await?;
246 247
247 248 Ok(StatusCode::NO_CONTENT)
248 249 }
@@ -252,11 +253,11 @@ pub(super) async fn reorder_items(
252 253 /// Get the current user's collections with membership state for a specific item.
253 254 #[tracing::instrument(skip_all, name = "collections::for_item")]
254 255 pub(super) async fn collections_for_item(
255 - State(state): State<AppState>,
256 + State(db): State<PgPool>,
256 257 AuthUser(user): AuthUser,
257 258 Path(item_id): Path<ItemId>,
258 259 ) -> Result<impl IntoResponse> {
259 - let rows = db::collections::get_user_collections_for_item(&state.db, user.id, item_id).await?;
260 + let rows = db::collections::get_user_collections_for_item(&db, user.id, item_id).await?;
260 261
261 262 let entries: Vec<CollectionForItemEntry> = rows
262 263 .into_iter()
@@ -7,6 +7,9 @@ use axum::{
7 7 };
8 8 use serde::{Deserialize, Serialize};
9 9
10 + use sqlx::PgPool;
11 + use crate::{AppStorage, Scanning};
12 +
10 13 use crate::{
11 14 auth::AuthUser,
12 15 db::{self, ContentInsertionId, ContentInsertionPlacementId, InsertionPosition, ItemId},
@@ -15,7 +18,6 @@ use crate::{
15 18 routes::storage::{commit_upload, CommitTarget},
16 19 storage::{FileType, S3Client},
17 20 templates::InsertionListTemplate,
18 - AppState,
19 21 };
20 22
21 23 use super::verify_item_ownership;
@@ -79,18 +81,19 @@ pub struct CreatePlacementRequest {
79 81 /// POST /api/users/me/insertions/presign
80 82 #[tracing::instrument(skip_all, name = "insertions::presign")]
81 83 pub(super) async fn presign_insertion(
82 - State(state): State<AppState>,
84 + State(db): State<PgPool>,
85 + State(storage): State<AppStorage>,
83 86 AuthUser(user): AuthUser,
84 87 Json(req): Json<InsertionPresignRequest>,
85 88 ) -> Result<impl IntoResponse> {
86 89 user.check_not_suspended()?;
87 - let s3 = state.require_s3()?;
90 + let s3 = storage.require_s3()?;
88 91
89 92 S3Client::validate_content_type(FileType::Insertion, &req.content_type)?;
90 93 S3Client::validate_extension(FileType::Insertion, &req.file_name)?;
91 94
92 95 // Check storage quota before issuing presigned URL
93 - db::creator_tiers::check_presign_allowed(&state.db, user.id, FileType::Insertion).await?;
96 + db::creator_tiers::check_presign_allowed(&db, user.id, FileType::Insertion).await?;
94 97
95 98 // Staging key (unserved); the scan worker promotes it to the content key on a
96 99 // Clean verdict (C1). Insertion clips are served presigned from the stored
@@ -98,7 +101,7 @@ pub(super) async fn presign_insertion(
98 101 let s3_key = S3Client::generate_staging_key(&req.file_name);
99 102
100 103 // Track the pending upload so the reaper can clean it up if never confirmed
101 - db::pending_uploads::record_pending_upload(&state.db, user.id, &s3_key, "main").await?;
104 + db::pending_uploads::record_pending_upload(&db, user.id, &s3_key, "main").await?;
102 105
103 106 let expires_in = 3600;
104 107 let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(crate::storage::CACHE_CONTROL_IMMUTABLE), None)
@@ -117,12 +120,14 @@ pub(super) async fn presign_insertion(
117 120 /// POST /api/users/me/insertions/confirm
118 121 #[tracing::instrument(skip_all, name = "insertions::confirm")]
119 122 pub(super) async fn confirm_insertion(
120 - State(state): State<AppState>,
123 + State(db): State<PgPool>,
124 + State(storage): State<AppStorage>,
125 + State(scanning): State<Scanning>,
121 126 AuthUser(user): AuthUser,
122 127 Json(req): Json<InsertionConfirmRequest>,
123 128 ) -> Result<impl IntoResponse> {
124 129 user.check_not_suspended()?;
125 - let s3 = state.require_s3()?;
130 + let s3 = storage.require_s3()?;
126 131
127 132 // Ownership of the staging key is proved below (after the replay
128 133 // short-circuit) via `pending_uploads` — a `staging/{uuid}` key carries no
@@ -133,7 +138,7 @@ pub(super) async fn confirm_insertion(
133 138 // Return the already-created insertion (ultra-fuzz Run 12 Storage: confirm
134 139 // idempotency).
135 140 if let Some(existing) =
136 - db::content_insertions::get_insertion_by_storage_key(&state.db, user.id, &req.s3_key).await?
141 + db::content_insertions::get_insertion_by_storage_key(&db, user.id, &req.s3_key).await?
137 142 {
138 143 return Ok(Json(InsertionResponse {
139 144 id: existing.id,
@@ -149,7 +154,7 @@ pub(super) async fn confirm_insertion(
149 154 // replay short-circuit — which is authorized by the row already existing —
150 155 // and before the size/tier reject paths, so an unowned (at most another
151 156 // user's in-flight) staging object is never enqueued for deletion.
152 - if !db::pending_uploads::is_owned(&state.db, user.id, &req.s3_key, "main").await? {
157 + if !db::pending_uploads::is_owned(&db, user.id, &req.s3_key, "main").await? {
153 158 return Err(AppError::BadRequest("Invalid upload key".to_string()));
154 159 }
155 160
@@ -160,7 +165,7 @@ pub(super) async fn confirm_insertion(
160 165
161 166 // Enforce static per-type size limit
162 167 if file_size_bytes as u64 > FileType::Insertion.max_size() {
163 - crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await;
168 + crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await;
164 169 return Err(AppError::BadRequest(format!(
165 170 "File exceeds maximum size of {} MB",
166 171 FileType::Insertion.max_size() / (1024 * 1024)
@@ -178,23 +183,23 @@ pub(super) async fn confirm_insertion(
178 183 }
179 184
180 185 // Enforce tier-based limits (per-file + storage cap)
181 - let max_storage = match db::creator_tiers::check_upload_allowed(&state.db, user.id, FileType::Insertion, file_size_bytes).await {
186 + let max_storage = match db::creator_tiers::check_upload_allowed(&db, user.id, FileType::Insertion, file_size_bytes).await {
182 187 Ok(max) => max,
183 188 Err(e) => {
184 - crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await;
189 + crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await;
185 190 return Err(e);
186 191 }
187 192 };
188 193
189 194 // Atomically increment storage BEFORE writing the DB record.
190 195 // Avoids orphaned unbilled file references.
191 - if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await {
192 - crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await;
196 + if let Err(e) = db::creator_tiers::try_increment_storage(&db, user.id, file_size_bytes, max_storage).await {
197 + crate::routes::storage::enqueue_s3_orphan(&db, &req.s3_key, crate::storage::S3Bucket::Main, "insertion_upload_rejected").await;
193 198 return Err(e);
194 199 }
195 200
196 201 // Clear the pending upload record now that the upload is confirmed
197 - db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?;
202 + db::pending_uploads::remove_pending_upload(&db, user.id, &req.s3_key, "main").await?;
198 203
199 204 // Persist the real media family (audio vs video) derived from the validated
200 205 // MIME, rather than assuming audio. Drives the placement type-compat guard
@@ -202,7 +207,7 @@ pub(super) async fn confirm_insertion(
202 207 let media_type = S3Client::insertion_media_type(&req.mime_type);
203 208
204 209 let insertion = db::content_insertions::create_insertion(
205 - &state.db,
210 + &db,
206 211 user.id,
207 212 &req.title,
208 213 media_type,
@@ -219,8 +224,8 @@ pub(super) async fn confirm_insertion(
219 224 // worker flips it to 'clean'; on quarantine the row is purged and a WAM ticket
220 225 // filed. The creator still sees the pending clip in their management library.
221 226 commit_upload(
222 - &state.db,
223 - state.scanner.as_ref(),
227 + &db,
228 + scanning.scanner.as_ref(),
224 229 CommitTarget::ContentInsertion(insertion.id),
225 230 &req.s3_key,
226 231 FileType::Insertion,
@@ -249,10 +254,10 @@ pub(super) async fn confirm_insertion(
249 254 /// GET /api/users/me/insertions
250 255 #[tracing::instrument(skip_all, name = "insertions::list")]
251 256 pub(super) async fn list_insertions(
252 - State(state): State<AppState>,
257 + State(db): State<PgPool>,
253 258 AuthUser(user): AuthUser,
254 259 ) -> Result<impl IntoResponse> {
255 - let insertions = db::content_insertions::list_insertions(&state.db, user.id).await?;
260 + let insertions = db::content_insertions::list_insertions(&db, user.id).await?;
256 261
257 262 let display: Vec<crate::templates::InsertionDisplay> = insertions
258 263 .iter()
@@ -275,7 +280,7 @@ pub(super) async fn list_insertions(
275 280 /// PUT /api/insertions/{id}
276 281 #[tracing::instrument(skip_all, name = "insertions::rename")]
277 282 pub(super) async fn rename_insertion(
278 - State(state): State<AppState>,
283 + State(db): State<PgPool>,
279 284 AuthUser(user): AuthUser,
280 285 Path(id): Path<ContentInsertionId>,
281 286 Json(req): Json<RenameInsertionRequest>,
@@ -286,7 +291,7 @@ pub(super) async fn rename_insertion(
286 291 }
287 292
288 293 let updated = db::content_insertions::update_insertion_title(
289 - &state.db,
294 + &db,
290 295 id,
291 296 user.id,
292 297 &req.title,
@@ -305,13 +310,13 @@ pub(super) async fn rename_insertion(
305 310 /// DELETE /api/insertions/{id}
306 311 #[tracing::instrument(skip_all, name = "insertions::delete")]
307 312 pub(super) async fn delete_insertion(
308 - State(state): State<AppState>,
313 + State(db): State<PgPool>,
309 314 AuthUser(user): AuthUser,
310 315 Path(id): Path<ContentInsertionId>,
311 316 ) -> Result<impl IntoResponse> {
312 317 user.check_not_suspended()?;
313 318 // Look up the insertion for S3 cleanup and storage decrement
314 - let insertion = db::content_insertions::get_insertion(&state.db, id, user.id).await?;
319 + let insertion = db::content_insertions::get_insertion(&db, id, user.id).await?;
315 320 let file_size = insertion.as_ref().map(|i| i.file_size).unwrap_or(0);
316 321
317 322 // Enqueue as the sole durable deletion path, BEFORE the row delete. Abort on
@@ -320,13 +325,13 @@ pub(super) async fn delete_insertion(
320 325 // The reaper's is_s3_key_live guard makes the reverse case safe.
321 326 if let Some(ref ins) = insertion {
322 327 db::pending_s3_deletions::enqueue_deletions(
323 - &state.db,
328 + &db,
324 329 &[(ins.storage_key.clone(), "main".to_string())],
325 330 "insertion_delete",
326 331 ).await?;
327 332 }
328 333
329 - let deleted = db::content_insertions::delete_insertion(&state.db, id, user.id).await?;
334 + let deleted = db::content_insertions::delete_insertion(&db, id, user.id).await?;
330 335
331 336 if !deleted {
332 337 return Err(AppError::NotFound);
@@ -334,7 +339,7 @@ pub(super) async fn delete_insertion(
334 339
335 340 // Decrement storage counter
336 341 if file_size > 0 {
337 - db::creator_tiers::decrement_storage_used(&state.db, user.id, file_size).await?;
342 + db::creator_tiers::decrement_storage_used(&db, user.id, file_size).await?;
338 343 }
339 344
340 345 Ok(htmx_toast_response("Clip deleted", "success"))
@@ -349,16 +354,16 @@ pub(super) async fn delete_insertion(
349 354 /// GET /api/items/{id}/insertions
350 355 #[tracing::instrument(skip_all, name = "insertions::list_placements")]
351 356 pub(super) async fn list_placements(
352 - State(state): State<AppState>,
357 + State(db): State<PgPool>,
353 358 AuthUser(user): AuthUser,
354 359 Path(item_id): Path<ItemId>,
355 360 ) -> Result<impl IntoResponse> {
356 - let (item, _project) = verify_item_ownership(&state.db, item_id, user.id).await?;
361 + let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?;
357 362
358 - let placements = db::content_insertions::list_placements_for_item(&state.db, item_id).await?;
363 + let placements = db::content_insertions::list_placements_for_item(&db, item_id).await?;
359 364 // Only offer clips that can legally be placed on this item (a video clip on an
360 365 // audio item would be rejected by create_placement, so don't surface it).
361 - let available: Vec<_> = db::content_insertions::list_insertions(&state.db, user.id)
366 + let available: Vec<_> = db::content_insertions::list_insertions(&db, user.id)
362 367 .await?
363 368 .into_iter()
364 369 .filter(|i| clip_compatible_with_item(&i.media_type, item.item_type))
@@ -400,16 +405,16 @@ pub(super) async fn list_placements(
400 405 /// POST /api/items/{id}/insertions
401 406 #[tracing::instrument(skip_all, name = "insertions::create_placement")]
402 407 pub(super) async fn create_placement(
403 - State(state): State<AppState>,
408 + State(db): State<PgPool>,
404 409 AuthUser(user): AuthUser,
405 410 Path(item_id): Path<ItemId>,
406 411 Json(req): Json<CreatePlacementRequest>,
407 412 ) -> Result<impl IntoResponse> {
408 413 user.check_not_suspended()?;
409 - let (item, _project) = verify_item_ownership(&state.db, item_id, user.id).await?;
414 + let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?;
410 415
411 416 // Verify the insertion belongs to this user
412 - let insertion = db::content_insertions::get_insertion(&state.db, req.insertion_id, user.id)
417 + let insertion = db::content_insertions::get_insertion(&db, req.insertion_id, user.id)
413 418 .await?
414 419 .ok_or(AppError::NotFound)?;
415 420
@@ -436,7 +441,7 @@ pub(super) async fn create_placement(
436 441 }
437 442
438 443 let _placement = db::content_insertions::create_placement(
439 - &state.db,
444 + &db,
440 445 item_id,
441 446 insertion.id,
442 447 req.position,
@@ -453,19 +458,19 @@ pub(super) async fn create_placement(
453 458 /// DELETE /api/item-insertions/{id}
454 459 #[tracing::instrument(skip_all, name = "insertions::delete_placement")]
455 460 pub(super) async fn delete_placement(
456 - State(state): State<AppState>,
461 + State(db): State<PgPool>,
457 462 AuthUser(user): AuthUser,
458 463 Path(placement_id): Path<ContentInsertionPlacementId>,
459 464 ) -> Result<impl IntoResponse> {
460 465 user.check_not_suspended()?;
461 466 // Get placement to verify item ownership
462 - let placement = db::content_insertions::get_placement_by_id(&state.db, placement_id)
467 + let placement = db::content_insertions::get_placement_by_id(&db, placement_id)
463 468 .await?
464 469 .ok_or(AppError::NotFound)?;
465 470
466 - verify_item_ownership(&state.db, placement.item_id, user.id).await?;
471 + verify_item_ownership(&db, placement.item_id, user.id).await?;
467 472
468 - db::content_insertions::delete_placement(&state.db, placement_id).await?;
473 + db::content_insertions::delete_placement(&db, placement_id).await?;
469 474
470 475 Ok(htmx_toast_response("Clip removed", "success"))
471 476 }
@@ -8,11 +8,13 @@ use axum::{
8 8 use serde::Deserialize;
9 9 use serde_json::json;
10 10
11 + use sqlx::PgPool;
12 + use crate::{AppCaches, AppLimiters};
13 +
11 14 use crate::{
12 15 auth::AuthUser,
13 16 db::{self, CustomDomainId},
14 17 error::{AppError, Result},
15 - AppState,
16 18 };
17 19
18 20 #[derive(Deserialize)]
@@ -24,7 +26,7 @@ pub(super) struct AddDomainRequest {
24 26 /// POST /api/domains: add a custom domain.
25 27 #[tracing::instrument(skip_all, name = "api::domains::add")]
26 28 pub(super) async fn add_domain(
27 - State(state): State<AppState>,
29 + State(db): State<PgPool>,
28 30 AuthUser(session_user): AuthUser,
29 31 Form(req): Form<AddDomainRequest>,
30 32 ) -> Result<impl IntoResponse> {
@@ -38,7 +40,7 @@ pub(super) async fn add_domain(
38 40 // durable `UNIQUE (domain)` is global, so a domain another user already holds
39 41 // surfaces here as a clean 409, not a raw 500 (ultra-fuzz Run 12 Storage).
40 42 let _row =
41 - db::custom_domains::create_custom_domain(&state.db, session_user.id, &domain, &verification_token)
43 + db::custom_domains::create_custom_domain(&db, session_user.id, &domain, &verification_token)
42 44 .await
43 45 .map_err(|e| crate::helpers::map_unique_violation(e, "That domain is already registered"))?;
44 46
@@ -67,12 +69,13 @@ pub(super) struct VerifyDomainRequest {
67 69 /// POST /api/domains/verify: trigger DNS verification.
68 70 #[tracing::instrument(skip_all, name = "api::domains::verify")]
69 71 pub(super) async fn verify_domain(
70 - State(state): State<AppState>,
72 + State(db): State<PgPool>,
73 + State(caches): State<AppCaches>,
71 74 AuthUser(session_user): AuthUser,
72 75 Form(req): Form<VerifyDomainRequest>,
73 76 ) -> Result<impl IntoResponse> {
74 77 session_user.check_not_sandbox()?;
75 - let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
78 + let cd = db::custom_domains::get_custom_domain_by_user(&db, session_user.id)
76 79 .await?
77 80 .ok_or(AppError::NotFound)?;
78 81
@@ -100,9 +103,8 @@ pub(super) async fn verify_domain(
100 103 }
101 104
102 105 // Mark verified in DB and update cache
103 - db::custom_domains::mark_domain_verified(&state.db, cd.id).await?;
104 - state
105 - .caches.domain_cache
106 + db::custom_domains::mark_domain_verified(&db, cd.id).await?;
107 + caches.domain_cache
106 108 .insert(cd.domain.clone(), session_user.id);
107 109
108 110 Ok(axum::response::Html("<p class=\"success\">Domain verified successfully. Reload to see changes.</p>".to_string()))
@@ -111,13 +113,14 @@ pub(super) async fn verify_domain(
111 113 /// DELETE /api/domains/{id}: remove a custom domain.
112 114 #[tracing::instrument(skip_all, name = "api::domains::remove")]
113 115 pub(super) async fn remove_domain(
114 - State(state): State<AppState>,
116 + State(db): State<PgPool>,
117 + State(caches): State<AppCaches>,
115 118 AuthUser(session_user): AuthUser,
116 119 Path(id): Path<CustomDomainId>,
117 120 ) -> Result<impl IntoResponse> {
118 121 session_user.check_not_sandbox()?;
119 122 // Fetch first so we can remove from cache
120 - let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
123 + let cd = db::custom_domains::get_custom_domain_by_user(&db, session_user.id)
121 124 .await?
122 125 .ok_or(AppError::NotFound)?;
123 126
@@ -125,10 +128,10 @@ pub(super) async fn remove_domain(
125 128 return Err(AppError::NotFound);
126 129 }
127 130
128 - db::custom_domains::delete_custom_domain(&state.db, id, session_user.id).await?;
131 + db::custom_domains::delete_custom_domain(&db, id, session_user.id).await?;
129 132
130 133 // Remove from cache
131 - state.caches.domain_cache.remove(&cd.domain);
134 + caches.domain_cache.remove(&cd.domain);
132 135
133 136 Ok(axum::http::StatusCode::NO_CONTENT)
134 137 }
@@ -136,10 +139,10 @@ pub(super) async fn remove_domain(
136 139 /// GET /api/domains: get current user's custom domain.
137 140 #[tracing::instrument(skip_all, name = "api::domains::get")]
138 141 pub(super) async fn get_domain(
139 - State(state): State<AppState>,
142 + State(db): State<PgPool>,
140 143 AuthUser(session_user): AuthUser,
141 144 ) -> Result<impl IntoResponse> {
142 - let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id).await?;
145 + let cd = db::custom_domains::get_custom_domain_by_user(&db, session_user.id).await?;
143 146
144 147 match cd {
145 148 Some(d) => {
@@ -183,7 +186,9 @@ pub(super) struct CaddyAskQuery {
183 186 /// at capacity, return 503 so Caddy backs off and the pool stays free.
184 187 #[tracing::instrument(skip_all, name = "api::domains::caddy_ask")]
185 188 pub(super) async fn caddy_ask(
186 - State(state): State<AppState>,
189 + State(db): State<PgPool>,
190 + State(caches): State<AppCaches>,
191 + State(limiters): State<AppLimiters>,
187 192 Query(q): Query<CaddyAskQuery>,
188 193 ) -> impl IntoResponse {
189 194 use metrics::{counter, gauge};
@@ -202,7 +207,7 @@ pub(super) async fn caddy_ask(
202 207 }
203 208
204 209 // Fast path: cache hit, no DB, no semaphore.
205 - if state.caches.domain_cache.contains_key(&domain) {
210 + if caches.domain_cache.contains_key(&domain) {
206 211 counter!("caddy_ask_total", "outcome" => "cache_hit").increment(1);
207 212 return axum::http::StatusCode::OK;
208 213 }
@@ -210,17 +215,17 @@ pub(super) async fn caddy_ask(
210 215 // Slow path: cap concurrent DB lookups. `try_acquire` is non-blocking;
211 216 // hitting the cap means we're already under pressure and want Caddy to
212 217 // retry rather than queue.
213 - let Ok(_permit) = state.limiters.caddy_ask_semaphore.try_acquire() else {
218 + let Ok(_permit) = limiters.caddy_ask_semaphore.try_acquire() else {
214 219 tracing::warn!(domain = %domain, "caddy-ask: cache-miss concurrency cap reached");
215 220 counter!("caddy_ask_total", "outcome" => "rejected_at_cap").increment(1);
216 221 return axum::http::StatusCode::SERVICE_UNAVAILABLE;
217 222 };
218 223
219 - match db::custom_domains::get_verified_domain(&state.db, &domain).await {
224 + match db::custom_domains::get_verified_domain(&db, &domain).await {
220 225 Ok(Some(d)) => {
221 - state.caches.domain_cache.insert(d.domain, d.user_id);
226 + caches.domain_cache.insert(d.domain, d.user_id);
222 227 // Update cache-size gauge after the insert so it reflects current state.
223 - gauge!("domain_cache_entries").set(state.caches.domain_cache.len() as f64);
228 + gauge!("domain_cache_entries").set(caches.domain_cache.len() as f64);
224 229 counter!("caddy_ask_total", "outcome" => "miss_found").increment(1);
225 230 axum::http::StatusCode::OK
226 231 }
@@ -13,12 +13,16 @@ use axum::{
13 13 use serde::Deserialize;
14 14 use zip::write::SimpleFileOptions;
15 15
16 + use sqlx::PgPool;
17 + use crate::email::EmailClient;
18 + use crate::background::BackgroundTx;
19 + use crate::AppStorage;
20 +
16 21 use crate::{
17 22 auth::AuthUser,
18 23 db,
19 24 error::{AppError, Result, ResultExt},
20 25 helpers::is_htmx_request,
21 - AppState,
22 26 };
23 27
24 28 use super::export_error_html;
@@ -49,22 +53,25 @@ pub(in crate::routes::api) struct ContentExportQuery {
49 53 /// (insertions are user-scoped and always excluded from per-project exports).
50 54 #[tracing::instrument(skip_all, name = "exports::export_content")]
51 55 pub(in crate::routes::api) async fn export_content(
52 - State(state): State<AppState>,
56 + State(db): State<PgPool>,
57 + State(storage): State<AppStorage>,
58 + State(email): State<EmailClient>,
59 + State(bg): State<BackgroundTx>,
53 60 headers: HeaderMap,
54 61 Query(query): Query<ContentExportQuery>,
55 62 AuthUser(user): AuthUser,
56 63 ) -> Result<Response> {
57 64 let is_htmx = is_htmx_request(&headers);
58 65
59 - let s3 = state.storage.s3.as_ref().ok_or_else(|| {
66 + let s3 = storage.s3.as_ref().ok_or_else(|| {
60 67 AppError::ServiceUnavailable("File storage is not configured".to_string())
61 68 })?;
62 69
63 70 // Collect all S3 keys from items, versions, and insertions. These are fast
64 71 // indexed reads, done up front so an empty export is reported immediately;
65 72 // the heavy download + zip + multipart upload runs in the background (below).
66 - let item_keys = db::items::get_user_s3_keys(&state.db, user.id).await?;
67 - let version_keys = db::versions::get_user_version_s3_keys(&state.db, user.id).await?;
73 + let item_keys = db::items::get_user_s3_keys(&db, user.id).await?;
74 + let version_keys = db::versions::get_user_version_s3_keys(&db, user.id).await?;
68 75
69 76 // Build the list of (s3_key, zip_path, db_size) triples. The DB-known file
70 77 // size lets us enforce the per-file/total caps without a per-file S3 HEAD
@@ -112,7 +119,7 @@ pub(in crate::routes::api) async fn export_content(
112 119 // Insertions are user-scoped (not project-scoped), so only include
113 120 // them when exporting all content (no project_id filter).
114 121 if query.project_id.is_none() {
115 - let insertions = db::content_insertions::list_insertions(&state.db, user.id).await?;
122 + let insertions = db::content_insertions::list_insertions(&db, user.id).await?;
116 123 for ins in &insertions {
117 124 let ext = extension_from_key(&ins.storage_key);
118 125 let title = sanitize_filename(&ins.title);
@@ -127,7 +134,7 @@ pub(in crate::routes::api) async fn export_content(
127 134 // keeps that shared query intact for storage accounting, which must still see
128 135 // every key.
129 136 let export_keys: Vec<String> = files.iter().map(|(k, _, _)| k.clone()).collect();
130 - let quarantined = db::scanning::quarantined_s3_keys(&state.db, &export_keys).await?;
137 + let quarantined = db::scanning::quarantined_s3_keys(&db, &export_keys).await?;
131 138 if !quarantined.is_empty() {
132 139 tracing::warn!(
133 140 user_id = %user.id, dropped = quarantined.len(),
@@ -148,12 +155,12 @@ pub(in crate::routes::api) async fn export_content(
148 155 // returns now instead of holding a connection + export permit for the whole
149 156 // multi-GB job — a wedged S3 GET used to pin both indefinitely.
150 157 let s3 = s3.clone();
151 - let email = state.email.clone();
158 + let email = email.clone();
152 159 let username = user.username.to_string();
153 160 let to_email = user.email.clone();
154 161 let to_name = user.display_name.clone();
155 162 let user_id = user.id;
156 - state.bg.spawn("content_export", async move {
163 + bg.spawn("content_export", async move {
157 164 match build_content_export(&s3, user_id, &username, files).await {
158 165 Ok(download_url) => {
159 166 if let Err(e) = email
@@ -13,13 +13,13 @@ use axum::{
13 13 use bytes::Bytes;
14 14 use tokio::sync::mpsc;
15 15 use tokio_stream::{wrappers::ReceiverStream, StreamExt};
16 + use sqlx::PgPool;
16 17 use crate::{
17 18 auth::AuthUser,
18 19 db,
19 20 error::{AppError, Result, ResultExt},
20 21 helpers::{is_htmx_request, sanitize_csv_cell},
21 22 templates::{ExportDownloadTemplate, FormStatusTemplate},
22 - AppState,
23 23 };
24 24
25 25 /// Rows per page for streamed CSV exports.
@@ -152,15 +152,15 @@ fn download_response(content: Vec<u8>, filename: &str, content_type: &str) -> Re
152 152 /// Export all projects and items as a downloadable JSON file.
153 153 #[tracing::instrument(skip_all, name = "exports::export_projects")]
154 154 pub(super) async fn export_projects(
155 - State(state): State<AppState>,
155 + State(db): State<PgPool>,
156 156 headers: HeaderMap,
157 157 AuthUser(user): AuthUser,
158 158 ) -> Result<Response> {
159 159 let is_htmx = is_htmx_request(&headers);
160 160
161 161 // Get all projects and all items in 2 queries (not N+1)
162 - let projects = db::projects::get_projects_by_user(&state.db, user.id).await?;
163 - let all_items = db::items::get_items_by_user(&state.db, user.id).await?;
162 + let projects = db::projects::get_projects_by_user(&db, user.id).await?;
163 + let all_items = db::items::get_items_by_user(&db, user.id).await?;
164 164
165 165 // DoS backstop: this handler materializes the entire catalog into an
166 166 // in-memory JSON tree and string on the request path. The ceiling is far
@@ -177,16 +177,16 @@ pub(super) async fn export_projects(
177 177 }
178 178
179 179 let all_item_ids: Vec<db::ItemId> = all_items.iter().map(|i| i.id).collect();
180 - let tags_map = db::tags::get_tags_for_items(&state.db, &all_item_ids).await?;
180 + let tags_map = db::tags::get_tags_for_items(&db, &all_item_ids).await?;
181 181
182 182 // Batch-load per-item data (chapters, versions, license keys, item-scoped promo codes)
183 - let chapters_map = db::chapters::get_chapters_by_items(&state.db, &all_item_ids).await?;
184 - let versions_map = db::versions::get_versions_by_items(&state.db, &all_item_ids).await?;
185 - let license_keys_map = db::license_keys::get_license_keys_by_items(&state.db, &all_item_ids).await?;
186 - let item_promo_codes_map = db::promo_codes::get_promo_codes_by_items(&state.db, &all_item_ids).await?;
183 + let chapters_map = db::chapters::get_chapters_by_items(&db, &all_item_ids).await?;
184 + let versions_map = db::versions::get_versions_by_items(&db, &all_item_ids).await?;
185 + let license_keys_map = db::license_keys::get_license_keys_by_items(&db, &all_item_ids).await?;
186 + let item_promo_codes_map = db::promo_codes::get_promo_codes_by_items(&db, &all_item_ids).await?;
187 187
188 188 // Promo codes are creator-scoped, fetch once
189 - let promo_codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?;
189 + let promo_codes = db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?;
190 190 let promo_codes_data: Vec<serde_json::Value> = promo_codes.iter().map(|pc| {
191 191 serde_json::json!({
192 192 "code": pc.code,
@@ -214,8 +214,8 @@ pub(super) async fn export_projects(
214 214
215 215 // Batch-load per-project data (blog posts, bundle maps)
216 216 let project_ids: Vec<db::ProjectId> = projects.iter().map(|p| p.id).collect();
217 - let blog_posts_map = db::blog_posts::get_blog_posts_by_projects(&state.db, &project_ids).await?;
218 - let bundle_pairs = db::bundles::get_bundle_maps_by_projects(&state.db, &project_ids).await?;
217 + let blog_posts_map = db::blog_posts::get_blog_posts_by_projects(&db, &project_ids).await?;
218 + let bundle_pairs = db::bundles::get_bundle_maps_by_projects(&db, &project_ids).await?;
219 219 let mut bundle_map: std::collections::HashMap<db::ItemId, Vec<db::ItemId>> =
220 220 std::collections::HashMap::new();
221 221 for (bundle_id, child_id) in &bundle_pairs {
@@ -379,9 +379,9 @@ pub(super) async fn export_projects(
379 379 }
380 380
381 381 // Collections (batch-loaded to avoid N+1)
382 - let collections = db::collections::get_collections_by_user(&state.db, user.id).await?;
382 + let collections = db::collections::get_collections_by_user(&db, user.id).await?;
383 383 let collection_ids: Vec<db::CollectionId> = collections.iter().map(|c| c.id).collect();
384 - let collection_items_map = db::collections::get_item_ids_by_collections(&state.db, &collection_ids).await?;
384 + let collection_items_map = db::collections::get_item_ids_by_collections(&db, &collection_ids).await?;
385 385 let mut collections_data = Vec::new();
386 386 for c in &collections {
387 387 let item_ids = collection_items_map.get(&c.id).cloned().unwrap_or_default();
@@ -397,7 +397,7 @@ pub(super) async fn export_projects(
397 397 }
398 398
399 399 // Custom domain
400 - let custom_domain = db::custom_domains::get_custom_domain_by_user(&state.db, user.id).await?;
400 + let custom_domain = db::custom_domains::get_custom_domain_by_user(&db, user.id).await?;
401 401 let custom_domain_data = custom_domain.map(|d| serde_json::json!({
402 402 "domain": d.domain,
403 403 "verified": d.verified,
@@ -433,12 +433,12 @@ pub(super) async fn export_projects(
433 433 /// Export all sales transactions as a downloadable CSV file.
434 434 #[tracing::instrument(skip_all, name = "exports::export_sales")]
435 435 pub(super) async fn export_sales(
436 - State(state): State<AppState>,
436 + State(db): State<PgPool>,
437 437 headers: HeaderMap,
438 438 AuthUser(user): AuthUser,
439 439 ) -> Result<Response> {
440 440 let is_htmx = is_htmx_request(&headers);
441 - let pool = state.db.clone();
441 + let pool = db.clone();
442 442 let uid = user.id;
443 443 let rx = spawn_paginated_csv(
444 444 "Date,Item ID,Item Title,Amount,Status,Buyer Email\n",
@@ -477,12 +477,12 @@ pub(super) async fn export_sales(
477 477 /// Export revenue splits as a downloadable CSV file.
478 478 #[tracing::instrument(skip_all, name = "exports::export_splits")]
479 479 pub(super) async fn export_splits(
480 - State(state): State<AppState>,
480 + State(db): State<PgPool>,
481 481 headers: HeaderMap,
482 482 AuthUser(user): AuthUser,
483 483 ) -> Result<Response> {
484 484 let is_htmx = is_htmx_request(&headers);
485 - let pool = state.db.clone();
485 + let pool = db.clone();
486 486 let uid = user.id;
487 487 let rx = spawn_paginated_csv(
488 488 "Date,Type,Direction,Recipient,Amount,Split %\n",
@@ -514,12 +514,12 @@ pub(super) async fn export_splits(
514 514 /// Export all purchase transactions as a downloadable CSV file.
515 515 #[tracing::instrument(skip_all, name = "exports::export_purchases")]
516 516 pub(super) async fn export_purchases(
517 - State(state): State<AppState>,
517 + State(db): State<PgPool>,
518 518 headers: HeaderMap,
519 519 AuthUser(user): AuthUser,
520 520 ) -> Result<Response> {
521 521 let is_htmx = is_htmx_request(&headers);
522 - let pool = state.db.clone();
522 + let pool = db.clone();
523 523 let uid = user.id;
524 524 let rx = spawn_paginated_csv(
525 525 "Date,Item ID,Item Title,Amount,Status\n",
@@ -574,12 +574,12 @@ pub(super) async fn export_purchases(
574 574 /// Export followers and subscribers as a downloadable CSV file.
575 575 #[tracing::instrument(skip_all, name = "exports::export_followers")]
576 576 pub(super) async fn export_followers(
577 - State(state): State<AppState>,
577 + State(db): State<PgPool>,
578 578 headers: HeaderMap,
579 579 AuthUser(user): AuthUser,
580 580 ) -> Result<Response> {
581 581 let is_htmx = is_htmx_request(&headers);
582 - let pool = state.db.clone();
582 + let pool = db.clone();
583 583 let uid = user.id;
584 584
585 585 // Two-section CSV (followers, then subscribers); each section pages
@@ -654,12 +654,12 @@ pub(super) async fn export_followers(
654 654 /// Export subscriptions as a downloadable CSV file with full detail.
655 655 #[tracing::instrument(skip_all, name = "exports::export_subscriptions")]
656 656 pub(super) async fn export_subscriptions(
657 - State(state): State<AppState>,
657 + State(db): State<PgPool>,
658 658 headers: HeaderMap,
659 659 AuthUser(user): AuthUser,
660 660 ) -> Result<Response> {
661 661 let is_htmx = is_htmx_request(&headers);
662 - let pool = state.db.clone();
662 + let pool = db.clone();
663 663 let uid = user.id;
664 664 let rx = spawn_paginated_csv(
665 665 "Project,Tier,Price,Username,Status,Period Start,Period End,Canceled At,Created At\n",
@@ -698,12 +698,12 @@ pub(super) async fn export_subscriptions(
698 698 /// Export buyer contacts (who opted to share their email) as CSV.
699 699 #[tracing::instrument(skip_all, name = "exports::export_contacts")]
700 700 pub(super) async fn export_contacts(
701 - State(state): State<AppState>,
701 + State(db): State<PgPool>,
702 702 headers: HeaderMap,
703 703 AuthUser(user): AuthUser,
704 704 ) -> Result<Response> {
705 705 let is_htmx = is_htmx_request(&headers);
706 - let pool = state.db.clone();
706 + let pool = db.clone();
707 707 let uid = user.id;
708 708 let rx = spawn_paginated_csv(
709 709 "Username,Email,Purchases,Total Spent,Last Purchase\n",
@@ -6,19 +6,25 @@ use axum::{
6 6 };
7 7 use uuid::Uuid;
8 8
9 + use sqlx::PgPool;
10 + use crate::config::Config;
11 + use crate::email::EmailClient;
12 + use crate::background::BackgroundTx;
13 +
9 14 use crate::{
10 15 auth::AuthUser,
11 16 db::{self, FollowTargetType, UserId},
12 17 error::{AppError, Result},
13 - helpers::spawn_email,
14 18 templates::{FollowButtonTemplate, TagFollowToggleTemplate},
15 - AppState,
16 19 };
17 20
18 21 /// POST /api/follow/{target_type}/{target_id}: follow a user or project.
19 22 #[tracing::instrument(skip_all, name = "follows::follow_target")]
20 23 pub(super) async fn follow_target(
21 - State(state): State<AppState>,
24 + State(db): State<PgPool>,
25 + State(config): State<Config>,
26 + State(email): State<EmailClient>,
27 + State(bg): State<BackgroundTx>,
22 28 AuthUser(user): AuthUser,
23 29 Path((target_type_str, target_id)): Path<(String, Uuid)>,
24 30 ) -> Result<Response> {
@@ -27,14 +33,14 @@ pub(super) async fn follow_target(
27 33 let target_type: FollowTargetType = target_type_str.parse()
28 34 .map_err(|_| AppError::BadRequest("Invalid target type".to_string()))?;
29 35
30 - validate_target(&state, target_type, target_id, user.id).await?;
36 + validate_target(&db, target_type, target_id, user.id).await?;
31 37
32 - db::follows::follow(&state.db, user.id, target_type, target_id).await?;
38 + db::follows::follow(&db, user.id, target_type, target_id).await?;
33 39
34 40 // Subscribe to project content mailing list (non-critical)
35 41 if target_type == FollowTargetType::Project
36 42 && let Err(e) = db::mailing_lists::subscribe_to_content_list(
37 - &state.db, db::ProjectId::from(target_id), user.id,
43 + &db, db::ProjectId::from(target_id), user.id,
38 44 ).await
39 45 {
40 46 tracing::warn!(project_id = %target_id, error = ?e, "failed to subscribe to content mailing list");
@@ -44,35 +50,38 @@ pub(super) async fn follow_target(
44 50 match target_type {
45 51 FollowTargetType::Tag => {} // no notification for tag follows
46 52 FollowTargetType::User => {
47 - if let Ok(Some(target_user)) = db::users::get_user_by_id(&state.db, UserId::from(target_id)).await
53 + if let Ok(Some(target_user)) = db::users::get_user_by_id(&db, UserId::from(target_id)).await
48 54 && target_user.notify_follower
49 55 {
50 - let follower_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten();
56 + let follower_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
51 57 let follower_username = follower_user.as_ref()
52 58 .map(|u| u.username.to_string())
53 59 .unwrap_or_else(|| "Someone".to_string());
54 60 let target_email = target_user.email.clone();
55 61 let target_name = target_user.display_name.clone();
56 62 let unsub_url = crate::email::generate_unsubscribe_url(
57 - &state.config.host_url, target_user.id, crate::email::UnsubscribeAction::Follower, &target_user.id.to_string(), &state.config.signing_secret,
63 + &config.host_url, target_user.id, crate::email::UnsubscribeAction::Follower, &target_user.id.to_string(), &config.signing_secret,
58 64 );
59 - spawn_email!(state, "follower notification", |email| {
60 - email.send_follower_notification(
65 + let email = email.clone();
66 + bg.spawn("follower notification", async move {
67 + if let Err(e) = email.send_follower_notification(
61 68 &target_email,
62 69 target_name.as_deref(),
63 70 &follower_username,
64 71 "you",
65 72 Some(&unsub_url),
66 - )
73 + ).await {
74 + tracing::error!(error = ?e, "failed to send follower notification");
75 + }
67 76 });
68 77 }
69 78 }
70 79 FollowTargetType::Project => {
71 - if let Ok(Some(project)) = db::projects::get_project_by_id(&state.db, db::ProjectId::from(target_id)).await
72 - && let Ok(Some(owner)) = db::users::get_user_by_id(&state.db, project.user_id).await
80 + if let Ok(Some(project)) = db::projects::get_project_by_id(&db, db::ProjectId::from(target_id)).await
81 + && let Ok(Some(owner)) = db::users::get_user_by_id(&db, project.user_id).await
73 82 && owner.notify_follower
74 83 {
75 - let follower_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten();
84 + let follower_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
76 85 let follower_username = follower_user.as_ref()
77 86 .map(|u| u.username.to_string())
78 87 .unwrap_or_else(|| "Someone".to_string());
@@ -80,16 +89,19 @@ pub(super) async fn follow_target(
80 89 let owner_email = owner.email.clone();
81 90 let owner_name = owner.display_name.clone();
82 91 let unsub_url = crate::email::generate_unsubscribe_url(
83 - &state.config.host_url, owner.id, crate::email::UnsubscribeAction::Follower, &owner.id.to_string(), &state.config.signing_secret,
92 + &config.host_url, owner.id, crate::email::UnsubscribeAction::Follower, &owner.id.to_string(), &config.signing_secret,
84 93 );
85 - spawn_email!(state, "follower notification", |email| {
86 - email.send_follower_notification(
94 + let email = email.clone();
95 + bg.spawn("follower notification", async move {
96 + if let Err(e) = email.send_follower_notification(
87 97 &owner_email,
88 98 owner_name.as_deref(),
89 99 &follower_username,
90 100 &context,
91 101 Some(&unsub_url),
92 - )
102 + ).await {
103 + tracing::error!(error = ?e, "failed to send follower notification");
104 + }
93 105 });
94 106 }
95 107 }
@@ -103,7 +115,7 @@ pub(super) async fn follow_target(
103 115 .into_response());
104 116 }
105 117
106 - let count = db::follows::get_follower_count(&state.db, target_type, target_id).await?;
118 + let count = db::follows::get_follower_count(&db, target_type, target_id).await?;
107 119
108 120 Ok(FollowButtonTemplate {
109 121 target_type: target_type.to_string(),
@@ -117,7 +129,7 @@ pub(super) async fn follow_target(
117 129 /// DELETE /api/follow/{target_type}/{target_id}: unfollow a user or project.
118 130 #[tracing::instrument(skip_all, name = "follows::unfollow_target")]
119 131 pub(super) async fn unfollow_target(
120 - State(state): State<AppState>,
132 + State(db): State<PgPool>,
121 133 AuthUser(user): AuthUser,
122 134 Path((target_type_str, target_id)): Path<(String, Uuid)>,
123 135 ) -> Result<Response> {
@@ -125,14 +137,14 @@ pub(super) async fn unfollow_target(
125 137 let target_type: FollowTargetType = target_type_str.parse()
126 138 .map_err(|_| AppError::BadRequest("Invalid target type".to_string()))?;
127 139
128 - validate_target(&state, target_type, target_id, user.id).await?;
140 + validate_target(&db, target_type, target_id, user.id).await?;
129 141
130 - db::follows::unfollow(&state.db, user.id, target_type, target_id).await?;
142 + db::follows::unfollow(&db, user.id, target_type, target_id).await?;
131 143
132 144 // Unsubscribe from all project mailing lists (non-critical)
133 145 if target_type == FollowTargetType::Project
134 146 && let Err(e) = db::mailing_lists::unsubscribe_from_project(
135 - &state.db, db::ProjectId::from(target_id), user.id,
147 + &db, db::ProjectId::from(target_id), user.id,
136 148 ).await
137 149 {
138 150 tracing::warn!(project_id = %target_id, error = ?e, "failed to unsubscribe from project mailing lists");
@@ -146,7 +158,7 @@ pub(super) async fn unfollow_target(
146 158 .into_response());
147 159 }
148 160
149 - let count = db::follows::get_follower_count(&state.db, target_type, target_id).await?;
161 + let count = db::follows::get_follower_count(&db, target_type, target_id).await?;
150 162
151 163 Ok(FollowButtonTemplate {
152 164 target_type: target_type.to_string(),
@@ -160,7 +172,7 @@ pub(super) async fn unfollow_target(
160 172 /// Validate that the target exists and prevent self-following for user targets.
161 173 /// Project self-following is allowed (creators may want to follow their own project feed).
162 174 async fn validate_target(
163 - state: &AppState,
175 + db: &PgPool,
164 176 target_type: FollowTargetType,
165 177 target_id: Uuid,
166 178 follower_id: UserId,
@@ -170,17 +182,17 @@ async fn validate_target(
170 182 if *follower_id == target_id {
171 183 return Err(AppError::BadRequest("Cannot follow yourself".to_string()));
172 184 }
173 - db::users::get_user_by_id(&state.db, UserId::from(target_id))
185 + db::users::get_user_by_id(db, UserId::from(target_id))
174 186 .await?
175 187 .ok_or(AppError::NotFound)?;
176 188 }
177 189 FollowTargetType::Project => {
178 - db::projects::get_project_by_id(&state.db, db::ProjectId::from(target_id))
190 + db::projects::get_project_by_id(db, db::ProjectId::from(target_id))
179 191 .await?
180 192 .ok_or(AppError::NotFound)?;
181 193 }
182 194 FollowTargetType::Tag => {
183 - db::tags::get_tag_by_id(&state.db, db::TagId::from(target_id))
195 + db::tags::get_tag_by_id(db, db::TagId::from(target_id))
184 196 .await?
185 197 .ok_or(AppError::NotFound)?;
186 198 }
@@ -13,7 +13,7 @@ use crate::auth::AuthUser;
13 13 use crate::db::{self, GitAccessTokenId};
14 14 use crate::error::{AppError, Result};
15 15 use crate::helpers::{hx_toast, is_htmx_request};
16 - use crate::AppState;
16 + use sqlx::PgPool;
17 17
18 18 #[derive(Debug, Deserialize)]
19 19 pub struct CreateTokenRequest {
@@ -29,17 +29,17 @@ pub struct CreateTokenRequest {
29 29 /// GET /api/users/me/git-tokens/list: HTMX partial listing the user's tokens.
30 30 #[tracing::instrument(skip_all, name = "git_tokens::list_html")]
31 31 pub(super) async fn list_html(
32 - State(state): State<AppState>,
32 + State(db): State<PgPool>,
33 33 AuthUser(user): AuthUser,
34 34 ) -> Result<impl IntoResponse> {
35 - let html = render_list(&state, user.id, None).await?;
35 + let html = render_list(&db, user.id, None).await?;
36 36 Ok(axum::response::Html(html))
37 37 }
38 38
39 39 /// POST /api/users/me/git-tokens: mint a new token. Returns the plaintext once.
40 40 #[tracing::instrument(skip_all, name = "git_tokens::create")]
41 41 pub(super) async fn create(
42 - State(state): State<AppState>,
42 + State(db): State<PgPool>,
43 43 headers: HeaderMap,
44 44 AuthUser(user): AuthUser,
45 45 Form(req): Form<CreateTokenRequest>,
@@ -70,10 +70,10 @@ pub(super) async fn create(
70 70 };
71 71
72 72 let (plaintext, hash) = crate::crypto::generate_git_token();
73 - db::git_access_tokens::create(&state.db, user.id, name, &hash, can_push, expires_at).await?;
73 + db::git_access_tokens::create(&db, user.id, name, &hash, can_push, expires_at).await?;
74 74
75 75 if is_htmx_request(&headers) {
76 - let html = render_list(&state, user.id, Some(&plaintext)).await?;
76 + let html = render_list(&db, user.id, Some(&plaintext)).await?;
77 77 return Ok((
78 78 [("HX-Trigger", hx_toast("Access token created", "success"))],
79 79 axum::response::Html(html),
@@ -88,17 +88,17 @@ pub(super) async fn create(
88 88 /// DELETE /api/users/me/git-tokens/{id}: revoke a token.
89 89 #[tracing::instrument(skip_all, name = "git_tokens::revoke")]
90 90 pub(super) async fn revoke(
91 - State(state): State<AppState>,
91 + State(db): State<PgPool>,
92 92 headers: HeaderMap,
93 93 AuthUser(user): AuthUser,
94 94 Path(id): Path<GitAccessTokenId>,
95 95 ) -> Result<Response> {
96 96 user.check_not_suspended()?;
97 - if !db::git_access_tokens::revoke(&state.db, id, user.id).await? {
97 + if !db::git_access_tokens::revoke(&db, id, user.id).await? {
98 98 return Err(AppError::NotFound);
99 99 }
100 100 if is_htmx_request(&headers) {
101 - let html = render_list(&state, user.id, None).await?;
101 + let html = render_list(&db, user.id, None).await?;
102 102 return Ok((
103 103 [("HX-Trigger", hx_toast("Access token revoked", "success"))],
104 104 axum::response::Html(html),
@@ -110,8 +110,8 @@ pub(super) async fn revoke(
110 110
111 111 /// Render the tokens list partial, optionally with a freshly-minted plaintext
112 112 /// shown once at the top.
113 - async fn render_list(state: &AppState, user_id: db::UserId, new_token: Option<&str>) -> Result<String> {
114 - let tokens = db::git_access_tokens::list_by_user(&state.db, user_id).await?;
113 + async fn render_list(db: &PgPool, user_id: db::UserId, new_token: Option<&str>) -> Result<String> {
114 + let tokens = db::git_access_tokens::list_by_user(db, user_id).await?;
115 115 let tokens: Vec<GitTokenView> = tokens.iter().map(GitTokenView::from).collect();
116 116 crate::helpers::render_fragment(&crate::templates::GitTokensListTemplate {
117 117 tokens,
@@ -11,10 +11,15 @@ use axum::{
11 11 };
12 12 use serde::{Deserialize, Serialize};
13 13
14 + use sqlx::PgPool;
15 + use crate::config::Config;
16 + use crate::email::EmailClient;
17 + use crate::background::BackgroundTx;
18 + use crate::{Billing, AppStorage, Integrations};
19 +
14 20 use crate::{
15 21 db::{self, Cents, ItemId},
16 22 error::{AppError, Result, ResultExt},
17 - AppState,
18 23 };
19 24
20 25 /// Request body for creating a guest checkout session.
@@ -39,12 +44,14 @@ struct CheckoutResponse {
39 44 /// or redirects to it.
40 45 #[tracing::instrument(skip_all, name = "guest_checkout::create")]
41 46 pub(super) async fn create_guest_checkout(
42 - State(state): State<AppState>,
47 + State(db): State<PgPool>,
48 + State(config): State<Config>,
49 + State(payments): State<Billing>,
43 50 Path(item_id): Path<ItemId>,
44 51 Json(body): Json<GuestCheckoutRequest>,
45 52 ) -> Result<Response> {
46 53 // Fetch item
47 - let item = db::items::get_item_by_id(&state.db, item_id)
54 + let item = db::items::get_item_by_id(&db, item_id)
48 55 .await?
49 56 .ok_or(AppError::NotFound)?;
50 57
@@ -53,10 +60,10 @@ pub(super) async fn create_guest_checkout(
53 60 }
54 61
55 62 // Fetch seller via project
56 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
63 + let project = db::projects::get_project_by_id(&db, item.project_id)
57 64 .await?
58 65 .ok_or(AppError::NotFound)?;
59 - let seller = db::users::get_user_by_id(&state.db, project.user_id)
66 + let seller = db::users::get_user_by_id(&db, project.user_id)
60 67 .await?
61 68 .ok_or(AppError::NotFound)?;
62 69
@@ -93,7 +100,7 @@ pub(super) async fn create_guest_checkout(
93 100 }
94 101 // Guests have no account, so no platform-wide Fan+ credit fallback (None).
95 102 if let Some(validated) =
96 - db::promo_codes::lookup_and_validate_promo(&state.db, seller_id, None, code_str).await?
103 + db::promo_codes::lookup_and_validate_promo(&db, seller_id, None, code_str).await?
97 104 {
98 105 use db::promo_codes::{PromoApplication, PromoIneligible};
99 106 match db::promo_codes::apply_promo_to_item(&validated, item_id, item.project_id, item.price_cents)? {
@@ -139,7 +146,7 @@ pub(super) async fn create_guest_checkout(
139 146 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
140 147 }
141 148
142 - let stripe = state.stripe.as_ref()
149 + let stripe = payments.stripe.as_ref()
143 150 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
144 151
145 152 // Reserve the promo code BEFORE creating the Stripe session or pending row,
@@ -149,7 +156,7 @@ pub(super) async fn create_guest_checkout(
149 156 // and a failed reservation could orphan the pending row's promo. Every
150 157 // failure path below now releases this reservation.
151 158 if let Some(pc_id) = promo_code_id {
152 - let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
159 + let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
153 160 .await
154 161 .context("reserve promo code use at guest checkout")?;
155 162 if !reserved {
@@ -160,13 +167,13 @@ pub(super) async fn create_guest_checkout(
160 167 // Release the reservation above on any failure path below (no-op if no promo).
161 168 let release_promo = || async {
162 169 if let Some(pc_id) = promo_code_id {
163 - db::promo_codes::release_use_count(&state.db, pc_id).await.ok();
170 + db::promo_codes::release_use_count(&db, pc_id).await.ok();
164 171 }
165 172 };
166 173
167 174 // Build URLs
168 - let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url);
169 - let cancel_url = format!("{}/i/{}", state.config.host_url, item_id);
175 + let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", config.host_url);
176 + let cancel_url = format!("{}/i/{}", config.host_url, item_id);
170 177
171 178 // Create guest checkout session
172 179 let checkout_params = crate::payments::GuestCheckoutParams {
@@ -195,7 +202,7 @@ pub(super) async fn create_guest_checkout(
195 202 // Release the promo and return an error (guests have no purchase page to
196 203 // redirect to, unlike the authenticated path).
197 204 match db::transactions::create_transaction(
198 - &state.db,
205 + &db,
199 206 &db::transactions::CreateTransactionParams {
200 207 buyer_id: None,
201 208 seller_id,
@@ -269,15 +276,16 @@ pub(super) async fn guest_checkout_preflight() -> Response {
269 276 /// No authentication required; the token is the proof of purchase.
270 277 #[tracing::instrument(skip_all, name = "guest_checkout::download")]
271 278 pub(super) async fn guest_download(
272 - State(state): State<AppState>,
279 + State(db): State<PgPool>,
280 + State(storage): State<AppStorage>,
273 281 Path(token): Path<db::DownloadToken>,
274 282 ) -> Result<Response> {
275 - let tx = db::transactions::get_transaction_by_download_token(&state.db, token)
283 + let tx = db::transactions::get_transaction_by_download_token(&db, token)
276 284 .await?
277 285 .ok_or(AppError::NotFound)?;
278 286
279 287 let item_id = tx.item_id.ok_or(AppError::NotFound)?;
280 - let item = db::items::get_item_by_id(&state.db, item_id)
288 + let item = db::items::get_item_by_id(&db, item_id)
281 289 .await?
282 290 .ok_or(AppError::NotFound)?;
283 291
@@ -290,7 +298,7 @@ pub(super) async fn guest_download(
290 298 // token that 404'd despite having paid (Run 15 fulfillment gap).
291 299 let (s3_key, scan_status) = match item.audio_s3_key.clone().or(item.video_s3_key.clone()) {
292 300 Some(key) => (key, item.scan_status),
293 - None => db::versions::get_versions_by_item(&state.db, item_id)
301 + None => db::versions::get_versions_by_item(&db, item_id)
294 302 .await?
295 303 .into_iter()
296 304 .filter_map(|v| v.s3_key.map(|k| (k, v.scan_status, v.is_current, v.created_at)))
@@ -310,7 +318,7 @@ pub(super) async fn guest_download(
310 318 return Err(AppError::NotFound);
311 319 }
312 320
313 - let s3 = state.storage.s3.as_ref()
321 + let s3 = storage.s3.as_ref()
314 322 .ok_or_else(|| AppError::ServiceUnavailable("File storage is not configured".to_string()))?;
315 323
316 324 let download_url = s3.presign_download(&crate::storage::S3Key::from_stored(&s3_key), Some(3600)).await?;
@@ -323,12 +331,13 @@ pub(super) async fn guest_download(
323 331 /// Attach a guest purchase to the authenticated user's account using a claim token.
324 332 #[tracing::instrument(skip_all, name = "guest_checkout::claim")]
325 333 pub(super) async fn claim_purchase(
326 - State(state): State<AppState>,
334 + State(db): State<PgPool>,
335 + State(integrations): State<Integrations>,
327 336 crate::auth::AuthUser(user): crate::auth::AuthUser,
328 337 Json(body): Json<ClaimRequest>,
329 338 ) -> Result<Response> {
330 339 user.check_not_sandbox()?;
331 - let tx = db::transactions::claim_guest_purchase(&state.db, body.claim_token, user.id)
340 + let tx = db::transactions::claim_guest_purchase(&db, body.claim_token, user.id)
332 341 .await?
333 342 .ok_or_else(|| AppError::BadRequest(
334 343 "Invalid or already-claimed token".to_string()
@@ -348,7 +357,7 @@ pub(super) async fn claim_purchase(
348 357 // once per purchase.
349 358 if let Some(item_id) = tx.item_id {
350 359 crate::routes::stripe::webhook::checkout_helpers::maybe_generate_license_key(
351 - &state.db, state.wam.as_ref(), item_id, user.id, tx.id,
360 + &db, integrations.wam.as_ref(), item_id, user.id, tx.id,
352 361 )
353 362 .await;
354 363 }
@@ -373,14 +382,17 @@ pub(super) struct FreeGuestClaimRequest {
373 382 /// and sends download + claim links via email. No Stripe involved.
374 383 #[tracing::instrument(skip_all, name = "guest_checkout::claim_free")]
375 384 pub(super) async fn claim_free_guest(
376 - State(state): State<AppState>,
385 + State(db): State<PgPool>,
386 + State(config): State<Config>,
387 + State(email_client): State<EmailClient>,
388 + State(bg): State<BackgroundTx>,
377 389 Path(item_id): Path<ItemId>,
378 390 Json(body): Json<FreeGuestClaimRequest>,
379 391 ) -> Result<Response> {
380 392 let email = db::Email::new(&body.email)
381 393 .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?;
382 394
383 - let item = db::items::get_item_by_id(&state.db, item_id)
395 + let item = db::items::get_item_by_id(&db, item_id)
384 396 .await?
385 397 .ok_or(AppError::NotFound)?;
386 398
@@ -389,10 +401,10 @@ pub(super) async fn claim_free_guest(
389 401 }
390 402
391 403 // Fetch seller
392 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
404 + let project = db::projects::get_project_by_id(&db, item.project_id)
393 405 .await?
394 406 .ok_or(AppError::NotFound)?;
395 - let seller = db::users::get_user_by_id(&state.db, project.user_id)
407 + let seller = db::users::get_user_by_id(&db, project.user_id)
396 408 .await?
397 409 .ok_or(AppError::NotFound)?;
398 410
@@ -409,7 +421,7 @@ pub(super) async fn claim_free_guest(
409 421
410 422 // Create completed transaction
411 423 let result = db::transactions::create_free_guest_transaction(
412 - &state.db,
424 + &db,
413 425 None,
414 426 seller.id,
415 427 item_id,
@@ -428,7 +440,7 @@ pub(super) async fn claim_free_guest(
428 440 }
429 441 Ok(_) => {
430 442 // Increment sales count
431 - let _ = db::items::increment_sales_count(&state.db, item_id).await;
443 + let _ = db::items::increment_sales_count(&db, item_id).await;
432 444 }
433 445 Err(e) => {
434 446 // Unique violation (already in library for existing user)
@@ -445,18 +457,18 @@ pub(super) async fn claim_free_guest(
445 457 }
446 458
447 459 // Send download email
448 - let host_url = &state.config.host_url;
460 + let host_url = &config.host_url;
449 461 let download_url = format!("{}/download/{}", host_url, download_token);
450 462 let claim_url = format!("{}/claim?token={}", host_url, claim_token);
451 463
452 464 // Always send the claim link — there's no auto-attach to skip it for.
453 465 {
454 - let email_client = state.email.clone();
466 + let email_client = email_client.clone();
455 467 let email_addr = email.clone().into_inner();
456 468 let item_title = item.title.clone();
457 469 let dl_url = download_url.clone();
458 470 let cl_url = claim_url.clone();
459 - state.bg.spawn("free guest claim email", async move {
471 + bg.spawn("free guest claim email", async move {
460 472 if let Err(e) = email_client.send_guest_purchase_confirmation(
461 473 &email_addr, &item_title, "Free", &dl_url, &cl_url,
462 474 ).await {
@@ -9,12 +9,14 @@ use base64::Engine;
9 9 use serde::Deserialize;
10 10 use serde_json::json;
11 11
12 + use sqlx::PgPool;
13 + use crate::background::BackgroundTx;
14 +
12 15 use crate::{
13 16 auth::AuthUser,
14 17 db::{self, ImportJobId, ProjectId},
15 18 error::{AppError, Result},
16 19 import::{self, ColumnMapping, ImportSource},
17 - AppState,
18 20 };
19 21
20 22 /// Maximum CSV size: 10 MB (base64-encoded).
@@ -35,7 +37,8 @@ pub(super) struct StartImportRequest {
35 37 /// to parse the CSV and run the import pipeline.
36 38 #[tracing::instrument(skip_all, name = "imports::start_import")]
37 39 pub(super) async fn start_import(
38 - State(state): State<AppState>,
40 + State(db): State<PgPool>,
41 + State(bg): State<BackgroundTx>,
39 42 AuthUser(user): AuthUser,
40 43 Json(req): Json<StartImportRequest>,
41 44 ) -> Result<impl IntoResponse> {
@@ -43,7 +46,7 @@ pub(super) async fn start_import(
43 46 user.check_not_sandbox()?;
44 47
45 48 // Validate project ownership
46 - super::verify_project_ownership(&state.db, req.project_id, user.id).await?;
49 + super::verify_project_ownership(&db, req.project_id, user.id).await?;
47 50
48 51 // Only generic_csv is currently supported
49 52 if req.source != ImportSource::GenericCsv {
@@ -71,7 +74,7 @@ pub(super) async fn start_import(
71 74
72 75 // Create import job
73 76 let job = db::imports::create_import_job(
74 - &state.db,
77 + &db,
75 78 user.id,
76 79 req.project_id,
77 80 req.source,
@@ -80,7 +83,7 @@ pub(super) async fn start_import(
80 83 .await?;
81 84
82 85 let job_id = job.id;
83 - let pool = state.db.clone();
86 + let pool = db.clone();
84 87 let project_id = req.project_id;
85 88 let user_id = user.id;
86 89
@@ -89,7 +92,7 @@ pub(super) async fn start_import(
89 92 // per-item write loop against the shared 25-connection pool, starving request
90 93 // handlers to their acquire timeout. `state.bg` caps concurrent execution and
91 94 // queues the rest (Run 21 perf, chronic import N+1/uncapped-spawn).
92 - state.bg.spawn("import-pipeline", async move {
95 + bg.spawn("import-pipeline", async move {
93 96 if let Err(e) = import::pipeline::run_import(
94 97 &pool,
95 98 job_id,
@@ -110,13 +113,13 @@ pub(super) async fn start_import(
110 113 /// Get the status and progress of an import job.
111 114 #[tracing::instrument(skip_all, name = "imports::get_import_status")]
112 115 pub(super) async fn get_import_status(
113 - State(state): State<AppState>,
116 + State(db): State<PgPool>,
114 117 AuthUser(user): AuthUser,
115 118 Path(id): Path<String>,
116 119 ) -> Result<impl IntoResponse> {
117 120 let job_id: ImportJobId = id.parse().map_err(|_| AppError::NotFound)?;
118 121
119 - let job = db::imports::get_import_job(&state.db, job_id, user.id)
122 + let job = db::imports::get_import_job(&db, job_id, user.id)
120 123 .await?
121 124 .ok_or(AppError::NotFound)?;
122 125
@@ -137,10 +140,10 @@ pub(super) async fn get_import_status(
137 140 /// List all import jobs for the authenticated user.
138 141 #[tracing::instrument(skip_all, name = "imports::list_imports")]
139 142 pub(super) async fn list_imports(
140 - State(state): State<AppState>,
143 + State(db): State<PgPool>,
141 144 AuthUser(user): AuthUser,
142 145 ) -> Result<impl IntoResponse> {
143 - let jobs = db::imports::list_import_jobs(&state.db, user.id).await?;
146 + let jobs = db::imports::list_import_jobs(&db, user.id).await?;
144 147
145 148 let data: Vec<serde_json::Value> = jobs
146 149 .into_iter()
@@ -11,6 +11,9 @@ use axum::{
11 11 use chrono::{DateTime, Datelike, Utc};
12 12 use serde::{Deserialize, Serialize};
13 13
14 + use sqlx::PgPool;
15 + use crate::config::Config;
16 +
14 17 use crate::{
15 18 auth::AuthUser,
16 19 db::{self, ItemId, KeyCode, LicenseKeyId},
@@ -20,7 +23,6 @@ use crate::{
20 23 types::LicenseKeyRow,
21 24 types::ListResponse,
22 25 validation,
23 - AppState,
24 26 };
25 27 use jsonwebtoken::{encode, EncodingKey, Header};
26 28
@@ -112,7 +114,7 @@ pub(crate) struct ValidateKeyRequest {
112 114 )]
113 115 #[tracing::instrument(skip_all, name = "license_keys::validate_key")]
114 116 pub(super) async fn validate_key(
115 - State(state): State<AppState>,
117 + State(db): State<PgPool>,
116 118 Json(req): Json<ValidateKeyRequest>,
117 119 ) -> Result<impl IntoResponse> {
118 120 // key is auto-validated by KeyCode deserialization
@@ -122,7 +124,7 @@ pub(super) async fn validate_key(
122 124 }
123 125
124 126 // Look up key
125 - let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? {
127 + let key = match db::license_keys::get_license_key_by_code(&db, &req.key).await? {
126 128 Some(k) => k,
127 129 None => return Ok(Json(ValidateKeyResponse {
128 130 valid: false,
@@ -143,13 +145,13 @@ pub(super) async fn validate_key(
143 145 }
144 146
145 147 // Check if already activated on this machine (fast path, no lock needed)
146 - if let Some(activation) = db::license_keys::get_activation(&state.db, key.id, &req.machine_id).await?
148 + if let Some(activation) = db::license_keys::get_activation(&db, key.id, &req.machine_id).await?
147 149 && activation.is_active
148 150 {
149 - db::license_keys::touch_activation(&state.db, activation.id).await?;
151 + db::license_keys::touch_activation(&db, activation.id).await?;
150 152 // Report the authoritative denormalized count, not the pre-lookup read,
151 153 // so a concurrent activation on another machine isn't reflected stale.
152 - let activation_count = db::license_keys::get_activation_count(&state.db, key.id).await?;
154 + let activation_count = db::license_keys::get_activation_count(&db, key.id).await?;
153 155 return Ok(Json(ValidateKeyResponse {
154 156 valid: true,
155 157 activated: None,
@@ -165,7 +167,7 @@ pub(super) async fn validate_key(
165 167
166 168 // Atomically check limit and create activation (serialized via FOR UPDATE)
167 169 let activation = db::license_keys::try_create_activation(
168 - &state.db,
170 + &db,
169 171 key.id,
170 172 &req.machine_id,
171 173 req.label.as_deref(),
@@ -183,7 +185,7 @@ pub(super) async fn validate_key(
183 185 // try_create_activation recomputed the denormalized count under the row
184 186 // lock; read it back rather than guessing `+ 1` (which is wrong if another
185 187 // machine activated concurrently).
186 - let activation_count = db::license_keys::get_activation_count(&state.db, key.id).await?;
188 + let activation_count = db::license_keys::get_activation_count(&db, key.id).await?;
187 189 Ok(Json(ValidateKeyResponse {
188 190 valid: true,
189 191 activated: Some(true),
@@ -217,12 +219,12 @@ pub(crate) struct DeactivateKeyRequest {
217 219 )]
218 220 #[tracing::instrument(skip_all, name = "license_keys::deactivate_key")]
219 221 pub(super) async fn deactivate_key(
220 - State(state): State<AppState>,
222 + State(db): State<PgPool>,
221 223 Json(req): Json<DeactivateKeyRequest>,
222 224 ) -> Result<impl IntoResponse> {
223 225 validation::validate_machine_id(&req.machine_id)?;
224 226
225 - let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? {
227 + let key = match db::license_keys::get_license_key_by_code(&db, &req.key).await? {
226 228 Some(k) => k,
227 229 None => return Ok(Json(DeactivateKeyResponse {
228 230 success: false,
@@ -230,7 +232,7 @@ pub(super) async fn deactivate_key(
230 232 })),
231 233 };
232 234
233 - let deactivated = db::license_keys::deactivate_machine(&state.db, key.id, &req.machine_id).await?;
235 + let deactivated = db::license_keys::deactivate_machine(&db, key.id, &req.machine_id).await?;
234 236
235 237 Ok(Json(DeactivateKeyResponse {
236 238 success: deactivated,
@@ -240,8 +242,8 @@ pub(super) async fn deactivate_key(
240 242
241 243 /// Compute the status response for a key code. Shared by the GET-path form and
242 244 /// the POST-body form so they can't drift.
243 - async fn resolve_key_status(state: &AppState, key_code: &KeyCode) -> Result<KeyStatusResponse> {
244 - let key = match db::license_keys::get_license_key_by_code(&state.db, key_code).await? {
245 + async fn resolve_key_status(db: &PgPool, key_code: &KeyCode) -> Result<KeyStatusResponse> {
246 + let key = match db::license_keys::get_license_key_by_code(db, key_code).await? {
245 247 Some(k) => k,
246 248 None => return Ok(KeyStatusResponse {
247 249 valid: false,
@@ -301,10 +303,10 @@ pub(crate) struct KeyStatusRequest {
301 303 )]
302 304 #[tracing::instrument(skip_all, name = "license_keys::key_status_post")]
303 305 pub(super) async fn key_status_post(
304 - State(state): State<AppState>,
306 + State(db): State<PgPool>,
305 307 Json(req): Json<KeyStatusRequest>,
306 308 ) -> Result<impl IntoResponse> {
307 - Ok(Json(resolve_key_status(&state, &req.key).await?))
309 + Ok(Json(resolve_key_status(&db, &req.key).await?))
308 310 }
309 311
310 312 /// Quick validity check without activating.
@@ -324,11 +326,11 @@ pub(super) async fn key_status_post(
324 326 )]
325 327 #[tracing::instrument(skip_all, name = "license_keys::key_status")]
326 328 pub(super) async fn key_status(
327 - State(state): State<AppState>,
329 + State(db): State<PgPool>,
328 330 Path(key_code): Path<String>,
329 331 ) -> Result<impl IntoResponse> {
330 332 let key_code = KeyCode::new(&key_code)?;
331 - Ok(Json(resolve_key_status(&state, &key_code).await?))
333 + Ok(Json(resolve_key_status(&db, &key_code).await?))
332 334 }
333 335
334 336 // =============================================================================
@@ -347,7 +349,7 @@ pub struct UpdateLicenseSettingsForm {
347 349 /// Toggle license keys and set default activation limit for an item.
348 350 #[tracing::instrument(skip_all, name = "license_keys::update_license_settings")]
349 351 pub(super) async fn update_license_settings(
350 - State(state): State<AppState>,
352 + State(db): State<PgPool>,
351 353 headers: HeaderMap,
352 354 AuthUser(user): AuthUser,
353 355 Path(id): Path<ItemId>,
@@ -355,10 +357,10 @@ pub(super) async fn update_license_settings(
355 357 ) -> Result<Response> {
356 358 user.check_not_suspended()?;
357 359
358 - verify_item_ownership(&state.db, id, user.id).await?;
360 + verify_item_ownership(&db, id, user.id).await?;
359 361
360 362 let enable = req.enable_license_keys.is_some();
361 - db::items::update_item_license_settings(&state.db, id, user.id, enable, req.default_max_activations).await?;
363 + db::items::update_item_license_settings(&db, id, user.id, enable, req.default_max_activations).await?;
362 364
363 365 // License text preset
364 366 let preset_str = req.license_preset.as_deref().filter(|s| !s.is_empty());
@@ -379,10 +381,10 @@ pub(super) async fn update_license_settings(
379 381 } else {
380 382 None
381 383 };
382 - db::items::update_item_license_text(&state.db, id, user.id, Some(preset_key), custom_text).await?;
384 + db::items::update_item_license_text(&db, id, user.id, Some(preset_key), custom_text).await?;
383 385 } else {
384 386 // Clear license
385 - db::items::update_item_license_text(&state.db, id, user.id, None, None).await?;
387 + db::items::update_item_license_text(&db, id, user.id, None, None).await?;
386 388 }
387 389
388 390 if is_htmx_request(&headers) {
@@ -404,7 +406,7 @@ pub struct GenerateKeyForm {
404 406 /// Generate a new license key for an item (creator dashboard).
405 407 #[tracing::instrument(skip_all, name = "license_keys::generate_key")]
406 408 pub(super) async fn generate_key(
407 - State(state): State<AppState>,
409 + State(db): State<PgPool>,
408 410 headers: HeaderMap,
409 411 AuthUser(user): AuthUser,
410 412 Path(item_id): Path<ItemId>,
@@ -412,7 +414,7 @@ pub(super) async fn generate_key(
412 414 ) -> Result<Response> {
413 415 user.check_not_suspended()?;
414 416
415 - let (item, _project) = verify_item_ownership(&state.db, item_id, user.id).await?;
417 + let (item, _project) = verify_item_ownership(&db, item_id, user.id).await?;
416 418
417 419 if !item.enable_license_keys {
418 420 return Err(AppError::BadRequest(
@@ -426,7 +428,7 @@ pub(super) async fn generate_key(
426 428 // Cap at 1000 manually-generated keys per item, enforced atomically under an
427 429 // item-row lock so concurrent generates can't race past it (fuzz 2026-07-06 C6-1).
428 430 let _key = match db::license_keys::create_manual_key_capped(
429 - &state.db,
431 + &db,
430 432 item_id,
431 433 user.id,
432 434 &key_code,
@@ -444,7 +446,7 @@ pub(super) async fn generate_key(
444 446 };
445 447
446 448 if is_htmx_request(&headers) {
447 - let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?;
449 + let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?;
448 450 return Ok(ItemLicenseKeysTemplate {
449 451 license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(),
450 452 }
@@ -463,14 +465,14 @@ pub(super) async fn generate_key(
463 465 /// List all license keys for an item (creator dashboard).
464 466 #[tracing::instrument(skip_all, name = "license_keys::list_keys")]
465 467 pub(super) async fn list_keys(
466 - State(state): State<AppState>,
468 + State(db): State<PgPool>,
467 469 headers: HeaderMap,
468 470 AuthUser(user): AuthUser,
469 471 Path(item_id): Path<ItemId>,
470 472 ) -> Result<Response> {
471 - verify_item_ownership(&state.db, item_id, user.id).await?;
473 + verify_item_ownership(&db, item_id, user.id).await?;
472 474
473 - let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?;
475 + let keys = db::license_keys::get_license_keys_by_item(&db, item_id).await?;
474 476
475 477 if is_htmx_request(&headers) {
476 478 return Ok(ItemLicenseKeysTemplate {
@@ -492,23 +494,23 @@ pub(super) async fn list_keys(
492 494 /// Revoke a license key (creator dashboard).
493 495 #[tracing::instrument(skip_all, name = "license_keys::revoke_key")]
494 496 pub(super) async fn revoke_key(
495 - State(state): State<AppState>,
497 + State(db): State<PgPool>,
496 498 headers: HeaderMap,
497 499 AuthUser(user): AuthUser,
498 500 Path(key_id): Path<LicenseKeyId>,
499 501 ) -> Result<Response> {
500 502 user.check_not_suspended()?;
501 503
502 - let key = db::license_keys::get_license_key_by_id_unchecked(&state.db, key_id)
504 + let key = db::license_keys::get_license_key_by_id_unchecked(&db, key_id)
503 505 .await?
504 506 .ok_or(AppError::NotFound)?;
505 507
506 - verify_item_ownership(&state.db, key.item_id, user.id).await?;
508 + verify_item_ownership(&db, key.item_id, user.id).await?;
507 509
508 - db::license_keys::revoke_license_key(&state.db, key_id).await?;
510 + db::license_keys::revoke_license_key(&db, key_id).await?;
509 511
510 512 if is_htmx_request(&headers) {
511 - let keys = db::license_keys::get_license_keys_by_item(&state.db, key.item_id).await?;
513 + let keys = db::license_keys::get_license_keys_by_item(&db, key.item_id).await?;
512 514 return Ok((
513 515 [("HX-Trigger", hx_toast("License key revoked", "success"))],
514 516 ItemLicenseKeysTemplate {
@@ -579,12 +581,13 @@ const LICENSE_VERIFY_EXPIRY_SECS: i64 = 7 * 24 * 3600;
579 581 )]
580 582 #[tracing::instrument(skip_all, name = "license_keys::license_verify")]
581 583 pub(super) async fn license_verify(
582 - State(state): State<AppState>,
584 + State(db): State<PgPool>,
585 + State(config): State<Config>,
583 586 Json(req): Json<LicenseVerifyRequest>,
584 587 ) -> Result<impl IntoResponse> {
585 588 validation::validate_machine_id(&req.machine_fingerprint)?;
586 589
587 - let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? {
590 + let key = match db::license_keys::get_license_key_by_code(&db, &req.key).await? {
588 591 Some(k) => k,
589 592 None => {
590 593 return Ok(Json(LicenseVerifyResponse {
@@ -606,10 +609,10 @@ pub(super) async fn license_verify(
606 609 }
607 610
608 611 // Check if the project has license verification enabled
609 - let item = db::items::get_item_by_id(&state.db, key.item_id)
612 + let item = db::items::get_item_by_id(&db, key.item_id)
610 613 .await?
611 614 .ok_or(AppError::NotFound)?;
612 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
615 + let project = db::projects::get_project_by_id(&db, item.project_id)
613 616 .await?
614 617 .ok_or(AppError::NotFound)?;
615 618
@@ -624,14 +627,14 @@ pub(super) async fn license_verify(
624 627
625 628 // Try to activate (re-activation on same machine is always OK)
626 629 if let Some(activation) =
627 - db::license_keys::get_activation(&state.db, key.id, &req.machine_fingerprint).await?
630 + db::license_keys::get_activation(&db, key.id, &req.machine_fingerprint).await?
628 631 {
629 632 if activation.is_active {
630 - db::license_keys::touch_activation(&state.db, activation.id).await?;
633 + db::license_keys::touch_activation(&db, activation.id).await?;
631 634 } else {
632 635 // Re-activate a previously deactivated machine
633 636 let reactivated = db::license_keys::try_create_activation(
634 - &state.db,
637 + &db,
635 638 key.id,
636 639 &req.machine_fingerprint,
637 640 None,
@@ -649,7 +652,7 @@ pub(super) async fn license_verify(
649 652 } else {
650 653 // New activation
651 654 let activation = db::license_keys::try_create_activation(
652 - &state.db,
655 + &db,
653 656 key.id,
654 657 &req.machine_fingerprint,
655 658 None,
@@ -678,7 +681,7 @@ pub(super) async fn license_verify(
678 681 let token = encode(
679 682 &Header::default(),
680 683 &claims,
681 - &EncodingKey::from_secret(state.config.signing_secret.as_bytes()),
684 + &EncodingKey::from_secret(config.signing_secret.as_bytes()),
682 685 )
683 686 .context("jwt encode")?;
684 687
@@ -710,12 +713,12 @@ pub(crate) struct LicenseDeactivateRequest {
710 713 )]
711 714 #[tracing::instrument(skip_all, name = "license_keys::license_deactivate")]
712 715 pub(super) async fn license_deactivate(
713 - State(state): State<AppState>,
716 + State(db): State<PgPool>,
714 717 Json(req): Json<LicenseDeactivateRequest>,
715 718 ) -> Result<impl IntoResponse> {
716 719 validation::validate_machine_id(&req.machine_fingerprint)?;
717 720
718 - let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? {
721 + let key = match db::license_keys::get_license_key_by_code(&db, &req.key).await? {
719 722 Some(k) => k,
720 723 None => {
721 724 return Ok(Json(DeactivateKeyResponse {
@@ -726,7 +729,7 @@ pub(super) async fn license_deactivate(
726 729 };
727 730
728 731 let deactivated =
729 - db::license_keys::deactivate_machine(&state.db, key.id, &req.machine_fingerprint).await?;
732 + db::license_keys::deactivate_machine(&db, key.id, &req.machine_fingerprint).await?;
730 733
731 734 Ok(Json(DeactivateKeyResponse {
732 735 success: deactivated,
@@ -755,12 +758,12 @@ pub(super) async fn license_deactivate(
755 758 )]
756 759 #[tracing::instrument(skip_all, name = "license_keys::license_text")]
757 760 pub(super) async fn license_text(
758 - State(state): State<AppState>,
761 + State(db): State<PgPool>,
759 762 Path(item_id): Path<ItemId>,
760 763 ) -> Result<Response> {
761 764 use crate::license_templates::{render_license_text, LicensePreset};
762 765
763 - let item = db::items::get_item_by_id(&state.db, item_id)
766 + let item = db::items::get_item_by_id(&db, item_id)
764 767 .await?
765 768 .ok_or(AppError::NotFound)?;
766 769
@@ -770,10 +773,10 @@ pub(super) async fn license_text(
770 773 .map_err(|_| AppError::NotFound)?;
771 774
772 775 // Get owner display name
773 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
776 + let project = db::projects::get_project_by_id(&db, item.project_id)
774 777 .await?
775 778 .ok_or(AppError::NotFound)?;
776 - let user = db::users::get_user_by_id(&state.db, project.user_id)
779 + let user = db::users::get_user_by_id(&db, project.user_id)
777 780 .await?
778 781 .ok_or(AppError::NotFound)?;
779 782
@@ -8,6 +8,8 @@ use axum::{
8 8 };
9 9 use serde::{Deserialize, Serialize};
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::AuthUser,
13 15 db::{self, CustomLinkId},
@@ -15,7 +17,6 @@ use crate::{
15 17 helpers::{htmx_toast_response, is_htmx_request},
16 18 templates::LinkRowTemplate,
17 19 validation,
18 - AppState,
19 20 };
20 21
21 22 // =============================================================================
@@ -43,7 +44,7 @@ pub struct LinkResponse {
43 44 /// Create a new custom profile link for the authenticated user.
44 45 #[tracing::instrument(skip_all, name = "links::create_link")]
45 46 pub(super) async fn create_link(
46 - State(state): State<AppState>,
47 + State(db): State<PgPool>,
47 48 headers: HeaderMap,
48 49 AuthUser(user): AuthUser,
49 50 Form(req): Form<CreateLinkRequest>,
@@ -54,7 +55,7 @@ pub(super) async fn create_link(
54 55 validation::validate_link_title(&req.title)?;
55 56
56 57 let link = db::custom_links::create_custom_link(
57 - &state.db,
58 + &db,
58 59 user.id,
59 60 &req.url,
60 61 &req.title,
@@ -90,7 +91,7 @@ pub struct UpdateLinkRequest {
90 91 /// Update an existing custom profile link owned by the user.
91 92 #[tracing::instrument(skip_all, name = "links::update_link")]
92 93 pub(super) async fn update_link(
93 - State(state): State<AppState>,
94 + State(db): State<PgPool>,
94 95 AuthUser(user): AuthUser,
95 96 Path(id): Path<CustomLinkId>,
96 97 Json(req): Json<UpdateLinkRequest>,
@@ -106,12 +107,12 @@ pub(super) async fn update_link(
106 107 }
107 108
108 109 // Verify ownership with efficient single-row check
109 - if !db::custom_links::user_owns_custom_link(&state.db, user.id, id).await? {
110 + if !db::custom_links::user_owns_custom_link(&db, user.id, id).await? {
110 111 return Err(AppError::NotFound);
111 112 }
112 113
113 114 let link = db::custom_links::update_custom_link(
114 - &state.db,
115 + &db,
115 116 id,
116 117 user.id,
117 118 req.url.as_deref(),
@@ -132,18 +133,18 @@ pub(super) async fn update_link(
132 133 /// Delete a custom profile link owned by the user.
133 134 #[tracing::instrument(skip_all, name = "links::delete_link")]
134 135 pub(super) async fn delete_link(
135 - State(state): State<AppState>,
136 + State(db): State<PgPool>,
136 137 headers: HeaderMap,
137 138 AuthUser(user): AuthUser,
138 139 Path(id): Path<CustomLinkId>,
139 140 ) -> Result<Response> {
140 141 user.check_not_suspended()?;
141 142 // Verify ownership with efficient single-row check
142 - if !db::custom_links::user_owns_custom_link(&state.db, user.id, id).await? {
143 + if !db::custom_links::user_owns_custom_link(&db, user.id, id).await? {
143 144 return Err(AppError::NotFound);
144 145 }
145 146
146 - db::custom_links::delete_custom_link(&state.db, id, user.id).await?;
147 + db::custom_links::delete_custom_link(&db, id, user.id).await?;
147 148
148 149 if is_htmx_request(&headers) {
149 150 return Ok(htmx_toast_response("Link removed", "success").into_response());
@@ -161,11 +162,11 @@ pub struct ReorderLinksRequest {
161 162 /// Reorder the authenticated user's custom profile links.
162 163 #[tracing::instrument(skip_all, name = "links::reorder_links")]
163 164 pub(super) async fn reorder_links(
164 - State(state): State<AppState>,
165 + State(db): State<PgPool>,
165 166 AuthUser(user): AuthUser,
166 167 Json(req): Json<ReorderLinksRequest>,
167 168 ) -> Result<impl IntoResponse> {
168 169 user.check_not_suspended()?;
169 - db::custom_links::reorder_custom_links(&state.db, user.id, &req.link_ids).await?;
170 + db::custom_links::reorder_custom_links(&db, user.id, &req.link_ids).await?;
170 171 Ok(StatusCode::NO_CONTENT)
171 172 }
@@ -9,13 +9,14 @@ use serde::Deserialize;
9 9 use tower_sessions::Session;
10 10 use webauthn_rs::prelude::*;
11 11
12 + use sqlx::PgPool;
13 +
12 14 use crate::{
13 15 auth::{verify_password_async, AuthUser},
14 16 db::{self, PasskeyId},
15 17 error::{AppError, Result, ResultExt},
16 18 helpers::hx_toast,
17 19 templates::{PasskeyListTemplate, PasskeyDisplay},
18 - AppState,
19 20 };
20 21
21 22 /// Session key for in-flight passkey registration challenge state.
@@ -34,7 +35,8 @@ pub struct RegisterStartForm {
34 35 /// Requires password confirmation to prevent session-theft → persistent backdoor.
35 36 #[tracing::instrument(skip_all, name = "passkeys::register_start")]
36 37 pub(super) async fn register_start(
37 - State(state): State<AppState>,
38 + State(db): State<PgPool>,
39 + State(webauthn): State<std::sync::Arc<Webauthn>>,
38 40 AuthUser(user): AuthUser,
39 41 session: Session,
40 42 Form(form): Form<RegisterStartForm>,
@@ -42,14 +44,14 @@ pub(super) async fn register_start(
42 44 user.check_not_sandbox()?;
43 45
44 46 // Require password confirmation (matches delete flow)
45 - let db_user = db::users::get_user_by_id(&state.db, user.id)
47 + let db_user = db::users::get_user_by_id(&db, user.id)
46 48 .await?
47 49 .ok_or(AppError::Unauthorized)?;
48 50 if !verify_password_async(form.password.clone(), db_user.password_hash.clone()).await? {
49 51 return Err(AppError::BadRequest("Incorrect password".to_string()));
50 52 }
51 53 // Enforce registration cap
52 - let count = db::passkeys::count_passkeys(&state.db, user.id).await?;
54 + let count = db::passkeys::count_passkeys(&db, user.id).await?;
53 55 if count >= MAX_PASSKEYS_PER_USER {
54 56 return Err(AppError::BadRequest(format!(
55 57 "Maximum of {} passkeys reached",
@@ -58,7 +60,7 @@ pub(super) async fn register_start(
58 60 }
59 61
60 62 // Load existing credentials to exclude (prevents re-registration of the same authenticator)
61 - let existing_json = db::passkeys::get_passkey_credentials(&state.db, user.id).await?;
63 + let existing_json = db::passkeys::get_passkey_credentials(&db, user.id).await?;
62 64 let exclude_creds: Vec<CredentialID> = existing_json
63 65 .iter()
64 66 .filter_map(|j| serde_json::from_value::<Passkey>(j.clone()).ok())
@@ -71,8 +73,7 @@ pub(super) async fn register_start(
71 73 Some(exclude_creds)
72 74 };
73 75
74 - let (ccr, reg_state) = state
75 - .webauthn
76 + let (ccr, reg_state) = webauthn
76 77 .start_passkey_registration(
77 78 *user.id.as_uuid(),
78 79 user.username.as_ref(),
@@ -93,7 +94,8 @@ pub(super) async fn register_start(
93 94 /// Finish passkey registration: verify attestation, store credential.
94 95 #[tracing::instrument(skip_all, name = "passkeys::register_finish")]
95 96 pub(super) async fn register_finish(
96 - State(state): State<AppState>,
97 + State(db): State<PgPool>,
98 + State(webauthn): State<std::sync::Arc<Webauthn>>,
97 99 AuthUser(user): AuthUser,
98 100 session: Session,
99 101 Json(reg): Json<RegisterPublicKeyCredential>,
@@ -110,8 +112,7 @@ pub(super) async fn register_finish(
110 112 .await
111 113 .ok();
112 114
113 - let passkey = state
114 - .webauthn
115 + let passkey = webauthn
115 116 .finish_passkey_registration(&reg, &reg_state)
116 117 .map_err(|e| AppError::BadRequest(format!("Registration failed: {}", e)))?;
117 118
@@ -119,7 +120,7 @@ pub(super) async fn register_finish(
119 120 .context("serialize passkey")?;
120 121 let credential_id = passkey.cred_id().to_vec();
121 122
122 - db::passkeys::create_passkey(&state.db, user.id, "Passkey", &credential_json, &credential_id)
123 + db::passkeys::create_passkey(&db, user.id, "Passkey", &credential_json, &credential_id)
123 124 .await
124 125 .map_err(|e| crate::helpers::map_unique_violation(e, "This passkey is already registered"))?;
125 126
@@ -127,7 +128,7 @@ pub(super) async fn register_finish(
127 128
128 129 Ok((
129 130 [("HX-Trigger", hx_toast("Passkey registered", "success"))],
130 - list_inner(&state, user.id).await?,
131 + list_inner(&db, user.id).await?,
131 132 )
132 133 .into_response())
133 134 }
@@ -135,15 +136,15 @@ pub(super) async fn register_finish(
135 136 /// List passkeys as HTMX partial.
136 137 #[tracing::instrument(skip_all, name = "passkeys::list")]
137 138 pub(super) async fn list(
138 - State(state): State<AppState>,
139 + State(db): State<PgPool>,
139 140 AuthUser(user): AuthUser,
140 141 ) -> Result<Response> {
141 - Ok(list_inner(&state, user.id).await?.into_response())
142 + Ok(list_inner(&db, user.id).await?.into_response())
142 143 }
143 144
144 145 /// Inner helper to build the passkey list template.
145 - async fn list_inner(state: &AppState, user_id: db::UserId) -> Result<PasskeyListTemplate> {
146 - let passkeys = db::passkeys::list_passkeys(&state.db, user_id).await?;
146 + async fn list_inner(db: &PgPool, user_id: db::UserId) -> Result<PasskeyListTemplate> {
147 + let passkeys = db::passkeys::list_passkeys(db, user_id).await?;
147 148 let passkeys = passkeys
148 149 .into_iter()
149 150 .map(|p| PasskeyDisplay {
@@ -165,7 +166,7 @@ pub struct RenameForm {
165 166
166 167 #[tracing::instrument(skip_all, name = "passkeys::rename")]
167 168 pub(super) async fn rename(
168 - State(state): State<AppState>,
169 + State(db): State<PgPool>,
169 170 AuthUser(user): AuthUser,
170 171 Path(id): Path<PasskeyId>,
171 172 Form(form): Form<RenameForm>,
@@ -175,13 +176,13 @@ pub(super) async fn rename(
175 176 return Err(AppError::validation("Name must be 1-100 characters".to_string()));
176 177 }
177 178
178 - if !db::passkeys::rename_passkey(&state.db, id, user.id, name).await? {
179 + if !db::passkeys::rename_passkey(&db, id, user.id, name).await? {
179 180 return Err(AppError::NotFound);
180 181 }
181 182
182 183 Ok((
183 184 [("HX-Trigger", hx_toast("Passkey renamed", "success"))],
184 - list_inner(&state, user.id).await?,
185 + list_inner(&db, user.id).await?,
185 186 )
186 187 .into_response())
187 188 }
@@ -194,12 +195,12 @@ pub struct DeleteForm {
194 195
195 196 #[tracing::instrument(skip_all, name = "passkeys::delete")]
196 197 pub(super) async fn delete(
197 - State(state): State<AppState>,
198 + State(db): State<PgPool>,
198 199 AuthUser(user): AuthUser,
199 200 Path(id): Path<PasskeyId>,
200 201 Form(form): Form<DeleteForm>,
201 202 ) -> Result<Response> {
202 - let db_user = db::users::get_user_by_id(&state.db, user.id)
203 + let db_user = db::users::get_user_by_id(&db, user.id)
203 204 .await?
204 205 .ok_or(AppError::Unauthorized)?;
205 206
@@ -207,7 +208,7 @@ pub(super) async fn delete(
207 208 return Err(AppError::BadRequest("Incorrect password".to_string()));
208 209 }
209 210
210 - if !db::passkeys::delete_passkey(&state.db, id, user.id).await? {
211 + if !db::passkeys::delete_passkey(&db, id, user.id).await? {
211 212 return Err(AppError::NotFound);
212 213 }
213 214
@@ -215,7 +216,7 @@ pub(super) async fn delete(
215 216
216 217 Ok((
217 218 [("HX-Trigger", hx_toast("Passkey deleted", "success"))],
218 - list_inner(&state, user.id).await?,
219 + list_inner(&db, user.id).await?,
219 220 )
220 221 .into_response())
221 222 }
@@ -9,6 +9,8 @@ use axum::{
9 9 };
10 10 use serde::{Deserialize, Serialize};
11 11
12 + use sqlx::PgPool;
13 +
12 14 use crate::{
13 15 auth::AuthUser,
14 16 db::{self, ProjectId, ProjectSectionId},
@@ -16,7 +18,6 @@ use crate::{
16 18 helpers::{htmx_toast_response, is_htmx_request, slugify},
17 19 types::ListResponse,
18 20 validation,
19 - AppState,
20 21 };
21 22
22 23 use super::verify_project_ownership;
@@ -68,7 +69,7 @@ impl From<db::DbProjectSection> for SectionResponse {
68 69
69 70 #[tracing::instrument(skip_all, name = "projects::create_section")]
70 71 pub(super) async fn create_section(
71 - State(state): State<AppState>,
72 + State(db): State<PgPool>,
72 73 AuthUser(user): AuthUser,
73 74 Path(project_id): Path<ProjectId>,
74 75 Json(req): Json<CreateSectionRequest>,
@@ -78,9 +79,9 @@ pub(super) async fn create_section(
78 79 validation::validate_section_title(&title)?;
79 80 validation::validate_section_body(&req.body)?;
80 81
81 - verify_project_ownership(&state.db, project_id, user.id).await?;
82 + verify_project_ownership(&db, project_id, user.id).await?;
82 83
83 - let count = db::project_sections::count_by_project(&state.db, project_id).await?;
84 + let count = db::project_sections::count_by_project(&db, project_id).await?;
84 85 if count >= MAX_SECTIONS_PER_PROJECT {
85 86 return Err(AppError::validation(format!(
86 87 "Maximum of {} sections per project",
@@ -95,37 +96,37 @@ pub(super) async fn create_section(
95 96 // owns the `-N` suffixing and 23505 retry, with the index as the race-safe
96 97 // source of truth.
97 98 let base = slugify(&title).to_string();
98 - let pool = &state.db;
99 + let pool = &db;
99 100 let (title_s, body_s) = (title.as_str(), req.body.as_str());
100 101 let section = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
101 102 db::project_sections::create(pool, project_id, title_s, &slug, body_s, sort_order).await
102 103 })
103 104 .await?;
104 105
105 - db::projects::bump_cache_generation(&state.db, project_id).await?;
106 + db::projects::bump_cache_generation(&db, project_id).await?;
106 107
107 108 Ok(Json(SectionResponse::from(section)))
108 109 }
109 110
110 111 #[tracing::instrument(skip_all, name = "projects::list_sections")]
111 112 pub(super) async fn list_sections(
112 - State(state): State<AppState>,
113 + State(db): State<PgPool>,
113 114 Path(project_id): Path<ProjectId>,
114 115 ) -> Result<impl IntoResponse> {
115 - let project = db::projects::get_project_by_id(&state.db, project_id)
116 + let project = db::projects::get_project_by_id(&db, project_id)
116 117 .await?
117 118 .ok_or(AppError::NotFound)?;
118 119 if !project.is_public {
119 120 return Err(AppError::NotFound);
120 121 }
121 - let sections = db::project_sections::list_by_project(&state.db, project_id).await?;
122 + let sections = db::project_sections::list_by_project(&db, project_id).await?;
122 123 let data: Vec<SectionResponse> = sections.into_iter().map(SectionResponse::from).collect();
123 124 Ok(Json(ListResponse { data }))
124 125 }
125 126
126 127 #[tracing::instrument(skip_all, name = "projects::update_section")]
127 128 pub(super) async fn update_section(
128 - State(state): State<AppState>,
129 + State(db): State<PgPool>,
129 130 AuthUser(user): AuthUser,
130 131 Path(section_id): Path<ProjectSectionId>,
131 132 Json(req): Json<UpdateSectionRequest>,
@@ -135,45 +136,45 @@ pub(super) async fn update_section(
135 136 validation::validate_section_title(&title)?;
136 137 validation::validate_section_body(&req.body)?;
137 138
138 - let section = db::project_sections::get_by_id(&state.db, section_id)
139 + let section = db::project_sections::get_by_id(&db, section_id)
139 140 .await?
140 141 .ok_or(AppError::NotFound)?;
141 142
142 - verify_project_ownership(&state.db, section.project_id, user.id).await?;
143 + verify_project_ownership(&db, section.project_id, user.id).await?;
143 144
144 145 // Same dedup as create. Re-saving under the section's own slug is not a
145 146 // conflict (same row); only a collision with a different section triggers
146 147 // the `-N` suffix retry.
147 148 let base = slugify(&title).to_string();
148 - let pool = &state.db;
149 + let pool = &db;
149 150 let (title_s, body_s) = (title.as_str(), req.body.as_str());
150 151 let updated = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
151 152 db::project_sections::update(pool, section_id, title_s, &slug, body_s).await
152 153 })
153 154 .await?;
154 155
155 - db::projects::bump_cache_generation(&state.db, section.project_id).await?;
156 + db::projects::bump_cache_generation(&db, section.project_id).await?;
156 157
157 158 Ok(Json(SectionResponse::from(updated)))
158 159 }
159 160
160 161 #[tracing::instrument(skip_all, name = "projects::delete_section")]
161 162 pub(super) async fn delete_section(
162 - State(state): State<AppState>,
163 + State(db): State<PgPool>,
163 164 headers: HeaderMap,
164 165 AuthUser(user): AuthUser,
165 166 Path(section_id): Path<ProjectSectionId>,
166 167 ) -> Result<Response> {
167 168 user.check_not_suspended()?;
168 169
169 - let section = db::project_sections::get_by_id(&state.db, section_id)
170 + let section = db::project_sections::get_by_id(&db, section_id)
170 171 .await?
171 172 .ok_or(AppError::NotFound)?;
172 173
173 - verify_project_ownership(&state.db, section.project_id, user.id).await?;
174 + verify_project_ownership(&db, section.project_id, user.id).await?;
174 175
175 - db::project_sections::delete(&state.db, section_id).await?;
176 - db::projects::bump_cache_generation(&state.db, section.project_id).await?;
176 + db::project_sections::delete(&db, section_id).await?;
177 + db::projects::bump_cache_generation(&db, section.project_id).await?;
177 178
178 179 if is_htmx_request(&headers) {
179 180 return Ok(htmx_toast_response("Section deleted", "success").into_response());
@@ -184,16 +185,16 @@ pub(super) async fn delete_section(
184 185
185 186 #[tracing::instrument(skip_all, name = "projects::reorder_sections")]
186 187 pub(super) async fn reorder_sections(
187 - State(state): State<AppState>,
188 + State(db): State<PgPool>,
188 189 AuthUser(user): AuthUser,
189 190 Path(project_id): Path<ProjectId>,
190 191 Json(req): Json<ReorderSectionsRequest>,
191 192 ) -> Result<impl IntoResponse> {
192 193 user.check_not_suspended()?;
193 - verify_project_ownership(&state.db, project_id, user.id).await?;
194 + verify_project_ownership(&db, project_id, user.id).await?;
194 195
195 - db::project_sections::reorder(&state.db, project_id, &req.section_ids).await?;
196 - db::projects::bump_cache_generation(&state.db, project_id).await?;
196 + db::project_sections::reorder(&db, project_id, &req.section_ids).await?;
197 + db::projects::bump_cache_generation(&db, project_id).await?;
197 198
198 199 Ok(StatusCode::NO_CONTENT)
199 200 }
@@ -8,6 +8,10 @@ use axum::{
8 8 };
9 9 use serde::{Deserialize, Serialize};
10 10
11 + use sqlx::PgPool;
12 + use crate::config::Config;
13 + use crate::{Integrations, AppStorage};
14 +
11 15 use crate::{
12 16 auth::AuthUser,
13 17 db::{self, GitRepoId, ProjectId, ProjectType, Slug, UserId, Visibility},
@@ -15,7 +19,6 @@ use crate::{
15 19 helpers::{htmx_toast_response, is_htmx_request},
16 20 types::ListResponse,
17 21 validation,
18 - AppState,
19 22 };
20 23
21 24 use crate::extractors::{ValidatedForm, ValidatedJson};
@@ -51,7 +54,8 @@ pub struct ProjectResponse {
51 54 /// Create a new project for the authenticated creator.
52 55 #[tracing::instrument(skip_all, name = "projects::create_project")]
53 56 pub(super) async fn create_project(
54 - State(state): State<AppState>,
57 + State(db): State<PgPool>,
58 + State(integrations): State<Integrations>,
55 59 headers: HeaderMap,
56 60 AuthUser(user): AuthUser,
57 61 ValidatedForm(req): ValidatedForm<CreateProjectRequest>,
@@ -73,7 +77,7 @@ pub(super) async fn create_project(
73 77 let category_id = if let Some(ref cat_name) = req.category {
74 78 let trimmed = cat_name.trim();
75 79 if !trimmed.is_empty() {
76 - let cat = db::categories::get_or_create_category(&state.db, trimmed).await?;
80 + let cat = db::categories::get_or_create_category(&db, trimmed).await?;
77 81 Some(cat.id)
78 82 } else {
79 83 None
@@ -89,7 +93,7 @@ pub(super) async fn create_project(
89 93 }
90 94
91 95 let project = db::projects::create_project(
92 - &state.db,
96 + &db,
93 97 user.id,
94 98 &req.slug,
95 99 &req.title,
@@ -100,21 +104,21 @@ pub(super) async fn create_project(
100 104
101 105 // Set category if resolved
102 106 if let Some(cat_id) = category_id {
103 - db::projects::set_project_category(&state.db, project.id, user.id, Some(cat_id)).await?;
107 + db::projects::set_project_category(&db, project.id, user.id, Some(cat_id)).await?;
104 108 }
105 109
106 110 // Create default mailing lists (non-blocking)
107 - if let Err(e) = db::mailing_lists::create_default_lists(&state.db, project.id, &req.title).await {
111 + if let Err(e) = db::mailing_lists::create_default_lists(&db, project.id, &req.title).await {
108 112 tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists");
109 113 }
110 114
111 - db::users::bump_cache_generation(&state.db, user.id).await?;
112 - db::projects::bump_cache_generation(&state.db, project.id).await?;
115 + db::users::bump_cache_generation(&db, user.id).await?;
116 + db::projects::bump_cache_generation(&db, project.id).await?;
113 117
114 118 // Fire-and-forget: provision a paired MT community
115 - if let Some(ref mt) = state.mt_client {
119 + if let Some(ref mt) = integrations.mt_client {
116 120 let mt = mt.clone();
117 - let db = state.db.clone();
121 + let db = db.clone();
118 122 let project_id = project.id;
119 123 let slug = project.slug.to_string();
120 124 let title = project.title.clone();
@@ -172,10 +176,10 @@ pub(super) async fn create_project(
172 176 /// List all projects for the authenticated user.
173 177 #[tracing::instrument(skip_all, name = "projects::list_projects")]
174 178 pub(super) async fn list_projects(
175 - State(state): State<AppState>,
179 + State(db): State<PgPool>,
176 180 AuthUser(user): AuthUser,
177 181 ) -> Result<impl IntoResponse> {
178 - let projects = db::projects::get_projects_by_user(&state.db, user.id).await?;
182 + let projects = db::projects::get_projects_by_user(&db, user.id).await?;
179 183
180 184 let data: Vec<ProjectResponse> = projects
181 185 .into_iter()
@@ -212,14 +216,14 @@ pub struct UpdateProjectRequest {
212 216 /// Update an existing project owned by the authenticated user.
213 217 #[tracing::instrument(skip_all, name = "projects::update_project", fields(project_id))]
214 218 pub(super) async fn update_project(
215 - State(state): State<AppState>,
219 + State(db): State<PgPool>,
216 220 AuthUser(user): AuthUser,
217 221 Path(id): Path<ProjectId>,
218 222 ValidatedJson(req): ValidatedJson<UpdateProjectRequest>,
219 223 ) -> Result<impl IntoResponse> {
220 224 tracing::Span::current().record("project_id", tracing::field::display(&id));
221 225 user.check_not_suspended()?;
222 - verify_project_ownership(&state.db, id, user.id).await?;
226 + verify_project_ownership(&db, id, user.id).await?;
223 227
224 228 // Validate input (same rules as create_project, but all fields are optional)
225 229 if let Some(ref title) = req.title {
@@ -233,10 +237,10 @@ pub(super) async fn update_project(
233 237 if let Some(ref cat_name) = req.category {
234 238 let trimmed = cat_name.trim();
235 239 if trimmed.is_empty() {
236 - db::projects::set_project_category(&state.db, id, user.id, None).await?;
240 + db::projects::set_project_category(&db, id, user.id, None).await?;
237 241 } else {
238 - let cat = db::categories::get_or_create_category(&state.db, trimmed).await?;
239 - db::projects::set_project_category(&state.db, id, user.id, Some(cat.id)).await?;
242 + let cat = db::categories::get_or_create_category(&db, trimmed).await?;
243 + db::projects::set_project_category(&db, id, user.id, Some(cat.id)).await?;
240 244 }
241 245 }
242 246
@@ -249,7 +253,7 @@ pub(super) async fn update_project(
249 253 }
250 254
251 255 let updated = db::projects::update_project(
252 - &state.db,
256 + &db,
253 257 id,
254 258 user.id,
255 259 req.title.as_deref(),
@@ -289,7 +293,7 @@ pub(super) async fn update_project(
289 293 };
290 294
291 295 db::projects::update_project_pricing(
292 - &state.db,
296 + &db,
293 297 id,
294 298 user.id,
295 299 kind,
@@ -299,7 +303,7 @@ pub(super) async fn update_project(
299 303 .await?;
300 304 }
301 305
302 - db::projects::bump_cache_generation(&state.db, id).await?;
306 + db::projects::bump_cache_generation(&db, id).await?;
303 307
304 308 Ok(Json(ProjectResponse {
305 309 id: updated.id,
@@ -324,19 +328,19 @@ pub struct UpdateProjectThemeRequest {
324 328 /// re-render with the new palette.
325 329 #[tracing::instrument(skip_all, name = "projects::update_project_theme", fields(project_id))]
326 330 pub(super) async fn update_project_theme(
327 - State(state): State<AppState>,
331 + State(db): State<PgPool>,
328 332 AuthUser(user): AuthUser,
329 333 Path(id): Path<ProjectId>,
330 334 Form(req): Form<UpdateProjectThemeRequest>,
331 335 ) -> Result<impl IntoResponse> {
332 336 tracing::Span::current().record("project_id", tracing::field::display(&id));
333 337 user.check_not_suspended()?;
334 - verify_project_ownership(&state.db, id, user.id).await?;
338 + verify_project_ownership(&db, id, user.id).await?;
335 339
336 340 let theme_id = crate::theming::normalize_theme_id(req.theme_id.as_deref())
337 341 .map_err(|t| AppError::validation(format!("Unknown theme: {t}")))?;
338 - db::projects::set_project_theme(&state.db, id, user.id, theme_id.as_deref()).await?;
339 - db::projects::bump_cache_generation(&state.db, id).await?;
342 + db::projects::set_project_theme(&db, id, user.id, theme_id.as_deref()).await?;
343 + db::projects::bump_cache_generation(&db, id).await?;
340 344
341 345 Ok(htmx_toast_response("Theme saved", "success"))
342 346 }
@@ -347,21 +351,23 @@ pub(super) async fn update_project_theme(
347 351 /// cover image) for durable deletion and decrements the user's storage counter.
348 352 #[tracing::instrument(skip_all, name = "projects::delete_project", fields(project_id))]
349 353 pub(super) async fn delete_project(
350 - State(state): State<AppState>,
354 + State(db): State<PgPool>,
355 + State(config): State<Config>,
356 + State(storage): State<AppStorage>,
351 357 AuthUser(user): AuthUser,
352 358 Path(id): Path<ProjectId>,
353 359 ) -> Result<impl IntoResponse> {
354 360 tracing::Span::current().record("project_id", tracing::field::display(&id));
355 361 user.check_not_suspended()?;
356 - let project = verify_project_ownership(&state.db, id, user.id).await?;
362 + let project = verify_project_ownership(&db, id, user.id).await?;
357 363
358 364 // Collect all S3 keys from items + versions + galleries before CASCADE
359 365 // delete destroys them. Gallery rows (item_images / project_images) cascade
360 366 // away too, so their keys must be swept here or they orphan with no durable
361 367 // record (Run #18 Storage B2).
362 - let item_keys = db::items::get_project_item_s3_keys(&state.db, id).await?;
363 - let version_keys = db::items::get_project_version_s3_keys(&state.db, id).await?;
364 - let gallery_keys = db::gallery_images::s3_keys_for_project(&state.db, id).await?;
368 + let item_keys = db::items::get_project_item_s3_keys(&db, id).await?;
369 + let version_keys = db::items::get_project_version_s3_keys(&db, id).await?;
370 + let gallery_keys = db::gallery_images::s3_keys_for_project(&db, id).await?;
365 371
366 372 let mut all_keys: Vec<(String, String)> = Vec::new();
367 373 // Version downloads are gated media — always the private bucket.
@@ -383,9 +389,9 @@ pub(super) async fn delete_project(
383 389 if let Some(ref url) = project.cover_image_url
384 390 && let Some(key) = crate::storage::extract_s3_key_from_url(
385 391 url,
386 - state.config.cdn_base_url.as_deref(),
387 - state.storage.s3.as_deref().map(|s| s.bucket()),
388 - state.config.storage.as_ref().map(|c| c.endpoint.as_str()),
392 + config.cdn_base_url.as_deref(),
393 + storage.s3.as_deref().map(|s| s.bucket()),
394 + config.storage.as_ref().map(|c| c.endpoint.as_str()),
389 395 )
390 396 {
391 397 all_keys.extend(crate::storage::both_bucket_delete(&key));
@@ -397,18 +403,18 @@ pub(super) async fn delete_project(
397 403 // deleting the rows anyway would orphan every object with no record (ultra-fuzz
398 404 // Run 12 Storage F3 sibling). The reverse case (enqueue succeeds, delete fails)
399 405 // is safe — the reaper's is_s3_key_live guard skips keys whose rows still exist.
400 - db::pending_s3_deletions::enqueue_deletions(&state.db, &all_keys, "project_delete").await?;
406 + db::pending_s3_deletions::enqueue_deletions(&db, &all_keys, "project_delete").await?;
401 407
402 408 // Decrement storage before deleting rows
403 - let storage_bytes = db::items::get_project_storage_bytes(&state.db, id).await?;
409 + let storage_bytes = db::items::get_project_storage_bytes(&db, id).await?;
404 410 if storage_bytes > 0
405 - && let Err(e) = db::creator_tiers::decrement_storage_used(&state.db, user.id, storage_bytes).await
411 + && let Err(e) = db::creator_tiers::decrement_storage_used(&db, user.id, storage_bytes).await
406 412 {
407 413 tracing::warn!(error = ?e, bytes = storage_bytes, "failed to decrement storage for project delete");
408 414 }
409 415
410 - db::projects::delete_project(&state.db, id, user.id).await?;
411 - db::users::bump_cache_generation(&state.db, user.id).await?;
416 + db::projects::delete_project(&db, id, user.id).await?;
417 + db::users::bump_cache_generation(&db, user.id).await?;
412 418 Ok(htmx_toast_response("Project deleted", "success"))
413 419 }
414 420
@@ -425,20 +431,20 @@ pub struct LinkRepoRequest {
425 431 /// Link a git repo to a project. The repo must exist and be owned by the same user.
426 432 #[tracing::instrument(skip_all, name = "projects::link_repo")]
427 433 pub(super) async fn link_repo(
428 - State(state): State<AppState>,
434 + State(db): State<PgPool>,
429 435 AuthUser(user): AuthUser,
430 436 Path(id): Path<ProjectId>,
431 437 Json(req): Json<LinkRepoRequest>,
432 438 ) -> Result<impl IntoResponse> {
433 439 user.check_not_suspended()?;
434 - verify_project_ownership(&state.db, id, user.id).await?;
440 + verify_project_ownership(&db, id, user.id).await?;
435 441
436 - let repo = db::git_repos::get_repo_by_user_and_name(&state.db, user.id, &req.name)
442 + let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &req.name)
437 443 .await?
438 444 .ok_or(AppError::validation("Repository not found".to_string()))?;
439 445
440 - db::git_repos::link_repo_to_project(&state.db, repo.id, id).await?;
441 - db::projects::bump_cache_generation(&state.db, id).await?;
446 + db::git_repos::link_repo_to_project(&db, repo.id, id).await?;
447 + db::projects::bump_cache_generation(&db, id).await?;
442 448
443 449 Ok(htmx_toast_response("Repository linked", "success"))
444 450 }
@@ -446,19 +452,19 @@ pub(super) async fn link_repo(
446 452 /// Unlink a git repo from a project. The repo must be owned by the same user.
447 453 #[tracing::instrument(skip_all, name = "projects::unlink_repo")]
448 454 pub(super) async fn unlink_repo(
449 - State(state): State<AppState>,
455 + State(db): State<PgPool>,
450 456 AuthUser(user): AuthUser,
451 457 Path((id, repo_name)): Path<(ProjectId, String)>,
452 458 ) -> Result<impl IntoResponse> {
453 459 user.check_not_suspended()?;
454 - verify_project_ownership(&state.db, id, user.id).await?;
460 + verify_project_ownership(&db, id, user.id).await?;
455 461
456 - let repo = db::git_repos::get_repo_by_user_and_name(&state.db, user.id, &repo_name)
462 + let repo = db::git_repos::get_repo_by_user_and_name(&db, user.id, &repo_name)
457 463 .await?
458 464 .ok_or(AppError::validation("Repository not found".to_string()))?;
459 465
460 - db::git_repos::unlink_repo_from_project(&state.db, repo.id).await?;
461 - db::projects::bump_cache_generation(&state.db, id).await?;
466 + db::git_repos::unlink_repo_from_project(&db, repo.id).await?;
467 + db::projects::bump_cache_generation(&db, id).await?;
462 468
463 469 Ok(htmx_toast_response("Repository unlinked", "success"))
464 470 }
@@ -485,7 +491,8 @@ pub struct RepoResponse {
485 491 /// Create a bare git repo on disk and register it in the DB.
486 492 #[tracing::instrument(skip_all, name = "projects::create_repo")]
487 493 pub(super) async fn create_repo(
488 - State(state): State<AppState>,
494 + State(db): State<PgPool>,
495 + State(config): State<Config>,
489 496 AuthUser(user): AuthUser,
490 497 Json(req): Json<CreateRepoRequest>,
491 498 ) -> Result<impl IntoResponse> {
@@ -510,14 +517,13 @@ pub(super) async fn create_repo(
510 517 let visibility = req.visibility.unwrap_or(Visibility::Public);
511 518
512 519 // Need git_repos_path configured
513 - let git_root = state
514 - .config
520 + let git_root = config
515 521 .build.git_repos_path
516 522 .as_deref()
517 523 .ok_or_else(|| AppError::validation("Git repositories are not configured on this server".to_string()))?;
518 524
519 525 // Check repo doesn't already exist in DB
520 - if db::git_repos::get_repo_by_user_and_name(&state.db, user.id, name).await?.is_some() {
526 + if db::git_repos::get_repo_by_user_and_name(&db, user.id, name).await?.is_some() {
521 527 return Err(AppError::validation("A repository with that name already exists".to_string()));
522 528 }
523 529
@@ -537,7 +543,7 @@ pub(super) async fn create_repo(
537 543 .context("init bare git repo")?;
538 544
539 545 // Install post-receive hook if build triggers are configured
540 - if let Some(token) = &state.config.build.trigger_token {
546 + if let Some(token) = &config.build.trigger_token {
541 547 let hooks_dir = repo_dir.join("hooks");
542 548 let hook_path = hooks_dir.join("post-receive");
543 549 let hook_content = crate::build_runner::post_receive_hook(token, &username, name);
@@ -556,7 +562,7 @@ pub(super) async fn create_repo(
556 562 }
557 563
558 564 // Register in DB
559 - let db_repo = db::git_repos::create_repo_with_visibility(&state.db, user.id, name, visibility).await?;
565 + let db_repo = db::git_repos::create_repo_with_visibility(&db, user.id, name, visibility).await?;
560 566
561 567 Ok(Json(RepoResponse {
562 568 id: db_repo.id,
@@ -574,7 +580,7 @@ pub struct UpdateRepoVisibilityRequest {
574 580 /// Update a repo's visibility. The repo must be owned by the authenticated user.
575 581 #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")]
576 582 pub(super) async fn update_repo_visibility(
577 - State(state): State<AppState>,
583 + State(db): State<PgPool>,
578 584 AuthUser(user): AuthUser,
579 585 Path(repo_id): Path<GitRepoId>,
580 586 Json(req): Json<UpdateRepoVisibilityRequest>,
@@ -583,7 +589,7 @@ pub(super) async fn update_repo_visibility(
583 589
584 590 // Indexed lookup by primary key, not fetch-all-then-find over the user's
585 591 // whole repo set (ultra-fuzz Run 4 Perf). Ownership is still enforced below.
586 - let repo = db::git_repos::get_repo_by_id(&state.db, repo_id)
592 + let repo = db::git_repos::get_repo_by_id(&db, repo_id)
587 593 .await?
588 594 .ok_or(AppError::NotFound)?;
589 595
@@ -591,7 +597,7 @@ pub(super) async fn update_repo_visibility(
591 597 return Err(AppError::Forbidden);
592 598 }
593 599
594 - db::git_repos::update_visibility(&state.db, repo_id, req.visibility).await?;
600 + db::git_repos::update_visibility(&db, repo_id, req.visibility).await?;
595 601
596 602 Ok(htmx_toast_response("Visibility updated", "success"))
597 603 }
@@ -611,12 +617,12 @@ pub struct AddMemberForm {
611 617 /// POST /api/projects/{id}/members - Add a member to a project
612 618 #[tracing::instrument(skip_all, name = "api::add_project_member")]
613 619 pub async fn add_project_member(
614 - State(state): State<AppState>,
620 + State(db): State<PgPool>,
615 621 AuthUser(session_user): AuthUser,
616 622 Path(project_id): Path<ProjectId>,
617 623 Form(form): Form<AddMemberForm>,
618 624 ) -> Result<Response> {
619 - let _project = verify_project_ownership(&state.db, project_id, session_user.id).await?;
625 + let _project = verify_project_ownership(&db, project_id, session_user.id).await?;
620 626
621 627 // Validate split percent
622 628 if form.split_percent < 1 || form.split_percent > 99 {
@@ -625,7 +631,7 @@ pub async fn add_project_member(
625 631
626 632 // Look up the member by username (validate untrusted form input)
627 633 let username = db::Username::new(&form.username)?;
628 - let member_user = db::users::get_user_by_username(&state.db, &username)
634 + let member_user = db::users::get_user_by_username(&db, &username)
629 635 .await?
630 636 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
631 637
@@ -637,7 +643,7 @@ pub async fn add_project_member(
637 643 let role = form.role.unwrap_or(db::ProjectRole::Member);
638 644
639 645 db::project_members::add_project_member(
640 - &state.db,
646 + &db,
641 647 project_id,
642 648 member_user.id,
643 649 role,
@@ -646,7 +652,7 @@ pub async fn add_project_member(
646 652 ).await?;
647 653
648 654 // Bump cache generation so the tab refreshes
649 - db::projects::bump_cache_generation(&state.db, project_id).await?;
655 + db::projects::bump_cache_generation(&db, project_id).await?;
650 656
651 657 Ok(htmx_toast_response(
652 658 &format!("Added @{} with {}% split", member_user.username, form.split_percent),
@@ -657,19 +663,19 @@ pub async fn add_project_member(
657 663 /// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member
658 664 #[tracing::instrument(skip_all, name = "api::remove_project_member")]
659 665 pub async fn remove_project_member(
660 - State(state): State<AppState>,
666 + State(db): State<PgPool>,
661 667 AuthUser(session_user): AuthUser,
662 668 Path((project_id, user_id)): Path<(ProjectId, db::UserId)>,
663 669 ) -> Result<Response> {
664 - verify_project_ownership(&state.db, project_id, session_user.id).await?;
670 + verify_project_ownership(&db, project_id, session_user.id).await?;
665 671
666 - let removed = db::project_members::remove_project_member(&state.db, project_id, user_id).await?;
672 + let removed = db::project_members::remove_project_member(&db, project_id, user_id).await?;
667 673
668 674 if !removed {
669 675 return Err(AppError::NotFound);
670 676 }
671 677
672 - db::projects::bump_cache_generation(&state.db, project_id).await?;
678 + db::projects::bump_cache_generation(&db, project_id).await?;
673 679
674 680 Ok(htmx_toast_response("Member removed", "success").into_response())
675 681 }
@@ -697,11 +703,11 @@ pub struct CollaboratorResponse {
697 703
698 704 /// Verify the authenticated user owns this repo and return it.
699 705 async fn verify_repo_ownership(
700 - state: &AppState,
706 + db: &PgPool,
701 707 repo_id: GitRepoId,
702 708 user_id: UserId,
703 709 ) -> Result<db::DbGitRepo> {
704 - let repo = db::git_repos::get_repo_by_id(&state.db, repo_id)
710 + let repo = db::git_repos::get_repo_by_id(db, repo_id)
705 711 .await?
706 712 .ok_or(AppError::NotFound)?;
707 713
@@ -715,17 +721,17 @@ async fn verify_repo_ownership(
715 721 /// POST /api/repos/{id}/collaborators: add a collaborator by username.
716 722 #[tracing::instrument(skip_all, name = "api::add_repo_collaborator")]
717 723 pub async fn add_repo_collaborator(
718 - State(state): State<AppState>,
724 + State(db): State<PgPool>,
719 725 AuthUser(user): AuthUser,
720 726 Path(repo_id): Path<GitRepoId>,
721 727 Form(form): Form<AddCollaboratorForm>,
722 728 ) -> Result<Response> {
723 729 user.check_not_suspended()?;
724 730
725 - let _repo = verify_repo_ownership(&state, repo_id, user.id).await?;
731 + let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
726 732
727 733 let username = db::Username::new(&form.username)?;
728 - let collab_user = db::users::get_user_by_username(&state.db, &username)
734 + let collab_user = db::users::get_user_by_username(&db, &username)
729 735 .await?
730 736 .ok_or_else(|| AppError::validation(format!("User '{}' not found", form.username)))?;
731 737
@@ -733,7 +739,7 @@ pub async fn add_repo_collaborator(
733 739 return Err(AppError::validation("You are already the repo owner".to_string()));
734 740 }
735 741
736 - db::repo_collaborators::add_collaborator(&state.db, repo_id, collab_user.id, form.can_push)
742 + db::repo_collaborators::add_collaborator(&db, repo_id, collab_user.id, form.can_push)
737 743 .await
738 744 .map_err(|e| {
739 745 if let AppError::Database(ref db_err) = e
@@ -753,15 +759,15 @@ pub async fn add_repo_collaborator(
753 759 /// DELETE /api/repos/{repo_id}/collaborators/{user_id}: remove a collaborator.
754 760 #[tracing::instrument(skip_all, name = "api::remove_repo_collaborator")]
755 761 pub async fn remove_repo_collaborator(
756 - State(state): State<AppState>,
762 + State(db): State<PgPool>,
757 763 AuthUser(user): AuthUser,
758 764 Path((repo_id, collab_user_id)): Path<(GitRepoId, UserId)>,
759 765 ) -> Result<Response> {
760 766 user.check_not_suspended()?;
761 767
762 - let _repo = verify_repo_ownership(&state, repo_id, user.id).await?;
768 + let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
763 769
764 - let removed = db::repo_collaborators::remove_collaborator(&state.db, repo_id, collab_user_id).await?;
770 + let removed = db::repo_collaborators::remove_collaborator(&db, repo_id, collab_user_id).await?;
765 771
766 772 if !removed {
767 773 return Err(AppError::NotFound);
@@ -773,13 +779,13 @@ pub async fn remove_repo_collaborator(
773 779 /// GET /api/repos/{id}/collaborators: list collaborators (JSON).
774 780 #[tracing::instrument(skip_all, name = "api::list_repo_collaborators")]
775 781 pub async fn list_repo_collaborators(
776 - State(state): State<AppState>,
782 + State(db): State<PgPool>,
777 783 AuthUser(user): AuthUser,
778 784 Path(repo_id): Path<GitRepoId>,
779 785 ) -> Result<impl IntoResponse> {
780 - let _repo = verify_repo_ownership(&state, repo_id, user.id).await?;
786 + let _repo = verify_repo_ownership(&db, repo_id, user.id).await?;
781 787
782 - let collabs = db::repo_collaborators::list_collaborators(&state.db, repo_id).await?;
788 + let collabs = db::repo_collaborators::list_collaborators(&db, repo_id).await?;
783 789
784 790 let data: Vec<CollaboratorResponse> = collabs
785 791 .into_iter()
@@ -8,6 +8,8 @@ use axum::{
8 8 };
9 9 use serde::{Deserialize, Serialize};
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::AuthUser,
13 15 db::{self, CodePurpose, DiscountType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId},
@@ -15,8 +17,7 @@ use crate::{
15 17 helpers::{self, hx_toast, is_htmx_request},
16 18 templates::PromoCodesListTemplate,
17 19 types::PromoCodeRow,
18 - types::ListResponse,
19 - AppState,
20 + types::ListResponse,
20 21 };
21 22
22 23 use super::verify_item_ownership;
@@ -67,7 +68,7 @@ pub struct CreatePromoCodeForm {
67 68 /// Create a new promo code (creator dashboard).
68 69 #[tracing::instrument(skip_all, name = "promo_codes::create_promo_code")]
69 70 pub(super) async fn create_promo_code(
70 - State(state): State<AppState>,
71 + State(db): State<PgPool>,
71 72 headers: HeaderMap,
72 73 AuthUser(user): AuthUser,
73 74 Form(req): Form<CreatePromoCodeForm>,
@@ -159,7 +160,7 @@ pub(super) async fn create_promo_code(
159 160 } else {
160 161 let item_id: ItemId = id_str.parse()
161 162 .map_err(|_| AppError::BadRequest("Invalid item ID".to_string()))?;
162 - verify_item_ownership(&state.db, item_id, user.id).await?;
163 + verify_item_ownership(&db, item_id, user.id).await?;
163 164 Some(item_id)
164 165 }
165 166 } else {
@@ -174,7 +175,7 @@ pub(super) async fn create_promo_code(
174 175 } else {
175 176 let pid: ProjectId = id_str.parse()
176 177 .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
177 - let project = db::projects::get_project_by_id(&state.db, pid)
178 + let project = db::projects::get_project_by_id(&db, pid)
178 179 .await?
179 180 .ok_or(AppError::NotFound)?;
180 181 if project.user_id != user.id {
@@ -194,12 +195,12 @@ pub(super) async fn create_promo_code(
194 195 } else {
195 196 let tid: SubscriptionTierId = id_str.parse()
196 197 .map_err(|_| AppError::BadRequest("Invalid tier ID".to_string()))?;
197 - let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tid)
198 + let tier = db::subscriptions::get_subscription_tier_by_id(&db, tid)
198 199 .await?
199 200 .ok_or(AppError::NotFound)?;
200 201 let tier_project_id = tier.project_id
201 202 .ok_or(AppError::BadRequest("Tier has no project".to_string()))?;
202 - let tier_project = db::projects::get_project_by_id(&state.db, tier_project_id)
203 + let tier_project = db::projects::get_project_by_id(&db, tier_project_id)
203 204 .await?
204 205 .ok_or(AppError::NotFound)?;
205 206 if tier_project.user_id != user.id {
@@ -258,7 +259,7 @@ pub(super) async fn create_promo_code(
258 259 }
259 260
260 261 let promo_code = match db::promo_codes::create_promo_code(
261 - &state.db,
262 + &db,
262 263 user.id,
263 264 &code,
264 265 req.code_purpose,
@@ -284,15 +285,15 @@ pub(super) async fn create_promo_code(
284 285 };
285 286
286 287 if let Some(pid) = project_id {
287 - db::projects::bump_cache_generation(&state.db, pid).await?;
288 + db::projects::bump_cache_generation(&db, pid).await?;
288 289 }
289 290
290 291 if is_htmx_request(&headers) {
291 292 // Return project-scoped codes if created from project context, otherwise creator-global
292 293 let codes = if let Some(pid) = project_id {
293 - db::promo_codes::get_promo_codes_by_project(&state.db, pid).await?
294 + db::promo_codes::get_promo_codes_by_project(&db, pid).await?
294 295 } else {
295 - db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?
296 + db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?
296 297 };
297 298 return Ok((
298 299 [("HX-Trigger", hx_toast("Promo code created", "success"))],
@@ -319,11 +320,11 @@ pub(super) async fn create_promo_code(
319 320 /// List all promo codes for the authenticated creator.
320 321 #[tracing::instrument(skip_all, name = "promo_codes::list_promo_codes")]
321 322 pub(super) async fn list_promo_codes(
322 - State(state): State<AppState>,
323 + State(db): State<PgPool>,
323 324 headers: HeaderMap,
324 325 AuthUser(user): AuthUser,
325 326 ) -> Result<Response> {
326 - let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?;
327 + let codes = db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?;
327 328
328 329 if is_htmx_request(&headers) {
329 330 return Ok(PromoCodesListTemplate {
@@ -355,11 +356,11 @@ pub(super) async fn list_promo_codes(
355 356 /// log a TODO if you hit it).
356 357 #[tracing::instrument(skip_all, name = "promo_codes::list_redemptions")]
357 358 pub(super) async fn list_redemptions(
358 - State(state): State<AppState>,
359 + State(db): State<PgPool>,
359 360 AuthUser(user): AuthUser,
360 361 Path(code_id): Path<PromoCodeId>,
361 362 ) -> Result<Response> {
362 - let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id)
363 + let promo_code = db::promo_codes::get_promo_code_by_id(&db, code_id)
363 364 .await?
364 365 .ok_or(AppError::NotFound)?;
365 366
@@ -367,21 +368,21 @@ pub(super) async fn list_redemptions(
367 368 return Err(AppError::Forbidden);
368 369 }
369 370
370 - let rows = db::promo_codes::list_redemptions(&state.db, code_id).await?;
371 + let rows = db::promo_codes::list_redemptions(&db, code_id).await?;
371 372 Ok(Json(serde_json::json!({ "redemptions": rows })).into_response())
372 373 }
373 374
374 375 /// Delete a promo code.
375 376 #[tracing::instrument(skip_all, name = "promo_codes::delete_promo_code")]
376 377 pub(super) async fn delete_promo_code(
377 - State(state): State<AppState>,
378 + State(db): State<PgPool>,
378 379 headers: HeaderMap,
379 380 AuthUser(user): AuthUser,
380 381 Path(code_id): Path<PromoCodeId>,
381 382 ) -> Result<Response> {
382 383 user.check_not_suspended()?;
383 384
384 - let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id)
385 + let promo_code = db::promo_codes::get_promo_code_by_id(&db, code_id)
385 386 .await?
386 387 .ok_or(AppError::NotFound)?;
387 388
@@ -390,17 +391,17 @@ pub(super) async fn delete_promo_code(
390 391 }
391 392
392 393 let deleted_project_id = promo_code.project_id;
393 - db::promo_codes::delete_promo_code(&state.db, code_id).await?;
394 + db::promo_codes::delete_promo_code(&db, code_id).await?;
394 395
395 396 if let Some(pid) = deleted_project_id {
396 - db::projects::bump_cache_generation(&state.db, pid).await?;
397 + db::projects::bump_cache_generation(&db, pid).await?;
397 398 }
398 399
399 400 if is_htmx_request(&headers) {
400 401 let codes = if let Some(pid) = deleted_project_id {
401 - db::promo_codes::get_promo_codes_by_project(&state.db, pid).await?
402 + db::promo_codes::get_promo_codes_by_project(&db, pid).await?
402 403 } else {
403 - db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?
404 + db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?
404 405 };
405 406 return Ok((
406 407 [("HX-Trigger", hx_toast("Promo code deleted", "success"))],
@@ -425,7 +426,7 @@ pub struct UpdatePromoCodeForm {
425 426 /// Update an existing promo code (expires_at, starts_at, max_uses only).
426 427 #[tracing::instrument(skip_all, name = "promo_codes::update_promo_code")]
427 428 pub(super) async fn update_promo_code(
428 - State(state): State<AppState>,
429 + State(db): State<PgPool>,
429 430 headers: HeaderMap,
430 431 AuthUser(user): AuthUser,
431 432 Path(code_id): Path<PromoCodeId>,
@@ -433,7 +434,7 @@ pub(super) async fn update_promo_code(
433 434 ) -> Result<Response> {
434 435 user.check_not_suspended()?;
435 436
436 - let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id)
437 + let promo_code = db::promo_codes::get_promo_code_by_id(&db, code_id)
437 438 .await?
438 439 .ok_or(AppError::NotFound)?;
439 440
@@ -490,17 +491,17 @@ pub(super) async fn update_promo_code(
490 491 Ok::<_, AppError>(Some(n))
491 492 }).transpose()?;
492 493
493 - db::promo_codes::update_promo_code(&state.db, code_id, expires_at, starts_at, max_uses).await?;
494 + db::promo_codes::update_promo_code(&db, code_id, expires_at, starts_at, max_uses).await?;
494 495
495 496 if let Some(pid) = promo_code.project_id {
496 - db::projects::bump_cache_generation(&state.db, pid).await?;
497 + db::projects::bump_cache_generation(&db, pid).await?;
497 498 }
498 499
499 500 if is_htmx_request(&headers) {
500 501 let codes = if let Some(pid) = promo_code.project_id {
501 - db::promo_codes::get_promo_codes_by_project(&state.db, pid).await?
502 + db::promo_codes::get_promo_codes_by_project(&db, pid).await?
502 503 } else {
503 - db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?
504 + db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?
504 505 };
505 506 return Ok((
506 507 [("HX-Trigger", hx_toast("Promo code updated", "success"))],
@@ -517,16 +518,16 @@ pub(super) async fn update_promo_code(
517 518 /// Delete all expired promo codes for this creator.
518 519 #[tracing::instrument(skip_all, name = "promo_codes::delete_expired")]
519 520 pub(super) async fn delete_expired_promo_codes(
520 - State(state): State<AppState>,
521 + State(db): State<PgPool>,
521 522 headers: HeaderMap,
522 523 AuthUser(user): AuthUser,
523 524 ) -> Result<Response> {
524 525 user.check_not_suspended()?;
525 526
526 - let count = db::promo_codes::delete_expired_by_creator(&state.db, user.id).await?;
527 + let count = db::promo_codes::delete_expired_by_creator(&db, user.id).await?;
527 528
528 529 if is_htmx_request(&headers) {
529 - let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?;
530 + let codes = db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?;
530 531 return Ok((
531 532 [("HX-Trigger", hx_toast(&format!("{count} expired code(s) deleted"), "success"))],
532 533 PromoCodesListTemplate {
@@ -552,7 +553,7 @@ pub struct ClaimPromoCodeForm {
552 553 /// Claim a free_access promo code: validates the code and grants free access to the item.
553 554 #[tracing::instrument(skip_all, name = "api::claim_promo_code")]
554 555 pub(super) async fn claim_promo_code(
555 - State(state): State<AppState>,
556 + State(db): State<PgPool>,
556 557 headers: HeaderMap,
557 558 AuthUser(user): AuthUser,
558 559 Form(req): Form<ClaimPromoCodeForm>,
@@ -563,7 +564,7 @@ pub(super) async fn claim_promo_code(
563 564 let is_htmx = is_htmx_request(&headers);
564 565
565 566 // Look up the code
566 - let promo_code = db::promo_codes::get_promo_code_by_code(&state.db, &req.code)
567 + let promo_code = db::promo_codes::get_promo_code_by_code(&db, &req.code)
567 568 .await?
568 569 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
569 570
@@ -596,7 +597,7 @@ pub(super) async fn claim_promo_code(
596 597 }
597 598
598 599 // Get the item and its seller info for the transaction record
599 - let item = db::items::get_item_by_id(&state.db, item_id)
600 + let item = db::items::get_item_by_id(&db, item_id)
600 601 .await?
601 602 .ok_or(AppError::NotFound)?;
602 603
@@ -604,11 +605,11 @@ pub(super) async fn claim_promo_code(
604 605 return Err(AppError::NotFound);
605 606 }
606 607
607 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
608 + let project = db::projects::get_project_by_id(&db, item.project_id)
608 609 .await?
609 610 .ok_or(AppError::NotFound)?;
610 611
611 - let seller = db::users::get_user_by_id(&state.db, project.user_id)
612 + let seller = db::users::get_user_by_id(&db, project.user_id)
612 613 .await?
613 614 .ok_or(AppError::NotFound)?;
614 615
@@ -625,7 +626,7 @@ pub(super) async fn claim_promo_code(
625 626
626 627 // Wrap promo code increment, claim, and license key in a single transaction
627 628 let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code(
628 - &state.db,
629 + &db,
629 630 promo_code.id,
630 631 &db::transactions::ClaimParams {
631 632 buyer_id: user.id,
@@ -8,12 +8,13 @@ use axum::{
8 8 use serde::Deserialize;
9 9 use uuid::Uuid;
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::AuthUser,
13 15 db::{self, ReportTargetType, ReportType},
14 16 error::{AppError, Result},
15 17 templates::AlertTemplate,
16 - AppState,
17 18 };
18 19
19 20 /// Form input for submitting a report.
@@ -29,7 +30,7 @@ pub struct ReportForm {
29 30 /// Submit a report (logged-in users only).
30 31 #[tracing::instrument(skip_all, name = "api::submit_report")]
31 32 pub async fn submit_report(
32 - State(state): State<AppState>,
33 + State(db): State<PgPool>,
33 34 AuthUser(user): AuthUser,
34 35 Form(form): Form<ReportForm>,
35 36 ) -> Result<impl IntoResponse> {
@@ -55,7 +56,7 @@ pub async fn submit_report(
55 56 // Prevent self-reporting
56 57 match target_type {
57 58 ReportTargetType::Project => {
58 - let project = db::projects::get_project_by_id(&state.db, target_id.into())
59 + let project = db::projects::get_project_by_id(&db, target_id.into())
59 60 .await?
60 61 .ok_or(AppError::NotFound)?;
61 62 if project.user_id == user.id {
@@ -63,10 +64,10 @@ pub async fn submit_report(
63 64 }
64 65 }
65 66 ReportTargetType::Item => {
66 - let item = db::items::get_item_by_id(&state.db, target_id.into())
67 + let item = db::items::get_item_by_id(&db, target_id.into())
67 68 .await?
68 69 .ok_or(AppError::NotFound)?;
69 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
70 + let project = db::projects::get_project_by_id(&db, item.project_id)
70 71 .await?
71 72 .ok_or(AppError::NotFound)?;
72 73 if project.user_id == user.id {
@@ -79,7 +80,7 @@ pub async fn submit_report(
79 80 // insert under a per-reporter advisory lock) so concurrent submits can't
80 81 // slip past the cap.
81 82 let created = db::reports::create_report_within_daily_limit(
82 - &state.db,
83 + &db,
83 84 user.id,
84 85 target_type,
85 86 target_id,
@@ -11,7 +11,7 @@ use crate::db::{self, SshKeyId};
11 11 use crate::error::{AppError, Result};
12 12 use crate::helpers::{hx_toast, is_htmx_request};
13 13 use crate::validation;
14 - use crate::AppState;
14 + use sqlx::PgPool;
15 15
16 16 #[derive(Debug, Deserialize)]
17 17 pub struct AddKeyRequest {
@@ -31,10 +31,10 @@ pub struct SshKeyResponse {
31 31 /// GET /api/users/me/ssh-keys/list: HTMX partial for the SSH keys list.
32 32 #[tracing::instrument(skip_all, name = "ssh_keys::list_keys_html")]
33 33 pub(super) async fn list_keys_html(
34 - State(state): State<AppState>,
34 + State(db): State<PgPool>,
35 35 AuthUser(user): AuthUser,
36 36 ) -> Result<impl IntoResponse> {
37 - let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
37 + let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?;
38 38 let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
39 39 let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?;
40 40 Ok(axum::response::Html(html))
@@ -43,10 +43,10 @@ pub(super) async fn list_keys_html(
43 43 /// GET /api/users/me/ssh-keys: list the authenticated user's SSH keys.
44 44 #[tracing::instrument(skip_all, name = "ssh_keys::list_keys")]
45 45 pub(super) async fn list_keys(
46 - State(state): State<AppState>,
46 + State(db): State<PgPool>,
47 47 AuthUser(user): AuthUser,
48 48 ) -> Result<impl IntoResponse> {
49 - let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
49 + let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?;
50 50
51 51 let data: Vec<SshKeyResponse> = keys
52 52 .into_iter()
@@ -64,7 +64,7 @@ pub(super) async fn list_keys(
64 64 /// POST /api/users/me/ssh-keys: add a new SSH key.
65 65 #[tracing::instrument(skip_all, name = "ssh_keys::add_key")]
66 66 pub(super) async fn add_key(
67 - State(state): State<AppState>,
67 + State(db): State<PgPool>,
68 68 headers: HeaderMap,
69 69 AuthUser(user): AuthUser,
70 70 Form(req): Form<AddKeyRequest>,
@@ -77,7 +77,7 @@ pub(super) async fn add_key(
77 77 validation::validate_ssh_key_label(&req.label)?;
78 78
79 79 // Insert (unique constraint on user_id + fingerprint handles races)
80 - let key = db::ssh_keys::add_key(&state.db, user.id, &normalized_key, &fingerprint, &req.label)
80 + let key = db::ssh_keys::add_key(&db, user.id, &normalized_key, &fingerprint, &req.label)
81 81 .await
82 82 .map_err(|e| {
83 83 // Check for unique constraint violation (duplicate fingerprint)
@@ -105,7 +105,7 @@ pub(super) async fn add_key(
105 105
106 106 if is_htmx_request(&headers) {
107 107 // Re-render the SSH keys section via HTMX
108 - let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
108 + let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?;
109 109 let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
110 110 let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?;
111 111 return Ok((
@@ -127,14 +127,14 @@ pub(super) async fn add_key(
127 127 /// DELETE /api/users/me/ssh-keys/{id}: remove an SSH key.
128 128 #[tracing::instrument(skip_all, name = "ssh_keys::delete_key")]
129 129 pub(super) async fn delete_key(
130 - State(state): State<AppState>,
130 + State(db): State<PgPool>,
131 131 headers: HeaderMap,
132 132 AuthUser(user): AuthUser,
133 133 Path(key_id): Path<SshKeyId>,
134 134 ) -> Result<Response> {
135 135 user.check_not_suspended()?;
136 136
137 - let deleted = db::ssh_keys::delete_key(&state.db, key_id, user.id).await?;
137 + let deleted = db::ssh_keys::delete_key(&db, key_id, user.id).await?;
138 138 if !deleted {
139 139 return Err(AppError::NotFound);
140 140 }
@@ -143,7 +143,7 @@ pub(super) async fn delete_key(
143 143 rebuild_authorized_keys();
144 144
145 145 if is_htmx_request(&headers) {
146 - let keys = db::ssh_keys::list_keys_by_user(&state.db, user.id).await?;
146 + let keys = db::ssh_keys::list_keys_by_user(&db, user.id).await?;
147 147 let ssh_keys: Vec<SshKeyView> = keys.iter().map(SshKeyView::from).collect();
148 148 let html = crate::helpers::render_fragment(&crate::templates::SshKeysListTemplate { ssh_keys })?;
149 149 return Ok((