Skip to main content

max / makenotwork

15.5 KB · 476 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 and pwyw_min_cents validated on deserialization via PriceCents
200
201 // Convert checkbox value: "on" = enabled, "off" = disabled, absent = no change
202 let pwyw_enabled = req.pwyw_enabled.as_deref().map(|v| v == "on");
203
204 // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule
205 let publish_at = parse_schedule_datetime(req.publish_at.as_deref());
206
207 // Reject scheduling in the past
208 if let Some(Some(dt)) = &publish_at
209 && *dt < chrono::Utc::now()
210 {
211 return Err(AppError::BadRequest(
212 "Scheduled publish date must be in the future".to_string(),
213 ));
214 }
215
216 // If scheduling, override is_public to false so it doesn't go live immediately
217 let is_public = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() {
218 Some(false)
219 } else {
220 req.is_public
221 };
222
223 // Validate AI tier disclosure if tier is being changed
224 let ai_disclosure: Option<Option<&str>> = if let Some(ai_tier) = req.ai_tier {
225 match ai_tier {
226 AiTier::Assisted => {
227 let text = req.ai_disclosure.as_deref().unwrap_or("").trim();
228 if text.is_empty() {
229 return Err(AppError::validation(
230 "AI disclosure is required for Assisted tier items".to_string(),
231 ));
232 }
233 Some(Some(text))
234 }
235 _ => Some(None), // Clear disclosure for Handmade/Generated
236 }
237 } else if req.ai_disclosure.is_some() {
238 // Disclosure text updated without changing tier
239 Some(req.ai_disclosure.as_deref())
240 } else {
241 None // No change
242 };
243
244 let updated = db::items::update_item(
245 &db,
246 id,
247 user.id,
248 req.title.as_deref(),
249 req.description.as_deref(),
250 req.price_cents,
251 req.item_type,
252 is_public,
253 pwyw_enabled,
254 req.pwyw_min_cents,
255 publish_at,
256 req.web_only,
257 req.ai_tier,
258 ai_disclosure,
259 )
260 .await?;
261
262 // Detect first publish: if the request set is_public=true and the item is now public,
263 // atomically mark as announced and send release emails to followers.
264 if req.is_public == Some(true) && updated.is_public {
265 crate::scheduler::send_release_announcements(&db, &mailer, &config, &updated).await;
266
267 // Create linked MT discussion thread on first publish
268 if updated.mt_thread_id.is_none() {
269 crate::scheduler::spawn_mt_thread_for_item(
270 &db,
271 &bg,
272 &integrations,
273 &config,
274 &updated,
275 &user,
276 );
277 }
278 }
279
280 db::projects::bump_cache_generation(&db, updated.project_id).await?;
281
282 if is_htmx_request(&headers) {
283 return Ok(axum::response::Html("Saved.".to_string()).into_response());
284 }
285
286 Ok(Json(ItemResponse {
287 id: updated.id,
288 project_id: updated.project_id,
289 title: updated.title,
290 description: updated.description,
291 price_cents: updated.price_cents,
292 item_type: updated.item_type.to_string(),
293 is_public: updated.is_public,
294 publish_at: updated.publish_at.map(|d| d.to_rfc3339()),
295 web_only: updated.web_only,
296 ai_tier: updated.ai_tier,
297 ai_disclosure: updated.ai_disclosure,
298 })
299 .into_response())
300 }
301
302 /// Soft-delete an item owned by the authenticated user (recoverable for 7 days).
303 #[tracing::instrument(skip_all, name = "items::delete_item", fields(item_id))]
304 pub(in crate::routes::api) async fn delete_item(
305 State(db): State<PgPool>,
306 _headers: HeaderMap,
307 AuthUser(user): AuthUser,
308 Path(id): Path<ItemId>,
309 ) -> Result<Response> {
310 tracing::Span::current().record("item_id", tracing::field::display(&id));
311 user.check_not_suspended()?;
312 let (item, _project) = verify_item_ownership(&db, id, user.id).await?;
313
314 db::items::delete_item(&db, id, user.id).await?;
315 db::projects::bump_cache_generation(&db, item.project_id).await?;
316
317 // Storage is reclaimed when the scheduler purges after 7 days
318
319 Ok(crate::helpers::htmx_toast_response(
320 "Item moved to Recently Deleted. You can restore it within 7 days.",
321 "success",
322 )
323 .into_response())
324 }
325
326 /// Restore a soft-deleted item.
327 #[tracing::instrument(skip_all, name = "items::restore_item", fields(item_id))]
328 pub(in crate::routes::api) async fn restore_item(
329 State(db): State<PgPool>,
330 AuthUser(user): AuthUser,
331 Path(id): Path<ItemId>,
332 ) -> Result<impl IntoResponse> {
333 user.check_not_suspended()?;
334 verify_item_ownership(&db, id, user.id).await?;
335
336 let restored = db::items::restore_item(&db, id, user.id).await?;
337 if !restored {
338 return Err(AppError::NotFound);
339 }
340
341 Ok(crate::helpers::htmx_toast_response(
342 "Item restored",
343 "success",
344 ))
345 }
346
347 /// Duplicate an item and its metadata, creating a new draft.
348 #[tracing::instrument(skip_all, name = "items::duplicate_item", fields(item_id))]
349 pub(in crate::routes::api) async fn duplicate_item(
350 State(db): State<PgPool>,
351 headers: HeaderMap,
352 AuthUser(user): AuthUser,
353 Path(id): Path<ItemId>,
354 ) -> Result<Response> {
355 tracing::Span::current().record("item_id", tracing::field::display(&id));
356 user.check_not_suspended()?;
357 verify_item_ownership(&db, id, user.id).await?;
358
359 let new_item = db::items::duplicate_item(&db, id, user.id).await?;
360
361 db::projects::bump_cache_generation(&db, new_item.project_id).await?;
362
363 if is_htmx_request(&headers) {
364 let mut response = Response::new(axum::body::Body::empty());
365 response.headers_mut().insert(
366 "HX-Redirect",
367 format!("/dashboard/item/{}", new_item.id)
368 .parse()
369 .expect("static redirect path is valid"),
370 );
371 return Ok(response);
372 }
373
374 Ok(Json(ItemResponse {
375 id: new_item.id,
376 project_id: new_item.project_id,
377 title: new_item.title,
378 description: new_item.description,
379 price_cents: new_item.price_cents,
380 item_type: new_item.item_type.to_string(),
381 is_public: new_item.is_public,
382 publish_at: new_item.publish_at.map(|d| d.to_rfc3339()),
383 web_only: new_item.web_only,
384 ai_tier: new_item.ai_tier,
385 ai_disclosure: new_item.ai_disclosure,
386 })
387 .into_response())
388 }
389
390 /// Form input for reordering an item within its project.
391 #[derive(Debug, Deserialize)]
392 pub(crate) struct MoveItemRequest {
393 pub direction: String,
394 }
395
396 /// Move an item up or down in its project's sort order.
397 #[tracing::instrument(skip_all, name = "items::move_item", fields(item_id))]
398 pub(in crate::routes::api) async fn move_item(
399 State(db): State<PgPool>,
400 AuthUser(user): AuthUser,
401 Path(id): Path<ItemId>,
402 Form(req): Form<MoveItemRequest>,
403 ) -> Result<impl IntoResponse> {
404 tracing::Span::current().record("item_id", tracing::field::display(&id));
405 user.check_not_suspended()?;
406 let (item, _project) = verify_item_ownership(&db, id, user.id).await?;
407
408 db::items::move_item(&db, item.project_id, user.id, id, &req.direction).await?;
409 db::projects::bump_cache_generation(&db, item.project_id).await?;
410
411 Ok(StatusCode::NO_CONTENT)
412 }
413
414 // Text Content API
415
416 /// JSON input for updating an item's text body content.
417 #[derive(Debug, Deserialize)]
418 pub(crate) struct UpdateTextRequest {
419 pub body: String,
420 }
421
422 /// JSON response for text content updates.
423 #[derive(Debug, Serialize)]
424 struct UpdateTextResponse {
425 id: ItemId,
426 body: Option<String>,
427 word_count: Option<i32>,
428 reading_time_minutes: Option<i32>,
429 }
430
431 /// Save or update the text body content for an owned item.
432 #[tracing::instrument(skip_all, name = "items::update_item_text", fields(item_id))]
433 pub(in crate::routes::api) async fn update_item_text(
434 State(db): State<PgPool>,
435 headers: HeaderMap,
436 AuthUser(user): AuthUser,
437 Path(id): Path<ItemId>,
438 Json(req): Json<UpdateTextRequest>,
439 ) -> Result<Response> {
440 tracing::Span::current().record("item_id", tracing::field::display(&id));
441 user.check_not_suspended()?;
442 validation::validate_item_text_body(&req.body)?;
443 verify_item_ownership(&db, id, user.id).await?;
444
445 let item = db::items::update_item_text(&db, id, user.id, &req.body).await?;
446 db::projects::bump_cache_generation(&db, item.project_id).await?;
447
448 let (body, word_count, reading_time_minutes) = match item.content() {
449 ContentData::Text {
450 body,
451 word_count,
452 reading_time_minutes,
453 } => (body, word_count, reading_time_minutes),
454 _ => (None, None, None),
455 };
456
457 if is_htmx_request(&headers) {
458 return Ok(axum::response::Html(
459 SaveStatusTemplate {
460 success: true,
461 message: format!("{} words saved", word_count.unwrap_or(0)),
462 }
463 .render_string()?,
464 )
465 .into_response());
466 }
467
468 Ok(Json(UpdateTextResponse {
469 id: item.id,
470 body,
471 word_count,
472 reading_time_minutes,
473 })
474 .into_response())
475 }
476