//! Project creation wizard; 5 steps: basics, appearance, monetization, //! first-content, preview. use std::collections::HashMap; use axum::{ Form, extract::{Path, State}, http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, }; use tower_sessions::Session; use crate::{ Integrations, auth::AuthUser, config::Config, db::{self, Slug}, error::{AppError, Result}, helpers::get_csrf_token, pricing::{self, parse_dollars_to_cents}, templates::{ WizardProjectAppearanceTemplate, WizardProjectBasicsTemplate, WizardProjectFirstContentTemplate, WizardProjectMonetizationTemplate, WizardProjectPreviewTemplate, WizardProjectTemplate, WizardTierRow, }, validation, }; use sqlx::PgPool; use super::build_step_nav; use crate::extractors::ValidatedHtmlForm; /// Ordered step names for the project wizard. pub(crate) 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( db: &PgPool, 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(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(crate) async fn wizard_page( 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(crate) 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(crate) async fn step_basics_create( State(db): State, session: Session, AuthUser(user): AuthUser, headers: HeaderMap, ValidatedHtmlForm(form): ValidatedHtmlForm, ) -> Result { // Surface validation errors as an inline toast (HX-Reswap: none) instead of // swapping the full-page 422 into #wizard-step and wiping typed input, the // guard the step_save handlers carry but this create handler had dropped // (ultra-fuzz Run 10 UX S2). match step_basics_create_inner(db, session, user, form).await { Ok(resp) => Ok(resp), Err(e) => super::wizard_validation_toast(&headers, e), } } async fn step_basics_create_inner( db: PgPool, session: Session, user: crate::auth::SessionUser, form: BasicsForm, ) -> 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() { None } else { let cat = db::categories::get_or_create_category(&db, trimmed).await?; Some(cat.id) } } 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( &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(&db, project.id, user.id, Some(cat_id)).await?; } // Save AI tier // A present-but-invalid ai_tier is a client error, not silently "handmade"; // absent/empty defaults to handmade (ultra-fuzz Run 10 UX M3). let ai_tier = match form.ai_tier.as_deref() { None | Some("") => db::AiTier::Handmade, Some(s) => s .parse::() .map_err(|_| AppError::validation("Invalid AI tier".to_string()))?, }; let ai_disclosure = if ai_tier == db::AiTier::Assisted { form.ai_disclosure.as_deref() } else { None }; db::projects::update_project_ai_tier(&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(&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(&db, &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(crate) async fn step_load( State(db): State, session: Session, AuthUser(user): AuthUser, Path((slug, step)): Path<(String, String)>, ) -> Result { let project = verify_wizard_access(&db, &user, &slug).await?; render_step(&db, &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")] #[allow(clippy::too_many_arguments)] pub(crate) async fn step_save( State(db): State, State(config): State, State(integrations): 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(&db, &user, &slug).await?; let save_result = match step.as_str() { "basics" => save_basics(&db, &user, &project, &form).await, "appearance" => save_appearance(&db, &config, &project, &form).await, "monetization" => save_monetization(&db, &user, &project, &form).await, "first-content" => save_first_content(&project, &form), "preview" => return save_preview(&db, &integrations, &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(&db, &session, &user, &project, next).await } // Step save handlers async fn save_basics( db: &PgPool, user: &crate::auth::SessionUser, project: &db::DbProject, form: &HashMap, ) -> Result<()> { // Present-but-invalid ai_tier is a client error, not silently "handmade". let ai_tier = match form.get("ai_tier").map(std::string::String::as_str) { None | Some("") => db::AiTier::Handmade, Some(s) => s .parse::() .map_err(|_| AppError::validation("Invalid AI tier".to_string()))?, }; let ai_disclosure = if ai_tier == db::AiTier::Assisted { form.get("ai_disclosure").map(std::string::String::as_str) } else { None }; db::projects::update_project_ai_tier(db, project.id, user.id, ai_tier, ai_disclosure).await?; Ok(()) } async fn save_appearance( db: &PgPool, config: &Config, 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. 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). The base is // required config, so this constrains every environment, dev included. 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 !image_url.starts_with(&format!("{}/", config.cdn_base_url.trim_end_matches('/'))) { return Err(AppError::validation("Invalid cover image URL")); } // Recover the bare s3_key from the canonical `{cdn_base}/{key}` URL so the // deletion worker can match the cover by exact key (migration 152). The // validated URL lives under the CDN base, so the CDN-prefix branch // recovers the key; bucket/endpoint (path-style fallback) aren't needed. let cover_s3_key = crate::storage::extract_s3_key_from_url(image_url, &config.cdn_base_url, None, None); db::projects::update_project_image_url( db, project.id, project.user_id, image_url, cover_s3_key.as_deref(), ) .await?; } Ok(()) } async fn save_monetization( db: &PgPool, 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}")))?; // Construct cap-enforcing PriceCents at the boundary. `buy_once` also applies // Stripe's minimum charge for the creator's settlement currency. Passing a // bare i32 to update_project_pricing is now a compile error, so this writer // can't bypass the cap (Run 11 UX F1). let price_cents = if pricing_kind == db::PricingKind::BuyOnce { let raw = parse_dollars_to_cents("Price", form.get("price_dollars").map(String::as_str))?; db::PriceCents::buy_once(raw, user.settlement_currency)? } else { db::PriceCents::ZERO }; let pwyw_min_cents = if pricing_kind == db::PricingKind::Pwyw { let raw = parse_dollars_to_cents( "Minimum price", form.get("pwyw_min_dollars").map(String::as_str), )?; Some(db::PriceCents::new(raw)?) } 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( 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( db, project.id, name, description.as_deref(), *price_cents, ) .await?; } Ok(()) } fn save_first_content(_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( db: &PgPool, integrations: &Integrations, user: &crate::auth::SessionUser, project: &db::DbProject, form: &HashMap, ) -> Result { let action = form .get("action") .map_or("draft", std::string::String::as_str); if action == "publish" { db::projects::update_project( 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) = integrations.mt_client { let mt = mt.clone(); let db = 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. The slug is validated, so the parse // should always succeed, but fall back to the dashboard root rather than // panicking the worker if a pathological value ever slips through (Run 11 UX MINOR). let mut response = Response::new(axum::body::Body::empty()); let redirect = format!("/dashboard/project/{}", project.slug) .parse() .unwrap_or_else(|_| axum::http::HeaderValue::from_static("/dashboard")); response.headers_mut().insert("HX-Redirect", redirect); Ok(response) } // Render a step partial (used by both GET and POST flows) async fn render_step( db: &PgPool, 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(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(db, project.id).await?; let stripe_connected = { let db_user = db::users::get_user_by_id(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: crate::formatting::format_revenue( i64::from(t.price_cents), user.settlement_currency, ), 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_or_else( || "0.00".to_string(), |c| format!("{}.{:02}", c / 100, c.unsigned_abs() % 100), ), } .into_response()) } "first-content" => { let items = db::items::get_items_by_project(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(db, project.id).await?; let tiers = db::subscriptions::get_all_tiers_by_project(db, project.id).await?; let category_name = db::categories::get_project_category_name(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: crate::formatting::format_revenue( i64::from(t.price_cents), user.settlement_currency, ), 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(user.settlement_currency), } .into_response()) } _ => Err(AppError::NotFound), } }