use crate::{ authentication::AuthenticatedUser, idempotency::{IdempotencyKey, save_response, try_processing}, routes::{ AdminError, AppError, EmailType, Path, enqueue_delivery_tasks, insert_newsletter_issue, }, startup::AppState, templates::{MessageTemplate, NewPostEmailTemplate}, }; use anyhow::Context; use askama::Template; use axum::{ Extension, Form, extract::State, response::{Html, IntoResponse, Response}, }; use chrono::Utc; use sqlx::{Executor, Postgres, Transaction}; use uuid::Uuid; #[derive(serde::Deserialize)] pub struct CreatePostForm { title: String, content: String, idempotency_key: String, } fn validate_form(form: &CreatePostForm) -> Result<(), anyhow::Error> { if form.title.is_empty() || form.content.is_empty() { anyhow::bail!("Fields cannot be empty.") } else { Ok(()) } } #[tracing::instrument( name = "Publishing new blog post", skip(connection_pool, base_url, form) fields(title = %form.title) )] pub async fn create_post( State(AppState { connection_pool, base_url, .. }): State, Extension(AuthenticatedUser { user_id, .. }): Extension, Form(form): Form, ) -> Result { validate_form(&form).map_err(AdminError::Publish)?; let idempotency_key: IdempotencyKey = form .idempotency_key .try_into() .map_err(AdminError::Idempotency)?; let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? { crate::idempotency::NextAction::StartProcessing(t) => t, crate::idempotency::NextAction::ReturnSavedResponse(response) => { return Ok(response); } }; let post_id = insert_post(&mut transaction, &form.title, &form.content, &user_id) .await .context("Failed to insert new post in the database.")?; let newsletter_uuid = create_newsletter(&mut transaction, &base_url, &form.title, &post_id) .await .context("Failed to create newsletter.")?; enqueue_delivery_tasks(&mut transaction, newsletter_uuid, EmailType::NewPost) .await .context("Failed to enqueue delivery tasks.")?; let template = MessageTemplate::success("Your new post has been published!".into()); let response = Html(template.render().unwrap()).into_response(); let response = save_response(transaction, &idempotency_key, user_id, response) .await .map_err(AdminError::UnexpectedError)?; Ok(response) } #[tracing::instrument(name = "Saving new blog post in the database", skip_all)] pub async fn insert_post( transaction: &mut Transaction<'static, Postgres>, title: &str, content: &str, author: &Uuid, ) -> Result { let post_id = Uuid::new_v4(); let query = sqlx::query!( r#" INSERT INTO posts (post_id, author_id, title, content, published_at) VALUES ($1, $2, $3, $4, $5) "#, post_id, author, title, content, Utc::now() ); transaction.execute(query).await?; Ok(post_id) } #[tracing::instrument(name = "Creating newsletter for new post", skip_all)] pub async fn create_newsletter( transaction: &mut Transaction<'static, Postgres>, base_url: &str, post_title: &str, post_id: &Uuid, ) -> Result { let template = NewPostEmailTemplate { base_url, post_title, post_id, post_excerpt: "", }; insert_newsletter_issue(transaction, post_title, &template).await } pub async fn delete_post( State(AppState { connection_pool, .. }): State, Path(post_id): Path, ) -> Result { let res = sqlx::query!("DELETE FROM posts WHERE post_id = $1", post_id) .execute(&connection_pool) .await .context("Failed to delete post from database.") .map_err(AppError::unexpected_message)?; if res.rows_affected() > 1 { Err(AppError::unexpected_message(anyhow::anyhow!( "We could not find the post in the database." ))) } else { let template = MessageTemplate::success("The subscriber has been deleted.".into()); Ok(template.render().unwrap().into_response()) } }