Skip to main content

max / makenotwork

16.0 KB · 490 lines History Blame Raw
1 //! Item CRUD and text content handlers.
2
3 use axum::{
4 Form, Json,
5 extract::{Path, State},
6 http::{StatusCode, header::HeaderMap},
7 response::{IntoResponse, Response},
8 };
9 use serde::{Deserialize, Serialize};
10 use sqlx::PgPool;
11
12 use crate::{
13 Integrations,
14 auth::AuthUser,
15 config::Config,
16 db::{self, AiTier, ContentData, ItemId, ItemType, PriceCents, ProjectId},
17 error::{AppError, Result},
18 helpers::{is_htmx_request, parse_schedule_datetime},
19 templates::SaveStatusTemplate,
20 validation,
21 };
22
23 use super::super::{verify_item_ownership, verify_project_ownership};
24
25 // Item API
26
27 /// Form input for creating a new item within a project.
28 #[derive(Debug, Deserialize)]
29 pub(crate) struct CreateItemRequest {
30 pub title: String,
31 pub description: Option<String>,
32 /// Price in cents. Validated non-negative on deserialization.
33 pub price_cents: Option<PriceCents>,
34 pub item_type: Option<ItemType>,
35 /// AI classification tier. Defaults to Handmade if not specified.
36 pub ai_tier: Option<AiTier>,
37 /// Disclosure text (required when ai_tier is Assisted).
38 pub ai_disclosure: Option<String>,
39 }
40
41 /// JSON response representing an item.
42 #[derive(Debug, Serialize)]
43 pub(super) struct ItemResponse {
44 pub id: ItemId,
45 pub project_id: ProjectId,
46 pub title: String,
47 pub description: Option<String>,
48 pub price_cents: i32,
49 pub item_type: String,
50 pub is_public: bool,
51 pub publish_at: Option<String>,
52 pub web_only: bool,
53 pub ai_tier: AiTier,
54 pub ai_disclosure: Option<String>,
55 }
56
57 /// Create a new item under an owned project.
58 #[tracing::instrument(skip_all, name = "items::create_item", fields(project_id))]
59 pub(in crate::routes::api) async fn create_item(
60 State(db): State<PgPool>,
61 headers: HeaderMap,
62 AuthUser(user): AuthUser,
63 Path(project_id): Path<ProjectId>,
64 Form(req): Form<CreateItemRequest>,
65 ) -> Result<Response> {
66 tracing::Span::current().record("project_id", tracing::field::display(&project_id));
67 user.check_not_suspended()?;
68 // Validate input (price_cents validated on deserialization via PriceCents)
69 validation::validate_item_title(&req.title)?;
70 if let Some(ref desc) = req.description {
71 validation::validate_item_description(desc)?;
72 }
73
74 verify_project_ownership(&db, project_id, user.id).await?;
75
76 // Validate item type against project features
77 let project = db::projects::get_project_by_id(&db, project_id)
78 .await?
79 .ok_or(AppError::NotFound)?;
80 let item_type = req.item_type.unwrap_or(ItemType::Digital);
81 let allowed = db::ProjectFeature::allowed_item_type_cards(&project.features);
82 if !allowed
83 .iter()
84 .any(|(v, _, _)| *v == item_type.to_string().as_str())
85 {
86 return Err(AppError::validation(format!(
87 "Item type '{item_type}' is not available for this project's features"
88 )));
89 }
90
91 // Inherit AI tier from project if not specified on the item
92 let ai_tier = req.ai_tier.unwrap_or(project.ai_tier);
93 let ai_disclosure = match ai_tier {
94 AiTier::Assisted => {
95 let text = req
96 .ai_disclosure
97 .as_deref()
98 .or(project.ai_disclosure.as_deref())
99 .unwrap_or("")
100 .trim();
101 if text.is_empty() {
102 None
103 } else {
104 Some(text.to_string())
105 }
106 }
107 _ => None,
108 };
109
110 let item = db::items::create_item(
111 &db,
112 project_id,
113 &req.title,
114 req.description.as_deref(),
115 req.price_cents.unwrap_or(PriceCents::from_db(0)),
116 item_type,
117 ai_tier,
118 ai_disclosure.as_deref(),
119 )
120 .await?;
121
122 db::projects::bump_cache_generation(&db, project_id).await?;
123
124 if is_htmx_request(&headers) {
125 // Return HX-Redirect header to redirect to the item dashboard
126 let mut response = Response::new(axum::body::Body::empty());
127 response.headers_mut().insert(
128 "HX-Redirect",
129 format!("/dashboard/item/{}", item.id)
130 .parse()
131 .expect("static redirect path is valid"),
132 );
133 return Ok(response);
134 }
135
136 Ok(Json(ItemResponse {
137 id: item.id,
138 project_id: item.project_id,
139 title: item.title,
140 description: item.description,
141 price_cents: item.price_cents,
142 item_type: item.item_type.to_string(),
143 is_public: item.is_public,
144 publish_at: item.publish_at.map(|d| d.to_rfc3339()),
145 web_only: item.web_only,
146 ai_tier: item.ai_tier,
147 ai_disclosure: item.ai_disclosure,
148 })
149 .into_response())
150 }
151
152 /// JSON input for updating an existing item.
153 #[derive(Debug, Deserialize)]
154 pub(crate) struct UpdateItemRequest {
155 pub title: Option<String>,
156 pub description: Option<String>,
157 /// Price in cents. Validated non-negative on deserialization.
158 pub price_cents: Option<PriceCents>,
159 pub item_type: Option<ItemType>,
160 pub is_public: Option<bool>,
161 /// Checkbox value: present means enabled, absent means disabled.
162 pub pwyw_enabled: Option<String>,
163 pub pwyw_min_cents: Option<PriceCents>,
164 /// ISO 8601 datetime string for scheduled publishing. Empty string clears the schedule.
165 pub publish_at: Option<String>,
166 /// Whether to skip email announcements when publishing.
167 pub web_only: Option<bool>,
168 /// AI classification tier.
169 pub ai_tier: Option<AiTier>,
170 /// AI disclosure text (required when ai_tier is Assisted).
171 pub ai_disclosure: Option<String>,
172 }
173
174 /// Update an existing item owned by the authenticated user.
175 #[tracing::instrument(skip_all, name = "items::update_item", fields(item_id))]
176 #[allow(clippy::too_many_arguments)]
177 pub(in crate::routes::api) async fn update_item(
178 State(db): State<PgPool>,
179 State(mailer): State<crate::email::EmailClient>,
180 State(config): State<Config>,
181 State(bg): State<crate::background::BackgroundTx>,
182 State(integrations): State<Integrations>,
183 headers: HeaderMap,
184 AuthUser(user): AuthUser,
185 Path(id): Path<ItemId>,
186 Form(req): Form<UpdateItemRequest>,
187 ) -> Result<Response> {
188 tracing::Span::current().record("item_id", tracing::field::display(&id));
189 user.check_not_suspended()?;
190 verify_item_ownership(&db, id, user.id).await?;
191
192 // Validate input (same rules as create_item, but all fields are optional)
193 if let Some(ref title) = req.title {
194 validation::validate_item_title(title)?;
195 }
196 if let Some(ref desc) = req.description {
197 validation::validate_item_description(desc)?;
198 }
199 // price_cents is validated on deserialization via PriceCents, which is
200 // currency-blind: it sees a number before it sees whose it is. A PWYW
201 // minimum has a floor that depends on the creator's settlement currency, so
202 // it is re-checked here, where the creator is known.
203 let pwyw_min_cents = req
204 .pwyw_min_cents
205 .map(|c| PriceCents::pwyw_minimum(c.as_i32(), user.settlement_currency))
206 .transpose()?;
207
208 // Convert checkbox value: "on" = enabled, "off" = disabled, absent = no change
209 let pwyw_enabled = req.pwyw_enabled.as_deref().map(|v| v == "on");
210
211 // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule
212 let publish_at = parse_schedule_datetime(req.publish_at.as_deref());
213
214 // Reject scheduling in the past
215 if let Some(Some(dt)) = &publish_at
216 && *dt < chrono::Utc::now()
217 {
218 return Err(AppError::BadRequest(
219 "Scheduled publish date must be in the future".to_string(),
220 ));
221 }
222
223 // If scheduling, override is_public to false so it doesn't go live immediately
224 let is_public = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() {
225 Some(false)
226 } else {
227 req.is_public
228 };
229
230 // Validate AI tier disclosure if tier is being changed
231 let ai_disclosure: Option<Option<&str>> = if let Some(ai_tier) = req.ai_tier {
232 match ai_tier {
233 AiTier::Assisted => {
234 let text = req.ai_disclosure.as_deref().unwrap_or("").trim();
235 if text.is_empty() {
236 return Err(AppError::validation(
237 "AI disclosure is required for Assisted tier items".to_string(),
238 ));
239 }
240 Some(Some(text))
241 }
242 _ => Some(None), // Clear disclosure for Handmade/Generated
243 }
244 } else if req.ai_disclosure.is_some() {
245 // Disclosure text updated without changing tier
246 Some(req.ai_disclosure.as_deref())
247 } else {
248 None // No change
249 };
250
251 let updated = db::items::update_item(
252 &db,
253 id,
254 user.id,
255 req.title.as_deref(),
256 req.description.as_deref(),
257 req.price_cents,
258 req.item_type,
259 is_public,
260 pwyw_enabled,
261 pwyw_min_cents,
262 publish_at,
263 req.web_only,
264 req.ai_tier,
265 ai_disclosure,
266 )
267 .await?;
268
269 // Detect first publish: if the request set is_public=true and the item is now public,
270 // atomically mark as announced and send release emails to followers.
271 if req.is_public == Some(true) && updated.is_public {
272 crate::scheduler::send_release_announcements(&db, &mailer, &config, &updated).await;
273
274 // Create linked MT discussion thread on first publish
275 if updated.mt_thread_id.is_none() {
276 crate::scheduler::spawn_mt_thread_for_item(
277 &db,
278 &bg,
279 &integrations,
280 &config,
281 &updated,
282 &user,
283 );
284 }
285 }
286
287 db::projects::bump_cache_generation(&db, updated.project_id).await?;
288
289 if is_htmx_request(&headers) {
290 return Ok(axum::response::Html(
291 SaveStatusTemplate {
292 success: true,
293 message: "Saved.".to_string(),
294 }
295 .render_string()?,
296 )
297 .into_response());
298 }
299
300 Ok(Json(ItemResponse {
301 id: updated.id,
302 project_id: updated.project_id,
303 title: updated.title,
304 description: updated.description,
305 price_cents: updated.price_cents,
306 item_type: updated.item_type.to_string(),
307 is_public: updated.is_public,
308 publish_at: updated.publish_at.map(|d| d.to_rfc3339()),
309 web_only: updated.web_only,
310 ai_tier: updated.ai_tier,
311 ai_disclosure: updated.ai_disclosure,
312 })
313 .into_response())
314 }
315
316 /// Soft-delete an item owned by the authenticated user (recoverable for 7 days).
317 #[tracing::instrument(skip_all, name = "items::delete_item", fields(item_id))]
318 pub(in crate::routes::api) async fn delete_item(
319 State(db): State<PgPool>,
320 _headers: HeaderMap,
321 AuthUser(user): AuthUser,
322 Path(id): Path<ItemId>,
323 ) -> Result<Response> {
324 tracing::Span::current().record("item_id", tracing::field::display(&id));
325 user.check_not_suspended()?;
326 let (item, _project) = verify_item_ownership(&db, id, user.id).await?;
327
328 db::items::delete_item(&db, id, user.id).await?;
329 db::projects::bump_cache_generation(&db, item.project_id).await?;
330
331 // Storage is reclaimed when the scheduler purges after 7 days
332
333 Ok(crate::helpers::htmx_toast_response(
334 "Item moved to Recently Deleted. You can restore it within 7 days.",
335 "success",
336 )
337 .into_response())
338 }
339
340 /// Restore a soft-deleted item.
341 #[tracing::instrument(skip_all, name = "items::restore_item", fields(item_id))]
342 pub(in crate::routes::api) async fn restore_item(
343 State(db): State<PgPool>,
344 AuthUser(user): AuthUser,
345 Path(id): Path<ItemId>,
346 ) -> Result<impl IntoResponse> {
347 user.check_not_suspended()?;
348 verify_item_ownership(&db, id, user.id).await?;
349
350 let restored = db::items::restore_item(&db, id, user.id).await?;
351 if !restored {
352 return Err(AppError::NotFound);
353 }
354
355 Ok(crate::helpers::htmx_toast_response(
356 "Item restored",
357 "success",
358 ))
359 }
360
361 /// Duplicate an item and its metadata, creating a new draft.
362 #[tracing::instrument(skip_all, name = "items::duplicate_item", fields(item_id))]
363 pub(in crate::routes::api) async fn duplicate_item(
364 State(db): State<PgPool>,
365 headers: HeaderMap,
366 AuthUser(user): AuthUser,
367 Path(id): Path<ItemId>,
368 ) -> Result<Response> {
369 tracing::Span::current().record("item_id", tracing::field::display(&id));
370 user.check_not_suspended()?;
371 verify_item_ownership(&db, id, user.id).await?;
372
373 let new_item = db::items::duplicate_item(&db, id, user.id).await?;
374
375 db::projects::bump_cache_generation(&db, new_item.project_id).await?;
376
377 if is_htmx_request(&headers) {
378 let mut response = Response::new(axum::body::Body::empty());
379 response.headers_mut().insert(
380 "HX-Redirect",
381 format!("/dashboard/item/{}", new_item.id)
382 .parse()
383 .expect("static redirect path is valid"),
384 );
385 return Ok(response);
386 }
387
388 Ok(Json(ItemResponse {
389 id: new_item.id,
390 project_id: new_item.project_id,
391 title: new_item.title,
392 description: new_item.description,
393 price_cents: new_item.price_cents,
394 item_type: new_item.item_type.to_string(),
395 is_public: new_item.is_public,
396 publish_at: new_item.publish_at.map(|d| d.to_rfc3339()),
397 web_only: new_item.web_only,
398 ai_tier: new_item.ai_tier,
399 ai_disclosure: new_item.ai_disclosure,
400 })
401 .into_response())
402 }
403
404 /// Form input for reordering an item within its project.
405 #[derive(Debug, Deserialize)]
406 pub(crate) struct MoveItemRequest {
407 pub direction: String,
408 }
409
410 /// Move an item up or down in its project's sort order.
411 #[tracing::instrument(skip_all, name = "items::move_item", fields(item_id))]
412 pub(in crate::routes::api) async fn move_item(
413 State(db): State<PgPool>,
414 AuthUser(user): AuthUser,
415 Path(id): Path<ItemId>,
416 Form(req): Form<MoveItemRequest>,
417 ) -> Result<impl IntoResponse> {
418 tracing::Span::current().record("item_id", tracing::field::display(&id));
419 user.check_not_suspended()?;
420 let (item, _project) = verify_item_ownership(&db, id, user.id).await?;
421
422 db::items::move_item(&db, item.project_id, user.id, id, &req.direction).await?;
423 db::projects::bump_cache_generation(&db, item.project_id).await?;
424
425 Ok(StatusCode::NO_CONTENT)
426 }
427
428 // Text Content API
429
430 /// JSON input for updating an item's text body content.
431 #[derive(Debug, Deserialize)]
432 pub(crate) struct UpdateTextRequest {
433 pub body: String,
434 }
435
436 /// JSON response for text content updates.
437 #[derive(Debug, Serialize)]
438 struct UpdateTextResponse {
439 id: ItemId,
440 body: Option<String>,
441 word_count: Option<i32>,
442 reading_time_minutes: Option<i32>,
443 }
444
445 /// Save or update the text body content for an owned item.
446 #[tracing::instrument(skip_all, name = "items::update_item_text", fields(item_id))]
447 pub(in crate::routes::api) async fn update_item_text(
448 State(db): State<PgPool>,
449 headers: HeaderMap,
450 AuthUser(user): AuthUser,
451 Path(id): Path<ItemId>,
452 Json(req): Json<UpdateTextRequest>,
453 ) -> Result<Response> {
454 tracing::Span::current().record("item_id", tracing::field::display(&id));
455 user.check_not_suspended()?;
456 validation::validate_item_text_body(&req.body)?;
457 verify_item_ownership(&db, id, user.id).await?;
458
459 let item = db::items::update_item_text(&db, id, user.id, &req.body).await?;
460 db::projects::bump_cache_generation(&db, item.project_id).await?;
461
462 let (body, word_count, reading_time_minutes) = match item.content() {
463 ContentData::Text {
464 body,
465 word_count,
466 reading_time_minutes,
467 } => (body, word_count, reading_time_minutes),
468 _ => (None, None, None),
469 };
470
471 if is_htmx_request(&headers) {
472 return Ok(axum::response::Html(
473 SaveStatusTemplate {
474 success: true,
475 message: format!("{} words saved", word_count.unwrap_or(0)),
476 }
477 .render_string()?,
478 )
479 .into_response());
480 }
481
482 Ok(Json(UpdateTextResponse {
483 id: item.id,
484 body,
485 word_count,
486 reading_time_minutes,
487 })
488 .into_response())
489 }
490