Skip to main content

max / makenotwork

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