//! Step save handlers for the item wizard. use std::collections::HashMap; use axum::response::Response; use crate::{ db::{self, ItemId, ItemType, PriceCents, ProjectFeature, UserId}, error::{AppError, Result}, pricing::parse_dollars_to_cents, validation, }; use sqlx::PgPool; /// Update the item type when going back to step 1 and re-submitting. pub(super) async fn save_type( db: &PgPool, project: &db::DbProject, item: &db::DbItem, form: &HashMap, user_id: UserId, ) -> Result<()> { let type_str = form .get("item_type") .ok_or(AppError::BadRequest("Missing item_type".to_string()))?; let item_type: ItemType = type_str .parse() .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?; // Validate the selected type is in the allowed wizard cards let cards = ProjectFeature::wizard_type_cards(&project.features); if !cards.iter().any(|(v, _, _)| *v == type_str.as_str()) { return Err(AppError::validation(format!( "Item type '{type_str}' is not available for this project", ))); } db::items::update_item( db, item.id, user_id, None, None, None, Some(item_type), None, None, None, None, None, None, None, // ai_tier, ai_disclosure ) .await?; Ok(()) } pub(super) async fn save_basics( db: &PgPool, item: &db::DbItem, form: &HashMap, user_id: UserId, ) -> Result<()> { let title = form.get("title").map_or("Untitled", |s| s.trim()); let description = form.get("description").map(std::string::String::as_str); validation::validate_item_title(title)?; if let Some(desc) = description && !desc.is_empty() { validation::validate_item_description(desc)?; } db::items::update_item( db, item.id, user_id, Some(title), description, None, None, None, None, None, None, None, None, None, // ai_tier, ai_disclosure ) .await?; // Cover image URL is set authoritatively by `item_image_confirm` (which // writes cover_image_url + cover_s3_key + cover_file_size_bytes together // and updates the storage counter). The wizard's hidden field used to // re-write cover_image_url here on form submit, which under client-side // hidden-field manipulation could desync the URL from the s3_key, a // future cover replacement would then probe the wrong old object for its // size and drift the storage counter. Trust confirm's write. Ok(()) } pub(super) async fn save_content( db: &PgPool, item: &db::DbItem, form: &HashMap, user_id: UserId, ) -> Result<()> { if item.item_type == ItemType::Text { // Text items: save body directly if let Some(body) = form.get("body") { db::items::update_item_text(db, item.id, user_id, body).await?; } } else if item.item_type == ItemType::Bundle { // Bundle items: parse selected item IDs and unlisted flags let bundle_ids: Vec = form .get("bundle_item_ids") .map(|s| { s.split(',') .filter(|v| !v.is_empty()) .filter_map(|v| v.parse().ok()) .collect() }) .unwrap_or_default(); let unlisted_ids: Vec = form .get("unlisted_item_ids") .map(|s| { s.split(',') .filter(|v| !v.is_empty()) .filter_map(|v| v.parse().ok()) .collect() }) .unwrap_or_default(); // Set bundle contents (replaces all existing) db::bundles::set_bundle_items(db, item.id, &bundle_ids, user_id).await?; // Update listed status for all bundleable items in this project let all_bundleable = db::bundles::get_bundleable_items(db, item.project_id, Some(item.id)).await?; for bi in &all_bundleable { let should_be_unlisted = unlisted_ids.contains(&bi.id); if bi.listed == should_be_unlisted { // listed=true but should be unlisted, or listed=false but shouldn't be db::bundles::set_item_listed(db, bi.id, !should_be_unlisted, user_id).await?; } } } // Audio/file items: content uploaded via presign flow (client-side S3) Ok(()) } pub(super) async fn save_pricing( db: &PgPool, item: &db::DbItem, form: &HashMap, user: &crate::auth::SessionUser, ) -> Result<()> { let user_id = user.id; // Reject missing/malformed pricing_model rather than silently defaulting // to "free", a typo or future variant would otherwise demote the item to // free on submit. Same disease class as the tier-row silent-drop bug // fixed in the project wizard at Run #6. let pricing_model = form .get("pricing_model") .map(String::as_str) .ok_or_else(|| AppError::validation("Select a pricing model"))?; match pricing_model { "free" => { db::items::update_item( db, item.id, user_id, None, None, Some(PriceCents::from_db(0)), None, None, Some(false), None, None, None, None, None, // ai_tier, ai_disclosure ) .await?; } "fixed" => { let price_cents = parse_dollars_to_cents("Price", form.get("price").map(String::as_str))?; // A fixed item price is a buy-once price, so it carries Stripe's // minimum charge for the creator's settlement currency. Without the // floor here the item saves and the sale fails at checkout instead. let price = PriceCents::buy_once(price_cents, user.settlement_currency)?; db::items::update_item( db, item.id, user_id, None, None, Some(price), None, None, Some(false), None, None, None, None, None, // ai_tier, ai_disclosure ) .await?; } "pwyw" => { let suggested_cents = parse_dollars_to_cents( "Suggested price", form.get("suggested_price").map(String::as_str), )?; let min_cents = parse_dollars_to_cents("Minimum price", form.get("min_price").map(String::as_str))?; if min_cents > suggested_cents { return Err(AppError::validation( "Minimum price cannot exceed the suggested price", )); } let suggested = PriceCents::new_in(suggested_cents, user.settlement_currency)?; let min = PriceCents::new_in(min_cents, user.settlement_currency)?; db::items::update_item( db, item.id, user_id, None, None, Some(suggested), None, None, Some(true), Some(min), None, None, None, None, // ai_tier, ai_disclosure ) .await?; } other => { return Err(AppError::validation(format!( "Unknown pricing model: {other}" ))); } } Ok(()) } #[allow(clippy::too_many_arguments)] pub(super) async fn save_preview( db: &sqlx::PgPool, mailer: &crate::email::EmailClient, config: &crate::config::Config, bg: &crate::background::BackgroundTx, integrations: &crate::Integrations, user: &crate::auth::SessionUser, _project: &db::DbProject, item: &db::DbItem, form: &HashMap, ) -> Result { let action = form .get("action") .map_or("draft", std::string::String::as_str); match action { "publish" => { db::items::update_item( db, item.id, user.id, None, None, None, None, Some(true), None, None, None, None, None, None, // ai_tier, ai_disclosure ) .await?; // Re-fetch to get updated is_public state let updated = db::items::get_item_by_id(db, item.id) .await? .ok_or(AppError::NotFound)?; if updated.is_public { crate::scheduler::send_release_announcements(db, mailer, config, &updated).await; if updated.mt_thread_id.is_none() { crate::scheduler::spawn_mt_thread_for_item( db, bg, integrations, config, &updated, user, ); } } } "schedule" => { // A missing or unparseable publish_at used to fall through silently, // leaving the item a draft while the creator believed it was scheduled // (Run #2 UX MINOR). Surface a validation error instead. let datetime_str = form .get("publish_at") .filter(|s| !s.is_empty()) .ok_or_else(|| { AppError::validation( "Enter a publish date and time to schedule this item.".to_string(), ) })?; let dt = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M") .map_err(|_| { AppError::validation("Enter a valid publish date and time.".to_string()) })?; let utc_dt = dt.and_utc(); db::items::update_item( db, item.id, user.id, None, None, None, None, None, None, None, Some(Some(utc_dt)), None, None, None, // ai_tier, ai_disclosure ) .await?; } _ => {} // draft, leave as is } // item.id is a UUID so the parse can't fail, but fall back rather than // panic the worker for consistency with the other HX-Redirect sites (Run 11 UX MINOR). let mut response = Response::new(axum::body::Body::empty()); let redirect = format!("/dashboard/item/{}", item.id) .parse() .unwrap_or_else(|_| axum::http::HeaderValue::from_static("/dashboard")); response.headers_mut().insert("HX-Redirect", redirect); Ok(response) }