Skip to main content

max / makenotwork

6.2 KB · 200 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 Json,
6 extract::{Path, State},
7 http::{StatusCode, header::HeaderMap},
8 response::{IntoResponse, Response},
9 };
10 use serde::{Deserialize, Serialize};
11
12 use sqlx::PgPool;
13
14 use crate::{
15 auth::AuthUser,
16 db::{self, ProjectId, ProjectSectionId},
17 error::{AppError, Result},
18 helpers::{htmx_toast_response, is_htmx_request, slugify},
19 types::ListResponse,
20 validation,
21 };
22
23 use super::verify_project_ownership;
24
25 /// Maximum number of sections per project.
26 const MAX_SECTIONS_PER_PROJECT: i64 = 10;
27
28 #[derive(Debug, Deserialize)]
29 pub(super) struct CreateSectionRequest {
30 pub title: String,
31 #[serde(default)]
32 pub body: String,
33 }
34
35 #[derive(Debug, Deserialize)]
36 pub(super) struct UpdateSectionRequest {
37 pub title: String,
38 #[serde(default)]
39 pub body: String,
40 }
41
42 #[derive(Debug, Deserialize)]
43 pub(super) struct ReorderSectionsRequest {
44 pub section_ids: Vec<ProjectSectionId>,
45 }
46
47 #[derive(Debug, Serialize)]
48 struct SectionResponse {
49 id: ProjectSectionId,
50 project_id: ProjectId,
51 title: String,
52 slug: String,
53 body: String,
54 sort_order: i32,
55 }
56
57 impl From<db::DbProjectSection> for SectionResponse {
58 fn from(s: db::DbProjectSection) -> Self {
59 Self {
60 id: s.id,
61 project_id: s.project_id,
62 title: s.title,
63 slug: s.slug,
64 body: s.body,
65 sort_order: s.sort_order,
66 }
67 }
68 }
69
70 #[tracing::instrument(skip_all, name = "projects::create_section")]
71 pub(super) async fn create_section(
72 State(db): State<PgPool>,
73 AuthUser(user): AuthUser,
74 Path(project_id): Path<ProjectId>,
75 Json(req): Json<CreateSectionRequest>,
76 ) -> Result<impl IntoResponse> {
77 user.check_not_suspended()?;
78 let title = req.title.trim().to_string();
79 validation::validate_section_title(&title)?;
80 validation::validate_section_body(&req.body)?;
81
82 verify_project_ownership(&db, project_id, user.id).await?;
83
84 let count = db::project_sections::count_by_project(&db, project_id).await?;
85 if count >= MAX_SECTIONS_PER_PROJECT {
86 return Err(AppError::validation(format!(
87 "Maximum of {MAX_SECTIONS_PER_PROJECT} sections per project"
88 )));
89 }
90
91 let sort_order = count as i32;
92
93 // Auto-suffix on slug collision instead of surfacing the UNIQUE(project_id,
94 // slug) violation as a raw 500 (ultra-fuzz Run #1 UX). `insert_with_unique_slug`
95 // owns the `-N` suffixing and 23505 retry, with the index as the race-safe
96 // source of truth.
97 let base = slugify(&title).to_string();
98 let pool = &db;
99 let (title_s, body_s) = (title.as_str(), req.body.as_str());
100 let section = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
101 db::project_sections::create(pool, project_id, title_s, &slug, body_s, sort_order).await
102 })
103 .await?;
104
105 db::projects::bump_cache_generation(&db, project_id).await?;
106
107 Ok(Json(SectionResponse::from(section)))
108 }
109
110 #[tracing::instrument(skip_all, name = "projects::list_sections")]
111 pub(super) async fn list_sections(
112 State(db): State<PgPool>,
113 Path(project_id): Path<ProjectId>,
114 ) -> Result<impl IntoResponse> {
115 let project = db::projects::get_project_by_id(&db, project_id)
116 .await?
117 .ok_or(AppError::NotFound)?;
118 if !project.is_public {
119 return Err(AppError::NotFound);
120 }
121 let sections = db::project_sections::list_by_project(&db, project_id).await?;
122 let data: Vec<SectionResponse> = sections.into_iter().map(SectionResponse::from).collect();
123 Ok(Json(ListResponse { data }))
124 }
125
126 #[tracing::instrument(skip_all, name = "projects::update_section")]
127 pub(super) async fn update_section(
128 State(db): State<PgPool>,
129 AuthUser(user): AuthUser,
130 Path(section_id): Path<ProjectSectionId>,
131 Json(req): Json<UpdateSectionRequest>,
132 ) -> Result<impl IntoResponse> {
133 user.check_not_suspended()?;
134 let title = req.title.trim().to_string();
135 validation::validate_section_title(&title)?;
136 validation::validate_section_body(&req.body)?;
137
138 let section = db::project_sections::get_by_id(&db, section_id)
139 .await?
140 .ok_or(AppError::NotFound)?;
141
142 verify_project_ownership(&db, section.project_id, user.id).await?;
143
144 // Same dedup as create. Re-saving under the section's own slug is not a
145 // conflict (same row); only a collision with a different section triggers
146 // the `-N` suffix retry.
147 let base = slugify(&title).to_string();
148 let pool = &db;
149 let (title_s, body_s) = (title.as_str(), req.body.as_str());
150 let updated = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
151 db::project_sections::update(pool, section_id, title_s, &slug, body_s).await
152 })
153 .await?;
154
155 db::projects::bump_cache_generation(&db, section.project_id).await?;
156
157 Ok(Json(SectionResponse::from(updated)))
158 }
159
160 #[tracing::instrument(skip_all, name = "projects::delete_section")]
161 pub(super) async fn delete_section(
162 State(db): State<PgPool>,
163 headers: HeaderMap,
164 AuthUser(user): AuthUser,
165 Path(section_id): Path<ProjectSectionId>,
166 ) -> Result<Response> {
167 user.check_not_suspended()?;
168
169 let section = db::project_sections::get_by_id(&db, section_id)
170 .await?
171 .ok_or(AppError::NotFound)?;
172
173 verify_project_ownership(&db, section.project_id, user.id).await?;
174
175 db::project_sections::delete(&db, section_id).await?;
176 db::projects::bump_cache_generation(&db, section.project_id).await?;
177
178 if is_htmx_request(&headers) {
179 return Ok(htmx_toast_response("Section deleted", "success").into_response());
180 }
181
182 Ok(StatusCode::NO_CONTENT.into_response())
183 }
184
185 #[tracing::instrument(skip_all, name = "projects::reorder_sections")]
186 pub(super) async fn reorder_sections(
187 State(db): State<PgPool>,
188 AuthUser(user): AuthUser,
189 Path(project_id): Path<ProjectId>,
190 Json(req): Json<ReorderSectionsRequest>,
191 ) -> Result<impl IntoResponse> {
192 user.check_not_suspended()?;
193 verify_project_ownership(&db, project_id, user.id).await?;
194
195 db::project_sections::reorder(&db, project_id, &req.section_ids).await?;
196 db::projects::bump_cache_generation(&db, project_id).await?;
197
198 Ok(StatusCode::NO_CONTENT)
199 }
200