//! Blog post API: create, update, delete, publish. use axum::{ extract::{Path, State}, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, BlogPostId, ProjectId, Slug}, error::{AppError, Result}, helpers::{htmx_toast_response, parse_schedule_datetime, slugify}, types::ListResponse, validation, AppState, }; use super::{verify_blog_post_ownership, verify_project_ownership}; // ============================================================================= // Blog API // ============================================================================= /// JSON input for creating a blog post. #[derive(Debug, Deserialize)] pub 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 struct UpdateBlogPostRequest { pub title: String, pub slug: Slug, pub body_markdown: String, pub is_published: bool, /// 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 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 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(state): State, AuthUser(user): AuthUser, Path(blog_post_id): Path, ) -> Result { let post = verify_blog_post_ownership(&state, 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")] pub(super) async fn create_blog_post( State(state): State, AuthUser(user): AuthUser, Path(project_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; verify_project_ownership(&state, project_id, user.id).await?; // Validate title validation::validate_blog_post_title(&req.title)?; // Generate or validate slug let mut slug = match req.slug { Some(ref s) if !s.is_empty() => Slug::new(s)?, _ => slugify(&req.title), }; // Fast path: append suffixes for known slug collisions if db::blog_posts::blog_post_slug_exists(&state.db, project_id, &slug).await? { let base = slug.clone(); let mut counter = 2u32; loop { slug = Slug::from_trusted(format!("{}-{}", base, counter)); if !db::blog_posts::blog_post_slug_exists(&state.db, project_id, &slug).await? { break; } counter += 1; } } let body_markdown = req.body_markdown.as_deref().unwrap_or(""); validation::validate_blog_post_body(body_markdown)?; let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work"); let body_html = crate::markdown::render_creator_markdown(body_markdown, user.id, cdn_base); let is_published = req.is_published.unwrap_or(false); // Retry with suffixes if a concurrent request creates the same slug // between our existence check and insert (TOCTOU race). let web_only = req.web_only.unwrap_or(false); let show_on_landing = req.show_on_landing.unwrap_or(false); let base_slug = slug.clone(); let mut suffix = 1u32; let post = loop { match db::blog_posts::create_blog_post( &state.db, project_id, user.id, &req.title, &slug, body_markdown, &body_html, is_published, web_only, show_on_landing, ).await { Ok(post) => break post, Err(e) => { let is_slug_conflict = matches!( &e, AppError::Database(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("23505") ); if is_slug_conflict && suffix < 100 { suffix += 1; slug = Slug::from_trusted(format!("{}-{}", base_slug, suffix)); continue; } return Err(e); } } }; db::projects::bump_cache_generation(&state.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(&state, &post).await; crate::scheduler::spawn_mt_thread_for_blog_post(&state, &post, &user); } Ok(Json(blog_post_response(&post))) } /// Update an existing blog post. #[tracing::instrument(skip_all, name = "blog::update_blog_post")] pub(super) async fn update_blog_post( State(state): State, AuthUser(user): AuthUser, Path(id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; let existing = verify_blog_post_ownership(&state, 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(&state.db, existing.project_id, &req.slug).await? { return Err(AppError::validation("A blog post with this slug already exists".to_string())); } let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work"); 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())); } // If scheduling, don't publish immediately let is_published = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() { false } else { req.is_published }; let post = db::blog_posts::update_blog_post( &state.db, id, &req.title, &req.slug, &req.body_markdown, &body_html, is_published, publish_at, req.web_only, req.show_on_landing, ) .await?; db::projects::bump_cache_generation(&state.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(&state, &post).await; if post.mt_thread_id.is_none() { crate::scheduler::spawn_mt_thread_for_blog_post(&state, &post, &user); } } Ok(Json(blog_post_response(&post))) } /// Delete a blog post. #[tracing::instrument(skip_all, name = "blog::delete_blog_post")] pub(super) async fn delete_blog_post( State(state): State, AuthUser(user): AuthUser, Path(id): Path, ) -> Result { user.check_not_suspended()?; let post = verify_blog_post_ownership(&state, id, user.id).await?; db::blog_posts::delete_blog_post(&state.db, id).await?; db::projects::bump_cache_generation(&state.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(state): State, Path(project_id): Path, ) -> Result { let posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, project_id).await?; let data: Vec = posts.iter().map(blog_post_response).collect(); Ok(Json(ListResponse { data })) }