//! 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, AppState, }; /// Update the item type when going back to step 1 and re-submitting. pub(super) async fn save_type( state: &AppState, 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( &state.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( state: &AppState, item: &db::DbItem, form: &HashMap, user_id: UserId, ) -> Result<()> { let title = form.get("title").map(|s| s.trim()).unwrap_or("Untitled"); let description = form.get("description").map(|s| s.as_str()); validation::validate_item_title(title)?; if let Some(desc) = description && !desc.is_empty() { validation::validate_item_description(desc)?; } db::items::update_item( &state.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( state: &AppState, 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(&state.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(&state.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(&state.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(&state.db, bi.id, !should_be_unlisted).await?; } } } // Audio/file items: content uploaded via presign flow (client-side S3) Ok(()) } pub(super) async fn save_pricing( state: &AppState, item: &db::DbItem, form: &HashMap, user_id: UserId, ) -> Result<()> { // 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( &state.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))?; let price = PriceCents::new(price_cents)?; db::items::update_item( &state.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(suggested_cents)?; let min = PriceCents::new(min_cents)?; db::items::update_item( &state.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(()) } pub(super) async fn save_preview( state: &AppState, user: &crate::auth::SessionUser, _project: &db::DbProject, item: &db::DbItem, form: &HashMap, ) -> Result { let action = form.get("action").map(|s| s.as_str()).unwrap_or("draft"); match action { "publish" => { db::items::update_item( &state.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(&state.db, item.id) .await? .ok_or(AppError::NotFound)?; if updated.is_public { crate::scheduler::send_release_announcements(state, &updated).await; if updated.mt_thread_id.is_none() { crate::scheduler::spawn_mt_thread_for_item(state, &updated, user); } } } "schedule" => { if let Some(datetime_str) = form.get("publish_at") && let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M") { let utc_dt = dt.and_utc(); db::items::update_item( &state.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 } let mut response = Response::new(axum::body::Body::empty()); response.headers_mut().insert( "HX-Redirect", format!("/dashboard/item/{}", item.id) .parse() .expect("redirect path is valid"), ); Ok(response) }