Skip to main content

max / makenotwork

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