Skip to main content

max / makenotwork

10.4 KB · 314 lines History Blame Raw
1 //! Item creation wizard; 6 steps: type, basics, content, sections,
2 //! pricing, preview.
3
4 mod render;
5 mod save;
6
7 use std::collections::HashMap;
8
9 use axum::{
10 Form,
11 extract::{Path, State},
12 http::{HeaderMap, HeaderValue, StatusCode},
13 response::{IntoResponse, Response},
14 };
15 use tower_sessions::Session;
16
17 use crate::{
18 Integrations,
19 auth::AuthUser,
20 config::Config,
21 db::{self, ItemId, ItemType, PriceCents, ProjectFeature, Slug},
22 error::{AppError, Result},
23 helpers::get_csrf_token,
24 templates::WizardItemTemplate,
25 };
26 use sqlx::PgPool;
27
28 use super::build_step_nav;
29
30 /// Format a price for display: "Free", "$X", or "PWYW (min $X)". Routes through
31 /// the canonical `format_price` so the wizard preview matches the public item
32 /// surfaces.
33 pub(super) fn format_price_display(
34 price_cents: i32,
35 pwyw_enabled: bool,
36 pwyw_min_cents: Option<i32>,
37 currency: crate::currency::SettlementCurrency,
38 ) -> String {
39 if pwyw_enabled {
40 match pwyw_min_cents.unwrap_or(0) {
41 min if min > 0 => format!(
42 "PWYW (min {})",
43 crate::formatting::format_price(min, currency)
44 ),
45 _ => "PWYW".to_string(),
46 }
47 } else {
48 // format_price renders 0 as "Free".
49 crate::formatting::format_price(price_cents, currency)
50 }
51 }
52
53 /// Ordered step names for the item wizard's LINEAR navigation (`next_step`).
54 ///
55 /// Deliberately excludes `sections`: that step is managed out-of-band via the
56 /// HTMX sections API and reached by a direct GET, not by stepping through the
57 /// wizard, so `step_save`/`render_step` handle `"sections"` even though it never
58 /// appears here. Keep the two in sync, a new linear step must be added here AND
59 /// given a handler arm.
60 pub(crate) const ITEM_STEPS: &[&str] = &["type", "basics", "content", "pricing", "preview"];
61
62 /// Human-readable labels for each step.
63 pub(super) const ITEM_LABELS: &[&str] = &["Type", "Basics", "Content", "Pricing", "Preview"];
64
65 /// Verify the user owns the project + item for wizard steps 2-6.
66 async fn verify_item_wizard_access(
67 db: &PgPool,
68 user: &crate::auth::SessionUser,
69 project_slug: &str,
70 item_id_str: &str,
71 ) -> Result<(db::DbProject, db::DbItem)> {
72 let slug = Slug::new(project_slug).map_err(|_| AppError::NotFound)?;
73 let project = db::projects::get_project_by_user_and_slug(db, user.id, &slug)
74 .await?
75 .ok_or(AppError::NotFound)?;
76
77 let item_id: ItemId = item_id_str.parse().map_err(|_| AppError::NotFound)?;
78 let item = db::items::get_item_by_id(db, item_id)
79 .await?
80 .ok_or(AppError::NotFound)?;
81
82 if item.project_id != project.id {
83 return Err(AppError::Forbidden);
84 }
85
86 Ok((project, item))
87 }
88
89 // Full page: GET /dashboard/project/{slug}/new-item
90
91 /// Render the full item wizard page with step 1 (type) inline.
92 ///
93 /// If all allowed item types share the same wizard behavior (e.g. all are
94 /// file uploads), the type step is skipped: an item is created automatically
95 /// and the user lands on the details step.
96 #[tracing::instrument(skip_all, name = "wizard::item_page")]
97 pub(crate) async fn wizard_page(
98 State(db): State<PgPool>,
99 session: Session,
100 AuthUser(user): AuthUser,
101 Path(slug): Path<String>,
102 ) -> Result<Response> {
103 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
104 let project = db::projects::get_project_by_user_and_slug(&db, user.id, &slug_val)
105 .await?
106 .ok_or(AppError::NotFound)?;
107
108 let type_cards = ProjectFeature::wizard_type_cards(&project.features);
109
110 // Only 1 wizard behavior group -> skip the type selector
111 if type_cards.len() == 1 {
112 let item_type: ItemType = type_cards[0]
113 .0
114 .parse()
115 .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
116
117 // Reuse an existing untitled draft for this project+type rather than
118 // minting a fresh "Untitled" row on every GET (a prefetch or re-visit
119 // otherwise piles up orphan drafts, this is a side-effecting GET).
120 let item = match db::items::find_untitled_wizard_draft(&db, project.id, item_type).await? {
121 Some(existing) => existing,
122 None => {
123 db::items::create_item(
124 &db,
125 project.id,
126 "Untitled",
127 None,
128 PriceCents::from_db(0),
129 item_type,
130 db::AiTier::Handmade,
131 None,
132 )
133 .await?
134 }
135 };
136
137 return Ok(axum::response::Redirect::to(&format!(
138 "/dashboard/project/{}/new-item/{}/step/basics",
139 slug, item.id
140 ))
141 .into_response());
142 }
143
144 let csrf_token = get_csrf_token(&session).await;
145 let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, "type");
146
147 Ok(WizardItemTemplate {
148 csrf_token,
149 session_user: Some(user),
150 nav,
151 project_slug: slug,
152 item_type_cards: type_cards,
153 }
154 .into_response())
155 }
156
157 // Step 1 POST: creates the item, returns step 2 partial
158
159 #[derive(serde::Deserialize)]
160 pub(crate) struct TypeForm {
161 pub item_type: String,
162 }
163
164 /// POST /dashboard/project/{slug}/new-item/step/type: create item, return step 2.
165 #[tracing::instrument(skip_all, name = "wizard::item_type_create")]
166 pub(crate) async fn step_type_create(
167 State(db): State<PgPool>,
168 session: Session,
169 AuthUser(user): AuthUser,
170 Path(slug): Path<String>,
171 headers: HeaderMap,
172 Form(form): Form<TypeForm>,
173 ) -> Result<Response> {
174 // Surface validation errors as an inline toast (HX-Reswap: none) instead of
175 // swapping the full-page 422 into #wizard-step and wiping the selection
176 // (ultra-fuzz Run 10 UX S2).
177 match step_type_create_inner(db, session, user, slug, form).await {
178 Ok(resp) => Ok(resp),
179 Err(e) => super::wizard_validation_toast(&headers, e),
180 }
181 }
182
183 async fn step_type_create_inner(
184 db: PgPool,
185 session: Session,
186 user: crate::auth::SessionUser,
187 slug: String,
188 form: TypeForm,
189 ) -> Result<Response> {
190 user.check_not_suspended()?;
191
192 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
193 let project = db::projects::get_project_by_user_and_slug(&db, user.id, &slug_val)
194 .await?
195 .ok_or(AppError::NotFound)?;
196
197 let item_type: ItemType = form
198 .item_type
199 .parse()
200 .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
201
202 // Validate the selected type is in the allowed item types
203 let cards = ProjectFeature::allowed_item_type_cards(&project.features);
204 if !cards.iter().any(|(v, _, _)| *v == form.item_type.as_str()) {
205 return Err(AppError::validation(format!(
206 "Item type '{}' is not available for this project",
207 form.item_type
208 )));
209 }
210
211 let item = db::items::create_item(
212 &db,
213 project.id,
214 "Untitled",
215 None,
216 PriceCents::from_db(0),
217 item_type,
218 db::AiTier::Handmade,
219 None,
220 )
221 .await?;
222
223 // Return step 2 (basics) partial
224 render::render_step(&db, &session, &user, &project, &item, "basics").await
225 }
226
227 // Step GET: load a specific step partial
228
229 /// GET /dashboard/project/{slug}/new-item/{id}/step/{step}
230 #[tracing::instrument(skip_all, name = "wizard::item_step_load")]
231 pub(crate) async fn step_load(
232 State(db): State<PgPool>,
233 session: Session,
234 AuthUser(user): AuthUser,
235 Path((slug, id, step)): Path<(String, String, String)>,
236 ) -> Result<Response> {
237 let (project, item) = verify_item_wizard_access(&db, &user, &slug, &id).await?;
238 render::render_step(&db, &session, &user, &project, &item, &step).await
239 }
240
241 // Step POST: save current step, return next step partial
242
243 /// POST /dashboard/project/{slug}/new-item/{id}/step/{step}
244 #[tracing::instrument(skip_all, name = "wizard::item_step_save")]
245 #[allow(clippy::too_many_arguments)]
246 pub(crate) async fn step_save(
247 State(db): State<PgPool>,
248 State(mailer): State<crate::email::EmailClient>,
249 State(config): State<Config>,
250 State(bg): State<crate::background::BackgroundTx>,
251 State(integrations): State<Integrations>,
252 session: Session,
253 AuthUser(user): AuthUser,
254 Path((slug, id, step)): Path<(String, String, String)>,
255 headers: HeaderMap,
256 Form(form): Form<HashMap<String, String>>,
257 ) -> Result<Response> {
258 user.check_not_suspended()?;
259 let (project, item) = verify_item_wizard_access(&db, &user, &slug, &id).await?;
260
261 let save_result = match step.as_str() {
262 "type" => save::save_type(&db, &project, &item, &form, user.id).await,
263 "basics" => save::save_basics(&db, &item, &form, user.id).await,
264 "content" => save::save_content(&db, &item, &form, user.id).await,
265 "sections" => Ok(()), // Sections managed via HTMX API; pass-through
266 "pricing" => save::save_pricing(&db, &item, &form, &user).await,
267 "preview" => {
268 return save::save_preview(
269 &db,
270 &mailer,
271 &config,
272 &bg,
273 &integrations,
274 &user,
275 &project,
276 &item,
277 &form,
278 )
279 .await;
280 }
281 _ => return Err(AppError::NotFound),
282 };
283
284 if let Err(e) = save_result {
285 // On an HTMX step submit, surface a validation error as an inline toast
286 // and tell HTMX NOT to swap (`HX-Reswap: none`), otherwise the full-page
287 // 422 error template replaces `#wizard-step` and the user loses everything
288 // they typed in this step. Mirrors the project wizard (`wizards/project.rs`).
289 // Non-validation errors and non-HTMX requests fall through to the normal
290 // error response.
291 if crate::helpers::is_htmx_request(&headers)
292 && let AppError::Validation(ref v) = e
293 {
294 let mut resp = StatusCode::OK.into_response();
295 resp.headers_mut().insert(
296 "HX-Trigger",
297 crate::helpers::hx_toast(&v.to_string(), "error"),
298 );
299 resp.headers_mut()
300 .insert("HX-Reswap", HeaderValue::from_static("none"));
301 return Ok(resp);
302 }
303 return Err(e);
304 }
305
306 // Re-fetch item after update
307 let item = db::items::get_item_by_id(&db, item.id)
308 .await?
309 .ok_or(AppError::NotFound)?;
310
311 let next = super::next_step(ITEM_STEPS, &step).ok_or(AppError::NotFound)?;
312 render::render_step(&db, &session, &user, &project, &item, next).await
313 }
314