Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate api/items to slices All api/items handlers -> State<PgPool> (refund also State<Billing>). bulk.rs verify_bulk_ownership helper narrowed to &PgPool. verify_* ownership calls flipped to &db. crud::update_item keeps State<AppState> (calls the un-migrated &AppState scheduler helpers send_release_announcements / spawn_mt_thread_for_item). No behavior change; item 164 + bundle 11 + chapter 9 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 00:37 UTC
Commit: 3ed3e954aa650293f85f72f6609ebc017833040a
Parent: 8a44e40
8 files changed, +150 insertions, -147 deletions
@@ -6,16 +6,16 @@ use axum::{
6 6 };
7 7 use axum_extra::extract::Form as HtmlForm;
8 8 use serde::Deserialize;
9 + use sqlx::PgPool;
9 10
10 11 use crate::{
11 12 auth::AuthUser,
12 13 db::{self, ItemId, ProjectId},
13 14 error::{AppError, Result},
14 15 helpers::htmx_toast_response,
15 - AppState,
16 16 };
17 17
18 - use super::super::{verify_project_ownership};
18 + use super::super::verify_project_ownership;
19 19
20 20 const BULK_ITEM_LIMIT: usize = 100;
21 21
@@ -31,7 +31,7 @@ pub struct BulkItemRequest {
31 31 /// Shared ownership check for bulk operations: verify all items belong to one
32 32 /// project owned by the user. Returns the confirmed project ID.
33 33 async fn verify_bulk_ownership(
34 - state: &AppState,
34 + db: &PgPool,
35 35 item_ids: &[ItemId],
36 36 user_id: db::UserId,
37 37 ) -> Result<ProjectId> {
@@ -45,7 +45,7 @@ async fn verify_bulk_ownership(
45 45 }
46 46
47 47 // Single query: fetch (item_id, project_id) for all items
48 - let pairs = db::items::get_item_project_ids_batch(&state.db, item_ids).await?;
48 + let pairs = db::items::get_item_project_ids_batch(db, item_ids).await?;
49 49
50 50 if pairs.len() != item_ids.len() {
51 51 return Err(AppError::NotFound);
@@ -62,7 +62,7 @@ async fn verify_bulk_ownership(
62 62 }
63 63
64 64 // Verify the user owns that project
65 - verify_project_ownership(&state.db, project_id, user_id).await?;
65 + verify_project_ownership(db, project_id, user_id).await?;
66 66
67 67 Ok(project_id)
68 68 }
@@ -70,15 +70,15 @@ async fn verify_bulk_ownership(
70 70 /// Bulk-publish selected items.
71 71 #[tracing::instrument(skip_all, name = "items::bulk_publish")]
72 72 pub(in crate::routes::api) async fn bulk_publish(
73 - State(state): State<AppState>,
73 + State(db): State<PgPool>,
74 74 AuthUser(user): AuthUser,
75 75 HtmlForm(req): HtmlForm<BulkItemRequest>,
76 76 ) -> Result<impl IntoResponse> {
77 77 user.check_not_suspended()?;
78 - let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?;
78 + let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
79 79
80 - let count = db::items::bulk_publish(&state.db, &req.item_ids, project_id, user.id).await?;
81 - db::projects::bump_cache_generation(&state.db, project_id).await?;
80 + let count = db::items::bulk_publish(&db, &req.item_ids, project_id, user.id).await?;
81 + db::projects::bump_cache_generation(&db, project_id).await?;
82 82
83 83 Ok(htmx_toast_response(&format!("{count} item(s) published"), "success"))
84 84 }
@@ -86,15 +86,15 @@ pub(in crate::routes::api) async fn bulk_publish(
86 86 /// Bulk-unpublish selected items.
87 87 #[tracing::instrument(skip_all, name = "items::bulk_unpublish")]
88 88 pub(in crate::routes::api) async fn bulk_unpublish(
89 - State(state): State<AppState>,
89 + State(db): State<PgPool>,
90 90 AuthUser(user): AuthUser,
91 91 HtmlForm(req): HtmlForm<BulkItemRequest>,
92 92 ) -> Result<impl IntoResponse> {
93 93 user.check_not_suspended()?;
94 - let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?;
94 + let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
95 95
96 - let count = db::items::bulk_unpublish(&state.db, &req.item_ids, project_id, user.id).await?;
97 - db::projects::bump_cache_generation(&state.db, project_id).await?;
96 + let count = db::items::bulk_unpublish(&db, &req.item_ids, project_id, user.id).await?;
97 + db::projects::bump_cache_generation(&db, project_id).await?;
98 98
99 99 Ok(htmx_toast_response(&format!("{count} item(s) unpublished"), "success"))
100 100 }
@@ -102,16 +102,16 @@ pub(in crate::routes::api) async fn bulk_unpublish(
102 102 /// Bulk-delete selected items.
103 103 #[tracing::instrument(skip_all, name = "items::bulk_delete")]
104 104 pub(in crate::routes::api) async fn bulk_delete(
105 - State(state): State<AppState>,
105 + State(db): State<PgPool>,
106 106 AuthUser(user): AuthUser,
107 107 HtmlForm(req): HtmlForm<BulkItemRequest>,
108 108 ) -> Result<impl IntoResponse> {
109 109 user.check_not_suspended()?;
110 - let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?;
110 + let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
111 111
112 112 // Soft-delete: items are recoverable for 7 days, then purged by the scheduler
113 - let count = db::items::bulk_delete(&state.db, &req.item_ids, project_id, user.id).await?;
114 - db::projects::bump_cache_generation(&state.db, project_id).await?;
113 + let count = db::items::bulk_delete(&db, &req.item_ids, project_id, user.id).await?;
114 + db::projects::bump_cache_generation(&db, project_id).await?;
115 115
116 116 Ok(htmx_toast_response(&format!("{count} item(s) moved to Recently Deleted"), "success"))
117 117 }
@@ -127,7 +127,7 @@ pub struct BulkPriceRequest {
127 127 /// Bulk-update price on selected items.
128 128 #[tracing::instrument(skip_all, name = "items::bulk_price")]
129 129 pub(in crate::routes::api) async fn bulk_price(
130 - State(state): State<AppState>,
130 + State(db): State<PgPool>,
131 131 AuthUser(user): AuthUser,
132 132 HtmlForm(req): HtmlForm<BulkPriceRequest>,
133 133 ) -> Result<impl IntoResponse> {
@@ -136,9 +136,9 @@ pub(in crate::routes::api) async fn bulk_price(
136 136 let price_cents_raw = crate::pricing::parse_dollars_to_cents("Price", Some(&req.price_dollars))?;
137 137 let price_cents = db::PriceCents::new(price_cents_raw)?;
138 138
139 - let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?;
140 - let count = db::items::bulk_update_price(&state.db, &req.item_ids, project_id, user.id, price_cents).await?;
141 - db::projects::bump_cache_generation(&state.db, project_id).await?;
139 + let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
140 + let count = db::items::bulk_update_price(&db, &req.item_ids, project_id, user.id, price_cents).await?;
141 + db::projects::bump_cache_generation(&db, project_id).await?;
142 142
143 143 let label = if *price_cents == 0 {
144 144 "Free".to_string()
@@ -160,7 +160,7 @@ pub struct BulkTagRequest {
160 160 /// Bulk-add a tag to selected items by slug lookup.
161 161 #[tracing::instrument(skip_all, name = "items::bulk_tag")]
162 162 pub(in crate::routes::api) async fn bulk_tag(
163 - State(state): State<AppState>,
163 + State(db): State<PgPool>,
164 164 AuthUser(user): AuthUser,
165 165 HtmlForm(req): HtmlForm<BulkTagRequest>,
166 166 ) -> Result<impl IntoResponse> {
@@ -169,14 +169,14 @@ pub(in crate::routes::api) async fn bulk_tag(
169 169 let slug = req.tag_slug.trim();
170 170 crate::validation::validate_tag_slug(slug)?;
171 171
172 - let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?;
172 + let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
173 173
174 - let tag = db::tags::get_tag_by_slug(&state.db, slug)
174 + let tag = db::tags::get_tag_by_slug(&db, slug)
175 175 .await?
176 176 .ok_or(AppError::NotFound)?;
177 177
178 - let count = db::items::bulk_add_tag(&state.db, &req.item_ids, project_id, user.id, tag.id).await?;
179 - db::projects::bump_cache_generation(&state.db, project_id).await?;
178 + let count = db::items::bulk_add_tag(&db, &req.item_ids, project_id, user.id, tag.id).await?;
179 + db::projects::bump_cache_generation(&db, project_id).await?;
180 180
181 181 Ok(htmx_toast_response(&format!("Tag \"{}\" added to {count} item(s)", tag.name), "success"))
182 182 }
@@ -12,8 +12,8 @@ use crate::{
12 12 auth::AuthUser,
13 13 db::{self, ItemId, ItemType},
14 14 error::{AppError, Result},
15 - AppState,
16 15 };
16 + use sqlx::PgPool;
17 17
18 18 use super::super::verify_item_ownership;
19 19
@@ -30,17 +30,17 @@ pub struct BundleListedRequest {
30 30 /// POST /api/items/{id}/bundle/add: add an item to this bundle.
31 31 #[tracing::instrument(skip_all, name = "items::bundle_add")]
32 32 pub async fn bundle_add(
33 - State(state): State<AppState>,
33 + State(db): State<PgPool>,
34 34 AuthUser(user): AuthUser,
35 35 Path(bundle_id): Path<ItemId>,
36 36 Json(req): Json<BundleAddRequest>,
37 37 ) -> Result<impl IntoResponse> {
38 38 user.check_not_suspended()?;
39 - let (item, _project) = verify_item_ownership(&state.db, bundle_id, user.id).await?;
39 + let (item, _project) = verify_item_ownership(&db, bundle_id, user.id).await?;
40 40 if item.item_type != ItemType::Bundle {
41 41 return Err(AppError::BadRequest("Item is not a bundle".to_string()));
42 42 }
43 - let target = db::items::get_item_by_id(&state.db, req.item_id)
43 + let target = db::items::get_item_by_id(&db, req.item_id)
44 44 .await?
45 45 .ok_or(AppError::NotFound)?;
46 46 if target.project_id != item.project_id {
@@ -49,45 +49,45 @@ pub async fn bundle_add(
49 49 if target.item_type == ItemType::Bundle {
50 50 return Err(AppError::BadRequest("Cannot nest bundles".to_string()));
51 51 }
52 - let count = db::bundles::get_bundle_item_count(&state.db, bundle_id).await?;
53 - db::bundles::add_item_to_bundle(&state.db, bundle_id, req.item_id, count as i32).await?;
52 + let count = db::bundles::get_bundle_item_count(&db, bundle_id).await?;
53 + db::bundles::add_item_to_bundle(&db, bundle_id, req.item_id, count as i32).await?;
54 54 Ok(StatusCode::OK)
55 55 }
56 56
57 57 /// DELETE /api/items/{id}/bundle/{child_id}: remove an item from this bundle.
58 58 #[tracing::instrument(skip_all, name = "items::bundle_remove")]
59 59 pub async fn bundle_remove(
60 - State(state): State<AppState>,
60 + State(db): State<PgPool>,
61 61 AuthUser(user): AuthUser,
62 62 Path((bundle_id, child_id)): Path<(ItemId, ItemId)>,
63 63 ) -> Result<impl IntoResponse> {
64 64 user.check_not_suspended()?;
65 - let (item, _project) = verify_item_ownership(&state.db, bundle_id, user.id).await?;
65 + let (item, _project) = verify_item_ownership(&db, bundle_id, user.id).await?;
66 66 if item.item_type != ItemType::Bundle {
67 67 return Err(AppError::BadRequest("Item is not a bundle".to_string()));
68 68 }
69 - db::bundles::remove_item_from_bundle(&state.db, bundle_id, child_id).await?;
69 + db::bundles::remove_item_from_bundle(&db, bundle_id, child_id).await?;
70 70 Ok(StatusCode::OK)
71 71 }
72 72
73 73 /// PUT /api/items/{id}/bundle/{child_id}/listed: toggle listed status.
74 74 #[tracing::instrument(skip_all, name = "items::bundle_toggle_listed")]
75 75 pub async fn bundle_toggle_listed(
76 - State(state): State<AppState>,
76 + State(db): State<PgPool>,
77 77 AuthUser(user): AuthUser,
78 78 Path((bundle_id, child_id)): Path<(ItemId, ItemId)>,
79 79 Json(req): Json<BundleListedRequest>,
80 80 ) -> Result<impl IntoResponse> {
81 81 user.check_not_suspended()?;
82 - let (item, _project) = verify_item_ownership(&state.db, bundle_id, user.id).await?;
82 + let (item, _project) = verify_item_ownership(&db, bundle_id, user.id).await?;
83 83 if item.item_type != ItemType::Bundle {
84 84 return Err(AppError::BadRequest("Item is not a bundle".to_string()));
85 85 }
86 86 // Verify child actually belongs to this bundle before toggling
87 - if !db::bundles::is_bundle_member(&state.db, bundle_id, child_id).await? {
87 + if !db::bundles::is_bundle_member(&db, bundle_id, child_id).await? {
88 88 return Err(AppError::NotFound);
89 89 }
90 - db::bundles::set_item_listed(&state.db, child_id, req.listed, user.id).await?;
90 + db::bundles::set_item_listed(&db, child_id, req.listed, user.id).await?;
91 91 Ok(StatusCode::OK)
92 92 }
93 93
@@ -106,13 +106,13 @@ pub struct BundleCreateChildResponse {
106 106 /// POST /api/items/{id}/bundle/create-child: create a new item, add to bundle, set unlisted.
107 107 #[tracing::instrument(skip_all, name = "items::bundle_create_child")]
108 108 pub async fn bundle_create_child(
109 - State(state): State<AppState>,
109 + State(db): State<PgPool>,
110 110 AuthUser(user): AuthUser,
111 111 Path(bundle_id): Path<ItemId>,
112 112 Json(req): Json<BundleCreateChildRequest>,
113 113 ) -> Result<impl IntoResponse> {
114 114 user.check_not_suspended()?;
115 - let (bundle, _project) = verify_item_ownership(&state.db, bundle_id, user.id).await?;
115 + let (bundle, _project) = verify_item_ownership(&db, bundle_id, user.id).await?;
116 116 if bundle.item_type != ItemType::Bundle {
117 117 return Err(AppError::BadRequest("Item is not a bundle".to_string()));
118 118 }
@@ -123,7 +123,7 @@ pub async fn bundle_create_child(
123 123 }
124 124
125 125 let child = db::items::create_item(
126 - &state.db,
126 + &db,
127 127 bundle.project_id,
128 128 &req.title,
129 129 req.description.as_deref(),
@@ -135,14 +135,14 @@ pub async fn bundle_create_child(
135 135 .await?;
136 136
137 137 // Add to bundle and set unlisted
138 - let count = db::bundles::get_bundle_item_count(&state.db, bundle_id).await?;
139 - db::bundles::add_item_to_bundle(&state.db, bundle_id, child.id, count as i32).await?;
140 - db::bundles::set_item_listed(&state.db, child.id, false, user.id).await?;
138 + let count = db::bundles::get_bundle_item_count(&db, bundle_id).await?;
139 + db::bundles::add_item_to_bundle(&db, bundle_id, child.id, count as i32).await?;
140 + db::bundles::set_item_listed(&db, child.id, false, user.id).await?;
141 141
142 142 // Publish the child so it's downloadable via the bundle
143 - db::items::bulk_publish(&state.db, &[child.id], bundle.project_id, user.id).await?;
143 + db::items::bulk_publish(&db, &[child.id], bundle.project_id, user.id).await?;
144 144
145 - db::projects::bump_cache_generation(&state.db, bundle.project_id).await?;
145 + db::projects::bump_cache_generation(&db, bundle.project_id).await?;
146 146
147 147 Ok(Json(BundleCreateChildResponse {
148 148 item_id: child.id,
@@ -15,8 +15,8 @@ use crate::{
15 15 helpers::{htmx_toast_response, is_htmx_request},
16 16 types::ListResponse,
17 17 validation,
18 - AppState,
19 18 };
19 + use sqlx::PgPool;
20 20
21 21 use super::super::verify_item_ownership;
22 22
@@ -50,17 +50,17 @@ struct ChapterResponse {
50 50 /// Create a new chapter marker on an owned audio item.
51 51 #[tracing::instrument(skip_all, name = "items::create_chapter")]
52 52 pub(in crate::routes::api) async fn create_chapter(
53 - State(state): State<AppState>,
53 + State(db): State<PgPool>,
54 54 AuthUser(user): AuthUser,
55 55 Path(item_id): Path<ItemId>,
56 56 Json(req): Json<CreateChapterRequest>,
57 57 ) -> Result<impl IntoResponse> {
58 58 user.check_not_suspended()?;
59 59 validation::validate_chapter_title(&req.title)?;
60 - let (item, _) = verify_item_ownership(&state.db, item_id, user.id).await?;
60 + let (item, _) = verify_item_ownership(&db, item_id, user.id).await?;
61 61
62 62 let chapter = db::chapters::create_chapter(
63 - &state.db,
63 + &db,
64 64 item_id,
65 65 &req.title,
66 66 req.start_seconds,
@@ -68,7 +68,7 @@ pub(in crate::routes::api) async fn create_chapter(
68 68 )
69 69 .await?;
70 70
71 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
71 + db::projects::bump_cache_generation(&db, item.project_id).await?;
72 72
73 73 Ok(Json(ChapterResponse {
74 74 id: chapter.id,
@@ -82,16 +82,16 @@ pub(in crate::routes::api) async fn create_chapter(
82 82 /// List all chapters for a given item (public items only; drafts return 404).
83 83 #[tracing::instrument(skip_all, name = "items::list_chapters")]
84 84 pub(in crate::routes::api) async fn list_chapters(
85 - State(state): State<AppState>,
85 + State(db): State<PgPool>,
86 86 Path(item_id): Path<ItemId>,
87 87 ) -> Result<impl IntoResponse> {
88 - let item = db::items::get_item_by_id(&state.db, item_id)
88 + let item = db::items::get_item_by_id(&db, item_id)
89 89 .await?
90 90 .ok_or(AppError::NotFound)?;
91 91 if !item.is_public {
92 92 return Err(AppError::NotFound);
93 93 }
94 - let chapters = db::chapters::get_chapters_by_item(&state.db, item_id).await?;
94 + let chapters = db::chapters::get_chapters_by_item(&db, item_id).await?;
95 95 let data: Vec<ChapterResponse> = chapters.into_iter().map(|c| ChapterResponse {
96 96 id: c.id,
97 97 item_id: c.item_id,
@@ -105,7 +105,7 @@ pub(in crate::routes::api) async fn list_chapters(
105 105 /// Update an existing chapter marker on an owned item.
106 106 #[tracing::instrument(skip_all, name = "items::update_chapter")]
107 107 pub(in crate::routes::api) async fn update_chapter(
108 - State(state): State<AppState>,
108 + State(db): State<PgPool>,
109 109 AuthUser(user): AuthUser,
110 110 Path(chapter_id): Path<ChapterId>,
111 111 Json(req): Json<UpdateChapterRequest>,
@@ -113,14 +113,14 @@ pub(in crate::routes::api) async fn update_chapter(
113 113 user.check_not_suspended()?;
114 114 validation::validate_chapter_title(&req.title)?;
115 115 // Get chapter to find item_id for ownership check
116 - let chapter = db::chapters::get_chapter_by_id(&state.db, chapter_id)
116 + let chapter = db::chapters::get_chapter_by_id(&db, chapter_id)
117 117 .await?
118 118 .ok_or(AppError::NotFound)?;
119 119
120 - let (item, _) = verify_item_ownership(&state.db, chapter.item_id, user.id).await?;
120 + let (item, _) = verify_item_ownership(&db, chapter.item_id, user.id).await?;
121 121
122 122 let updated = db::chapters::update_chapter(
123 - &state.db,
123 + &db,
124 124 chapter_id,
125 125 &req.title,
126 126 req.start_seconds,
@@ -128,7 +128,7 @@ pub(in crate::routes::api) async fn update_chapter(
128 128 )
129 129 .await?;
130 130
131 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
131 + db::projects::bump_cache_generation(&db, item.project_id).await?;
132 132
133 133 Ok(Json(ChapterResponse {
134 134 id: updated.id,
@@ -142,21 +142,21 @@ pub(in crate::routes::api) async fn update_chapter(
142 142 /// Delete a chapter marker from an owned item.
143 143 #[tracing::instrument(skip_all, name = "items::delete_chapter")]
144 144 pub(in crate::routes::api) async fn delete_chapter(
145 - State(state): State<AppState>,
145 + State(db): State<PgPool>,
146 146 headers: HeaderMap,
147 147 AuthUser(user): AuthUser,
148 148 Path(chapter_id): Path<ChapterId>,
149 149 ) -> Result<Response> {
150 150 user.check_not_suspended()?;
151 151 // Get chapter to find item_id for ownership check
152 - let chapter = db::chapters::get_chapter_by_id(&state.db, chapter_id)
152 + let chapter = db::chapters::get_chapter_by_id(&db, chapter_id)
153 153 .await?
154 154 .ok_or(AppError::NotFound)?;
155 155
156 - let (item, _) = verify_item_ownership(&state.db, chapter.item_id, user.id).await?;
156 + let (item, _) = verify_item_ownership(&db, chapter.item_id, user.id).await?;
157 157
158 - db::chapters::delete_chapter(&state.db, chapter_id).await?;
159 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
158 + db::chapters::delete_chapter(&db, chapter_id).await?;
159 + db::projects::bump_cache_generation(&db, item.project_id).await?;
160 160
161 161 if is_htmx_request(&headers) {
162 162 return Ok(htmx_toast_response("Chapter deleted", "success").into_response());
@@ -7,6 +7,7 @@ use axum::{
7 7 Form, Json,
8 8 };
9 9 use serde::{Deserialize, Serialize};
10 + use sqlx::PgPool;
10 11
11 12 use crate::{
12 13 auth::AuthUser,
@@ -57,7 +58,7 @@ pub struct ItemResponse {
57 58 /// Create a new item under an owned project.
58 59 #[tracing::instrument(skip_all, name = "items::create_item", fields(project_id))]
59 60 pub(in crate::routes::api) async fn create_item(
60 - State(state): State<AppState>,
61 + State(db): State<PgPool>,
61 62 headers: HeaderMap,
62 63 AuthUser(user): AuthUser,
63 64 Path(project_id): Path<ProjectId>,
@@ -71,10 +72,10 @@ pub(in crate::routes::api) async fn create_item(
71 72 validation::validate_item_description(desc)?;
72 73 }
73 74
74 - verify_project_ownership(&state.db, project_id, user.id).await?;
75 + verify_project_ownership(&db, project_id, user.id).await?;
75 76
76 77 // Validate item type against project features
77 - let project = db::projects::get_project_by_id(&state.db, project_id)
78 + let project = db::projects::get_project_by_id(&db, project_id)
78 79 .await?
79 80 .ok_or(AppError::NotFound)?;
80 81 let item_type = req.item_type.unwrap_or(ItemType::Digital);
@@ -99,7 +100,7 @@ pub(in crate::routes::api) async fn create_item(
99 100 };
100 101
101 102 let item = db::items::create_item(
102 - &state.db,
103 + &db,
103 104 project_id,
104 105 &req.title,
105 106 req.description.as_deref(),
@@ -110,7 +111,7 @@ pub(in crate::routes::api) async fn create_item(
110 111 )
111 112 .await?;
112 113
113 - db::projects::bump_cache_generation(&state.db, project_id).await?;
114 + db::projects::bump_cache_generation(&db, project_id).await?;
114 115
115 116 if is_htmx_request(&headers) {
116 117 // Return HX-Redirect header to redirect to the item dashboard
@@ -277,17 +278,17 @@ pub(in crate::routes::api) async fn update_item(
277 278 /// Soft-delete an item owned by the authenticated user (recoverable for 7 days).
278 279 #[tracing::instrument(skip_all, name = "items::delete_item", fields(item_id))]
279 280 pub(in crate::routes::api) async fn delete_item(
280 - State(state): State<AppState>,
281 + State(db): State<PgPool>,
281 282 _headers: HeaderMap,
282 283 AuthUser(user): AuthUser,
283 284 Path(id): Path<ItemId>,
284 285 ) -> Result<Response> {
285 286 tracing::Span::current().record("item_id", tracing::field::display(&id));
286 287 user.check_not_suspended()?;
287 - let (item, _project) = verify_item_ownership(&state.db, id, user.id).await?;
288 + let (item, _project) = verify_item_ownership(&db, id, user.id).await?;
288 289
289 - db::items::delete_item(&state.db, id, user.id).await?;
290 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
290 + db::items::delete_item(&db, id, user.id).await?;
291 + db::projects::bump_cache_generation(&db, item.project_id).await?;
291 292
292 293 // Storage is reclaimed when the scheduler purges after 7 days
293 294
@@ -297,14 +298,14 @@ pub(in crate::routes::api) async fn delete_item(
297 298 /// Restore a soft-deleted item.
298 299 #[tracing::instrument(skip_all, name = "items::restore_item", fields(item_id))]
299 300 pub(in crate::routes::api) async fn restore_item(
300 - State(state): State<AppState>,
301 + State(db): State<PgPool>,
301 302 AuthUser(user): AuthUser,
302 303 Path(id): Path<ItemId>,
303 304 ) -> Result<impl IntoResponse> {
304 305 user.check_not_suspended()?;
305 - verify_item_ownership(&state.db, id, user.id).await?;
306 + verify_item_ownership(&db, id, user.id).await?;
306 307
307 - let restored = db::items::restore_item(&state.db, id, user.id).await?;
308 + let restored = db::items::restore_item(&db, id, user.id).await?;
308 309 if !restored {
309 310 return Err(AppError::NotFound);
310 311 }
@@ -315,18 +316,18 @@ pub(in crate::routes::api) async fn restore_item(
315 316 /// Duplicate an item and its metadata, creating a new draft.
316 317 #[tracing::instrument(skip_all, name = "items::duplicate_item", fields(item_id))]
317 318 pub(in crate::routes::api) async fn duplicate_item(
318 - State(state): State<AppState>,
319 + State(db): State<PgPool>,
319 320 headers: HeaderMap,
320 321 AuthUser(user): AuthUser,
321 322 Path(id): Path<ItemId>,
322 323 ) -> Result<Response> {
323 324 tracing::Span::current().record("item_id", tracing::field::display(&id));
324 325 user.check_not_suspended()?;
325 - verify_item_ownership(&state.db, id, user.id).await?;
326 + verify_item_ownership(&db, id, user.id).await?;
326 327
327 - let new_item = db::items::duplicate_item(&state.db, id, user.id).await?;
328 + let new_item = db::items::duplicate_item(&db, id, user.id).await?;
328 329
329 - db::projects::bump_cache_generation(&state.db, new_item.project_id).await?;
330 + db::projects::bump_cache_generation(&db, new_item.project_id).await?;
330 331
331 332 if is_htmx_request(&headers) {
332 333 let mut response = Response::new(axum::body::Body::empty());
@@ -363,17 +364,17 @@ pub struct MoveItemRequest {
363 364 /// Move an item up or down in its project's sort order.
364 365 #[tracing::instrument(skip_all, name = "items::move_item", fields(item_id))]
365 366 pub(in crate::routes::api) async fn move_item(
366 - State(state): State<AppState>,
367 + State(db): State<PgPool>,
367 368 AuthUser(user): AuthUser,
368 369 Path(id): Path<ItemId>,
369 370 Form(req): Form<MoveItemRequest>,
370 371 ) -> Result<impl IntoResponse> {
371 372 tracing::Span::current().record("item_id", tracing::field::display(&id));
372 373 user.check_not_suspended()?;
373 - let (item, _project) = verify_item_ownership(&state.db, id, user.id).await?;
374 + let (item, _project) = verify_item_ownership(&db, id, user.id).await?;
374 375
375 - db::items::move_item(&state.db, item.project_id, user.id, id, &req.direction).await?;
376 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
376 + db::items::move_item(&db, item.project_id, user.id, id, &req.direction).await?;
377 + db::projects::bump_cache_generation(&db, item.project_id).await?;
377 378
378 379 Ok(StatusCode::NO_CONTENT)
379 380 }
@@ -400,7 +401,7 @@ struct UpdateTextResponse {
400 401 /// Save or update the text body content for an owned item.
401 402 #[tracing::instrument(skip_all, name = "items::update_item_text", fields(item_id))]
402 403 pub(in crate::routes::api) async fn update_item_text(
403 - State(state): State<AppState>,
404 + State(db): State<PgPool>,
404 405 headers: HeaderMap,
405 406 AuthUser(user): AuthUser,
406 407 Path(id): Path<ItemId>,
@@ -409,10 +410,10 @@ pub(in crate::routes::api) async fn update_item_text(
409 410 tracing::Span::current().record("item_id", tracing::field::display(&id));
410 411 user.check_not_suspended()?;
411 412 validation::validate_item_text_body(&req.body)?;
412 - verify_item_ownership(&state.db, id, user.id).await?;
413 + verify_item_ownership(&db, id, user.id).await?;
413 414
414 - let item = db::items::update_item_text(&state.db, id, user.id, &req.body).await?;
415 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
415 + let item = db::items::update_item_text(&db, id, user.id, &req.body).await?;
416 + db::projects::bump_cache_generation(&db, item.project_id).await?;
416 417
417 418 let (body, word_count, reading_time_minutes) = match item.content() {
418 419 ContentData::Text { body, word_count, reading_time_minutes } => (body, word_count, reading_time_minutes),
@@ -4,12 +4,13 @@ use axum::extract::{Path, State};
4 4 use axum::response::IntoResponse;
5 5 use axum::Json;
6 6 use serde::Deserialize;
7 + use sqlx::PgPool;
7 8
8 9 use crate::{
9 10 auth::AuthUser,
10 11 db::{self, ItemId, TransactionId},
11 12 error::{AppError, Result},
12 - AppState,
13 + Billing,
13 14 };
14 15
15 16 use super::super::verify_item_ownership;
@@ -28,16 +29,17 @@ pub struct RefundRequest {
28 29 /// would silently reverse the entire order (Run #2 Payments SERIOUS).
29 30 #[tracing::instrument(skip_all, name = "items::refund_transaction")]
30 31 pub(in crate::routes::api) async fn refund_transaction(
31 - State(state): State<AppState>,
32 + State(db): State<PgPool>,
33 + State(payments): State<Billing>,
32 34 AuthUser(user): AuthUser,
33 35 Path(id): Path<ItemId>,
34 36 Json(req): Json<RefundRequest>,
35 37 ) -> Result<impl IntoResponse> {
36 38 user.check_not_suspended()?;
37 - verify_item_ownership(&state.db, id, user.id).await?;
39 + verify_item_ownership(&db, id, user.id).await?;
38 40
39 41 // Fetch the transaction and validate it belongs to this item
40 - let tx = db::transactions::get_transaction_by_id(&state.db, req.transaction_id)
42 + let tx = db::transactions::get_transaction_by_id(&db, req.transaction_id)
41 43 .await?
42 44 .ok_or(AppError::NotFound)?;
43 45
@@ -55,13 +57,13 @@ pub(in crate::routes::api) async fn refund_transaction(
55 57 .ok_or_else(|| AppError::BadRequest("No payment intent — free claims cannot be refunded".into()))?;
56 58
57 59 // Get the creator's Stripe connected account ID
58 - let seller = db::users::get_user_by_id(&state.db, user.id)
60 + let seller = db::users::get_user_by_id(&db, user.id)
59 61 .await?
60 62 .ok_or(AppError::NotFound)?;
61 63 let stripe_account_id = seller.stripe_account_id.as_deref()
62 64 .ok_or_else(|| AppError::BadRequest("No Stripe account connected".into()))?;
63 65
64 - let stripe = state.stripe.as_ref()
66 + let stripe = payments.stripe.as_ref()
65 67 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
66 68
67 69 // Atomically claim the row (completed -> refunding) BEFORE calling Stripe. The
@@ -69,7 +71,7 @@ pub(in crate::routes::api) async fn refund_transaction(
69 71 // double-submit would pass that check twice (the row stays `completed` until the
70 72 // async refund.created webhook) and, on a shared-cart PaymentIntent, the second
71 73 // refund would consume another line's refundable balance (Pay-S1, Run 9).
72 - if db::transactions::claim_transaction_for_refund(&state.db, tx.id).await?.is_none() {
74 + if db::transactions::claim_transaction_for_refund(&db, tx.id).await?.is_none() {
73 75 return Err(AppError::BadRequest("A refund for this transaction is already in progress".into()));
74 76 }
75 77
@@ -82,7 +84,7 @@ pub(in crate::routes::api) async fn refund_transaction(
82 84 tx.amount_cents.as_i64(),
83 85 tx.id,
84 86 ).await {
85 - db::transactions::release_refund_claim(&state.db, tx.id).await?;
87 + db::transactions::release_refund_claim(&db, tx.id).await?;
86 88 return Err(e);
87 89 }
88 90
@@ -7,6 +7,7 @@ use axum::{
7 7 Json,
8 8 };
9 9 use serde::{Deserialize, Serialize};
10 + use sqlx::PgPool;
10 11
11 12 use crate::{
12 13 auth::AuthUser,
@@ -15,7 +16,6 @@ use crate::{
15 16 helpers::{htmx_toast_response, is_htmx_request, slugify},
16 17 types::ListResponse,
17 18 validation,
18 - AppState,
19 19 };
20 20
21 21 use super::super::verify_item_ownership;
@@ -72,7 +72,7 @@ impl From<db::DbItemSection> for SectionResponse {
72 72 /// Create a new section on an owned item.
73 73 #[tracing::instrument(skip_all, name = "items::create_section")]
74 74 pub(in crate::routes::api) async fn create_section(
75 - State(state): State<AppState>,
75 + State(db): State<PgPool>,
76 76 AuthUser(user): AuthUser,
77 77 Path(item_id): Path<ItemId>,
78 78 Json(req): Json<CreateSectionRequest>,
@@ -82,10 +82,10 @@ pub(in crate::routes::api) async fn create_section(
82 82 validation::validate_section_title(&title)?;
83 83 validation::validate_section_body(&req.body)?;
84 84
85 - let (item, _) = verify_item_ownership(&state.db, item_id, user.id).await?;
85 + let (item, _) = verify_item_ownership(&db, item_id, user.id).await?;
86 86
87 87 // Enforce max sections limit
88 - let count = db::item_sections::count_by_item(&state.db, item_id).await?;
88 + let count = db::item_sections::count_by_item(&db, item_id).await?;
89 89 if count >= MAX_SECTIONS_PER_ITEM {
90 90 return Err(AppError::validation(format!(
91 91 "Maximum of {} sections per item",
@@ -101,14 +101,14 @@ pub(in crate::routes::api) async fn create_section(
101 101 // Run #1 UX). `insert_with_unique_slug` owns the `-N` suffixing and 23505
102 102 // retry, with the index as the race-safe source of truth.
103 103 let base = slugify(&title).to_string();
104 - let pool = &state.db;
104 + let pool = &db;
105 105 let (title_s, body_s) = (title.as_str(), req.body.as_str());
106 106 let section = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
107 107 db::item_sections::create(pool, item_id, title_s, &slug, body_s, sort_order).await
108 108 })
109 109 .await?;
110 110
111 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
111 + db::projects::bump_cache_generation(&db, item.project_id).await?;
112 112
113 113 Ok(Json(SectionResponse::from(section)))
114 114 }
@@ -116,16 +116,16 @@ pub(in crate::routes::api) async fn create_section(
116 116 /// List all sections for a given item (public items only; drafts return 404).
117 117 #[tracing::instrument(skip_all, name = "items::list_sections")]
118 118 pub(in crate::routes::api) async fn list_sections(
119 - State(state): State<AppState>,
119 + State(db): State<PgPool>,
120 120 Path(item_id): Path<ItemId>,
121 121 ) -> Result<impl IntoResponse> {
122 - let item = db::items::get_item_by_id(&state.db, item_id)
122 + let item = db::items::get_item_by_id(&db, item_id)
123 123 .await?
124 124 .ok_or(AppError::NotFound)?;
125 125 if !item.is_public {
126 126 return Err(AppError::NotFound);
127 127 }
128 - let sections = db::item_sections::list_by_item(&state.db, item_id).await?;
128 + let sections = db::item_sections::list_by_item(&db, item_id).await?;
129 129 let data: Vec<SectionResponse> = sections.into_iter().map(SectionResponse::from).collect();
130 130 Ok(Json(ListResponse { data }))
131 131 }
@@ -133,7 +133,7 @@ pub(in crate::routes::api) async fn list_sections(
133 133 /// Update an existing section on an owned item.
134 134 #[tracing::instrument(skip_all, name = "items::update_section")]
135 135 pub(in crate::routes::api) async fn update_section(
136 - State(state): State<AppState>,
136 + State(db): State<PgPool>,
137 137 AuthUser(user): AuthUser,
138 138 Path(section_id): Path<ItemSectionId>,
139 139 Json(req): Json<UpdateSectionRequest>,
@@ -143,24 +143,24 @@ pub(in crate::routes::api) async fn update_section(
143 143 validation::validate_section_title(&title)?;
144 144 validation::validate_section_body(&req.body)?;
145 145
146 - let section = db::item_sections::get_by_id(&state.db, section_id)
146 + let section = db::item_sections::get_by_id(&db, section_id)
147 147 .await?
148 148 .ok_or(AppError::NotFound)?;
149 149
150 - let (item, _) = verify_item_ownership(&state.db, section.item_id, user.id).await?;
150 + let (item, _) = verify_item_ownership(&db, section.item_id, user.id).await?;
151 151
152 152 // Same dedup as create. Re-saving the section under its own slug is not a
153 153 // conflict (same row), so the bare base succeeds; only a collision with a
154 154 // *different* section triggers the `-N` suffix retry.
155 155 let base = slugify(&title).to_string();
156 - let pool = &state.db;
156 + let pool = &db;
157 157 let (title_s, body_s) = (title.as_str(), req.body.as_str());
158 158 let updated = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
159 159 db::item_sections::update(pool, section_id, title_s, &slug, body_s).await
160 160 })
161 161 .await?;
162 162
163 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
163 + db::projects::bump_cache_generation(&db, item.project_id).await?;
164 164
165 165 Ok(Json(SectionResponse::from(updated)))
166 166 }
@@ -168,21 +168,21 @@ pub(in crate::routes::api) async fn update_section(
168 168 /// Delete a section from an owned item.
169 169 #[tracing::instrument(skip_all, name = "items::delete_section")]
170 170 pub(in crate::routes::api) async fn delete_section(
171 - State(state): State<AppState>,
171 + State(db): State<PgPool>,
172 172 headers: HeaderMap,
173 173 AuthUser(user): AuthUser,
174 174 Path(section_id): Path<ItemSectionId>,
175 175 ) -> Result<Response> {
176 176 user.check_not_suspended()?;
177 177
178 - let section = db::item_sections::get_by_id(&state.db, section_id)
178 + let section = db::item_sections::get_by_id(&db, section_id)
179 179 .await?
180 180 .ok_or(AppError::NotFound)?;
181 181
182 - let (item, _) = verify_item_ownership(&state.db, section.item_id, user.id).await?;
182 + let (item, _) = verify_item_ownership(&db, section.item_id, user.id).await?;
183 183
184 - db::item_sections::delete(&state.db, section_id).await?;
185 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
184 + db::item_sections::delete(&db, section_id).await?;
185 + db::projects::bump_cache_generation(&db, item.project_id).await?;
186 186
187 187 if is_htmx_request(&headers) {
188 188 return Ok(htmx_toast_response("Section deleted", "success").into_response());
@@ -194,16 +194,16 @@ pub(in crate::routes::api) async fn delete_section(
194 194 /// Reorder sections for an owned item.
195 195 #[tracing::instrument(skip_all, name = "items::reorder_sections")]
196 196 pub(in crate::routes::api) async fn reorder_sections(
197 - State(state): State<AppState>,
197 + State(db): State<PgPool>,
198 198 AuthUser(user): AuthUser,
199 199 Path(item_id): Path<ItemId>,
200 200 Json(req): Json<ReorderSectionsRequest>,
201 201 ) -> Result<impl IntoResponse> {
202 202 user.check_not_suspended()?;
203 - let (item, _) = verify_item_ownership(&state.db, item_id, user.id).await?;
203 + let (item, _) = verify_item_ownership(&db, item_id, user.id).await?;
204 204
205 - db::item_sections::reorder(&state.db, item_id, &req.section_ids).await?;
206 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
205 + db::item_sections::reorder(&db, item_id, &req.section_ids).await?;
206 + db::projects::bump_cache_generation(&db, item.project_id).await?;
207 207
208 208 Ok(StatusCode::NO_CONTENT)
209 209 }
@@ -13,8 +13,8 @@ use crate::{
13 13 error::{AppError, Result},
14 14 helpers::htmx_toast_response,
15 15 templates::TagTemplate,
16 - AppState,
17 16 };
17 + use sqlx::PgPool;
18 18
19 19 use super::super::verify_item_ownership;
20 20
@@ -33,15 +33,15 @@ const MAX_TAGS_PER_ITEM: usize = 5;
33 33 /// Items are limited to [`MAX_TAGS_PER_ITEM`] tags.
34 34 #[tracing::instrument(skip_all, name = "items::add_tag")]
35 35 pub(in crate::routes::api) async fn add_tag(
36 - State(state): State<AppState>,
36 + State(db): State<PgPool>,
37 37 AuthUser(user): AuthUser,
38 38 Path(id): Path<ItemId>,
39 39 Form(form): Form<AddTagForm>,
40 40 ) -> Result<impl IntoResponse> {
41 41 user.check_not_suspended()?;
42 - let (item, _) = verify_item_ownership(&state.db, id, user.id).await?;
42 + let (item, _) = verify_item_ownership(&db, id, user.id).await?;
43 43
44 - let tag = db::tags::get_tag_by_id(&state.db, form.tag_id)
44 + let tag = db::tags::get_tag_by_id(&db, form.tag_id)
45 45 .await?
46 46 .ok_or(AppError::NotFound)?;
47 47
@@ -53,15 +53,15 @@ pub(in crate::routes::api) async fn add_tag(
53 53 }
54 54
55 55 // Enforce per-item tag limit
56 - let existing = db::tags::get_tags_for_item(&state.db, id).await?;
56 + let existing = db::tags::get_tags_for_item(&db, id).await?;
57 57 if existing.len() >= MAX_TAGS_PER_ITEM && !existing.iter().any(|t| t.tag_id == form.tag_id) {
58 58 return Err(AppError::validation(
59 59 format!("Items may have at most {MAX_TAGS_PER_ITEM} tags"),
60 60 ));
61 61 }
62 62
63 - db::tags::add_tag_to_item(&state.db, id, form.tag_id, false).await?;
64 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
63 + db::tags::add_tag_to_item(&db, id, form.tag_id, false).await?;
64 + db::projects::bump_cache_generation(&db, item.project_id).await?;
65 65
66 66 Ok(Html(TagTemplate {
67 67 item_id: id.to_string(),
@@ -74,15 +74,15 @@ pub(in crate::routes::api) async fn add_tag(
74 74 /// Remove a tag from an owned item.
75 75 #[tracing::instrument(skip_all, name = "items::remove_tag")]
76 76 pub(in crate::routes::api) async fn remove_tag(
77 - State(state): State<AppState>,
77 + State(db): State<PgPool>,
78 78 AuthUser(user): AuthUser,
79 79 Path((id, tag_id)): Path<(ItemId, TagId)>,
80 80 ) -> Result<impl IntoResponse> {
81 81 user.check_not_suspended()?;
82 - let (item, _) = verify_item_ownership(&state.db, id, user.id).await?;
82 + let (item, _) = verify_item_ownership(&db, id, user.id).await?;
83 83
84 - db::tags::remove_tag_from_item(&state.db, id, tag_id).await?;
85 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
84 + db::tags::remove_tag_from_item(&db, id, tag_id).await?;
85 + db::projects::bump_cache_generation(&db, item.project_id).await?;
86 86 Ok(htmx_toast_response("Tag removed", "success"))
87 87 }
88 88
@@ -95,15 +95,15 @@ pub struct SetPrimaryTagForm {
95 95 /// Set the primary tag for an owned item.
96 96 #[tracing::instrument(skip_all, name = "items::set_primary_tag")]
97 97 pub(in crate::routes::api) async fn set_primary_tag(
98 - State(state): State<AppState>,
98 + State(db): State<PgPool>,
99 99 AuthUser(user): AuthUser,
100 100 Path(id): Path<ItemId>,
101 101 Form(form): Form<SetPrimaryTagForm>,
102 102 ) -> Result<impl IntoResponse> {
103 103 user.check_not_suspended()?;
104 - let (item, _) = verify_item_ownership(&state.db, id, user.id).await?;
104 + let (item, _) = verify_item_ownership(&db, id, user.id).await?;
105 105
106 - db::tags::set_primary_tag(&state.db, id, form.tag_id).await?;
107 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
106 + db::tags::set_primary_tag(&db, id, form.tag_id).await?;
107 + db::projects::bump_cache_generation(&db, item.project_id).await?;
108 108 Ok(htmx_toast_response("Primary tag updated", "success"))
109 109 }
@@ -13,8 +13,8 @@ use crate::{
13 13 error::{AppError, Result},
14 14 types::ListResponse,
15 15 validation,
16 - AppState,
17 16 };
17 + use sqlx::PgPool;
18 18
19 19 use super::super::verify_item_ownership;
20 20
@@ -44,7 +44,7 @@ pub struct VersionResponse {
44 44 /// Create a new version for an owned item.
45 45 #[tracing::instrument(skip_all, name = "items::create_version")]
46 46 pub(in crate::routes::api) async fn create_version(
47 - State(state): State<AppState>,
47 + State(db): State<PgPool>,
48 48 AuthUser(user): AuthUser,
49 49 Path(item_id): Path<ItemId>,
50 50 Json(req): Json<CreateVersionRequest>,
@@ -56,10 +56,10 @@ pub(in crate::routes::api) async fn create_version(
56 56 validation::validate_changelog(changelog)?;
57 57 }
58 58
59 - let (item, _) = verify_item_ownership(&state.db, item_id, user.id).await?;
59 + let (item, _) = verify_item_ownership(&db, item_id, user.id).await?;
60 60
61 61 let version = db::versions::create_version(
62 - &state.db,
62 + &db,
63 63 item_id,
64 64 &req.version_number,
65 65 req.changelog.as_deref(),
@@ -70,7 +70,7 @@ pub(in crate::routes::api) async fn create_version(
70 70 )
71 71 .await?;
72 72
73 - db::projects::bump_cache_generation(&state.db, item.project_id).await?;
73 + db::projects::bump_cache_generation(&db, item.project_id).await?;
74 74
75 75 Ok(Json(VersionResponse {
76 76 id: version.id,
@@ -86,14 +86,14 @@ pub(in crate::routes::api) async fn create_version(
86 86 /// Delete a version from an owned item. Enqueues S3 deletion if file exists.
87 87 #[tracing::instrument(skip_all, name = "items::delete_version")]
88 88 pub(in crate::routes::api) async fn delete_version(
89 - State(state): State<AppState>,
89 + State(db): State<PgPool>,
90 90 AuthUser(user): AuthUser,
91 91 Path((item_id, version_id)): Path<(ItemId, VersionId)>,
92 92 ) -> Result<impl IntoResponse> {
93 93 user.check_not_suspended()?;
94 - verify_item_ownership(&state.db, item_id, user.id).await?;
94 + verify_item_ownership(&db, item_id, user.id).await?;
95 95
96 - let version = db::versions::get_version_by_id(&state.db, version_id)
96 + let version = db::versions::get_version_by_id(&db, version_id)
97 97 .await?
98 98 .ok_or(AppError::NotFound)?;
99 99
@@ -103,7 +103,7 @@ pub(in crate::routes::api) async fn delete_version(
103 103
104 104 // delete_version handles storage decrement + S3 enqueue atomically
105 105 let _ = version;
106 - db::versions::delete_version(&state.db, version_id).await?;
106 + db::versions::delete_version(&db, version_id).await?;
107 107
108 108 Ok(axum::http::StatusCode::OK)
109 109 }
@@ -111,16 +111,16 @@ pub(in crate::routes::api) async fn delete_version(
111 111 /// List all versions for a given item (public items only; drafts return 404).
112 112 #[tracing::instrument(skip_all, name = "items::list_versions")]
113 113 pub(in crate::routes::api) async fn list_versions(
114 - State(state): State<AppState>,
114 + State(db): State<PgPool>,
115 115 Path(item_id): Path<ItemId>,
116 116 ) -> Result<impl IntoResponse> {
117 - let item = db::items::get_item_by_id(&state.db, item_id)
117 + let item = db::items::get_item_by_id(&db, item_id)
118 118 .await?
119 119 .ok_or(AppError::NotFound)?;
120 120 if !item.is_public {
121 121 return Err(AppError::NotFound);
122 122 }
123 - let versions = db::versions::get_versions_by_item(&state.db, item_id).await?;
123 + let versions = db::versions::get_versions_by_item(&db, item_id).await?;
124 124
125 125 let data: Vec<VersionResponse> = versions
126 126 .into_iter()