Skip to main content

max / makenotwork

9.7 KB · 290 lines History Blame Raw
1 //! Blog post API: create, update, delete, publish.
2
3 use axum::{
4 extract::{Path, State},
5 response::IntoResponse,
6 Json,
7 };
8 use serde::{Deserialize, Serialize};
9
10 use crate::{
11 auth::AuthUser,
12 db::{self, BlogPostId, ProjectId, Slug},
13 error::{AppError, Result},
14 helpers::{htmx_toast_response, parse_schedule_datetime, slugify},
15 types::ListResponse,
16 validation,
17 AppState,
18 };
19
20 use super::{verify_blog_post_ownership, verify_project_ownership};
21
22 // =============================================================================
23 // Blog API
24 // =============================================================================
25
26 /// JSON input for creating a blog post.
27 #[derive(Debug, Deserialize)]
28 pub struct CreateBlogPostRequest {
29 pub title: String,
30 pub slug: Option<String>,
31 pub body_markdown: Option<String>,
32 pub is_published: Option<bool>,
33 /// Whether to skip email announcements when publishing.
34 pub web_only: Option<bool>,
35 }
36
37 /// JSON input for updating a blog post.
38 #[derive(Debug, Deserialize)]
39 pub struct UpdateBlogPostRequest {
40 pub title: String,
41 pub slug: Slug,
42 pub body_markdown: String,
43 pub is_published: bool,
44 /// ISO 8601 datetime string for scheduled publishing. Empty string clears the schedule.
45 pub publish_at: Option<String>,
46 /// Whether to skip email announcements when publishing.
47 pub web_only: Option<bool>,
48 }
49
50 /// JSON response representing a blog post.
51 #[derive(Debug, Serialize)]
52 pub struct BlogPostResponse {
53 pub id: BlogPostId,
54 pub project_id: ProjectId,
55 pub title: String,
56 pub slug: String,
57 pub is_published: bool,
58 pub published_at: Option<String>,
59 pub web_only: bool,
60 pub created_at: String,
61 pub updated_at: String,
62 }
63
64 /// JSON response for editing a blog post (includes body_markdown and publish_at).
65 #[derive(Debug, Serialize)]
66 pub struct BlogPostEditResponse {
67 pub id: BlogPostId,
68 pub title: String,
69 pub slug: String,
70 pub body_markdown: String,
71 pub is_published: bool,
72 pub publish_at: Option<String>,
73 pub web_only: bool,
74 }
75
76 fn blog_post_edit_response(post: &db::DbBlogPost) -> BlogPostEditResponse {
77 BlogPostEditResponse {
78 id: post.id,
79 title: post.title.clone(),
80 slug: post.slug.to_string(),
81 body_markdown: post.body_markdown.clone(),
82 is_published: post.published_at.is_some(),
83 publish_at: post.publish_at.map(|d| d.to_rfc3339()),
84 web_only: post.web_only,
85 }
86 }
87
88 fn blog_post_response(post: &db::DbBlogPost) -> BlogPostResponse {
89 BlogPostResponse {
90 id: post.id,
91 project_id: post.project_id,
92 title: post.title.clone(),
93 slug: post.slug.to_string(),
94 is_published: post.published_at.is_some(),
95 published_at: post.published_at.map(|d| d.to_rfc3339()),
96 web_only: post.web_only,
97 created_at: post.created_at.to_rfc3339(),
98 updated_at: post.updated_at.to_rfc3339(),
99 }
100 }
101
102 /// Get a single blog post for editing.
103 #[tracing::instrument(skip_all, name = "blog::get_blog_post")]
104 pub(super) async fn get_blog_post(
105 State(state): State<AppState>,
106 AuthUser(user): AuthUser,
107 Path(blog_post_id): Path<BlogPostId>,
108 ) -> Result<impl IntoResponse> {
109 let post = verify_blog_post_ownership(&state, blog_post_id, user.id).await?;
110 Ok(Json(blog_post_edit_response(&post)))
111 }
112
113 /// Create a new blog post under a project.
114 #[tracing::instrument(skip_all, name = "blog::create_blog_post")]
115 pub(super) async fn create_blog_post(
116 State(state): State<AppState>,
117 AuthUser(user): AuthUser,
118 Path(project_id): Path<ProjectId>,
119 Json(req): Json<CreateBlogPostRequest>,
120 ) -> Result<impl IntoResponse> {
121 user.check_not_suspended()?;
122 verify_project_ownership(&state, project_id, user.id).await?;
123
124 // Validate title
125 validation::validate_blog_post_title(&req.title)?;
126
127 // Generate or validate slug
128 let mut slug = match req.slug {
129 Some(ref s) if !s.is_empty() => Slug::new(s)?,
130 _ => slugify(&req.title),
131 };
132
133 // Fast path: append suffixes for known slug collisions
134 if db::blog_posts::blog_post_slug_exists(&state.db, project_id, &slug).await? {
135 let base = slug.clone();
136 let mut counter = 2u32;
137 loop {
138 slug = Slug::from_trusted(format!("{}-{}", base, counter));
139 if !db::blog_posts::blog_post_slug_exists(&state.db, project_id, &slug).await? {
140 break;
141 }
142 counter += 1;
143 }
144 }
145
146 let body_markdown = req.body_markdown.as_deref().unwrap_or("");
147 validation::validate_blog_post_body(body_markdown)?;
148
149 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
150 let body_html = crate::markdown::render_creator_markdown(body_markdown, user.id, cdn_base);
151 let is_published = req.is_published.unwrap_or(false);
152
153 // Retry with suffixes if a concurrent request creates the same slug
154 // between our existence check and insert (TOCTOU race).
155 let web_only = req.web_only.unwrap_or(false);
156
157 let base_slug = slug.clone();
158 let mut suffix = 1u32;
159 let post = loop {
160 match db::blog_posts::create_blog_post(
161 &state.db, project_id, user.id, &req.title, &slug,
162 body_markdown, &body_html, is_published, web_only,
163 ).await {
164 Ok(post) => break post,
165 Err(e) => {
166 let is_slug_conflict = matches!(
167 &e,
168 AppError::Database(sqlx::Error::Database(db_err))
169 if db_err.code().as_deref() == Some("23505")
170 );
171 if is_slug_conflict && suffix < 100 {
172 suffix += 1;
173 slug = Slug::from_trusted(format!("{}-{}", base_slug, suffix));
174 continue;
175 }
176 return Err(e);
177 }
178 }
179 };
180
181 db::projects::bump_cache_generation(&state.db, project_id).await?;
182
183 // Create linked MT discussion thread and send announcements if published immediately
184 // (skip for sandbox users — no real emails or MT threads)
185 if post.published_at.is_some() && !user.is_sandbox {
186 crate::scheduler::send_blog_post_announcements(&state, &post).await;
187 crate::scheduler::spawn_mt_thread_for_blog_post(&state, &post, &user);
188 }
189
190 Ok(Json(blog_post_response(&post)))
191 }
192
193 /// Update an existing blog post.
194 #[tracing::instrument(skip_all, name = "blog::update_blog_post")]
195 pub(super) async fn update_blog_post(
196 State(state): State<AppState>,
197 AuthUser(user): AuthUser,
198 Path(id): Path<BlogPostId>,
199 Json(req): Json<UpdateBlogPostRequest>,
200 ) -> Result<impl IntoResponse> {
201 user.check_not_suspended()?;
202 let existing = verify_blog_post_ownership(&state, id, user.id).await?;
203
204 validation::validate_blog_post_title(&req.title)?;
205 // slug is validated by Slug's Deserialize impl
206 validation::validate_blog_post_body(&req.body_markdown)?;
207
208 // Check slug uniqueness if changed
209 if req.slug != existing.slug
210 && db::blog_posts::blog_post_slug_exists(&state.db, existing.project_id, &req.slug).await?
211 {
212 return Err(AppError::validation("A blog post with this slug already exists".to_string()));
213 }
214
215 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
216 let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, user.id, cdn_base);
217
218 // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule
219 let publish_at = parse_schedule_datetime(req.publish_at.as_deref());
220
221 // Reject scheduling in the past
222 if let Some(Some(dt)) = &publish_at
223 && *dt < chrono::Utc::now()
224 {
225 return Err(AppError::BadRequest("Scheduled publish date must be in the future".to_string()));
226 }
227
228 // If scheduling, don't publish immediately
229 let is_published = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() {
230 false
231 } else {
232 req.is_published
233 };
234
235 let post = db::blog_posts::update_blog_post(
236 &state.db,
237 id,
238 &req.title,
239 &req.slug,
240 &req.body_markdown,
241 &body_html,
242 is_published,
243 publish_at,
244 req.web_only,
245 )
246 .await?;
247
248 db::projects::bump_cache_generation(&state.db, existing.project_id).await?;
249
250 // Detect first publish: was unpublished before, now published
251 // (skip for sandbox users — no real emails or MT threads)
252 if existing.published_at.is_none() && post.published_at.is_some() && !user.is_sandbox {
253 crate::scheduler::send_blog_post_announcements(&state, &post).await;
254 if post.mt_thread_id.is_none() {
255 crate::scheduler::spawn_mt_thread_for_blog_post(&state, &post, &user);
256 }
257 }
258
259 Ok(Json(blog_post_response(&post)))
260 }
261
262 /// Delete a blog post.
263 #[tracing::instrument(skip_all, name = "blog::delete_blog_post")]
264 pub(super) async fn delete_blog_post(
265 State(state): State<AppState>,
266 AuthUser(user): AuthUser,
267 Path(id): Path<BlogPostId>,
268 ) -> Result<impl IntoResponse> {
269 user.check_not_suspended()?;
270 let post = verify_blog_post_ownership(&state, id, user.id).await?;
271
272 db::blog_posts::delete_blog_post(&state.db, id).await?;
273 db::projects::bump_cache_generation(&state.db, post.project_id).await?;
274
275 Ok(htmx_toast_response("Blog post deleted", "success"))
276 }
277
278 /// List published blog posts for a project.
279 #[tracing::instrument(skip_all, name = "blog::list_blog_posts")]
280 pub(super) async fn list_blog_posts(
281 State(state): State<AppState>,
282 Path(project_id): Path<ProjectId>,
283 ) -> Result<impl IntoResponse> {
284 let posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, project_id).await?;
285
286 let data: Vec<BlogPostResponse> = posts.iter().map(blog_post_response).collect();
287
288 Ok(Json(ListResponse { data }))
289 }
290