//! Item section handlers: tabbed markdown content blocks. use axum::{ extract::{Path, State}, http::{header::HeaderMap, StatusCode}, response::{IntoResponse, Response}, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, ItemId, ItemSectionId}, error::{AppError, Result}, helpers::{htmx_toast_response, is_htmx_request, slugify}, types::ListResponse, validation, AppState, }; use super::super::verify_item_ownership; /// Maximum number of sections per item. const MAX_SECTIONS_PER_ITEM: i64 = 10; /// JSON input for creating a section. #[derive(Debug, Deserialize)] pub struct CreateSectionRequest { pub title: String, #[serde(default)] pub body: String, } /// JSON input for updating a section. #[derive(Debug, Deserialize)] pub struct UpdateSectionRequest { pub title: String, #[serde(default)] pub body: String, } /// JSON input for reordering sections. #[derive(Debug, Deserialize)] pub struct ReorderSectionsRequest { pub section_ids: Vec, } /// JSON response representing a section. #[derive(Debug, Serialize)] struct SectionResponse { id: ItemSectionId, item_id: ItemId, title: String, slug: String, body: String, sort_order: i32, } impl From for SectionResponse { fn from(s: db::DbItemSection) -> Self { Self { id: s.id, item_id: s.item_id, title: s.title, slug: s.slug, body: s.body, sort_order: s.sort_order, } } } /// Create a new section on an owned item. #[tracing::instrument(skip_all, name = "items::create_section")] pub(in crate::routes::api) async fn create_section( State(state): State, AuthUser(user): AuthUser, Path(item_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; let title = req.title.trim().to_string(); validation::validate_section_title(&title)?; validation::validate_section_body(&req.body)?; let (item, _) = verify_item_ownership(&state, item_id, user.id).await?; // Enforce max sections limit let count = db::item_sections::count_by_item(&state.db, item_id).await?; if count >= MAX_SECTIONS_PER_ITEM { return Err(AppError::validation(format!( "Maximum of {} sections per item", MAX_SECTIONS_PER_ITEM ))); } let slug = slugify(&title).to_string(); let sort_order = count as i32; let section = db::item_sections::create( &state.db, item_id, &title, &slug, &req.body, sort_order, ) .await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; Ok(Json(SectionResponse::from(section))) } /// List all sections for a given item (public items only; drafts return 404). #[tracing::instrument(skip_all, name = "items::list_sections")] pub(in crate::routes::api) async fn list_sections( State(state): State, Path(item_id): Path, ) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; if !item.is_public { return Err(AppError::NotFound); } let sections = db::item_sections::list_by_item(&state.db, item_id).await?; let data: Vec = sections.into_iter().map(SectionResponse::from).collect(); Ok(Json(ListResponse { data })) } /// Update an existing section on an owned item. #[tracing::instrument(skip_all, name = "items::update_section")] pub(in crate::routes::api) async fn update_section( State(state): State, AuthUser(user): AuthUser, Path(section_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; let title = req.title.trim().to_string(); validation::validate_section_title(&title)?; validation::validate_section_body(&req.body)?; let section = db::item_sections::get_by_id(&state.db, section_id) .await? .ok_or(AppError::NotFound)?; let (item, _) = verify_item_ownership(&state, section.item_id, user.id).await?; let slug = slugify(&title).to_string(); let updated = db::item_sections::update( &state.db, section_id, &title, &slug, &req.body, ) .await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; Ok(Json(SectionResponse::from(updated))) } /// Delete a section from an owned item. #[tracing::instrument(skip_all, name = "items::delete_section")] pub(in crate::routes::api) async fn delete_section( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(section_id): Path, ) -> Result { user.check_not_suspended()?; let section = db::item_sections::get_by_id(&state.db, section_id) .await? .ok_or(AppError::NotFound)?; let (item, _) = verify_item_ownership(&state, section.item_id, user.id).await?; db::item_sections::delete(&state.db, section_id).await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; if is_htmx_request(&headers) { return Ok(htmx_toast_response("Section deleted", "success").into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Reorder sections for an owned item. #[tracing::instrument(skip_all, name = "items::reorder_sections")] pub(in crate::routes::api) async fn reorder_sections( State(state): State, AuthUser(user): AuthUser, Path(item_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; let (item, _) = verify_item_ownership(&state, item_id, user.id).await?; db::item_sections::reorder(&state.db, item_id, &req.section_ids).await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; Ok(StatusCode::NO_CONTENT) }