Skip to main content

max / makenotwork

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