Skip to main content

max / makenotwork

11.2 KB · 359 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: &crate::auth::SessionUser,
159 ) -> Result<()> {
160 let user_id = user.id;
161 // Reject missing/malformed pricing_model rather than silently defaulting
162 // to "free", a typo or future variant would otherwise demote the item to
163 // free on submit. Same disease class as the tier-row silent-drop bug
164 // fixed in the project wizard at Run #6.
165 let pricing_model = form
166 .get("pricing_model")
167 .map(String::as_str)
168 .ok_or_else(|| AppError::validation("Select a pricing model"))?;
169
170 match pricing_model {
171 "free" => {
172 db::items::update_item(
173 db,
174 item.id,
175 user_id,
176 None,
177 None,
178 Some(PriceCents::from_db(0)),
179 None,
180 None,
181 Some(false),
182 None,
183 None,
184 None,
185 None,
186 None, // ai_tier, ai_disclosure
187 )
188 .await?;
189 }
190 "fixed" => {
191 let price_cents =
192 parse_dollars_to_cents("Price", form.get("price").map(String::as_str))?;
193 // A fixed item price is a buy-once price, so it carries Stripe's
194 // minimum charge for the creator's settlement currency. Without the
195 // floor here the item saves and the sale fails at checkout instead.
196 let price = PriceCents::buy_once(price_cents, user.settlement_currency)?;
197 db::items::update_item(
198 db,
199 item.id,
200 user_id,
201 None,
202 None,
203 Some(price),
204 None,
205 None,
206 Some(false),
207 None,
208 None,
209 None,
210 None,
211 None, // ai_tier, ai_disclosure
212 )
213 .await?;
214 }
215 "pwyw" => {
216 let suggested_cents = parse_dollars_to_cents(
217 "Suggested price",
218 form.get("suggested_price").map(String::as_str),
219 )?;
220 let min_cents =
221 parse_dollars_to_cents("Minimum price", form.get("min_price").map(String::as_str))?;
222 if min_cents > suggested_cents {
223 return Err(AppError::validation(
224 "Minimum price cannot exceed the suggested price",
225 ));
226 }
227 let suggested = PriceCents::new_in(suggested_cents, user.settlement_currency)?;
228 let min = PriceCents::new_in(min_cents, user.settlement_currency)?;
229 db::items::update_item(
230 db,
231 item.id,
232 user_id,
233 None,
234 None,
235 Some(suggested),
236 None,
237 None,
238 Some(true),
239 Some(min),
240 None,
241 None,
242 None,
243 None, // ai_tier, ai_disclosure
244 )
245 .await?;
246 }
247 other => {
248 return Err(AppError::validation(format!(
249 "Unknown pricing model: {other}"
250 )));
251 }
252 }
253 Ok(())
254 }
255
256 #[allow(clippy::too_many_arguments)]
257 pub(super) async fn save_preview(
258 db: &sqlx::PgPool,
259 mailer: &crate::email::EmailClient,
260 config: &crate::config::Config,
261 bg: &crate::background::BackgroundTx,
262 integrations: &crate::Integrations,
263 user: &crate::auth::SessionUser,
264 _project: &db::DbProject,
265 item: &db::DbItem,
266 form: &HashMap<String, String>,
267 ) -> Result<Response> {
268 let action = form
269 .get("action")
270 .map_or("draft", std::string::String::as_str);
271
272 match action {
273 "publish" => {
274 db::items::update_item(
275 db,
276 item.id,
277 user.id,
278 None,
279 None,
280 None,
281 None,
282 Some(true),
283 None,
284 None,
285 None,
286 None,
287 None,
288 None, // ai_tier, ai_disclosure
289 )
290 .await?;
291
292 // Re-fetch to get updated is_public state
293 let updated = db::items::get_item_by_id(db, item.id)
294 .await?
295 .ok_or(AppError::NotFound)?;
296
297 if updated.is_public {
298 crate::scheduler::send_release_announcements(db, mailer, config, &updated).await;
299
300 if updated.mt_thread_id.is_none() {
301 crate::scheduler::spawn_mt_thread_for_item(
302 db,
303 bg,
304 integrations,
305 config,
306 &updated,
307 user,
308 );
309 }
310 }
311 }
312 "schedule" => {
313 // A missing or unparseable publish_at used to fall through silently,
314 // leaving the item a draft while the creator believed it was scheduled
315 // (Run #2 UX MINOR). Surface a validation error instead.
316 let datetime_str = form
317 .get("publish_at")
318 .filter(|s| !s.is_empty())
319 .ok_or_else(|| {
320 AppError::validation(
321 "Enter a publish date and time to schedule this item.".to_string(),
322 )
323 })?;
324 let dt = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M")
325 .map_err(|_| {
326 AppError::validation("Enter a valid publish date and time.".to_string())
327 })?;
328 let utc_dt = dt.and_utc();
329 db::items::update_item(
330 db,
331 item.id,
332 user.id,
333 None,
334 None,
335 None,
336 None,
337 None,
338 None,
339 None,
340 Some(Some(utc_dt)),
341 None,
342 None,
343 None, // ai_tier, ai_disclosure
344 )
345 .await?;
346 }
347 _ => {} // draft, leave as is
348 }
349
350 // item.id is a UUID so the parse can't fail, but fall back rather than
351 // panic the worker for consistency with the other HX-Redirect sites (Run 11 UX MINOR).
352 let mut response = Response::new(axum::body::Body::empty());
353 let redirect = format!("/dashboard/item/{}", item.id)
354 .parse()
355 .unwrap_or_else(|_| axum::http::HeaderValue::from_static("/dashboard"));
356 response.headers_mut().insert("HX-Redirect", redirect);
357 Ok(response)
358 }
359