//! Bulk item operations (publish, unpublish, delete). use axum::{ extract::State, response::IntoResponse, }; use axum_extra::extract::Form as HtmlForm; use serde::Deserialize; use crate::{ auth::AuthUser, db::{self, ItemId, ProjectId}, error::{AppError, Result}, helpers::htmx_toast_response, AppState, }; use super::super::{verify_project_ownership}; const BULK_ITEM_LIMIT: usize = 100; /// Form input for bulk item operations. /// /// Accepts repeated `item_ids` form fields (one per checkbox). #[derive(Debug, Deserialize)] pub struct BulkItemRequest { #[serde(default)] pub item_ids: Vec, } /// Shared ownership check for bulk operations: verify all items belong to one /// project owned by the user. Returns the confirmed project ID. async fn verify_bulk_ownership( state: &AppState, item_ids: &[ItemId], user_id: db::UserId, ) -> Result { if item_ids.is_empty() { return Err(AppError::BadRequest("No items selected".into())); } if item_ids.len() > BULK_ITEM_LIMIT { return Err(AppError::BadRequest( format!("Too many items (max {})", BULK_ITEM_LIMIT), )); } // Single query: fetch (item_id, project_id) for all items let pairs = db::items::get_item_project_ids_batch(&state.db, item_ids).await?; if pairs.len() != item_ids.len() { return Err(AppError::NotFound); } // Confirm all items share one project let project_id = pairs[0].1; for &(_, pid) in &pairs[1..] { if pid != project_id { return Err(AppError::BadRequest( "All items must belong to the same project".into(), )); } } // Verify the user owns that project verify_project_ownership(state, project_id, user_id).await?; Ok(project_id) } /// Bulk-publish selected items. #[tracing::instrument(skip_all, name = "items::bulk_publish")] pub(in crate::routes::api) async fn bulk_publish( State(state): State, AuthUser(user): AuthUser, HtmlForm(req): HtmlForm, ) -> Result { user.check_not_suspended()?; let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?; let count = db::items::bulk_publish(&state.db, &req.item_ids, project_id, user.id).await?; db::projects::bump_cache_generation(&state.db, project_id).await?; Ok(htmx_toast_response(&format!("{count} item(s) published"), "success")) } /// Bulk-unpublish selected items. #[tracing::instrument(skip_all, name = "items::bulk_unpublish")] pub(in crate::routes::api) async fn bulk_unpublish( State(state): State, AuthUser(user): AuthUser, HtmlForm(req): HtmlForm, ) -> Result { user.check_not_suspended()?; let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?; let count = db::items::bulk_unpublish(&state.db, &req.item_ids, project_id, user.id).await?; db::projects::bump_cache_generation(&state.db, project_id).await?; Ok(htmx_toast_response(&format!("{count} item(s) unpublished"), "success")) } /// Bulk-delete selected items. #[tracing::instrument(skip_all, name = "items::bulk_delete")] pub(in crate::routes::api) async fn bulk_delete( State(state): State, AuthUser(user): AuthUser, HtmlForm(req): HtmlForm, ) -> Result { user.check_not_suspended()?; let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?; // Soft-delete: items are recoverable for 7 days, then purged by the scheduler let count = db::items::bulk_delete(&state.db, &req.item_ids, project_id, user.id).await?; db::projects::bump_cache_generation(&state.db, project_id).await?; Ok(htmx_toast_response(&format!("{count} item(s) moved to Recently Deleted"), "success")) } /// Form input for bulk price change. #[derive(Debug, Deserialize)] pub struct BulkPriceRequest { #[serde(default)] pub item_ids: Vec, pub price_dollars: String, } /// Bulk-update price on selected items. #[tracing::instrument(skip_all, name = "items::bulk_price")] pub(in crate::routes::api) async fn bulk_price( State(state): State, AuthUser(user): AuthUser, HtmlForm(req): HtmlForm, ) -> Result { user.check_not_suspended()?; let price_cents_raw = crate::pricing::parse_dollars_to_cents("Price", Some(&req.price_dollars))?; let price_cents = db::PriceCents::new(price_cents_raw)?; let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?; let count = db::items::bulk_update_price(&state.db, &req.item_ids, project_id, user.id, price_cents).await?; db::projects::bump_cache_generation(&state.db, project_id).await?; let label = if *price_cents == 0 { "Free".to_string() } else { format!("${}.{:02}", price_cents_raw / 100, (price_cents_raw % 100).unsigned_abs()) }; Ok(htmx_toast_response(&format!("{count} item(s) set to {label}"), "success")) } /// Form input for bulk tag addition. #[derive(Debug, Deserialize)] pub struct BulkTagRequest { #[serde(default)] pub item_ids: Vec, /// Dot-notation tag slug, e.g. "audio.genre.electronic". pub tag_slug: String, } /// Bulk-add a tag to selected items by slug lookup. #[tracing::instrument(skip_all, name = "items::bulk_tag")] pub(in crate::routes::api) async fn bulk_tag( State(state): State, AuthUser(user): AuthUser, HtmlForm(req): HtmlForm, ) -> Result { user.check_not_suspended()?; let slug = req.tag_slug.trim(); crate::validation::validate_tag_slug(slug)?; let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?; let tag = db::tags::get_tag_by_slug(&state.db, slug) .await? .ok_or(AppError::NotFound)?; let count = db::items::bulk_add_tag(&state.db, &req.item_ids, project_id, user.id, tag.id).await?; db::projects::bump_cache_generation(&state.db, project_id).await?; Ok(htmx_toast_response(&format!("Tag \"{}\" added to {count} item(s)", tag.name), "success")) }