//! Project creation wizard; 5 steps: basics, appearance, monetization, //! first-content, preview. use std::collections::HashMap; use axum::{ extract::{Path, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Form, }; use axum_extra::extract::Form as HtmlForm; use tower_sessions::Session; use crate::{ auth::AuthUser, db::{self, Slug}, error::{AppError, Result}, helpers::get_csrf_token, pricing::{self, parse_dollars_to_cents}, templates::*, validation, AppState, }; use super::build_step_nav; /// Ordered step names for the project wizard. pub const PROJECT_STEPS: &[&str] = &[ "basics", "appearance", "monetization", "first-content", "preview", ]; /// Human-readable labels for each step. const PROJECT_LABELS: &[&str] = &[ "Basics", "Appearance", "Monetization", "First Content", "Preview", ]; /// Verify the current user owns this project (for wizard steps 2-5). async fn verify_wizard_access( state: &AppState, user: &crate::auth::SessionUser, slug: &str, ) -> Result { let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?; let project = db::projects::get_project_by_user_and_slug(&state.db, user.id, &slug) .await? .ok_or(AppError::NotFound)?; Ok(project) } // ============================================================================= // Full page: GET /dashboard/new-project // ============================================================================= /// Render the full wizard page with step 1 (basics) inline. #[tracing::instrument(skip_all, name = "wizard::project_page")] pub async fn wizard_page( State(_state): State, session: Session, AuthUser(user): AuthUser, ) -> Result { if !user.can_create_projects { return Err(AppError::Forbidden); } let csrf_token = get_csrf_token(&session).await; let nav = build_step_nav(PROJECT_STEPS, PROJECT_LABELS, "basics"); Ok(WizardProjectTemplate { csrf_token, session_user: Some(user), nav, project_features: db::ProjectFeature::all(), // Step 1 is rendered inline in the full page template }) } // ============================================================================= // Step 1 POST: creates the project, returns step 2 partial // ============================================================================= #[derive(serde::Deserialize)] pub struct BasicsForm { pub title: String, pub slug: Slug, /// Comma-separated feature values from checkbox form (or repeated params). #[serde(default)] pub features: Vec, pub category: Option, pub description: Option, pub ai_tier: Option, pub ai_disclosure: Option, } /// POST /dashboard/new-project/step/basics: create project, return step 2. #[tracing::instrument(skip_all, name = "wizard::project_basics_create")] pub async fn step_basics_create( State(state): State, session: Session, AuthUser(user): AuthUser, HtmlForm(form): HtmlForm, ) -> Result { user.check_not_suspended()?; if !user.can_create_projects { return Err(AppError::Forbidden); } validation::validate_project_title(&form.title)?; if let Some(ref desc) = form.description { validation::validate_project_description(desc)?; } // Resolve category let category_id = if let Some(ref cat) = form.category { let trimmed = cat.trim(); if !trimmed.is_empty() { let cat = db::categories::get_or_create_category(&state.db, trimmed).await?; Some(cat.id) } else { None } } else { None }; // Validate feature values for f in &form.features { f.parse::() .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?; } let project = db::projects::create_project( &state.db, user.id, &form.slug, &form.title, form.description.as_deref(), &form.features, ) .await?; if let Some(cat_id) = category_id { db::projects::set_project_category(&state.db, project.id, user.id, Some(cat_id)).await?; } // Save AI tier let ai_tier = form.ai_tier.as_deref().unwrap_or("handmade").parse::().unwrap_or(db::AiTier::Handmade); let ai_disclosure = if ai_tier == db::AiTier::Assisted { form.ai_disclosure.as_deref() } else { None }; db::projects::update_project_ai_tier(&state.db, project.id, user.id, ai_tier, ai_disclosure).await?; // Create default mailing lists (non-blocking) if let Err(e) = db::mailing_lists::create_default_lists(&state.db, project.id, &form.title).await { tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists"); } // Return step 2 (appearance) partial render_step(&state, &session, &user, &project, "appearance").await } // ============================================================================= // Step GET: load a specific step partial (for back nav / direct URL) // ============================================================================= /// GET /dashboard/new-project/{slug}/step/{step} #[tracing::instrument(skip_all, name = "wizard::project_step_load")] pub async fn step_load( State(state): State, session: Session, AuthUser(user): AuthUser, Path((slug, step)): Path<(String, String)>, ) -> Result { let project = verify_wizard_access(&state, &user, &slug).await?; render_step(&state, &session, &user, &project, &step).await } // ============================================================================= // Step POST: save current step, return next step partial // ============================================================================= /// POST /dashboard/new-project/{slug}/step/{step} #[tracing::instrument(skip_all, name = "wizard::project_step_save")] pub async fn step_save( State(state): State, session: Session, AuthUser(user): AuthUser, Path((slug, step)): Path<(String, String)>, headers: HeaderMap, Form(form): Form>, ) -> Result { user.check_not_suspended()?; let project = verify_wizard_access(&state, &user, &slug).await?; let save_result = match step.as_str() { "basics" => save_basics(&state, &user, &project, &form).await, "appearance" => save_appearance(&state, &project, &form).await, "monetization" => save_monetization(&state, &user, &project, &form).await, "first-content" => save_first_content(&state, &project, &form).await, "preview" => return save_preview(&state, &user, &project, &form).await, _ => return Err(AppError::NotFound), }; if let Err(e) = save_result { // On an HTMX step submit, surface a validation error as an inline toast // and tell HTMX NOT to swap (`HX-Reswap: none`) — otherwise the full-page // 422 error template replaces `#wizard-step` and the user loses everything // they typed in the step (Run #12 UX MINOR; this is the load-bearing half // of the save_monetization atomicity fix, which now rejects more inputs // up front). Non-validation errors and non-HTMX requests fall through to // the normal error response. if crate::helpers::is_htmx_request(&headers) && let AppError::Validation(ref v) = e { let mut resp = StatusCode::OK.into_response(); resp.headers_mut().insert("HX-Trigger", crate::helpers::hx_toast(&v.to_string(), "error")); resp.headers_mut().insert("HX-Reswap", HeaderValue::from_static("none")); return Ok(resp); } return Err(e); } let next = super::next_step(PROJECT_STEPS, &step).ok_or(AppError::NotFound)?; render_step(&state, &session, &user, &project, next).await } // ============================================================================= // Step save handlers // ============================================================================= async fn save_basics( state: &AppState, user: &crate::auth::SessionUser, project: &db::DbProject, form: &HashMap, ) -> Result<()> { let ai_tier = form.get("ai_tier").map(|s| s.as_str()).unwrap_or("handmade") .parse::().unwrap_or(db::AiTier::Handmade); let ai_disclosure = if ai_tier == db::AiTier::Assisted { form.get("ai_disclosure").map(|s| s.as_str()) } else { None }; db::projects::update_project_ai_tier(&state.db, project.id, user.id, ai_tier, ai_disclosure).await?; Ok(()) } async fn save_appearance( state: &AppState, project: &db::DbProject, form: &HashMap, ) -> Result<()> { // Image URL is set by the presign/confirm flow (JS stores it in a hidden // field). The blessed path (/api/projects/image/confirm) server-builds this // URL from the CDN base; this wizard field trusts the client, so validate it // before persisting. In production (CDN configured) the URL must live under // the CDN base — blocking an arbitrary or hostile URL from being stored and // later rendered in sitewide (data-quality / SSRF-adjacent; Run #11 // UX NOTE). Without a CDN (dev/test) the canonical URL is a presigned link // that varies, so it isn't constrained there. if let Some(image_url) = form.get("cover_image_url") && !image_url.is_empty() { // Compare against `{cdn_base}/` (with the trailing slash), not a bare // `cdn_base` prefix — otherwise `https://cdn.makenot.work.attacker.com/x` // would slip past a `cdn_base = "https://cdn.makenot.work"` check // (host-prefix confusion). Canonical URLs are `{cdn_base}/{s3_key}`. if let Some(cdn_base) = state.config.cdn_base_url.as_deref() && !image_url.starts_with(&format!("{}/", cdn_base.trim_end_matches('/'))) { return Err(AppError::validation("Invalid cover image URL")); } db::projects::update_project_image_url(&state.db, project.id, project.user_id, image_url).await?; } Ok(()) } async fn save_monetization( state: &AppState, _user: &crate::auth::SessionUser, project: &db::DbProject, form: &HashMap, ) -> Result<()> { // Save project pricing model. Reject missing/malformed values rather than // silently defaulting to Free — a typo or future enum variant would // otherwise demote the project to free on submit. Same disease class as // the tier-row silent-drop bug fixed in Run #6. let pricing_model_str = form .get("pricing_model") .map(String::as_str) .ok_or_else(|| AppError::validation("Select a pricing model"))?; let pricing_kind: db::PricingKind = pricing_model_str .parse() .map_err(|_| AppError::validation(format!("Unknown pricing model: {pricing_model_str}")))?; let price_cents = if pricing_kind == db::PricingKind::BuyOnce { parse_dollars_to_cents("Price", form.get("price_dollars").map(String::as_str))? } else { 0 }; let pwyw_min_cents = if pricing_kind == db::PricingKind::Pwyw { Some(parse_dollars_to_cents("Minimum price", form.get("pwyw_min_dollars").map(String::as_str))?) } else { None }; // Parse and validate ALL tier rows BEFORE any write. Previously // `update_project_pricing` committed first and a malformed tier price then // errored mid-loop, leaving pricing persisted with tiers half-written and // the user on an error page (non-atomic step; Run #11 UX MINOR). Validating // the whole form up front means a rejected input writes nothing. let mut tiers: Vec<(String, Option, db::PriceCents)> = Vec::new(); let mut i = 0; loop { let name = match form.get(&format!("tier_name_{i}")) { Some(n) if !n.trim().is_empty() => n.trim().to_string(), _ => break, }; // Propagate parse errors (don't silently drop malformed tiers — the // silent-failure class fixed in Run #6). let price_cents_raw = parse_dollars_to_cents( &format!("Tier {} price", i + 1), form.get(&format!("tier_price_{i}")).map(String::as_str), )?; let price_cents = db::PriceCents::new(price_cents_raw).map_err(|_| { AppError::validation(format!("Tier {} price is invalid", i + 1)) })?; let description = form.get(&format!("tier_desc_{i}")).map(|d| d.trim().to_string()); tiers.push((name, description, price_cents)); i += 1; } // All inputs validated — now perform the writes. db::projects::update_project_pricing(&state.db, project.id, project.user_id, pricing_kind, price_cents, pwyw_min_cents) .await?; for (name, description, price_cents) in &tiers { db::subscriptions::create_subscription_tier( &state.db, project.id, name, description.as_deref(), *price_cents, ) .await?; } Ok(()) } async fn save_first_content( _state: &AppState, _project: &db::DbProject, _form: &HashMap, ) -> Result<()> { // First content step is informational — choices (create item, blog post, // skip) are handled by navigation links, not form submission. Ok(()) } async fn save_preview( state: &AppState, user: &crate::auth::SessionUser, project: &db::DbProject, form: &HashMap, ) -> Result { let action = form.get("action").map(|s| s.as_str()).unwrap_or("draft"); if action == "publish" { db::projects::update_project( &state.db, project.id, user.id, None, // title None, // description None, // features Some(true), ) .await?; // Fire-and-forget: provision a paired MT community if project.mt_community_id.is_none() && let Some(ref mt) = state.mt_client { let mt = mt.clone(); let db = state.db.clone(); let project_id = project.id; let slug = project.slug.to_string(); let title = project.title.clone(); let desc = project.description.clone(); let username = user.username.to_string(); let display_name = user.display_name.clone(); let user_id = user.id; tokio::spawn(async move { match mt .create_community(&crate::mt_client::CreateCommunityRequest { name: title, slug, description: desc, owner_mnw_id: *user_id, owner_username: username, owner_display_name: display_name, }) .await { Ok(resp) => { if let Err(e) = db::projects::set_mt_community_id(&db, project_id, resp.community_id) .await { tracing::warn!(error = ?e, "failed to store MT community ID"); } } Err(e) => tracing::warn!(error = ?e, "MT community provisioning failed"), } }); } } // Redirect to the project dashboard let mut response = Response::new(axum::body::Body::empty()); response.headers_mut().insert( "HX-Redirect", format!("/dashboard/project/{}", project.slug) .parse() .expect("redirect path is valid"), ); Ok(response) } // ============================================================================= // Render a step partial (used by both GET and POST flows) // ============================================================================= async fn render_step( state: &AppState, session: &Session, user: &crate::auth::SessionUser, project: &db::DbProject, step: &str, ) -> Result { let nav = build_step_nav(PROJECT_STEPS, PROJECT_LABELS, step); let slug = project.slug.to_string(); let csrf_token = get_csrf_token(session).await; match step { "basics" => { Ok(WizardProjectBasicsTemplate { nav, slug, project_features: db::ProjectFeature::all(), title: project.title.clone(), features: project.features.clone(), description: project.description.clone().unwrap_or_default(), category_name: db::categories::get_project_category_name(&state.db, project.id) .await? .unwrap_or_default(), ai_tier: project.ai_tier.to_string(), ai_disclosure: project.ai_disclosure.clone().unwrap_or_default(), } .into_response()) } "appearance" => { Ok(WizardProjectAppearanceTemplate { nav, slug, project_id: project.id.to_string(), cover_image_url: project.cover_image_url.clone(), project_title: project.title.clone(), } .into_response()) } "monetization" => { let tiers = db::subscriptions::get_all_tiers_by_project(&state.db, project.id).await?; let stripe_connected = { let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; db_user.stripe_onboarding_complete && db_user.stripe_charges_enabled }; Ok(WizardProjectMonetizationTemplate { nav, slug, tiers: tiers .into_iter() .map(|t| WizardTierRow { id: t.id.to_string(), name: t.name, price_display: format!( "${}.{:02}", t.price_cents / 100, t.price_cents % 100 ), price_dollars: format!( "{}.{:02}", t.price_cents / 100, t.price_cents % 100 ), description: t.description.unwrap_or_default(), }) .collect(), stripe_connected, pricing_model: project.pricing_model.to_string(), price_dollars: format!( "{}.{:02}", project.price_cents / 100, project.price_cents.unsigned_abs() % 100 ), pwyw_min_dollars: project .pwyw_min_cents .map(|c| format!("{}.{:02}", c / 100, c.unsigned_abs() % 100)) .unwrap_or_else(|| "0.00".to_string()), } .into_response()) } "first-content" => { let items = db::items::get_items_by_project(&state.db, project.id).await?; Ok(WizardProjectFirstContentTemplate { nav, slug, item_count: items.len() as u32, } .into_response()) } "preview" => { let items = db::items::get_items_by_project(&state.db, project.id).await?; let tiers = db::subscriptions::get_all_tiers_by_project(&state.db, project.id).await?; let category_name = db::categories::get_project_category_name(&state.db, project.id).await?; let project_pricing = pricing::for_project(project); Ok(WizardProjectPreviewTemplate { csrf_token, nav, slug, title: project.title.clone(), features: project.features.clone(), description: project.description.clone().unwrap_or_default(), cover_image_url: project.cover_image_url.clone(), category_name, tier_count: tiers.len() as u32, item_count: items.len() as u32, tiers: tiers .into_iter() .map(|t| WizardTierRow { id: t.id.to_string(), name: t.name, price_display: format!( "${}.{:02}", t.price_cents / 100, t.price_cents % 100 ), price_dollars: format!( "{}.{:02}", t.price_cents / 100, t.price_cents % 100 ), description: t.description.unwrap_or_default(), }) .collect(), is_public: project.is_public, pricing_display: project_pricing.price_display(), } .into_response()) } _ => Err(AppError::NotFound), } }