Skip to main content

max / makenotwork

5.6 KB · 199 lines History Blame Raw
1 //! Project section handlers: tabbed markdown content blocks on projects
2 //! (privacy policy, terms, FAQ, etc; shared across all platform releases).
3
4 use axum::{
5 extract::{Path, State},
6 http::{header::HeaderMap, StatusCode},
7 response::{IntoResponse, Response},
8 Json,
9 };
10 use serde::{Deserialize, Serialize};
11
12 use crate::{
13 auth::AuthUser,
14 db::{self, ProjectId, ProjectSectionId},
15 error::{AppError, Result},
16 helpers::{htmx_toast_response, is_htmx_request, slugify},
17 types::ListResponse,
18 validation,
19 AppState,
20 };
21
22 use super::verify_project_ownership;
23
24 /// Maximum number of sections per project.
25 const MAX_SECTIONS_PER_PROJECT: i64 = 10;
26
27 #[derive(Debug, Deserialize)]
28 pub struct CreateSectionRequest {
29 pub title: String,
30 #[serde(default)]
31 pub body: String,
32 }
33
34 #[derive(Debug, Deserialize)]
35 pub struct UpdateSectionRequest {
36 pub title: String,
37 #[serde(default)]
38 pub body: String,
39 }
40
41 #[derive(Debug, Deserialize)]
42 pub struct ReorderSectionsRequest {
43 pub section_ids: Vec<ProjectSectionId>,
44 }
45
46 #[derive(Debug, Serialize)]
47 struct SectionResponse {
48 id: ProjectSectionId,
49 project_id: ProjectId,
50 title: String,
51 slug: String,
52 body: String,
53 sort_order: i32,
54 }
55
56 impl From<db::DbProjectSection> for SectionResponse {
57 fn from(s: db::DbProjectSection) -> Self {
58 Self {
59 id: s.id,
60 project_id: s.project_id,
61 title: s.title,
62 slug: s.slug,
63 body: s.body,
64 sort_order: s.sort_order,
65 }
66 }
67 }
68
69 #[tracing::instrument(skip_all, name = "projects::create_section")]
70 pub(super) async fn create_section(
71 State(state): State<AppState>,
72 AuthUser(user): AuthUser,
73 Path(project_id): Path<ProjectId>,
74 Json(req): Json<CreateSectionRequest>,
75 ) -> Result<impl IntoResponse> {
76 user.check_not_suspended()?;
77 let title = req.title.trim().to_string();
78 validation::validate_section_title(&title)?;
79 validation::validate_section_body(&req.body)?;
80
81 verify_project_ownership(&state, project_id, user.id).await?;
82
83 let count = db::project_sections::count_by_project(&state.db, project_id).await?;
84 if count >= MAX_SECTIONS_PER_PROJECT {
85 return Err(AppError::validation(format!(
86 "Maximum of {} sections per project",
87 MAX_SECTIONS_PER_PROJECT
88 )));
89 }
90
91 let slug = slugify(&title).to_string();
92 let sort_order = count as i32;
93
94 let section = db::project_sections::create(
95 &state.db,
96 project_id,
97 &title,
98 &slug,
99 &req.body,
100 sort_order,
101 )
102 .await?;
103
104 db::projects::bump_cache_generation(&state.db, project_id).await?;
105
106 Ok(Json(SectionResponse::from(section)))
107 }
108
109 #[tracing::instrument(skip_all, name = "projects::list_sections")]
110 pub(super) async fn list_sections(
111 State(state): State<AppState>,
112 Path(project_id): Path<ProjectId>,
113 ) -> Result<impl IntoResponse> {
114 let project = db::projects::get_project_by_id(&state.db, project_id)
115 .await?
116 .ok_or(AppError::NotFound)?;
117 if !project.is_public {
118 return Err(AppError::NotFound);
119 }
120 let sections = db::project_sections::list_by_project(&state.db, project_id).await?;
121 let data: Vec<SectionResponse> = sections.into_iter().map(SectionResponse::from).collect();
122 Ok(Json(ListResponse { data }))
123 }
124
125 #[tracing::instrument(skip_all, name = "projects::update_section")]
126 pub(super) async fn update_section(
127 State(state): State<AppState>,
128 AuthUser(user): AuthUser,
129 Path(section_id): Path<ProjectSectionId>,
130 Json(req): Json<UpdateSectionRequest>,
131 ) -> Result<impl IntoResponse> {
132 user.check_not_suspended()?;
133 let title = req.title.trim().to_string();
134 validation::validate_section_title(&title)?;
135 validation::validate_section_body(&req.body)?;
136
137 let section = db::project_sections::get_by_id(&state.db, section_id)
138 .await?
139 .ok_or(AppError::NotFound)?;
140
141 verify_project_ownership(&state, section.project_id, user.id).await?;
142
143 let slug = slugify(&title).to_string();
144
145 let updated = db::project_sections::update(
146 &state.db,
147 section_id,
148 &title,
149 &slug,
150 &req.body,
151 )
152 .await?;
153
154 db::projects::bump_cache_generation(&state.db, section.project_id).await?;
155
156 Ok(Json(SectionResponse::from(updated)))
157 }
158
159 #[tracing::instrument(skip_all, name = "projects::delete_section")]
160 pub(super) async fn delete_section(
161 State(state): State<AppState>,
162 headers: HeaderMap,
163 AuthUser(user): AuthUser,
164 Path(section_id): Path<ProjectSectionId>,
165 ) -> Result<Response> {
166 user.check_not_suspended()?;
167
168 let section = db::project_sections::get_by_id(&state.db, section_id)
169 .await?
170 .ok_or(AppError::NotFound)?;
171
172 verify_project_ownership(&state, section.project_id, user.id).await?;
173
174 db::project_sections::delete(&state.db, section_id).await?;
175 db::projects::bump_cache_generation(&state.db, section.project_id).await?;
176
177 if is_htmx_request(&headers) {
178 return Ok(htmx_toast_response("Section deleted", "success").into_response());
179 }
180
181 Ok(StatusCode::NO_CONTENT.into_response())
182 }
183
184 #[tracing::instrument(skip_all, name = "projects::reorder_sections")]
185 pub(super) async fn reorder_sections(
186 State(state): State<AppState>,
187 AuthUser(user): AuthUser,
188 Path(project_id): Path<ProjectId>,
189 Json(req): Json<ReorderSectionsRequest>,
190 ) -> Result<impl IntoResponse> {
191 user.check_not_suspended()?;
192 verify_project_ownership(&state, project_id, user.id).await?;
193
194 db::project_sections::reorder(&state.db, project_id, &req.section_ids).await?;
195 db::projects::bump_cache_generation(&state.db, project_id).await?;
196
197 Ok(StatusCode::NO_CONTENT)
198 }
199