Skip to main content

max / makenotwork

6.2 KB · 201 lines History Blame Raw
1 //! Bulk item operations (publish, unpublish, delete).
2
3 use axum::{extract::State, response::IntoResponse};
4 use axum_extra::extract::Form as HtmlForm;
5 use serde::Deserialize;
6 use sqlx::PgPool;
7
8 use crate::{
9 auth::AuthUser,
10 db::{self, ItemId, ProjectId},
11 error::{AppError, Result},
12 helpers::htmx_toast_response,
13 };
14
15 use super::super::verify_project_ownership;
16
17 const BULK_ITEM_LIMIT: usize = 100;
18
19 /// Form input for bulk item operations.
20 ///
21 /// Accepts repeated `item_ids` form fields (one per checkbox).
22 #[derive(Debug, Deserialize)]
23 pub(crate) struct BulkItemRequest {
24 #[serde(default)]
25 pub item_ids: Vec<ItemId>,
26 }
27
28 /// Shared ownership check for bulk operations: verify all items belong to one
29 /// project owned by the user. Returns the confirmed project ID.
30 async fn verify_bulk_ownership(
31 db: &PgPool,
32 item_ids: &[ItemId],
33 user_id: db::UserId,
34 ) -> Result<ProjectId> {
35 if item_ids.is_empty() {
36 return Err(AppError::BadRequest("No items selected".into()));
37 }
38 if item_ids.len() > BULK_ITEM_LIMIT {
39 return Err(AppError::BadRequest(format!(
40 "Too many items (max {BULK_ITEM_LIMIT})"
41 )));
42 }
43
44 // Single query: fetch (item_id, project_id) for all items
45 let pairs = db::items::get_item_project_ids_batch(db, item_ids).await?;
46
47 if pairs.len() != item_ids.len() {
48 return Err(AppError::NotFound);
49 }
50
51 // Confirm all items share one project
52 let project_id = pairs[0].1;
53 for &(_, pid) in &pairs[1..] {
54 if pid != project_id {
55 return Err(AppError::BadRequest(
56 "All items must belong to the same project".into(),
57 ));
58 }
59 }
60
61 // Verify the user owns that project
62 verify_project_ownership(db, project_id, user_id).await?;
63
64 Ok(project_id)
65 }
66
67 /// Bulk-publish selected items.
68 #[tracing::instrument(skip_all, name = "items::bulk_publish")]
69 pub(in crate::routes::api) async fn bulk_publish(
70 State(db): State<PgPool>,
71 AuthUser(user): AuthUser,
72 HtmlForm(req): HtmlForm<BulkItemRequest>,
73 ) -> Result<impl IntoResponse> {
74 user.check_not_suspended()?;
75 let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
76
77 let count = db::items::bulk_publish(&db, &req.item_ids, project_id, user.id).await?;
78 db::projects::bump_cache_generation(&db, project_id).await?;
79
80 Ok(htmx_toast_response(
81 &format!("{count} item(s) published"),
82 "success",
83 ))
84 }
85
86 /// Bulk-unpublish selected items.
87 #[tracing::instrument(skip_all, name = "items::bulk_unpublish")]
88 pub(in crate::routes::api) async fn bulk_unpublish(
89 State(db): State<PgPool>,
90 AuthUser(user): AuthUser,
91 HtmlForm(req): HtmlForm<BulkItemRequest>,
92 ) -> Result<impl IntoResponse> {
93 user.check_not_suspended()?;
94 let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
95
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
99 Ok(htmx_toast_response(
100 &format!("{count} item(s) unpublished"),
101 "success",
102 ))
103 }
104
105 /// Bulk-delete selected items.
106 #[tracing::instrument(skip_all, name = "items::bulk_delete")]
107 pub(in crate::routes::api) async fn bulk_delete(
108 State(db): State<PgPool>,
109 AuthUser(user): AuthUser,
110 HtmlForm(req): HtmlForm<BulkItemRequest>,
111 ) -> Result<impl IntoResponse> {
112 user.check_not_suspended()?;
113 let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
114
115 // Soft-delete: items are recoverable for 7 days, then purged by the scheduler
116 let count = db::items::bulk_delete(&db, &req.item_ids, project_id, user.id).await?;
117 db::projects::bump_cache_generation(&db, project_id).await?;
118
119 Ok(htmx_toast_response(
120 &format!("{count} item(s) moved to Recently Deleted"),
121 "success",
122 ))
123 }
124
125 /// Form input for bulk price change.
126 #[derive(Debug, Deserialize)]
127 pub(crate) struct BulkPriceRequest {
128 #[serde(default)]
129 pub item_ids: Vec<ItemId>,
130 pub price_dollars: String,
131 }
132
133 /// Bulk-update price on selected items.
134 #[tracing::instrument(skip_all, name = "items::bulk_price")]
135 pub(in crate::routes::api) async fn bulk_price(
136 State(db): State<PgPool>,
137 AuthUser(user): AuthUser,
138 HtmlForm(req): HtmlForm<BulkPriceRequest>,
139 ) -> Result<impl IntoResponse> {
140 user.check_not_suspended()?;
141
142 let price_cents_raw =
143 crate::pricing::parse_dollars_to_cents("Price", Some(&req.price_dollars))?;
144 let price_cents = db::PriceCents::new(price_cents_raw)?;
145
146 let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
147 let count =
148 db::items::bulk_update_price(&db, &req.item_ids, project_id, user.id, price_cents).await?;
149 db::projects::bump_cache_generation(&db, project_id).await?;
150
151 let label = if *price_cents == 0 {
152 "Free".to_string()
153 } else {
154 format!(
155 "${}.{:02}",
156 price_cents_raw / 100,
157 (price_cents_raw % 100).unsigned_abs()
158 )
159 };
160 Ok(htmx_toast_response(
161 &format!("{count} item(s) set to {label}"),
162 "success",
163 ))
164 }
165
166 /// Form input for bulk tag addition.
167 #[derive(Debug, Deserialize)]
168 pub(crate) struct BulkTagRequest {
169 #[serde(default)]
170 pub item_ids: Vec<ItemId>,
171 /// Dot-notation tag slug, e.g. "audio.genre.electronic".
172 pub tag_slug: String,
173 }
174
175 /// Bulk-add a tag to selected items by slug lookup.
176 #[tracing::instrument(skip_all, name = "items::bulk_tag")]
177 pub(in crate::routes::api) async fn bulk_tag(
178 State(db): State<PgPool>,
179 AuthUser(user): AuthUser,
180 HtmlForm(req): HtmlForm<BulkTagRequest>,
181 ) -> Result<impl IntoResponse> {
182 user.check_not_suspended()?;
183
184 let slug = req.tag_slug.trim();
185 crate::validation::validate_tag_slug(slug)?;
186
187 let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
188
189 let tag = db::tags::get_tag_by_slug(&db, slug)
190 .await?
191 .ok_or(AppError::NotFound)?;
192
193 let count = db::items::bulk_add_tag(&db, &req.item_ids, project_id, user.id, tag.id).await?;
194 db::projects::bump_cache_generation(&db, project_id).await?;
195
196 Ok(htmx_toast_response(
197 &format!("Tag \"{}\" added to {count} item(s)", tag.name),
198 "success",
199 ))
200 }
201