Skip to main content

max / makenotwork

15.6 KB · 483 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(
284 SaveStatusTemplate {
285 success: true,
286 message: "Saved.".to_string(),
287 }
288 .render_string()?,
289 )
290 .into_response());
291 }
292
293 Ok(Json(ItemResponse {
294 id: updated.id,
295 project_id: updated.project_id,
296 title: updated.title,
297 description: updated.description,
298 price_cents: updated.price_cents,
299 item_type: updated.item_type.to_string(),
300 is_public: updated.is_public,
301 publish_at: updated.publish_at.map(|d| d.to_rfc3339()),
302 web_only: updated.web_only,
303 ai_tier: updated.ai_tier,
304 ai_disclosure: updated.ai_disclosure,
305 })
306 .into_response())
307 }
308
309 /// Soft-delete an item owned by the authenticated user (recoverable for 7 days).
310 #[tracing::instrument(skip_all, name = "items::delete_item", fields(item_id))]
311 pub(in crate::routes::api) async fn delete_item(
312 State(db): State<PgPool>,
313 _headers: HeaderMap,
314 AuthUser(user): AuthUser,
315 Path(id): Path<ItemId>,
316 ) -> Result<Response> {
317 tracing::Span::current().record("item_id", tracing::field::display(&id));
318 user.check_not_suspended()?;
319 let (item, _project) = verify_item_ownership(&db, id, user.id).await?;
320
321 db::items::delete_item(&db, id, user.id).await?;
322 db::projects::bump_cache_generation(&db, item.project_id).await?;
323
324 // Storage is reclaimed when the scheduler purges after 7 days
325
326 Ok(crate::helpers::htmx_toast_response(
327 "Item moved to Recently Deleted. You can restore it within 7 days.",
328 "success",
329 )
330 .into_response())
331 }
332
333 /// Restore a soft-deleted item.
334 #[tracing::instrument(skip_all, name = "items::restore_item", fields(item_id))]
335 pub(in crate::routes::api) async fn restore_item(
336 State(db): State<PgPool>,
337 AuthUser(user): AuthUser,
338 Path(id): Path<ItemId>,
339 ) -> Result<impl IntoResponse> {
340 user.check_not_suspended()?;
341 verify_item_ownership(&db, id, user.id).await?;
342
343 let restored = db::items::restore_item(&db, id, user.id).await?;
344 if !restored {
345 return Err(AppError::NotFound);
346 }
347
348 Ok(crate::helpers::htmx_toast_response(
349 "Item restored",
350 "success",
351 ))
352 }
353
354 /// Duplicate an item and its metadata, creating a new draft.
355 #[tracing::instrument(skip_all, name = "items::duplicate_item", fields(item_id))]
356 pub(in crate::routes::api) async fn duplicate_item(
357 State(db): State<PgPool>,
358 headers: HeaderMap,
359 AuthUser(user): AuthUser,
360 Path(id): Path<ItemId>,
361 ) -> Result<Response> {
362 tracing::Span::current().record("item_id", tracing::field::display(&id));
363 user.check_not_suspended()?;
364 verify_item_ownership(&db, id, user.id).await?;
365
366 let new_item = db::items::duplicate_item(&db, id, user.id).await?;
367
368 db::projects::bump_cache_generation(&db, new_item.project_id).await?;
369
370 if is_htmx_request(&headers) {
371 let mut response = Response::new(axum::body::Body::empty());
372 response.headers_mut().insert(
373 "HX-Redirect",
374 format!("/dashboard/item/{}", new_item.id)
375 .parse()
376 .expect("static redirect path is valid"),
377 );
378 return Ok(response);
379 }
380
381 Ok(Json(ItemResponse {
382 id: new_item.id,
383 project_id: new_item.project_id,
384 title: new_item.title,
385 description: new_item.description,
386 price_cents: new_item.price_cents,
387 item_type: new_item.item_type.to_string(),
388 is_public: new_item.is_public,
389 publish_at: new_item.publish_at.map(|d| d.to_rfc3339()),
390 web_only: new_item.web_only,
391 ai_tier: new_item.ai_tier,
392 ai_disclosure: new_item.ai_disclosure,
393 })
394 .into_response())
395 }
396
397 /// Form input for reordering an item within its project.
398 #[derive(Debug, Deserialize)]
399 pub(crate) struct MoveItemRequest {
400 pub direction: String,
401 }
402
403 /// Move an item up or down in its project's sort order.
404 #[tracing::instrument(skip_all, name = "items::move_item", fields(item_id))]
405 pub(in crate::routes::api) async fn move_item(
406 State(db): State<PgPool>,
407 AuthUser(user): AuthUser,
408 Path(id): Path<ItemId>,
409 Form(req): Form<MoveItemRequest>,
410 ) -> Result<impl IntoResponse> {
411 tracing::Span::current().record("item_id", tracing::field::display(&id));
412 user.check_not_suspended()?;
413 let (item, _project) = verify_item_ownership(&db, id, user.id).await?;
414
415 db::items::move_item(&db, item.project_id, user.id, id, &req.direction).await?;
416 db::projects::bump_cache_generation(&db, item.project_id).await?;
417
418 Ok(StatusCode::NO_CONTENT)
419 }
420
421 // Text Content API
422
423 /// JSON input for updating an item's text body content.
424 #[derive(Debug, Deserialize)]
425 pub(crate) struct UpdateTextRequest {
426 pub body: String,
427 }
428
429 /// JSON response for text content updates.
430 #[derive(Debug, Serialize)]
431 struct UpdateTextResponse {
432 id: ItemId,
433 body: Option<String>,
434 word_count: Option<i32>,
435 reading_time_minutes: Option<i32>,
436 }
437
438 /// Save or update the text body content for an owned item.
439 #[tracing::instrument(skip_all, name = "items::update_item_text", fields(item_id))]
440 pub(in crate::routes::api) async fn update_item_text(
441 State(db): State<PgPool>,
442 headers: HeaderMap,
443 AuthUser(user): AuthUser,
444 Path(id): Path<ItemId>,
445 Json(req): Json<UpdateTextRequest>,
446 ) -> Result<Response> {
447 tracing::Span::current().record("item_id", tracing::field::display(&id));
448 user.check_not_suspended()?;
449 validation::validate_item_text_body(&req.body)?;
450 verify_item_ownership(&db, id, user.id).await?;
451
452 let item = db::items::update_item_text(&db, id, user.id, &req.body).await?;
453 db::projects::bump_cache_generation(&db, item.project_id).await?;
454
455 let (body, word_count, reading_time_minutes) = match item.content() {
456 ContentData::Text {
457 body,
458 word_count,
459 reading_time_minutes,
460 } => (body, word_count, reading_time_minutes),
461 _ => (None, None, None),
462 };
463
464 if is_htmx_request(&headers) {
465 return Ok(axum::response::Html(
466 SaveStatusTemplate {
467 success: true,
468 message: format!("{} words saved", word_count.unwrap_or(0)),
469 }
470 .render_string()?,
471 )
472 .into_response());
473 }
474
475 Ok(Json(UpdateTextResponse {
476 id: item.id,
477 body,
478 word_count,
479 reading_time_minutes,
480 })
481 .into_response())
482 }
483