Skip to main content

max / makenotwork

6.7 KB · 209 lines History Blame Raw
1 //! Item section handlers: tabbed markdown content blocks.
2
3 use axum::{
4 Json,
5 extract::{Path, State},
6 http::{StatusCode, header::HeaderMap},
7 response::{IntoResponse, Response},
8 };
9 use serde::{Deserialize, Serialize};
10 use sqlx::PgPool;
11
12 use crate::{
13 auth::AuthUser,
14 db::{self, ItemId, ItemSectionId},
15 error::{AppError, Result},
16 helpers::{htmx_toast_response, is_htmx_request, slugify},
17 types::ListResponse,
18 validation,
19 };
20
21 use super::super::verify_item_ownership;
22
23 /// Maximum number of sections per item.
24 const MAX_SECTIONS_PER_ITEM: i64 = 10;
25
26 /// JSON input for creating a section.
27 #[derive(Debug, Deserialize)]
28 pub(crate) struct CreateSectionRequest {
29 pub title: String,
30 #[serde(default)]
31 pub body: String,
32 }
33
34 /// JSON input for updating a section.
35 #[derive(Debug, Deserialize)]
36 pub(crate) struct UpdateSectionRequest {
37 pub title: String,
38 #[serde(default)]
39 pub body: String,
40 }
41
42 /// JSON input for reordering sections.
43 #[derive(Debug, Deserialize)]
44 pub(crate) struct ReorderSectionsRequest {
45 pub section_ids: Vec<ItemSectionId>,
46 }
47
48 /// JSON response representing a section.
49 #[derive(Debug, Serialize)]
50 struct SectionResponse {
51 id: ItemSectionId,
52 item_id: ItemId,
53 title: String,
54 slug: String,
55 body: String,
56 sort_order: i32,
57 }
58
59 impl From<db::DbItemSection> for SectionResponse {
60 fn from(s: db::DbItemSection) -> Self {
61 Self {
62 id: s.id,
63 item_id: s.item_id,
64 title: s.title,
65 slug: s.slug,
66 body: s.body,
67 sort_order: s.sort_order,
68 }
69 }
70 }
71
72 /// Create a new section on an owned item.
73 #[tracing::instrument(skip_all, name = "items::create_section")]
74 pub(in crate::routes::api) async fn create_section(
75 State(db): State<PgPool>,
76 AuthUser(user): AuthUser,
77 Path(item_id): Path<ItemId>,
78 Json(req): Json<CreateSectionRequest>,
79 ) -> Result<impl IntoResponse> {
80 user.check_not_suspended()?;
81 let title = req.title.trim().to_string();
82 validation::validate_section_title(&title)?;
83 validation::validate_section_body(&req.body)?;
84
85 let (item, _) = verify_item_ownership(&db, item_id, user.id).await?;
86
87 // Enforce max sections limit
88 let count = db::item_sections::count_by_item(&db, item_id).await?;
89 if count >= MAX_SECTIONS_PER_ITEM {
90 return Err(AppError::validation(format!(
91 "Maximum of {MAX_SECTIONS_PER_ITEM} sections per item"
92 )));
93 }
94
95 let sort_order = count as i32;
96
97 // Two sections whose titles slugify to the same value (e.g. "Intro" twice,
98 // or non-Latin titles sharing the hash-fallback base) get an auto-suffixed
99 // slug rather than a raw 500 from the UNIQUE(item_id, slug) index (ultra-fuzz
100 // Run #1 UX). `insert_with_unique_slug` owns the `-N` suffixing and 23505
101 // retry, with the index as the race-safe source of truth.
102 let base = slugify(&title).to_string();
103 let pool = &db;
104 let (title_s, body_s) = (title.as_str(), req.body.as_str());
105 let section = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
106 db::item_sections::create(pool, item_id, title_s, &slug, body_s, sort_order).await
107 })
108 .await?;
109
110 db::projects::bump_cache_generation(&db, item.project_id).await?;
111
112 Ok(Json(SectionResponse::from(section)))
113 }
114
115 /// List all sections for a given item (public items only; drafts return 404).
116 #[tracing::instrument(skip_all, name = "items::list_sections")]
117 pub(in crate::routes::api) async fn list_sections(
118 State(db): State<PgPool>,
119 Path(item_id): Path<ItemId>,
120 ) -> Result<impl IntoResponse> {
121 let item = db::items::get_item_by_id(&db, item_id)
122 .await?
123 .ok_or(AppError::NotFound)?;
124 if !item.is_public {
125 return Err(AppError::NotFound);
126 }
127 let sections = db::item_sections::list_by_item(&db, item_id).await?;
128 let data: Vec<SectionResponse> = sections.into_iter().map(SectionResponse::from).collect();
129 Ok(Json(ListResponse { data }))
130 }
131
132 /// Update an existing section on an owned item.
133 #[tracing::instrument(skip_all, name = "items::update_section")]
134 pub(in crate::routes::api) async fn update_section(
135 State(db): State<PgPool>,
136 AuthUser(user): AuthUser,
137 Path(section_id): Path<ItemSectionId>,
138 Json(req): Json<UpdateSectionRequest>,
139 ) -> Result<impl IntoResponse> {
140 user.check_not_suspended()?;
141 let title = req.title.trim().to_string();
142 validation::validate_section_title(&title)?;
143 validation::validate_section_body(&req.body)?;
144
145 let section = db::item_sections::get_by_id(&db, section_id)
146 .await?
147 .ok_or(AppError::NotFound)?;
148
149 let (item, _) = verify_item_ownership(&db, section.item_id, user.id).await?;
150
151 // Same dedup as create. Re-saving the section under its own slug is not a
152 // conflict (same row), so the bare base succeeds; only a collision with a
153 // *different* section triggers the `-N` suffix retry.
154 let base = slugify(&title).to_string();
155 let pool = &db;
156 let (title_s, body_s) = (title.as_str(), req.body.as_str());
157 let updated = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
158 db::item_sections::update(pool, section_id, title_s, &slug, body_s).await
159 })
160 .await?;
161
162 db::projects::bump_cache_generation(&db, item.project_id).await?;
163
164 Ok(Json(SectionResponse::from(updated)))
165 }
166
167 /// Delete a section from an owned item.
168 #[tracing::instrument(skip_all, name = "items::delete_section")]
169 pub(in crate::routes::api) async fn delete_section(
170 State(db): State<PgPool>,
171 headers: HeaderMap,
172 AuthUser(user): AuthUser,
173 Path(section_id): Path<ItemSectionId>,
174 ) -> Result<Response> {
175 user.check_not_suspended()?;
176
177 let section = db::item_sections::get_by_id(&db, section_id)
178 .await?
179 .ok_or(AppError::NotFound)?;
180
181 let (item, _) = verify_item_ownership(&db, section.item_id, user.id).await?;
182
183 db::item_sections::delete(&db, section_id).await?;
184 db::projects::bump_cache_generation(&db, item.project_id).await?;
185
186 if is_htmx_request(&headers) {
187 return Ok(htmx_toast_response("Section deleted", "success").into_response());
188 }
189
190 Ok(StatusCode::NO_CONTENT.into_response())
191 }
192
193 /// Reorder sections for an owned item.
194 #[tracing::instrument(skip_all, name = "items::reorder_sections")]
195 pub(in crate::routes::api) async fn reorder_sections(
196 State(db): State<PgPool>,
197 AuthUser(user): AuthUser,
198 Path(item_id): Path<ItemId>,
199 Json(req): Json<ReorderSectionsRequest>,
200 ) -> Result<impl IntoResponse> {
201 user.check_not_suspended()?;
202 let (item, _) = verify_item_ownership(&db, item_id, user.id).await?;
203
204 db::item_sections::reorder(&db, item_id, &req.section_ids).await?;
205 db::projects::bump_cache_generation(&db, item.project_id).await?;
206
207 Ok(StatusCode::NO_CONTENT)
208 }
209