Skip to main content

max / makenotwork

9.6 KB · 318 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 AppState,
13 };
14
15 /// Update the item type when going back to step 1 and re-submitting.
16 pub(super) async fn save_type(
17 state: &AppState,
18 project: &db::DbProject,
19 item: &db::DbItem,
20 form: &HashMap<String, String>,
21 user_id: UserId,
22 ) -> Result<()> {
23 let type_str = form.get("item_type").ok_or(AppError::BadRequest(
24 "Missing item_type".to_string(),
25 ))?;
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 &state.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, None, // ai_tier, ai_disclosure
52 )
53 .await?;
54 Ok(())
55 }
56
57 pub(super) async fn save_basics(
58 state: &AppState,
59 item: &db::DbItem,
60 form: &HashMap<String, String>,
61 user_id: UserId,
62 ) -> Result<()> {
63 let title = form.get("title").map(|s| s.trim()).unwrap_or("Untitled");
64 let description = form.get("description").map(|s| s.as_str());
65
66 validation::validate_item_title(title)?;
67 if let Some(desc) = description
68 && !desc.is_empty()
69 {
70 validation::validate_item_description(desc)?;
71 }
72
73 db::items::update_item(
74 &state.db,
75 item.id,
76 user_id,
77 Some(title),
78 description,
79 None,
80 None,
81 None,
82 None,
83 None,
84 None,
85 None,
86 None, None, // ai_tier, ai_disclosure
87 )
88 .await?;
89
90 // Cover image URL is set authoritatively by `item_image_confirm` (which
91 // writes cover_image_url + cover_s3_key + cover_file_size_bytes together
92 // and updates the storage counter). The wizard's hidden field used to
93 // re-write cover_image_url here on form submit, which under client-side
94 // hidden-field manipulation could desync the URL from the s3_key — a
95 // future cover replacement would then probe the wrong old object for its
96 // size and drift the storage counter. Trust confirm's write.
97
98 Ok(())
99 }
100
101 pub(super) async fn save_content(
102 state: &AppState,
103 item: &db::DbItem,
104 form: &HashMap<String, String>,
105 user_id: UserId,
106 ) -> Result<()> {
107 if item.item_type == ItemType::Text {
108 // Text items: save body directly
109 if let Some(body) = form.get("body") {
110 db::items::update_item_text(&state.db, item.id, user_id, body).await?;
111 }
112 } else if item.item_type == ItemType::Bundle {
113 // Bundle items: parse selected item IDs and unlisted flags
114 let bundle_ids: Vec<ItemId> = form
115 .get("bundle_item_ids")
116 .map(|s| {
117 s.split(',')
118 .filter(|v| !v.is_empty())
119 .filter_map(|v| v.parse().ok())
120 .collect()
121 })
122 .unwrap_or_default();
123
124 let unlisted_ids: Vec<ItemId> = form
125 .get("unlisted_item_ids")
126 .map(|s| {
127 s.split(',')
128 .filter(|v| !v.is_empty())
129 .filter_map(|v| v.parse().ok())
130 .collect()
131 })
132 .unwrap_or_default();
133
134 // Set bundle contents (replaces all existing)
135 db::bundles::set_bundle_items(&state.db, item.id, &bundle_ids, user_id).await?;
136
137 // Update listed status for all bundleable items in this project
138 let all_bundleable =
139 db::bundles::get_bundleable_items(&state.db, item.project_id, Some(item.id)).await?;
140 for bi in &all_bundleable {
141 let should_be_unlisted = unlisted_ids.contains(&bi.id);
142 if bi.listed == should_be_unlisted {
143 // listed=true but should be unlisted, or listed=false but shouldn't be
144 db::bundles::set_item_listed(&state.db, bi.id, !should_be_unlisted).await?;
145 }
146 }
147 }
148 // Audio/file items: content uploaded via presign flow (client-side S3)
149 Ok(())
150 }
151
152 pub(super) async fn save_pricing(
153 state: &AppState,
154 item: &db::DbItem,
155 form: &HashMap<String, String>,
156 user_id: UserId,
157 ) -> Result<()> {
158 // Reject missing/malformed pricing_model rather than silently defaulting
159 // to "free" — a typo or future variant would otherwise demote the item to
160 // free on submit. Same disease class as the tier-row silent-drop bug
161 // fixed in the project wizard at Run #6.
162 let pricing_model = form
163 .get("pricing_model")
164 .map(String::as_str)
165 .ok_or_else(|| AppError::validation("Select a pricing model"))?;
166
167 match pricing_model {
168 "free" => {
169 db::items::update_item(
170 &state.db,
171 item.id,
172 user_id,
173 None,
174 None,
175 Some(PriceCents::from_db(0)),
176 None,
177 None,
178 Some(false),
179 None,
180 None,
181 None,
182 None, None, // ai_tier, ai_disclosure
183 )
184 .await?;
185 }
186 "fixed" => {
187 let price_cents = parse_dollars_to_cents("Price", form.get("price").map(String::as_str))?;
188 let price = PriceCents::new(price_cents)?;
189 db::items::update_item(
190 &state.db,
191 item.id,
192 user_id,
193 None,
194 None,
195 Some(price),
196 None,
197 None,
198 Some(false),
199 None,
200 None,
201 None,
202 None, None, // ai_tier, ai_disclosure
203 )
204 .await?;
205 }
206 "pwyw" => {
207 let suggested_cents = parse_dollars_to_cents("Suggested price", form.get("suggested_price").map(String::as_str))?;
208 let min_cents = parse_dollars_to_cents("Minimum price", form.get("min_price").map(String::as_str))?;
209 if min_cents > suggested_cents {
210 return Err(AppError::validation(
211 "Minimum price cannot exceed the suggested price",
212 ));
213 }
214 let suggested = PriceCents::new(suggested_cents)?;
215 let min = PriceCents::new(min_cents)?;
216 db::items::update_item(
217 &state.db,
218 item.id,
219 user_id,
220 None,
221 None,
222 Some(suggested),
223 None,
224 None,
225 Some(true),
226 Some(min),
227 None,
228 None,
229 None, None, // ai_tier, ai_disclosure
230 )
231 .await?;
232 }
233 other => {
234 return Err(AppError::validation(format!(
235 "Unknown pricing model: {other}"
236 )));
237 }
238 }
239 Ok(())
240 }
241
242 pub(super) async fn save_preview(
243 state: &AppState,
244 user: &crate::auth::SessionUser,
245 _project: &db::DbProject,
246 item: &db::DbItem,
247 form: &HashMap<String, String>,
248 ) -> Result<Response> {
249 let action = form.get("action").map(|s| s.as_str()).unwrap_or("draft");
250
251 match action {
252 "publish" => {
253 db::items::update_item(
254 &state.db,
255 item.id,
256 user.id,
257 None,
258 None,
259 None,
260 None,
261 Some(true),
262 None,
263 None,
264 None,
265 None,
266 None, None, // ai_tier, ai_disclosure
267 )
268 .await?;
269
270 // Re-fetch to get updated is_public state
271 let updated = db::items::get_item_by_id(&state.db, item.id)
272 .await?
273 .ok_or(AppError::NotFound)?;
274
275 if updated.is_public {
276 crate::scheduler::send_release_announcements(state, &updated).await;
277
278 if updated.mt_thread_id.is_none() {
279 crate::scheduler::spawn_mt_thread_for_item(state, &updated, user);
280 }
281 }
282 }
283 "schedule" => {
284 if let Some(datetime_str) = form.get("publish_at")
285 && let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M")
286 {
287 let utc_dt = dt.and_utc();
288 db::items::update_item(
289 &state.db,
290 item.id,
291 user.id,
292 None,
293 None,
294 None,
295 None,
296 None,
297 None,
298 None,
299 Some(Some(utc_dt)),
300 None,
301 None, None, // ai_tier, ai_disclosure
302 )
303 .await?;
304 }
305 }
306 _ => {} // draft -- leave as is
307 }
308
309 let mut response = Response::new(axum::body::Body::empty());
310 response.headers_mut().insert(
311 "HX-Redirect",
312 format!("/dashboard/item/{}", item.id)
313 .parse()
314 .expect("redirect path is valid"),
315 );
316 Ok(response)
317 }
318