//! Item CRUD and text content handlers. use axum::{ extract::{Path, State}, http::{header::HeaderMap, StatusCode}, response::{IntoResponse, Response}, Form, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, AiTier, ContentData, ItemId, ItemType, PriceCents, ProjectId}, error::{AppError, Result}, helpers::{is_htmx_request, parse_schedule_datetime}, templates::SaveStatusTemplate, validation, AppState, }; use super::super::{verify_item_ownership, verify_project_ownership}; // ============================================================================= // Item API // ============================================================================= /// Form input for creating a new item within a project. #[derive(Debug, Deserialize)] pub struct CreateItemRequest { pub title: String, pub description: Option, /// Price in cents. Validated non-negative on deserialization. pub price_cents: Option, pub item_type: Option, /// AI classification tier. Defaults to Handmade if not specified. pub ai_tier: Option, /// Disclosure text (required when ai_tier is Assisted). pub ai_disclosure: Option, } /// JSON response representing an item. #[derive(Debug, Serialize)] pub struct ItemResponse { pub id: ItemId, pub project_id: ProjectId, pub title: String, pub description: Option, pub price_cents: i32, pub item_type: String, pub is_public: bool, pub publish_at: Option, pub web_only: bool, pub ai_tier: AiTier, pub ai_disclosure: Option, } /// Create a new item under an owned project. #[tracing::instrument(skip_all, name = "items::create_item", fields(project_id))] pub(in crate::routes::api) async fn create_item( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(project_id): Path, Form(req): Form, ) -> Result { tracing::Span::current().record("project_id", tracing::field::display(&project_id)); user.check_not_suspended()?; // Validate input (price_cents validated on deserialization via PriceCents) validation::validate_item_title(&req.title)?; if let Some(ref desc) = req.description { validation::validate_item_description(desc)?; } verify_project_ownership(&state, project_id, user.id).await?; // Validate item type against project features let project = db::projects::get_project_by_id(&state.db, project_id) .await? .ok_or(AppError::NotFound)?; let item_type = req.item_type.unwrap_or(ItemType::Digital); let allowed = db::ProjectFeature::allowed_item_type_cards(&project.features); if !allowed.iter().any(|(v, _, _)| *v == item_type.to_string().as_str()) { return Err(AppError::validation(format!( "Item type '{}' is not available for this project's features", item_type ))); } // Inherit AI tier from project if not specified on the item let ai_tier = req.ai_tier.unwrap_or(project.ai_tier); let ai_disclosure = match ai_tier { AiTier::Assisted => { let text = req.ai_disclosure.as_deref() .or(project.ai_disclosure.as_deref()) .unwrap_or("").trim(); if text.is_empty() { None } else { Some(text.to_string()) } } _ => None, }; let item = db::items::create_item( &state.db, project_id, &req.title, req.description.as_deref(), req.price_cents.unwrap_or(PriceCents::from_db(0)), item_type, ai_tier, ai_disclosure.as_deref(), ) .await?; db::projects::bump_cache_generation(&state.db, project_id).await?; if is_htmx_request(&headers) { // Return HX-Redirect header to redirect to the item dashboard let mut response = Response::new(axum::body::Body::empty()); response.headers_mut().insert( "HX-Redirect", format!("/dashboard/item/{}", item.id) .parse() .expect("static redirect path is valid"), ); return Ok(response); } Ok(Json(ItemResponse { id: item.id, project_id: item.project_id, title: item.title, description: item.description, price_cents: item.price_cents, item_type: item.item_type.to_string(), is_public: item.is_public, publish_at: item.publish_at.map(|d| d.to_rfc3339()), web_only: item.web_only, ai_tier: item.ai_tier, ai_disclosure: item.ai_disclosure, }).into_response()) } /// JSON input for updating an existing item. #[derive(Debug, Deserialize)] pub struct UpdateItemRequest { pub title: Option, pub description: Option, /// Price in cents. Validated non-negative on deserialization. pub price_cents: Option, pub item_type: Option, pub is_public: Option, /// Checkbox value: present means enabled, absent means disabled. pub pwyw_enabled: Option, pub pwyw_min_cents: Option, /// ISO 8601 datetime string for scheduled publishing. Empty string clears the schedule. pub publish_at: Option, /// Whether to skip email announcements when publishing. pub web_only: Option, /// AI classification tier. pub ai_tier: Option, /// AI disclosure text (required when ai_tier is Assisted). pub ai_disclosure: Option, } /// Update an existing item owned by the authenticated user. #[tracing::instrument(skip_all, name = "items::update_item", fields(item_id))] pub(in crate::routes::api) async fn update_item( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, Form(req): Form, ) -> Result { tracing::Span::current().record("item_id", tracing::field::display(&id)); user.check_not_suspended()?; verify_item_ownership(&state, id, user.id).await?; // Validate input (same rules as create_item, but all fields are optional) if let Some(ref title) = req.title { validation::validate_item_title(title)?; } if let Some(ref desc) = req.description { validation::validate_item_description(desc)?; } // price_cents and pwyw_min_cents validated on deserialization via PriceCents // Convert checkbox value: "on" = enabled, "off" = disabled, absent = no change let pwyw_enabled = req.pwyw_enabled.as_deref().map(|v| v == "on"); // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule let publish_at = parse_schedule_datetime(req.publish_at.as_deref()); // Reject scheduling in the past if let Some(Some(dt)) = &publish_at && *dt < chrono::Utc::now() { return Err(AppError::BadRequest("Scheduled publish date must be in the future".to_string())); } // If scheduling, override is_public to false so it doesn't go live immediately let is_public = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() { Some(false) } else { req.is_public }; // Validate AI tier disclosure if tier is being changed let ai_disclosure: Option> = if let Some(ai_tier) = req.ai_tier { match ai_tier { AiTier::Assisted => { let text = req.ai_disclosure.as_deref().unwrap_or("").trim(); if text.is_empty() { return Err(AppError::validation( "AI disclosure is required for Assisted tier items".to_string(), )); } Some(Some(text)) } _ => Some(None), // Clear disclosure for Handmade/Generated } } else if req.ai_disclosure.is_some() { // Disclosure text updated without changing tier Some(req.ai_disclosure.as_deref()) } else { None // No change }; let updated = db::items::update_item( &state.db, id, user.id, req.title.as_deref(), req.description.as_deref(), req.price_cents, req.item_type, is_public, pwyw_enabled, req.pwyw_min_cents, publish_at, req.web_only, req.ai_tier, ai_disclosure, ) .await?; // Detect first publish: if the request set is_public=true and the item is now public, // atomically mark as announced and send release emails to followers. if req.is_public == Some(true) && updated.is_public { crate::scheduler::send_release_announcements(&state, &updated).await; // Create linked MT discussion thread on first publish if updated.mt_thread_id.is_none() { crate::scheduler::spawn_mt_thread_for_item(&state, &updated, &user); } } db::projects::bump_cache_generation(&state.db, updated.project_id).await?; if is_htmx_request(&headers) { return Ok(axum::response::Html("Saved.".to_string()).into_response()); } Ok(Json(ItemResponse { id: updated.id, project_id: updated.project_id, title: updated.title, description: updated.description, price_cents: updated.price_cents, item_type: updated.item_type.to_string(), is_public: updated.is_public, publish_at: updated.publish_at.map(|d| d.to_rfc3339()), web_only: updated.web_only, ai_tier: updated.ai_tier, ai_disclosure: updated.ai_disclosure, }).into_response()) } /// Soft-delete an item owned by the authenticated user (recoverable for 7 days). #[tracing::instrument(skip_all, name = "items::delete_item", fields(item_id))] pub(in crate::routes::api) async fn delete_item( State(state): State, _headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { tracing::Span::current().record("item_id", tracing::field::display(&id)); user.check_not_suspended()?; let (item, _project) = verify_item_ownership(&state, id, user.id).await?; db::items::delete_item(&state.db, id, user.id).await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; // Storage is reclaimed when the scheduler purges after 7 days Ok(crate::helpers::htmx_toast_response("Item moved to Recently Deleted. You can restore it within 7 days.", "success").into_response()) } /// Restore a soft-deleted item. #[tracing::instrument(skip_all, name = "items::restore_item", fields(item_id))] pub(in crate::routes::api) async fn restore_item( State(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { user.check_not_suspended()?; verify_item_ownership(&state, id, user.id).await?; let restored = db::items::restore_item(&state.db, id, user.id).await?; if !restored { return Err(AppError::NotFound); } Ok(crate::helpers::htmx_toast_response("Item restored", "success")) } /// Duplicate an item and its metadata, creating a new draft. #[tracing::instrument(skip_all, name = "items::duplicate_item", fields(item_id))] pub(in crate::routes::api) async fn duplicate_item( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { tracing::Span::current().record("item_id", tracing::field::display(&id)); user.check_not_suspended()?; verify_item_ownership(&state, id, user.id).await?; let new_item = db::items::duplicate_item(&state.db, id, user.id).await?; db::projects::bump_cache_generation(&state.db, new_item.project_id).await?; if is_htmx_request(&headers) { let mut response = Response::new(axum::body::Body::empty()); response.headers_mut().insert( "HX-Redirect", format!("/dashboard/item/{}", new_item.id) .parse() .expect("static redirect path is valid"), ); return Ok(response); } Ok(Json(ItemResponse { id: new_item.id, project_id: new_item.project_id, title: new_item.title, description: new_item.description, price_cents: new_item.price_cents, item_type: new_item.item_type.to_string(), is_public: new_item.is_public, publish_at: new_item.publish_at.map(|d| d.to_rfc3339()), web_only: new_item.web_only, ai_tier: new_item.ai_tier, ai_disclosure: new_item.ai_disclosure, }).into_response()) } /// Form input for reordering an item within its project. #[derive(Debug, Deserialize)] pub struct MoveItemRequest { pub direction: String, } /// Move an item up or down in its project's sort order. #[tracing::instrument(skip_all, name = "items::move_item", fields(item_id))] pub(in crate::routes::api) async fn move_item( State(state): State, AuthUser(user): AuthUser, Path(id): Path, Form(req): Form, ) -> Result { tracing::Span::current().record("item_id", tracing::field::display(&id)); user.check_not_suspended()?; let (item, _project) = verify_item_ownership(&state, id, user.id).await?; db::items::move_item(&state.db, item.project_id, user.id, id, &req.direction).await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; Ok(StatusCode::NO_CONTENT) } // ============================================================================= // Text Content API // ============================================================================= /// JSON input for updating an item's text body content. #[derive(Debug, Deserialize)] pub struct UpdateTextRequest { pub body: String, } /// JSON response for text content updates. #[derive(Debug, Serialize)] struct UpdateTextResponse { id: ItemId, body: Option, word_count: Option, reading_time_minutes: Option, } /// Save or update the text body content for an owned item. #[tracing::instrument(skip_all, name = "items::update_item_text", fields(item_id))] pub(in crate::routes::api) async fn update_item_text( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, Json(req): Json, ) -> Result { tracing::Span::current().record("item_id", tracing::field::display(&id)); user.check_not_suspended()?; validation::validate_item_text_body(&req.body)?; verify_item_ownership(&state, id, user.id).await?; let item = db::items::update_item_text(&state.db, id, user.id, &req.body).await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; let (body, word_count, reading_time_minutes) = match item.content() { ContentData::Text { body, word_count, reading_time_minutes } => (body, word_count, reading_time_minutes), _ => (None, None, None), }; if is_htmx_request(&headers) { return Ok(axum::response::Html(SaveStatusTemplate { success: true, message: format!("{} words saved", word_count.unwrap_or(0)), }.render_string()).into_response()); } Ok(Json(UpdateTextResponse { id: item.id, body, word_count, reading_time_minutes, }).into_response()) }