//! Blog post API: create, update, delete, publish. use axum::{ Json, extract::{Path, State}, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use crate::{ Integrations, auth::AuthUser, config::Config, db::{self, BlogPostId, ProjectId, Slug}, error::{AppError, Result}, helpers::{htmx_toast_response, parse_schedule_datetime, slugify}, types::ListResponse, validation, }; use super::{verify_blog_post_ownership, verify_project_ownership}; use crate::extractors::ValidatedJson; // Blog API /// JSON input for creating a blog post. #[derive(Debug, Deserialize)] pub(super) struct CreateBlogPostRequest { pub title: String, pub slug: Option, pub body_markdown: Option, pub is_published: Option, /// Whether to skip email announcements when publishing. pub web_only: Option, /// Operator-only: surface this post as the landing "Last shipped" line. /// Only meaningful on the changelog project; inert elsewhere. pub show_on_landing: Option, } /// JSON input for updating a blog post. #[derive(Debug, Deserialize)] pub(super) struct UpdateBlogPostRequest { pub title: String, pub slug: Slug, pub body_markdown: String, /// `None` leaves the publish state unchanged (auto-save omits it so a /// background save can't silently unpublish a live post); `Some(true)` /// publishes, `Some(false)` unpublishes. pub is_published: Option, /// ISO 8601 datetime string for scheduled publishing. Empty string clears the schedule. pub publish_at: Option, /// Whether to skip email announcements when publishing. pub web_only: Option, /// Operator-only: surface this post as the landing "Last shipped" line. /// `None` leaves the existing flag unchanged (e.g. autosave). pub show_on_landing: Option, } /// JSON response representing a blog post. #[derive(Debug, Serialize)] pub(super) struct BlogPostResponse { pub id: BlogPostId, pub project_id: ProjectId, pub title: String, pub slug: String, pub is_published: bool, pub published_at: Option, pub web_only: bool, pub show_on_landing: bool, pub created_at: String, pub updated_at: String, } /// JSON response for editing a blog post (includes body_markdown and publish_at). #[derive(Debug, Serialize)] pub(super) struct BlogPostEditResponse { pub id: BlogPostId, pub title: String, pub slug: String, pub body_markdown: String, pub is_published: bool, pub publish_at: Option, pub web_only: bool, pub show_on_landing: bool, } fn blog_post_edit_response(post: &db::DbBlogPost) -> BlogPostEditResponse { BlogPostEditResponse { id: post.id, title: post.title.clone(), slug: post.slug.to_string(), body_markdown: post.body_markdown.clone(), is_published: post.published_at.is_some(), publish_at: post.publish_at.map(|d| d.to_rfc3339()), web_only: post.web_only, show_on_landing: post.show_on_landing, } } fn blog_post_response(post: &db::DbBlogPost) -> BlogPostResponse { BlogPostResponse { id: post.id, project_id: post.project_id, title: post.title.clone(), slug: post.slug.to_string(), is_published: post.published_at.is_some(), published_at: post.published_at.map(|d| d.to_rfc3339()), web_only: post.web_only, show_on_landing: post.show_on_landing, created_at: post.created_at.to_rfc3339(), updated_at: post.updated_at.to_rfc3339(), } } /// Get a single blog post for editing. #[tracing::instrument(skip_all, name = "blog::get_blog_post")] pub(super) async fn get_blog_post( State(db): State, AuthUser(user): AuthUser, Path(blog_post_id): Path, ) -> Result { let post = verify_blog_post_ownership(&db, blog_post_id, user.id).await?; Ok(Json(blog_post_edit_response(&post))) } /// Create a new blog post under a project. #[tracing::instrument(skip_all, name = "blog::create_blog_post")] #[allow(clippy::too_many_arguments)] pub(super) async fn create_blog_post( State(db): State, State(mailer): State, State(config): State, State(bg): State, State(integrations): State, AuthUser(user): AuthUser, Path(project_id): Path, ValidatedJson(req): ValidatedJson, ) -> Result { user.check_not_suspended()?; verify_project_ownership(&db, project_id, user.id).await?; validation::validate_blog_post_title(&req.title)?; // Generate or validate slug let slug = match req.slug { Some(ref s) if !s.is_empty() => Slug::new(s)?, _ => slugify(&req.title), }; let body_markdown = req.body_markdown.as_deref().unwrap_or(""); validation::validate_blog_post_body(body_markdown)?; let cdn_base = config.cdn_base_url.as_str(); let body_html = crate::markdown::render_creator_markdown(body_markdown, user.id, cdn_base); let is_published = req.is_published.unwrap_or(false); let web_only = req.web_only.unwrap_or(false); let show_on_landing = req.show_on_landing.unwrap_or(false); // The UNIQUE(project_id, slug) index is the race-safe source of truth: // `insert_with_unique_slug` auto-suffixes (`slug`, `slug-2`, ...) on a 23505 // collision, covering the TOCTOU race where a concurrent request grabs the // same slug. let base = slug.to_string(); let pool = &db; let (title_s, body_html_s) = (req.title.as_str(), body_html.as_str()); let post = crate::helpers::insert_with_unique_slug(&base, |slug| async move { let slug = Slug::from_trusted(slug); db::blog_posts::create_blog_post( pool, project_id, user.id, title_s, &slug, body_markdown, body_html_s, is_published, web_only, show_on_landing, ) .await }) .await?; db::projects::bump_cache_generation(&db, project_id).await?; // Create linked MT discussion thread and send announcements if published immediately // (skip for sandbox users, no real emails or MT threads) if post.published_at.is_some() && !user.is_sandbox { crate::scheduler::send_blog_post_announcements(&db, &mailer, &config, &post).await; crate::scheduler::spawn_mt_thread_for_blog_post( &db, &bg, &integrations, &config, &post, &user, ); } Ok(Json(blog_post_response(&post))) } /// Update an existing blog post. #[tracing::instrument(skip_all, name = "blog::update_blog_post")] #[allow(clippy::too_many_arguments)] pub(super) async fn update_blog_post( State(db): State, State(mailer): State, State(config): State, State(bg): State, State(integrations): State, AuthUser(user): AuthUser, Path(id): Path, ValidatedJson(req): ValidatedJson, ) -> Result { user.check_not_suspended()?; let existing = verify_blog_post_ownership(&db, id, user.id).await?; validation::validate_blog_post_title(&req.title)?; // slug is validated by Slug's Deserialize impl validation::validate_blog_post_body(&req.body_markdown)?; // Check slug uniqueness if changed if req.slug != existing.slug && db::blog_posts::blog_post_slug_exists(&db, existing.project_id, &req.slug).await? { return Err(AppError::validation( "A blog post with this slug already exists".to_string(), )); } let cdn_base = config.cdn_base_url.as_str(); let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, user.id, cdn_base); // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule let publish_at = parse_schedule_datetime(req.publish_at.as_deref()); // Reject scheduling in the past if let Some(Some(dt)) = &publish_at && *dt < chrono::Utc::now() { return Err(AppError::BadRequest( "Scheduled publish date must be in the future".to_string(), )); } // Publish-state change: `None` = leave `published_at` untouched (auto-save). // Scheduling forces the post unpublished-now; the SQL scheduling branch sets // `published_at` to NULL, so we pass an explicit `Some(false)` to match. let publish_change = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() { Some(false) } else { req.is_published }; let post = db::blog_posts::update_blog_post( &db, id, &req.title, &req.slug, &req.body_markdown, &body_html, publish_change, publish_at, req.web_only, req.show_on_landing, ) .await?; db::projects::bump_cache_generation(&db, existing.project_id).await?; // Detect first publish: was unpublished before, now published // (skip for sandbox users, no real emails or MT threads) if existing.published_at.is_none() && post.published_at.is_some() && !user.is_sandbox { crate::scheduler::send_blog_post_announcements(&db, &mailer, &config, &post).await; if post.mt_thread_id.is_none() { crate::scheduler::spawn_mt_thread_for_blog_post( &db, &bg, &integrations, &config, &post, &user, ); } } Ok(Json(blog_post_response(&post))) } #[tracing::instrument(skip_all, name = "blog::delete_blog_post")] pub(super) async fn delete_blog_post( State(db): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { user.check_not_suspended()?; let post = verify_blog_post_ownership(&db, id, user.id).await?; db::blog_posts::delete_blog_post(&db, id, user.id).await?; db::projects::bump_cache_generation(&db, post.project_id).await?; Ok(htmx_toast_response("Blog post deleted", "success")) } /// List published blog posts for a project. #[tracing::instrument(skip_all, name = "blog::list_blog_posts")] pub(super) async fn list_blog_posts( State(db): State, Path(project_id): Path, ) -> Result { let posts = db::blog_posts::get_published_blog_posts_by_project(&db, project_id).await?; let data: Vec = posts.iter().map(blog_post_response).collect(); Ok(Json(ListResponse { data })) }