Skip to main content

max / makenotwork

22.6 KB · 630 lines History Blame Raw
1 //! Project creation wizard; 5 steps: basics, appearance, monetization,
2 //! first-content, preview.
3
4 use std::collections::HashMap;
5
6 use axum::{
7 Form,
8 extract::{Path, State},
9 http::{HeaderMap, HeaderValue, StatusCode},
10 response::{IntoResponse, Response},
11 };
12 use tower_sessions::Session;
13
14 use crate::{
15 Integrations,
16 auth::AuthUser,
17 config::Config,
18 db::{self, Slug},
19 error::{AppError, Result},
20 helpers::get_csrf_token,
21 pricing::{self, parse_dollars_to_cents},
22 templates::{
23 WizardProjectAppearanceTemplate, WizardProjectBasicsTemplate,
24 WizardProjectFirstContentTemplate, WizardProjectMonetizationTemplate,
25 WizardProjectPreviewTemplate, WizardProjectTemplate, WizardTierRow,
26 },
27 validation,
28 };
29 use sqlx::PgPool;
30
31 use super::build_step_nav;
32 use crate::extractors::ValidatedHtmlForm;
33
34 /// Ordered step names for the project wizard.
35 pub(crate) const PROJECT_STEPS: &[&str] = &[
36 "basics",
37 "appearance",
38 "monetization",
39 "first-content",
40 "preview",
41 ];
42
43 /// Human-readable labels for each step.
44 const PROJECT_LABELS: &[&str] = &[
45 "Basics",
46 "Appearance",
47 "Monetization",
48 "First Content",
49 "Preview",
50 ];
51
52 /// Verify the current user owns this project (for wizard steps 2-5).
53 async fn verify_wizard_access(
54 db: &PgPool,
55 user: &crate::auth::SessionUser,
56 slug: &str,
57 ) -> Result<db::DbProject> {
58 let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?;
59 let project = db::projects::get_project_by_user_and_slug(db, user.id, &slug)
60 .await?
61 .ok_or(AppError::NotFound)?;
62 Ok(project)
63 }
64
65 // Full page: GET /dashboard/new-project
66
67 /// Render the full wizard page with step 1 (basics) inline.
68 #[tracing::instrument(skip_all, name = "wizard::project_page")]
69 pub(crate) async fn wizard_page(
70 session: Session,
71 AuthUser(user): AuthUser,
72 ) -> Result<impl IntoResponse> {
73 if !user.can_create_projects {
74 return Err(AppError::Forbidden);
75 }
76 let csrf_token = get_csrf_token(&session).await;
77 let nav = build_step_nav(PROJECT_STEPS, PROJECT_LABELS, "basics");
78
79 Ok(WizardProjectTemplate {
80 csrf_token,
81 session_user: Some(user),
82 nav,
83 project_features: db::ProjectFeature::all(),
84 // Step 1 is rendered inline in the full page template
85 })
86 }
87
88 // Step 1 POST: creates the project, returns step 2 partial
89
90 #[derive(serde::Deserialize)]
91 pub(crate) struct BasicsForm {
92 pub title: String,
93 pub slug: Slug,
94 /// Comma-separated feature values from checkbox form (or repeated params).
95 #[serde(default)]
96 pub features: Vec<String>,
97 pub category: Option<String>,
98 pub description: Option<String>,
99 pub ai_tier: Option<String>,
100 pub ai_disclosure: Option<String>,
101 }
102
103 /// POST /dashboard/new-project/step/basics: create project, return step 2.
104 #[tracing::instrument(skip_all, name = "wizard::project_basics_create")]
105 pub(crate) async fn step_basics_create(
106 State(db): State<PgPool>,
107 session: Session,
108 AuthUser(user): AuthUser,
109 headers: HeaderMap,
110 ValidatedHtmlForm(form): ValidatedHtmlForm<BasicsForm>,
111 ) -> Result<Response> {
112 // Surface validation errors as an inline toast (HX-Reswap: none) instead of
113 // swapping the full-page 422 into #wizard-step and wiping typed input, the
114 // guard the step_save handlers carry but this create handler had dropped
115 // (ultra-fuzz Run 10 UX S2).
116 match step_basics_create_inner(db, session, user, form).await {
117 Ok(resp) => Ok(resp),
118 Err(e) => super::wizard_validation_toast(&headers, e),
119 }
120 }
121
122 async fn step_basics_create_inner(
123 db: PgPool,
124 session: Session,
125 user: crate::auth::SessionUser,
126 form: BasicsForm,
127 ) -> Result<Response> {
128 user.check_not_suspended()?;
129 if !user.can_create_projects {
130 return Err(AppError::Forbidden);
131 }
132
133 validation::validate_project_title(&form.title)?;
134 if let Some(ref desc) = form.description {
135 validation::validate_project_description(desc)?;
136 }
137
138 // Resolve category
139 let category_id = if let Some(ref cat) = form.category {
140 let trimmed = cat.trim();
141 if trimmed.is_empty() {
142 None
143 } else {
144 let cat = db::categories::get_or_create_category(&db, trimmed).await?;
145 Some(cat.id)
146 }
147 } else {
148 None
149 };
150
151 // Validate feature values
152 for f in &form.features {
153 f.parse::<db::ProjectFeature>()
154 .map_err(|_| AppError::validation(format!("Invalid feature: {f}")))?;
155 }
156
157 let project = db::projects::create_project(
158 &db,
159 user.id,
160 &form.slug,
161 &form.title,
162 form.description.as_deref(),
163 &form.features,
164 )
165 .await?;
166
167 if let Some(cat_id) = category_id {
168 db::projects::set_project_category(&db, project.id, user.id, Some(cat_id)).await?;
169 }
170
171 // Save AI tier
172 // A present-but-invalid ai_tier is a client error, not silently "handmade";
173 // absent/empty defaults to handmade (ultra-fuzz Run 10 UX M3).
174 let ai_tier = match form.ai_tier.as_deref() {
175 None | Some("") => db::AiTier::Handmade,
176 Some(s) => s
177 .parse::<db::AiTier>()
178 .map_err(|_| AppError::validation("Invalid AI tier".to_string()))?,
179 };
180 let ai_disclosure = if ai_tier == db::AiTier::Assisted {
181 form.ai_disclosure.as_deref()
182 } else {
183 None
184 };
185 db::projects::update_project_ai_tier(&db, project.id, user.id, ai_tier, ai_disclosure).await?;
186
187 // Create default mailing lists (non-blocking)
188 if let Err(e) = db::mailing_lists::create_default_lists(&db, project.id, &form.title).await {
189 tracing::warn!(project_id = %project.id, error = ?e, "failed to create default mailing lists");
190 }
191
192 // Return step 2 (appearance) partial
193 render_step(&db, &session, &user, &project, "appearance").await
194 }
195
196 // Step GET: load a specific step partial (for back nav / direct URL)
197
198 /// GET /dashboard/new-project/{slug}/step/{step}
199 #[tracing::instrument(skip_all, name = "wizard::project_step_load")]
200 pub(crate) async fn step_load(
201 State(db): State<PgPool>,
202 session: Session,
203 AuthUser(user): AuthUser,
204 Path((slug, step)): Path<(String, String)>,
205 ) -> Result<Response> {
206 let project = verify_wizard_access(&db, &user, &slug).await?;
207 render_step(&db, &session, &user, &project, &step).await
208 }
209
210 // Step POST: save current step, return next step partial
211
212 /// POST /dashboard/new-project/{slug}/step/{step}
213 #[tracing::instrument(skip_all, name = "wizard::project_step_save")]
214 #[allow(clippy::too_many_arguments)]
215 pub(crate) async fn step_save(
216 State(db): State<PgPool>,
217 State(config): State<Config>,
218 State(integrations): State<Integrations>,
219 session: Session,
220 AuthUser(user): AuthUser,
221 Path((slug, step)): Path<(String, String)>,
222 headers: HeaderMap,
223 Form(form): Form<HashMap<String, String>>,
224 ) -> Result<Response> {
225 user.check_not_suspended()?;
226 let project = verify_wizard_access(&db, &user, &slug).await?;
227
228 let save_result = match step.as_str() {
229 "basics" => save_basics(&db, &user, &project, &form).await,
230 "appearance" => save_appearance(&db, &config, &project, &form).await,
231 "monetization" => save_monetization(&db, &user, &project, &form).await,
232 "first-content" => save_first_content(&project, &form),
233 "preview" => return save_preview(&db, &integrations, &user, &project, &form).await,
234 _ => return Err(AppError::NotFound),
235 };
236
237 if let Err(e) = save_result {
238 // On an HTMX step submit, surface a validation error as an inline toast
239 // and tell HTMX NOT to swap (`HX-Reswap: none`), otherwise the full-page
240 // 422 error template replaces `#wizard-step` and the user loses everything
241 // they typed in the step (Run #12 UX MINOR; this is the load-bearing half
242 // of the save_monetization atomicity fix, which now rejects more inputs
243 // up front). Non-validation errors and non-HTMX requests fall through to
244 // the normal error response.
245 if crate::helpers::is_htmx_request(&headers)
246 && let AppError::Validation(ref v) = e
247 {
248 let mut resp = StatusCode::OK.into_response();
249 resp.headers_mut().insert(
250 "HX-Trigger",
251 crate::helpers::hx_toast(&v.to_string(), "error"),
252 );
253 resp.headers_mut()
254 .insert("HX-Reswap", HeaderValue::from_static("none"));
255 return Ok(resp);
256 }
257 return Err(e);
258 }
259
260 let next = super::next_step(PROJECT_STEPS, &step).ok_or(AppError::NotFound)?;
261 render_step(&db, &session, &user, &project, next).await
262 }
263
264 // Step save handlers
265
266 async fn save_basics(
267 db: &PgPool,
268 user: &crate::auth::SessionUser,
269 project: &db::DbProject,
270 form: &HashMap<String, String>,
271 ) -> Result<()> {
272 // Present-but-invalid ai_tier is a client error, not silently "handmade".
273 let ai_tier = match form.get("ai_tier").map(std::string::String::as_str) {
274 None | Some("") => db::AiTier::Handmade,
275 Some(s) => s
276 .parse::<db::AiTier>()
277 .map_err(|_| AppError::validation("Invalid AI tier".to_string()))?,
278 };
279 let ai_disclosure = if ai_tier == db::AiTier::Assisted {
280 form.get("ai_disclosure").map(std::string::String::as_str)
281 } else {
282 None
283 };
284 db::projects::update_project_ai_tier(db, project.id, user.id, ai_tier, ai_disclosure).await?;
285 Ok(())
286 }
287
288 async fn save_appearance(
289 db: &PgPool,
290 config: &Config,
291 project: &db::DbProject,
292 form: &HashMap<String, String>,
293 ) -> Result<()> {
294 // Image URL is set by the presign/confirm flow (JS stores it in a hidden
295 // field). The blessed path (/api/projects/image/confirm) server-builds this
296 // URL from the CDN base; this wizard field trusts the client, so validate it
297 // before persisting. The URL must live under the CDN base, blocking an
298 // arbitrary or hostile URL from being stored and later rendered in <img src>
299 // sitewide (data-quality / SSRF-adjacent; Run #11 UX NOTE). The base is
300 // required config, so this constrains every environment, dev included.
301 if let Some(image_url) = form.get("cover_image_url")
302 && !image_url.is_empty()
303 {
304 // Compare against `{cdn_base}/` (with the trailing slash), not a bare
305 // `cdn_base` prefix, otherwise `https://cdn.makenot.work.attacker.com/x`
306 // would slip past a `cdn_base = "https://cdn.makenot.work"` check
307 // (host-prefix confusion). Canonical URLs are `{cdn_base}/{s3_key}`.
308 if !image_url.starts_with(&format!("{}/", config.cdn_base_url.trim_end_matches('/'))) {
309 return Err(AppError::validation("Invalid cover image URL"));
310 }
311 // Recover the bare s3_key from the canonical `{cdn_base}/{key}` URL so the
312 // deletion worker can match the cover by exact key (migration 152). The
313 // validated URL lives under the CDN base, so the CDN-prefix branch
314 // recovers the key; bucket/endpoint (path-style fallback) aren't needed.
315 let cover_s3_key =
316 crate::storage::extract_s3_key_from_url(image_url, &config.cdn_base_url, None, None);
317 db::projects::update_project_image_url(
318 db,
319 project.id,
320 project.user_id,
321 image_url,
322 cover_s3_key.as_deref(),
323 )
324 .await?;
325 }
326 Ok(())
327 }
328
329 async fn save_monetization(
330 db: &PgPool,
331 _user: &crate::auth::SessionUser,
332 project: &db::DbProject,
333 form: &HashMap<String, String>,
334 ) -> Result<()> {
335 // Save project pricing model. Reject missing/malformed values rather than
336 // silently defaulting to Free, a typo or future enum variant would
337 // otherwise demote the project to free on submit. Same disease class as
338 // the tier-row silent-drop bug fixed in Run #6.
339 let pricing_model_str = form
340 .get("pricing_model")
341 .map(String::as_str)
342 .ok_or_else(|| AppError::validation("Select a pricing model"))?;
343 let pricing_kind: db::PricingKind = pricing_model_str
344 .parse()
345 .map_err(|_| AppError::validation(format!("Unknown pricing model: {pricing_model_str}")))?;
346
347 // Construct cap-enforcing PriceCents at the boundary. `buy_once` also applies
348 // the $0.50 floor. Passing a bare i32 to update_project_pricing is now a
349 // compile error, so this writer can't bypass the $10k cap (Run 11 UX F1).
350 let price_cents = if pricing_kind == db::PricingKind::BuyOnce {
351 let raw = parse_dollars_to_cents("Price", form.get("price_dollars").map(String::as_str))?;
352 db::PriceCents::buy_once(raw)?
353 } else {
354 db::PriceCents::ZERO
355 };
356
357 let pwyw_min_cents = if pricing_kind == db::PricingKind::Pwyw {
358 let raw = parse_dollars_to_cents(
359 "Minimum price",
360 form.get("pwyw_min_dollars").map(String::as_str),
361 )?;
362 Some(db::PriceCents::new(raw)?)
363 } else {
364 None
365 };
366
367 // Parse and validate ALL tier rows BEFORE any write. Previously
368 // `update_project_pricing` committed first and a malformed tier price then
369 // errored mid-loop, leaving pricing persisted with tiers half-written and
370 // the user on an error page (non-atomic step; Run #11 UX MINOR). Validating
371 // the whole form up front means a rejected input writes nothing.
372 let mut tiers: Vec<(String, Option<String>, db::PriceCents)> = Vec::new();
373 let mut i = 0;
374 loop {
375 let name = match form.get(&format!("tier_name_{i}")) {
376 Some(n) if !n.trim().is_empty() => n.trim().to_string(),
377 _ => break,
378 };
379
380 // Propagate parse errors (don't silently drop malformed tiers, the
381 // silent-failure class fixed in Run #6).
382 let price_cents_raw = parse_dollars_to_cents(
383 &format!("Tier {} price", i + 1),
384 form.get(&format!("tier_price_{i}")).map(String::as_str),
385 )?;
386 let price_cents = db::PriceCents::new(price_cents_raw)
387 .map_err(|_| AppError::validation(format!("Tier {} price is invalid", i + 1)))?;
388 let description = form
389 .get(&format!("tier_desc_{i}"))
390 .map(|d| d.trim().to_string());
391
392 tiers.push((name, description, price_cents));
393 i += 1;
394 }
395
396 // All inputs validated, now perform the writes.
397 db::projects::update_project_pricing(
398 db,
399 project.id,
400 project.user_id,
401 pricing_kind,
402 price_cents,
403 pwyw_min_cents,
404 )
405 .await?;
406 for (name, description, price_cents) in &tiers {
407 db::subscriptions::create_subscription_tier(
408 db,
409 project.id,
410 name,
411 description.as_deref(),
412 *price_cents,
413 )
414 .await?;
415 }
416 Ok(())
417 }
418
419 fn save_first_content(_project: &db::DbProject, _form: &HashMap<String, String>) -> Result<()> {
420 // First content step is informational, choices (create item, blog post,
421 // skip) are handled by navigation links, not form submission.
422 Ok(())
423 }
424
425 async fn save_preview(
426 db: &PgPool,
427 integrations: &Integrations,
428 user: &crate::auth::SessionUser,
429 project: &db::DbProject,
430 form: &HashMap<String, String>,
431 ) -> Result<Response> {
432 let action = form
433 .get("action")
434 .map_or("draft", std::string::String::as_str);
435
436 if action == "publish" {
437 db::projects::update_project(
438 db,
439 project.id,
440 user.id,
441 None, // title
442 None, // description
443 None, // features
444 Some(true),
445 )
446 .await?;
447
448 // Fire-and-forget: provision a paired MT community
449 if project.mt_community_id.is_none()
450 && let Some(ref mt) = integrations.mt_client
451 {
452 let mt = mt.clone();
453 let db = db.clone();
454 let project_id = project.id;
455 let slug = project.slug.to_string();
456 let title = project.title.clone();
457 let desc = project.description.clone();
458 let username = user.username.to_string();
459 let display_name = user.display_name.clone();
460 let user_id = user.id;
461 tokio::spawn(async move {
462 match mt
463 .create_community(&crate::mt_client::CreateCommunityRequest {
464 name: title,
465 slug,
466 description: desc,
467 owner_mnw_id: *user_id,
468 owner_username: username,
469 owner_display_name: display_name,
470 })
471 .await
472 {
473 Ok(resp) => {
474 if let Err(e) =
475 db::projects::set_mt_community_id(&db, project_id, resp.community_id)
476 .await
477 {
478 tracing::warn!(error = ?e, "failed to store MT community ID");
479 }
480 }
481 Err(e) => tracing::warn!(error = ?e, "MT community provisioning failed"),
482 }
483 });
484 }
485 }
486
487 // Redirect to the project dashboard. The slug is validated, so the parse
488 // should always succeed, but fall back to the dashboard root rather than
489 // panicking the worker if a pathological value ever slips through (Run 11 UX MINOR).
490 let mut response = Response::new(axum::body::Body::empty());
491 let redirect = format!("/dashboard/project/{}", project.slug)
492 .parse()
493 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("/dashboard"));
494 response.headers_mut().insert("HX-Redirect", redirect);
495 Ok(response)
496 }
497
498 // Render a step partial (used by both GET and POST flows)
499
500 async fn render_step(
501 db: &PgPool,
502 session: &Session,
503 user: &crate::auth::SessionUser,
504 project: &db::DbProject,
505 step: &str,
506 ) -> Result<Response> {
507 let nav = build_step_nav(PROJECT_STEPS, PROJECT_LABELS, step);
508 let slug = project.slug.to_string();
509 let csrf_token = get_csrf_token(session).await;
510
511 match step {
512 "basics" => Ok(WizardProjectBasicsTemplate {
513 nav,
514 slug,
515 project_features: db::ProjectFeature::all(),
516 title: project.title.clone(),
517 features: project.features.clone(),
518 description: project.description.clone().unwrap_or_default(),
519 category_name: db::categories::get_project_category_name(db, project.id)
520 .await?
521 .unwrap_or_default(),
522 ai_tier: project.ai_tier.to_string(),
523 ai_disclosure: project.ai_disclosure.clone().unwrap_or_default(),
524 }
525 .into_response()),
526 "appearance" => Ok(WizardProjectAppearanceTemplate {
527 nav,
528 slug,
529 project_id: project.id.to_string(),
530 cover_image_url: project.cover_image_url.clone(),
531 project_title: project.title.clone(),
532 }
533 .into_response()),
534 "monetization" => {
535 let tiers = db::subscriptions::get_all_tiers_by_project(db, project.id).await?;
536 let stripe_connected = {
537 let db_user = db::users::get_user_by_id(db, user.id)
538 .await?
539 .ok_or(AppError::NotFound)?;
540 db_user.stripe_onboarding_complete && db_user.stripe_charges_enabled
541 };
542
543 Ok(WizardProjectMonetizationTemplate {
544 nav,
545 slug,
546 tiers: tiers
547 .into_iter()
548 .map(|t| WizardTierRow {
549 id: t.id.to_string(),
550 name: t.name,
551 price_display: format!(
552 "${}.{:02}",
553 t.price_cents / 100,
554 t.price_cents % 100
555 ),
556 price_dollars: format!(
557 "{}.{:02}",
558 t.price_cents / 100,
559 t.price_cents % 100
560 ),
561 description: t.description.unwrap_or_default(),
562 })
563 .collect(),
564 stripe_connected,
565 pricing_model: project.pricing_model.to_string(),
566 price_dollars: format!(
567 "{}.{:02}",
568 project.price_cents / 100,
569 project.price_cents.unsigned_abs() % 100
570 ),
571 pwyw_min_dollars: project.pwyw_min_cents.map_or_else(
572 || "0.00".to_string(),
573 |c| format!("{}.{:02}", c / 100, c.unsigned_abs() % 100),
574 ),
575 }
576 .into_response())
577 }
578 "first-content" => {
579 let items = db::items::get_items_by_project(db, project.id).await?;
580 Ok(WizardProjectFirstContentTemplate {
581 nav,
582 slug,
583 item_count: items.len() as u32,
584 }
585 .into_response())
586 }
587 "preview" => {
588 let items = db::items::get_items_by_project(db, project.id).await?;
589 let tiers = db::subscriptions::get_all_tiers_by_project(db, project.id).await?;
590 let category_name = db::categories::get_project_category_name(db, project.id).await?;
591 let project_pricing = pricing::for_project(project);
592
593 Ok(WizardProjectPreviewTemplate {
594 csrf_token,
595 nav,
596 slug,
597 title: project.title.clone(),
598 features: project.features.clone(),
599 description: project.description.clone().unwrap_or_default(),
600 cover_image_url: project.cover_image_url.clone(),
601 category_name,
602 tier_count: tiers.len() as u32,
603 item_count: items.len() as u32,
604 tiers: tiers
605 .into_iter()
606 .map(|t| WizardTierRow {
607 id: t.id.to_string(),
608 name: t.name,
609 price_display: format!(
610 "${}.{:02}",
611 t.price_cents / 100,
612 t.price_cents % 100
613 ),
614 price_dollars: format!(
615 "{}.{:02}",
616 t.price_cents / 100,
617 t.price_cents % 100
618 ),
619 description: t.description.unwrap_or_default(),
620 })
621 .collect(),
622 is_public: project.is_public,
623 pricing_display: project_pricing.price_display(),
624 }
625 .into_response())
626 }
627 _ => Err(AppError::NotFound),
628 }
629 }
630