Skip to main content

max / makenotwork

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