//! Project section handlers: tabbed markdown content blocks on projects //! (privacy policy, terms, FAQ, etc; shared across all platform releases). use axum::{ extract::{Path, State}, http::{header::HeaderMap, StatusCode}, response::{IntoResponse, Response}, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, ProjectId, ProjectSectionId}, error::{AppError, Result}, helpers::{htmx_toast_response, is_htmx_request, slugify}, types::ListResponse, validation, AppState, }; use super::verify_project_ownership; /// Maximum number of sections per project. const MAX_SECTIONS_PER_PROJECT: i64 = 10; #[derive(Debug, Deserialize)] pub struct CreateSectionRequest { pub title: String, #[serde(default)] pub body: String, } #[derive(Debug, Deserialize)] pub struct UpdateSectionRequest { pub title: String, #[serde(default)] pub body: String, } #[derive(Debug, Deserialize)] pub struct ReorderSectionsRequest { pub section_ids: Vec, } #[derive(Debug, Serialize)] struct SectionResponse { id: ProjectSectionId, project_id: ProjectId, title: String, slug: String, body: String, sort_order: i32, } impl From for SectionResponse { fn from(s: db::DbProjectSection) -> Self { Self { id: s.id, project_id: s.project_id, title: s.title, slug: s.slug, body: s.body, sort_order: s.sort_order, } } } #[tracing::instrument(skip_all, name = "projects::create_section")] pub(super) async fn create_section( State(state): State, AuthUser(user): AuthUser, Path(project_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)?; verify_project_ownership(&state, project_id, user.id).await?; let count = db::project_sections::count_by_project(&state.db, project_id).await?; if count >= MAX_SECTIONS_PER_PROJECT { return Err(AppError::validation(format!( "Maximum of {} sections per project", MAX_SECTIONS_PER_PROJECT ))); } let slug = slugify(&title).to_string(); let sort_order = count as i32; let section = db::project_sections::create( &state.db, project_id, &title, &slug, &req.body, sort_order, ) .await?; db::projects::bump_cache_generation(&state.db, project_id).await?; Ok(Json(SectionResponse::from(section))) } #[tracing::instrument(skip_all, name = "projects::list_sections")] pub(super) async fn list_sections( State(state): State, Path(project_id): Path, ) -> Result { let project = db::projects::get_project_by_id(&state.db, project_id) .await? .ok_or(AppError::NotFound)?; if !project.is_public { return Err(AppError::NotFound); } let sections = db::project_sections::list_by_project(&state.db, project_id).await?; let data: Vec = sections.into_iter().map(SectionResponse::from).collect(); Ok(Json(ListResponse { data })) } #[tracing::instrument(skip_all, name = "projects::update_section")] pub(super) 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::project_sections::get_by_id(&state.db, section_id) .await? .ok_or(AppError::NotFound)?; verify_project_ownership(&state, section.project_id, user.id).await?; let slug = slugify(&title).to_string(); let updated = db::project_sections::update( &state.db, section_id, &title, &slug, &req.body, ) .await?; db::projects::bump_cache_generation(&state.db, section.project_id).await?; Ok(Json(SectionResponse::from(updated))) } #[tracing::instrument(skip_all, name = "projects::delete_section")] pub(super) async fn delete_section( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(section_id): Path, ) -> Result { user.check_not_suspended()?; let section = db::project_sections::get_by_id(&state.db, section_id) .await? .ok_or(AppError::NotFound)?; verify_project_ownership(&state, section.project_id, user.id).await?; db::project_sections::delete(&state.db, section_id).await?; db::projects::bump_cache_generation(&state.db, section.project_id).await?; if is_htmx_request(&headers) { return Ok(htmx_toast_response("Section deleted", "success").into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } #[tracing::instrument(skip_all, name = "projects::reorder_sections")] pub(super) async fn reorder_sections( State(state): State, AuthUser(user): AuthUser, Path(project_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; verify_project_ownership(&state, project_id, user.id).await?; db::project_sections::reorder(&state.db, project_id, &req.section_ids).await?; db::projects::bump_cache_generation(&state.db, project_id).await?; Ok(StatusCode::NO_CONTENT) }