//! Project section handlers: tabbed markdown content blocks on projects //! (privacy policy, terms, FAQ, etc; shared across all platform releases). use axum::{ Json, extract::{Path, State}, http::{StatusCode, header::HeaderMap}, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use crate::{ auth::AuthUser, db::{self, ProjectId, ProjectSectionId}, error::{AppError, Result}, helpers::{htmx_toast_response, is_htmx_request, slugify}, types::ListResponse, validation, }; use super::verify_project_ownership; /// Maximum number of sections per project. const MAX_SECTIONS_PER_PROJECT: i64 = 10; #[derive(Debug, Deserialize)] pub(super) struct CreateSectionRequest { pub title: String, #[serde(default)] pub body: String, } #[derive(Debug, Deserialize)] pub(super) struct UpdateSectionRequest { pub title: String, #[serde(default)] pub body: String, } #[derive(Debug, Deserialize)] pub(super) 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(db): 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(&db, project_id, user.id).await?; let count = db::project_sections::count_by_project(&db, project_id).await?; if count >= MAX_SECTIONS_PER_PROJECT { return Err(AppError::validation(format!( "Maximum of {MAX_SECTIONS_PER_PROJECT} sections per project" ))); } let sort_order = count as i32; // Auto-suffix on slug collision instead of surfacing the UNIQUE(project_id, // slug) violation as a raw 500 (ultra-fuzz Run #1 UX). `insert_with_unique_slug` // owns the `-N` suffixing and 23505 retry, with the index as the race-safe // source of truth. let base = slugify(&title).to_string(); let pool = &db; let (title_s, body_s) = (title.as_str(), req.body.as_str()); let section = crate::helpers::insert_with_unique_slug(&base, |slug| async move { db::project_sections::create(pool, project_id, title_s, &slug, body_s, sort_order).await }) .await?; db::projects::bump_cache_generation(&db, project_id).await?; Ok(Json(SectionResponse::from(section))) } #[tracing::instrument(skip_all, name = "projects::list_sections")] pub(super) async fn list_sections( State(db): State, Path(project_id): Path, ) -> Result { let project = db::projects::get_project_by_id(&db, project_id) .await? .ok_or(AppError::NotFound)?; if !project.is_public { return Err(AppError::NotFound); } let sections = db::project_sections::list_by_project(&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(db): 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(&db, section_id) .await? .ok_or(AppError::NotFound)?; verify_project_ownership(&db, section.project_id, user.id).await?; // Same dedup as create. Re-saving under the section's own slug is not a // conflict (same row); only a collision with a different section triggers // the `-N` suffix retry. let base = slugify(&title).to_string(); let pool = &db; let (title_s, body_s) = (title.as_str(), req.body.as_str()); let updated = crate::helpers::insert_with_unique_slug(&base, |slug| async move { db::project_sections::update(pool, section_id, title_s, &slug, body_s).await }) .await?; db::projects::bump_cache_generation(&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(db): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(section_id): Path, ) -> Result { user.check_not_suspended()?; let section = db::project_sections::get_by_id(&db, section_id) .await? .ok_or(AppError::NotFound)?; verify_project_ownership(&db, section.project_id, user.id).await?; db::project_sections::delete(&db, section_id).await?; db::projects::bump_cache_generation(&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(db): State, AuthUser(user): AuthUser, Path(project_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; verify_project_ownership(&db, project_id, user.id).await?; db::project_sections::reorder(&db, project_id, &req.section_ids).await?; db::projects::bump_cache_generation(&db, project_id).await?; Ok(StatusCode::NO_CONTENT) }