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
Signed with PGP, not checked
Commit: ea98deee4ad3060f8e9c2b0a0b66512fd8997bc3
Parent: e1b71ae
25 files changed, +597 insertions, -523 deletions
@@ -7,6 +7,8 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 }
@@ -6,19 +6,25 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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()