Skip to main content

max / makenotwork

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