use crate::authentication::AuthenticatedUser; use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page, join_error_messages}; use crate::session_state::TypedSession; use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate}; use crate::{ domain::PostEntry, routes::{ AppError, Path, get_comments_count_for_post, get_comments_page_for_post, not_found_html, }, startup::AppState, templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate}, }; use anyhow::Context; use askama::Template; use axum::{ Extension, Form, extract::State, response::{Html, IntoResponse, Redirect, Response}, }; use chrono::Utc; use sqlx::PgPool; use uuid::Uuid; use validator::Validate; pub const POSTS_PER_PAGE: i64 = 3; #[tracing::instrument(name = "Fetching most recent posts from database", skip_all)] pub async fn list_posts( State(AppState { connection_pool, .. }): State, ) -> Result { let count = get_posts_count(&connection_pool) .await .context("Could not fetch posts table size.") .map_err(AppError::unexpected_page)?; let next_page = if count > POSTS_PER_PAGE { Some(2) } else { None }; let posts = get_posts(&connection_pool, POSTS_PER_PAGE, None) .await .context("Could not fetch latest posts") .map_err(AppError::unexpected_page)?; let template = PostsTemplate { posts, next_page }; Ok(Html(template.render().unwrap()).into_response()) } #[tracing::instrument(name = "Fetching next posts from database", skip_all)] pub async fn get_posts_page_dashboard( State(AppState { connection_pool, .. }): State, Query(LoadMoreParams { page }): Query, ) -> Result { let posts = get_posts_page(&connection_pool, page) .await .context("Could not fetch next posts page.")?; let posts_current_page = page; let count = get_posts_count(&connection_pool) .await .context("Could not fetch number of posts.")?; let posts_max_page = get_max_page(count, POSTS_PER_PAGE); let template = HtmlTemplate(PostsPageDashboardTemplate { posts, posts_current_page, posts_max_page, }); Ok(template.into_response()) } async fn get_posts( connection_pool: &PgPool, n: i64, offset: Option, ) -> Result, sqlx::Error> { sqlx::query_as!( PostEntry, r#" SELECT p.post_id, p.author_id, u.username AS author, u.full_name, p.title, p.content, p.published_at, p.last_modified FROM posts p LEFT JOIN users u ON p.author_id = u.user_id ORDER BY p.published_at DESC LIMIT $1 OFFSET $2 "#, n, offset ) .fetch_all(connection_pool) .await } pub async fn get_posts_page( connection_pool: &PgPool, page: i64, ) -> Result, sqlx::Error> { let offset = (page - 1) * POSTS_PER_PAGE; sqlx::query_as!( PostEntry, r#" SELECT p.post_id, p.author_id, u.username AS author, u.full_name, p.title, p.content, p.published_at, p.last_modified FROM posts p LEFT JOIN users u ON p.author_id = u.user_id ORDER BY p.published_at DESC LIMIT $1 OFFSET $2 "#, POSTS_PER_PAGE, offset ) .fetch_all(connection_pool) .await } pub async fn get_posts_count(connection_pool: &PgPool) -> Result { sqlx::query!("SELECT count(*) FROM posts") .fetch_one(connection_pool) .await .map(|r| r.count.unwrap()) } #[derive(Validate, serde::Deserialize)] pub struct EditPostForm { #[validate(length(min = 1, message = "Title must be at least one character."))] pub title: String, #[validate(length(min = 1, message = "Content must be at least one character."))] pub content: String, } #[tracing::instrument(name = "Editing post", skip_all, fields(post_id = %post_id))] pub async fn update_post( State(AppState { connection_pool, .. }): State, Extension(AuthenticatedUser { user_id, .. }): Extension, Path(post_id): Path, Form(form): Form, ) -> Result { let record = sqlx::query!("SELECT author_id FROM posts WHERE post_id = $1", post_id) .fetch_optional(&connection_pool) .await .context("Could not fetch post author.")?; match record { None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()), Some(record) if record.author_id == user_id => { if let Err(e) = form.validate().map_err(join_error_messages) { let template = HtmlTemplate(MessageTemplate::error(e)); return Ok(template.into_response()); } sqlx::query!( " UPDATE posts SET title = $1, content = $2, last_modified = $3 WHERE post_id = $4 ", form.title, form.content, Utc::now(), post_id ) .execute(&connection_pool) .await .context("Could not update post")?; Ok(HtmlTemplate(MessageTemplate::success( "Your changes have been saved.".into(), )) .into_response()) } _ => Ok(HtmlTemplate(MessageTemplate::error( "You are not authorized. Only the author can edit his post.".into(), )) .into_response()), } } #[derive(serde::Deserialize)] pub struct OriginQueryParam { origin: Option, } #[tracing::instrument( name = "Fetching post from database", skip(connection_pool, origin, session) )] pub async fn see_post( session: TypedSession, State(AppState { connection_pool, .. }): State, Path(post_id): Path, Query(OriginQueryParam { origin }): Query, ) -> Result { if let Some(origin) = origin { mark_email_as_opened(&connection_pool, origin).await?; return Ok(Redirect::to(&format!("/posts/{}", post_id)).into_response()); } if let Some(post) = get_post_data(&connection_pool, post_id) .await .context(format!("Failed to fetch post #{}.", post_id)) .map_err(AppError::unexpected_page)? { let post_html = post .to_html() .context("Could not render markdown with extension.")?; let current_page = 1; let comments_count = get_comments_count_for_post(&connection_pool, post_id) .await .context("Could not fetch comment count.")?; let max_page = get_max_page(comments_count, COMMENTS_PER_PAGE); let comments = get_comments_page_for_post(&connection_pool, post_id, 1) .await .context("Failed to fetch latest comments.")?; let idempotency_key = Uuid::new_v4().to_string(); let session_user_id = session .get_user_id() .await .context("Could not check for session user id.")?; let session_username = session .get_username() .await .context("Could not check for session username.")?; let template = HtmlTemplate(PostTemplate { post, post_html, comments, idempotency_key, current_page, max_page, comments_count, session_user_id, session_username, }); Ok(template.into_response()) } else { Ok(not_found_html()) } } #[tracing::instrument(name = "Mark email notification as opened", skip(connection_pool))] async fn mark_email_as_opened(connection_pool: &PgPool, email_id: Uuid) -> Result<(), AppError> { sqlx::query!( "UPDATE notifications_delivered SET opened = TRUE WHERE email_id = $1", email_id, ) .execute(connection_pool) .await .context("Failed to mark email as opened.") .map_err(AppError::unexpected_page)?; Ok(()) } async fn get_post_data( connection_pool: &PgPool, post_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( PostEntry, r#" SELECT p.post_id, p.author_id, u.username AS author, u.full_name, p.title, p.content, p.published_at, last_modified FROM posts p LEFT JOIN users u ON p.author_id = u.user_id WHERE p.post_id = $1 "#, post_id ) .fetch_optional(connection_pool) .await } #[derive(serde::Deserialize)] pub struct LoadMoreParams { page: i64, } #[tracing::instrument(name = "Fetching next posts in the database", skip(connection_pool))] pub async fn load_more( State(AppState { connection_pool, .. }): State, Query(LoadMoreParams { page }): Query, ) -> Result { let posts = get_posts_page(&connection_pool, page) .await .context("Could not fetch posts from database.")?; let count = get_posts_count(&connection_pool) .await .context("Could not fetch posts count.")?; let max_page = get_max_page(count, POSTS_PER_PAGE); Ok(Html( PostListTemplate { posts, next_page: if page < max_page { Some(page + 1) } else { None }, } .render() .unwrap(), ) .into_response()) }