Skip to main content

max / makenotwork

6.3 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 /// How many items one bulk write may name.
18 ///
19 /// `pub(crate)` since 2026-08-21: the described Content panel's own bulk route
20 /// enforces the same ceiling, and two copies of it would be two ceilings.
21 pub(crate) const BULK_ITEM_LIMIT: usize = 100;
22
23 /// Form input for bulk item operations.
24 ///
25 /// Accepts repeated `item_ids` form fields (one per checkbox).
26 #[derive(Debug, Deserialize)]
27 pub(crate) struct BulkItemRequest {
28 #[serde(default)]
29 pub item_ids: Vec<ItemId>,
30 }
31
32 /// Shared ownership check for bulk operations: verify all items belong to one
33 /// project owned by the user. Returns the confirmed project ID.
34 async fn verify_bulk_ownership(
35 db: &PgPool,
36 item_ids: &[ItemId],
37 user_id: db::UserId,
38 ) -> Result<ProjectId> {
39 if item_ids.is_empty() {
40 return Err(AppError::BadRequest("No items selected".into()));
41 }
42 if item_ids.len() > BULK_ITEM_LIMIT {
43 return Err(AppError::BadRequest(format!(
44 "Too many items (max {BULK_ITEM_LIMIT})"
45 )));
46 }
47
48 // Single query: fetch (item_id, project_id) for all items
49 let pairs = db::items::get_item_project_ids_batch(db, item_ids).await?;
50
51 if pairs.len() != item_ids.len() {
52 return Err(AppError::NotFound);
53 }
54
55 // Confirm all items share one project
56 let project_id = pairs[0].1;
57 for &(_, pid) in &pairs[1..] {
58 if pid != project_id {
59 return Err(AppError::BadRequest(
60 "All items must belong to the same project".into(),
61 ));
62 }
63 }
64
65 // Verify the user owns that project
66 verify_project_ownership(db, project_id, user_id).await?;
67
68 Ok(project_id)
69 }
70
71 /// Bulk-publish selected items.
72 #[tracing::instrument(skip_all, name = "items::bulk_publish")]
73 pub(in crate::routes::api) async fn bulk_publish(
74 State(db): State<PgPool>,
75 AuthUser(user): AuthUser,
76 HtmlForm(req): HtmlForm<BulkItemRequest>,
77 ) -> Result<impl IntoResponse> {
78 user.check_not_suspended()?;
79 let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
80
81 let count = db::items::bulk_publish(&db, &req.item_ids, project_id, user.id).await?;
82 db::projects::bump_cache_generation(&db, project_id).await?;
83
84 Ok(htmx_toast_response(
85 &format!("{count} item(s) published"),
86 "success",
87 ))
88 }
89
90 /// Bulk-unpublish selected items.
91 #[tracing::instrument(skip_all, name = "items::bulk_unpublish")]
92 pub(in crate::routes::api) async fn bulk_unpublish(
93 State(db): State<PgPool>,
94 AuthUser(user): AuthUser,
95 HtmlForm(req): HtmlForm<BulkItemRequest>,
96 ) -> Result<impl IntoResponse> {
97 user.check_not_suspended()?;
98 let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
99
100 let count = db::items::bulk_unpublish(&db, &req.item_ids, project_id, user.id).await?;
101 db::projects::bump_cache_generation(&db, project_id).await?;
102
103 Ok(htmx_toast_response(
104 &format!("{count} item(s) unpublished"),
105 "success",
106 ))
107 }
108
109 /// Bulk-delete selected items.
110 #[tracing::instrument(skip_all, name = "items::bulk_delete")]
111 pub(in crate::routes::api) async fn bulk_delete(
112 State(db): State<PgPool>,
113 AuthUser(user): AuthUser,
114 HtmlForm(req): HtmlForm<BulkItemRequest>,
115 ) -> Result<impl IntoResponse> {
116 user.check_not_suspended()?;
117 let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
118
119 // Soft-delete: items are recoverable for 7 days, then purged by the scheduler
120 let count = db::items::bulk_delete(&db, &req.item_ids, project_id, user.id).await?;
121 db::projects::bump_cache_generation(&db, project_id).await?;
122
123 Ok(htmx_toast_response(
124 &format!("{count} item(s) moved to Recently Deleted"),
125 "success",
126 ))
127 }
128
129 /// Form input for bulk price change.
130 #[derive(Debug, Deserialize)]
131 pub(crate) struct BulkPriceRequest {
132 #[serde(default)]
133 pub item_ids: Vec<ItemId>,
134 pub price_dollars: String,
135 }
136
137 /// Bulk-update price on selected items.
138 #[tracing::instrument(skip_all, name = "items::bulk_price")]
139 pub(in crate::routes::api) async fn bulk_price(
140 State(db): State<PgPool>,
141 AuthUser(user): AuthUser,
142 HtmlForm(req): HtmlForm<BulkPriceRequest>,
143 ) -> Result<impl IntoResponse> {
144 user.check_not_suspended()?;
145
146 let price_cents_raw =
147 crate::pricing::parse_dollars_to_cents("Price", Some(&req.price_dollars))?;
148 let price_cents = db::PriceCents::new(price_cents_raw)?;
149
150 let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?;
151 let count =
152 db::items::bulk_update_price(&db, &req.item_ids, project_id, user.id, price_cents).await?;
153 db::projects::bump_cache_generation(&db, project_id).await?;
154
155 let label = if *price_cents == 0 {
156 "Free".to_string()
157 } else {
158 crate::formatting::format_revenue(i64::from(price_cents_raw), user.settlement_currency)
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