Skip to main content

max / makenotwork

10.6 KB · 321 lines History Blame Raw
1 //! Blog post API: create, update, delete, publish.
2
3 use axum::{
4 Json,
5 extract::{Path, State},
6 response::IntoResponse,
7 };
8 use serde::{Deserialize, Serialize};
9
10 use sqlx::PgPool;
11
12 use crate::{
13 Integrations,
14 auth::AuthUser,
15 config::Config,
16 db::{self, BlogPostId, ProjectId, Slug},
17 error::{AppError, Result},
18 helpers::{htmx_toast_response, parse_schedule_datetime, slugify},
19 types::ListResponse,
20 validation,
21 };
22
23 use super::{verify_blog_post_ownership, verify_project_ownership};
24 use crate::extractors::ValidatedJson;
25
26 // Blog API
27
28 /// JSON input for creating a blog post.
29 #[derive(Debug, Deserialize)]
30 pub(super) struct CreateBlogPostRequest {
31 pub title: String,
32 pub slug: Option<String>,
33 pub body_markdown: Option<String>,
34 pub is_published: Option<bool>,
35 /// Whether to skip email announcements when publishing.
36 pub web_only: Option<bool>,
37 /// Operator-only: surface this post as the landing "Last shipped" line.
38 /// Only meaningful on the changelog project; inert elsewhere.
39 pub show_on_landing: Option<bool>,
40 }
41
42 /// JSON input for updating a blog post.
43 #[derive(Debug, Deserialize)]
44 pub(super) struct UpdateBlogPostRequest {
45 pub title: String,
46 pub slug: Slug,
47 pub body_markdown: String,
48 /// `None` leaves the publish state unchanged (auto-save omits it so a
49 /// background save can't silently unpublish a live post); `Some(true)`
50 /// publishes, `Some(false)` unpublishes.
51 pub is_published: Option<bool>,
52 /// ISO 8601 datetime string for scheduled publishing. Empty string clears the schedule.
53 pub publish_at: Option<String>,
54 /// Whether to skip email announcements when publishing.
55 pub web_only: Option<bool>,
56 /// Operator-only: surface this post as the landing "Last shipped" line.
57 /// `None` leaves the existing flag unchanged (e.g. autosave).
58 pub show_on_landing: Option<bool>,
59 }
60
61 /// JSON response representing a blog post.
62 #[derive(Debug, Serialize)]
63 pub(super) struct BlogPostResponse {
64 pub id: BlogPostId,
65 pub project_id: ProjectId,
66 pub title: String,
67 pub slug: String,
68 pub is_published: bool,
69 pub published_at: Option<String>,
70 pub web_only: bool,
71 pub show_on_landing: bool,
72 pub created_at: String,
73 pub updated_at: String,
74 }
75
76 /// JSON response for editing a blog post (includes body_markdown and publish_at).
77 #[derive(Debug, Serialize)]
78 pub(super) struct BlogPostEditResponse {
79 pub id: BlogPostId,
80 pub title: String,
81 pub slug: String,
82 pub body_markdown: String,
83 pub is_published: bool,
84 pub publish_at: Option<String>,
85 pub web_only: bool,
86 pub show_on_landing: bool,
87 }
88
89 fn blog_post_edit_response(post: &db::DbBlogPost) -> BlogPostEditResponse {
90 BlogPostEditResponse {
91 id: post.id,
92 title: post.title.clone(),
93 slug: post.slug.to_string(),
94 body_markdown: post.body_markdown.clone(),
95 is_published: post.published_at.is_some(),
96 publish_at: post.publish_at.map(|d| d.to_rfc3339()),
97 web_only: post.web_only,
98 show_on_landing: post.show_on_landing,
99 }
100 }
101
102 fn blog_post_response(post: &db::DbBlogPost) -> BlogPostResponse {
103 BlogPostResponse {
104 id: post.id,
105 project_id: post.project_id,
106 title: post.title.clone(),
107 slug: post.slug.to_string(),
108 is_published: post.published_at.is_some(),
109 published_at: post.published_at.map(|d| d.to_rfc3339()),
110 web_only: post.web_only,
111 show_on_landing: post.show_on_landing,
112 created_at: post.created_at.to_rfc3339(),
113 updated_at: post.updated_at.to_rfc3339(),
114 }
115 }
116
117 /// Get a single blog post for editing.
118 #[tracing::instrument(skip_all, name = "blog::get_blog_post")]
119 pub(super) async fn get_blog_post(
120 State(db): State<PgPool>,
121 AuthUser(user): AuthUser,
122 Path(blog_post_id): Path<BlogPostId>,
123 ) -> Result<impl IntoResponse> {
124 let post = verify_blog_post_ownership(&db, blog_post_id, user.id).await?;
125 Ok(Json(blog_post_edit_response(&post)))
126 }
127
128 /// Create a new blog post under a project.
129 #[tracing::instrument(skip_all, name = "blog::create_blog_post")]
130 #[allow(clippy::too_many_arguments)]
131 pub(super) async fn create_blog_post(
132 State(db): State<PgPool>,
133 State(mailer): State<crate::email::EmailClient>,
134 State(config): State<Config>,
135 State(bg): State<crate::background::BackgroundTx>,
136 State(integrations): State<Integrations>,
137 AuthUser(user): AuthUser,
138 Path(project_id): Path<ProjectId>,
139 ValidatedJson(req): ValidatedJson<CreateBlogPostRequest>,
140 ) -> Result<impl IntoResponse> {
141 user.check_not_suspended()?;
142 verify_project_ownership(&db, project_id, user.id).await?;
143
144 validation::validate_blog_post_title(&req.title)?;
145
146 // Generate or validate slug
147 let slug = match req.slug {
148 Some(ref s) if !s.is_empty() => Slug::new(s)?,
149 _ => slugify(&req.title),
150 };
151
152 let body_markdown = req.body_markdown.as_deref().unwrap_or("");
153 validation::validate_blog_post_body(body_markdown)?;
154
155 let cdn_base = config.cdn_base_url.as_str();
156 let body_html = crate::markdown::render_creator_markdown(body_markdown, user.id, cdn_base);
157 let is_published = req.is_published.unwrap_or(false);
158
159 let web_only = req.web_only.unwrap_or(false);
160 let show_on_landing = req.show_on_landing.unwrap_or(false);
161
162 // The UNIQUE(project_id, slug) index is the race-safe source of truth:
163 // `insert_with_unique_slug` auto-suffixes (`slug`, `slug-2`, ...) on a 23505
164 // collision, covering the TOCTOU race where a concurrent request grabs the
165 // same slug.
166 let base = slug.to_string();
167 let pool = &db;
168 let (title_s, body_html_s) = (req.title.as_str(), body_html.as_str());
169 let post = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
170 let slug = Slug::from_trusted(slug);
171 db::blog_posts::create_blog_post(
172 pool,
173 project_id,
174 user.id,
175 title_s,
176 &slug,
177 body_markdown,
178 body_html_s,
179 is_published,
180 web_only,
181 show_on_landing,
182 )
183 .await
184 })
185 .await?;
186
187 db::projects::bump_cache_generation(&db, project_id).await?;
188
189 // Create linked MT discussion thread and send announcements if published immediately
190 // (skip for sandbox users, no real emails or MT threads)
191 if post.published_at.is_some() && !user.is_sandbox {
192 crate::scheduler::send_blog_post_announcements(&db, &mailer, &config, &post).await;
193 crate::scheduler::spawn_mt_thread_for_blog_post(
194 &db,
195 &bg,
196 &integrations,
197 &config,
198 &post,
199 &user,
200 );
201 }
202
203 Ok(Json(blog_post_response(&post)))
204 }
205
206 /// Update an existing blog post.
207 #[tracing::instrument(skip_all, name = "blog::update_blog_post")]
208 #[allow(clippy::too_many_arguments)]
209 pub(super) async fn update_blog_post(
210 State(db): State<PgPool>,
211 State(mailer): State<crate::email::EmailClient>,
212 State(config): State<Config>,
213 State(bg): State<crate::background::BackgroundTx>,
214 State(integrations): State<Integrations>,
215 AuthUser(user): AuthUser,
216 Path(id): Path<BlogPostId>,
217 ValidatedJson(req): ValidatedJson<UpdateBlogPostRequest>,
218 ) -> Result<impl IntoResponse> {
219 user.check_not_suspended()?;
220 let existing = verify_blog_post_ownership(&db, id, user.id).await?;
221
222 validation::validate_blog_post_title(&req.title)?;
223 // slug is validated by Slug's Deserialize impl
224 validation::validate_blog_post_body(&req.body_markdown)?;
225
226 // Check slug uniqueness if changed
227 if req.slug != existing.slug
228 && db::blog_posts::blog_post_slug_exists(&db, existing.project_id, &req.slug).await?
229 {
230 return Err(AppError::validation(
231 "A blog post with this slug already exists".to_string(),
232 ));
233 }
234
235 let cdn_base = config.cdn_base_url.as_str();
236 let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, user.id, cdn_base);
237
238 // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule
239 let publish_at = parse_schedule_datetime(req.publish_at.as_deref());
240
241 // Reject scheduling in the past
242 if let Some(Some(dt)) = &publish_at
243 && *dt < chrono::Utc::now()
244 {
245 return Err(AppError::BadRequest(
246 "Scheduled publish date must be in the future".to_string(),
247 ));
248 }
249
250 // Publish-state change: `None` = leave `published_at` untouched (auto-save).
251 // Scheduling forces the post unpublished-now; the SQL scheduling branch sets
252 // `published_at` to NULL, so we pass an explicit `Some(false)` to match.
253 let publish_change = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() {
254 Some(false)
255 } else {
256 req.is_published
257 };
258
259 let post = db::blog_posts::update_blog_post(
260 &db,
261 id,
262 &req.title,
263 &req.slug,
264 &req.body_markdown,
265 &body_html,
266 publish_change,
267 publish_at,
268 req.web_only,
269 req.show_on_landing,
270 )
271 .await?;
272
273 db::projects::bump_cache_generation(&db, existing.project_id).await?;
274
275 // Detect first publish: was unpublished before, now published
276 // (skip for sandbox users, no real emails or MT threads)
277 if existing.published_at.is_none() && post.published_at.is_some() && !user.is_sandbox {
278 crate::scheduler::send_blog_post_announcements(&db, &mailer, &config, &post).await;
279 if post.mt_thread_id.is_none() {
280 crate::scheduler::spawn_mt_thread_for_blog_post(
281 &db,
282 &bg,
283 &integrations,
284 &config,
285 &post,
286 &user,
287 );
288 }
289 }
290
291 Ok(Json(blog_post_response(&post)))
292 }
293
294 #[tracing::instrument(skip_all, name = "blog::delete_blog_post")]
295 pub(super) async fn delete_blog_post(
296 State(db): State<PgPool>,
297 AuthUser(user): AuthUser,
298 Path(id): Path<BlogPostId>,
299 ) -> Result<impl IntoResponse> {
300 user.check_not_suspended()?;
301 let post = verify_blog_post_ownership(&db, id, user.id).await?;
302
303 db::blog_posts::delete_blog_post(&db, id, user.id).await?;
304 db::projects::bump_cache_generation(&db, post.project_id).await?;
305
306 Ok(htmx_toast_response("Blog post deleted", "success"))
307 }
308
309 /// List published blog posts for a project.
310 #[tracing::instrument(skip_all, name = "blog::list_blog_posts")]
311 pub(super) async fn list_blog_posts(
312 State(db): State<PgPool>,
313 Path(project_id): Path<ProjectId>,
314 ) -> Result<impl IntoResponse> {
315 let posts = db::blog_posts::get_published_blog_posts_by_project(&db, project_id).await?;
316
317 let data: Vec<BlogPostResponse> = posts.iter().map(blog_post_response).collect();
318
319 Ok(Json(ListResponse { data }))
320 }
321