//! Bulk item operations (publish, unpublish, delete). use axum::{extract::State, response::IntoResponse}; use axum_extra::extract::Form as HtmlForm; use serde::Deserialize; use sqlx::PgPool; use crate::{ auth::AuthUser, db::{self, ItemId, ProjectId}, error::{AppError, Result}, helpers::htmx_toast_response, }; use super::super::verify_project_ownership; /// How many items one bulk write may name. /// /// `pub(crate)`: the described Content panel's own bulk route enforces the same /// ceiling, and two copies of it would be two ceilings. pub(crate) 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(crate) 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( db: &PgPool, 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(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(db, 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(db): State, AuthUser(user): AuthUser, HtmlForm(req): HtmlForm, ) -> Result { user.check_not_suspended()?; let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?; let count = db::items::bulk_publish(&db, &req.item_ids, project_id, user.id).await?; db::projects::bump_cache_generation(&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(db): State, AuthUser(user): AuthUser, HtmlForm(req): HtmlForm, ) -> Result { user.check_not_suspended()?; let project_id = verify_bulk_ownership(&db, &req.item_ids, user.id).await?; let count = db::items::bulk_unpublish(&db, &req.item_ids, project_id, user.id).await?; db::projects::bump_cache_generation(&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(db): State, AuthUser(user): AuthUser, HtmlForm(req): HtmlForm, ) -> Result { user.check_not_suspended()?; let project_id = verify_bulk_ownership(&db, &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(&db, &req.item_ids, project_id, user.id).await?; db::projects::bump_cache_generation(&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(crate) 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(db): 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(&db, &req.item_ids, user.id).await?; let count = db::items::bulk_update_price(&db, &req.item_ids, project_id, user.id, price_cents).await?; db::projects::bump_cache_generation(&db, project_id).await?; let label = if *price_cents == 0 { "Free".to_string() } else { crate::formatting::format_revenue(i64::from(price_cents_raw), user.settlement_currency) }; Ok(htmx_toast_response( &format!("{count} item(s) set to {label}"), "success", )) } /// Form input for bulk tag addition. #[derive(Debug, Deserialize)] pub(crate) 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(db): 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(&db, &req.item_ids, user.id).await?; let tag = db::tags::get_tag_by_slug(&db, slug) .await? .ok_or(AppError::NotFound)?; let count = db::items::bulk_add_tag(&db, &req.item_ids, project_id, user.id, tag.id).await?; db::projects::bump_cache_generation(&db, project_id).await?; Ok(htmx_toast_response( &format!("Tag \"{}\" added to {count} item(s)", tag.name), "success", )) }