Skip to main content

max / makenotwork

10.8 KB · 355 lines History Blame Raw
1 //! Step save handlers for the item wizard.
2
3 use std::collections::HashMap;
4
5 use axum::response::Response;
6
7 use crate::{
8 db::{self, ItemId, ItemType, PriceCents, ProjectFeature, UserId},
9 error::{AppError, Result},
10 pricing::parse_dollars_to_cents,
11 validation,
12 };
13 use sqlx::PgPool;
14
15 /// Update the item type when going back to step 1 and re-submitting.
16 pub(super) async fn save_type(
17 db: &PgPool,
18 project: &db::DbProject,
19 item: &db::DbItem,
20 form: &HashMap<String, String>,
21 user_id: UserId,
22 ) -> Result<()> {
23 let type_str = form
24 .get("item_type")
25 .ok_or(AppError::BadRequest("Missing item_type".to_string()))?;
26 let item_type: ItemType = type_str
27 .parse()
28 .map_err(|_| AppError::BadRequest("Invalid item type".to_string()))?;
29
30 // Validate the selected type is in the allowed wizard cards
31 let cards = ProjectFeature::wizard_type_cards(&project.features);
32 if !cards.iter().any(|(v, _, _)| *v == type_str.as_str()) {
33 return Err(AppError::validation(format!(
34 "Item type '{type_str}' is not available for this project",
35 )));
36 }
37
38 db::items::update_item(
39 db,
40 item.id,
41 user_id,
42 None,
43 None,
44 None,
45 Some(item_type),
46 None,
47 None,
48 None,
49 None,
50 None,
51 None,
52 None, // ai_tier, ai_disclosure
53 )
54 .await?;
55 Ok(())
56 }
57
58 pub(super) async fn save_basics(
59 db: &PgPool,
60 item: &db::DbItem,
61 form: &HashMap<String, String>,
62 user_id: UserId,
63 ) -> Result<()> {
64 let title = form.get("title").map_or("Untitled", |s| s.trim());
65 let description = form.get("description").map(std::string::String::as_str);
66
67 validation::validate_item_title(title)?;
68 if let Some(desc) = description
69 && !desc.is_empty()
70 {
71 validation::validate_item_description(desc)?;
72 }
73
74 db::items::update_item(
75 db,
76 item.id,
77 user_id,
78 Some(title),
79 description,
80 None,
81 None,
82 None,
83 None,
84 None,
85 None,
86 None,
87 None,
88 None, // ai_tier, ai_disclosure
89 )
90 .await?;
91
92 // Cover image URL is set authoritatively by `item_image_confirm` (which
93 // writes cover_image_url + cover_s3_key + cover_file_size_bytes together
94 // and updates the storage counter). The wizard's hidden field used to
95 // re-write cover_image_url here on form submit, which under client-side
96 // hidden-field manipulation could desync the URL from the s3_key, a
97 // future cover replacement would then probe the wrong old object for its
98 // size and drift the storage counter. Trust confirm's write.
99
100 Ok(())
101 }
102
103 pub(super) async fn save_content(
104 db: &PgPool,
105 item: &db::DbItem,
106 form: &HashMap<String, String>,
107 user_id: UserId,
108 ) -> Result<()> {
109 if item.item_type == ItemType::Text {
110 // Text items: save body directly
111 if let Some(body) = form.get("body") {
112 db::items::update_item_text(db, item.id, user_id, body).await?;
113 }
114 } else if item.item_type == ItemType::Bundle {
115 // Bundle items: parse selected item IDs and unlisted flags
116 let bundle_ids: Vec<ItemId> = form
117 .get("bundle_item_ids")
118 .map(|s| {
119 s.split(',')
120 .filter(|v| !v.is_empty())
121 .filter_map(|v| v.parse().ok())
122 .collect()
123 })
124 .unwrap_or_default();
125
126 let unlisted_ids: Vec<ItemId> = form
127 .get("unlisted_item_ids")
128 .map(|s| {
129 s.split(',')
130 .filter(|v| !v.is_empty())
131 .filter_map(|v| v.parse().ok())
132 .collect()
133 })
134 .unwrap_or_default();
135
136 // Set bundle contents (replaces all existing)
137 db::bundles::set_bundle_items(db, item.id, &bundle_ids, user_id).await?;
138
139 // Update listed status for all bundleable items in this project
140 let all_bundleable =
141 db::bundles::get_bundleable_items(db, item.project_id, Some(item.id)).await?;
142 for bi in &all_bundleable {
143 let should_be_unlisted = unlisted_ids.contains(&bi.id);
144 if bi.listed == should_be_unlisted {
145 // listed=true but should be unlisted, or listed=false but shouldn't be
146 db::bundles::set_item_listed(db, bi.id, !should_be_unlisted, user_id).await?;
147 }
148 }
149 }
150 // Audio/file items: content uploaded via presign flow (client-side S3)
151 Ok(())
152 }
153
154 pub(super) async fn save_pricing(
155 db: &PgPool,
156 item: &db::DbItem,
157 form: &HashMap<String, String>,
158 user_id: UserId,
159 ) -> Result<()> {
160 // Reject missing/malformed pricing_model rather than silently defaulting
161 // to "free", a typo or future variant would otherwise demote the item to
162 // free on submit. Same disease class as the tier-row silent-drop bug
163 // fixed in the project wizard at Run #6.
164 let pricing_model = form
165 .get("pricing_model")
166 .map(String::as_str)
167 .ok_or_else(|| AppError::validation("Select a pricing model"))?;
168
169 match pricing_model {
170 "free" => {
171 db::items::update_item(
172 db,
173 item.id,
174 user_id,
175 None,
176 None,
177 Some(PriceCents::from_db(0)),
178 None,
179 None,
180 Some(false),
181 None,
182 None,
183 None,
184 None,
185 None, // ai_tier, ai_disclosure
186 )
187 .await?;
188 }
189 "fixed" => {
190 let price_cents =
191 parse_dollars_to_cents("Price", form.get("price").map(String::as_str))?;
192 let price = PriceCents::new(price_cents)?;
193 db::items::update_item(
194 db,
195 item.id,
196 user_id,
197 None,
198 None,
199 Some(price),
200 None,
201 None,
202 Some(false),
203 None,
204 None,
205 None,
206 None,
207 None, // ai_tier, ai_disclosure
208 )
209 .await?;
210 }
211 "pwyw" => {
212 let suggested_cents = parse_dollars_to_cents(
213 "Suggested price",
214 form.get("suggested_price").map(String::as_str),
215 )?;
216 let min_cents =
217 parse_dollars_to_cents("Minimum price", form.get("min_price").map(String::as_str))?;
218 if min_cents > suggested_cents {
219 return Err(AppError::validation(
220 "Minimum price cannot exceed the suggested price",
221 ));
222 }
223 let suggested = PriceCents::new(suggested_cents)?;
224 let min = PriceCents::new(min_cents)?;
225 db::items::update_item(
226 db,
227 item.id,
228 user_id,
229 None,
230 None,
231 Some(suggested),
232 None,
233 None,
234 Some(true),
235 Some(min),
236 None,
237 None,
238 None,
239 None, // ai_tier, ai_disclosure
240 )
241 .await?;
242 }
243 other => {
244 return Err(AppError::validation(format!(
245 "Unknown pricing model: {other}"
246 )));
247 }
248 }
249 Ok(())
250 }
251
252 #[allow(clippy::too_many_arguments)]
253 pub(super) async fn save_preview(
254 db: &sqlx::PgPool,
255 mailer: &crate::email::EmailClient,
256 config: &crate::config::Config,
257 bg: &crate::background::BackgroundTx,
258 integrations: &crate::Integrations,
259 user: &crate::auth::SessionUser,
260 _project: &db::DbProject,
261 item: &db::DbItem,
262 form: &HashMap<String, String>,
263 ) -> Result<Response> {
264 let action = form
265 .get("action")
266 .map_or("draft", std::string::String::as_str);
267
268 match action {
269 "publish" => {
270 db::items::update_item(
271 db,
272 item.id,
273 user.id,
274 None,
275 None,
276 None,
277 None,
278 Some(true),
279 None,
280 None,
281 None,
282 None,
283 None,
284 None, // ai_tier, ai_disclosure
285 )
286 .await?;
287
288 // Re-fetch to get updated is_public state
289 let updated = db::items::get_item_by_id(db, item.id)
290 .await?
291 .ok_or(AppError::NotFound)?;
292
293 if updated.is_public {
294 crate::scheduler::send_release_announcements(db, mailer, config, &updated).await;
295
296 if updated.mt_thread_id.is_none() {
297 crate::scheduler::spawn_mt_thread_for_item(
298 db,
299 bg,
300 integrations,
301 config,
302 &updated,
303 user,
304 );
305 }
306 }
307 }
308 "schedule" => {
309 // A missing or unparseable publish_at used to fall through silently,
310 // leaving the item a draft while the creator believed it was scheduled
311 // (Run #2 UX MINOR). Surface a validation error instead.
312 let datetime_str = form
313 .get("publish_at")
314 .filter(|s| !s.is_empty())
315 .ok_or_else(|| {
316 AppError::validation(
317 "Enter a publish date and time to schedule this item.".to_string(),
318 )
319 })?;
320 let dt = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M")
321 .map_err(|_| {
322 AppError::validation("Enter a valid publish date and time.".to_string())
323 })?;
324 let utc_dt = dt.and_utc();
325 db::items::update_item(
326 db,
327 item.id,
328 user.id,
329 None,
330 None,
331 None,
332 None,
333 None,
334 None,
335 None,
336 Some(Some(utc_dt)),
337 None,
338 None,
339 None, // ai_tier, ai_disclosure
340 )
341 .await?;
342 }
343 _ => {} // draft, leave as is
344 }
345
346 // item.id is a UUID so the parse can't fail, but fall back rather than
347 // panic the worker for consistency with the other HX-Redirect sites (Run 11 UX MINOR).
348 let mut response = Response::new(axum::body::Body::empty());
349 let redirect = format!("/dashboard/item/{}", item.id)
350 .parse()
351 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("/dashboard"));
352 response.headers_mut().insert("HX-Redirect", redirect);
353 Ok(response)
354 }
355