Skip to main content

max / makenotwork

6.2 KB · 208 lines History Blame Raw
1 //! Item section handlers: tabbed markdown content blocks.
2
3 use axum::{
4 extract::{Path, State},
5 http::{header::HeaderMap, StatusCode},
6 response::{IntoResponse, Response},
7 Json,
8 };
9 use serde::{Deserialize, Serialize};
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, ItemId, ItemSectionId},
14 error::{AppError, Result},
15 helpers::{htmx_toast_response, is_htmx_request, slugify},
16 types::ListResponse,
17 validation,
18 AppState,
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 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 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 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(state): State<AppState>,
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(&state, item_id, user.id).await?;
86
87 // Enforce max sections limit
88 let count = db::item_sections::count_by_item(&state.db, item_id).await?;
89 if count >= MAX_SECTIONS_PER_ITEM {
90 return Err(AppError::validation(format!(
91 "Maximum of {} sections per item",
92 MAX_SECTIONS_PER_ITEM
93 )));
94 }
95
96 let slug = slugify(&title).to_string();
97 let sort_order = count as i32;
98
99 let section = db::item_sections::create(
100 &state.db,
101 item_id,
102 &title,
103 &slug,
104 &req.body,
105 sort_order,
106 )
107 .await?;
108
109 db::projects::bump_cache_generation(&state.db, item.project_id).await?;
110
111 Ok(Json(SectionResponse::from(section)))
112 }
113
114 /// List all sections for a given item (public items only; drafts return 404).
115 #[tracing::instrument(skip_all, name = "items::list_sections")]
116 pub(in crate::routes::api) async fn list_sections(
117 State(state): State<AppState>,
118 Path(item_id): Path<ItemId>,
119 ) -> Result<impl IntoResponse> {
120 let item = db::items::get_item_by_id(&state.db, item_id)
121 .await?
122 .ok_or(AppError::NotFound)?;
123 if !item.is_public {
124 return Err(AppError::NotFound);
125 }
126 let sections = db::item_sections::list_by_item(&state.db, item_id).await?;
127 let data: Vec<SectionResponse> = sections.into_iter().map(SectionResponse::from).collect();
128 Ok(Json(ListResponse { data }))
129 }
130
131 /// Update an existing section on an owned item.
132 #[tracing::instrument(skip_all, name = "items::update_section")]
133 pub(in crate::routes::api) async fn update_section(
134 State(state): State<AppState>,
135 AuthUser(user): AuthUser,
136 Path(section_id): Path<ItemSectionId>,
137 Json(req): Json<UpdateSectionRequest>,
138 ) -> Result<impl IntoResponse> {
139 user.check_not_suspended()?;
140 let title = req.title.trim().to_string();
141 validation::validate_section_title(&title)?;
142 validation::validate_section_body(&req.body)?;
143
144 let section = db::item_sections::get_by_id(&state.db, section_id)
145 .await?
146 .ok_or(AppError::NotFound)?;
147
148 let (item, _) = verify_item_ownership(&state, section.item_id, user.id).await?;
149
150 let slug = slugify(&title).to_string();
151
152 let updated = db::item_sections::update(
153 &state.db,
154 section_id,
155 &title,
156 &slug,
157 &req.body,
158 )
159 .await?;
160
161 db::projects::bump_cache_generation(&state.db, item.project_id).await?;
162
163 Ok(Json(SectionResponse::from(updated)))
164 }
165
166 /// Delete a section from an owned item.
167 #[tracing::instrument(skip_all, name = "items::delete_section")]
168 pub(in crate::routes::api) async fn delete_section(
169 State(state): State<AppState>,
170 headers: HeaderMap,
171 AuthUser(user): AuthUser,
172 Path(section_id): Path<ItemSectionId>,
173 ) -> Result<Response> {
174 user.check_not_suspended()?;
175
176 let section = db::item_sections::get_by_id(&state.db, section_id)
177 .await?
178 .ok_or(AppError::NotFound)?;
179
180 let (item, _) = verify_item_ownership(&state, section.item_id, user.id).await?;
181
182 db::item_sections::delete(&state.db, section_id).await?;
183 db::projects::bump_cache_generation(&state.db, item.project_id).await?;
184
185 if is_htmx_request(&headers) {
186 return Ok(htmx_toast_response("Section deleted", "success").into_response());
187 }
188
189 Ok(StatusCode::NO_CONTENT.into_response())
190 }
191
192 /// Reorder sections for an owned item.
193 #[tracing::instrument(skip_all, name = "items::reorder_sections")]
194 pub(in crate::routes::api) async fn reorder_sections(
195 State(state): State<AppState>,
196 AuthUser(user): AuthUser,
197 Path(item_id): Path<ItemId>,
198 Json(req): Json<ReorderSectionsRequest>,
199 ) -> Result<impl IntoResponse> {
200 user.check_not_suspended()?;
201 let (item, _) = verify_item_ownership(&state, item_id, user.id).await?;
202
203 db::item_sections::reorder(&state.db, item_id, &req.section_ids).await?;
204 db::projects::bump_cache_generation(&state.db, item.project_id).await?;
205
206 Ok(StatusCode::NO_CONTENT)
207 }
208