Support for comments
Some checks failed
Rust / Test (push) Failing after 4m13s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Failing after 31s
Rust / Code coverage (push) Successful in 6m21s

This commit is contained in:
Alphonse Paix
2025-10-02 00:26:18 +02:00
parent 2c7282475f
commit cb3f216591
14 changed files with 380 additions and 2751 deletions

View File

@@ -1,9 +1,11 @@
mod comment;
mod new_subscriber;
mod post;
mod subscriber_email;
mod subscribers;
mod user;
pub use comment::CommentEntry;
pub use new_subscriber::NewSubscriber;
pub use post::PostEntry;
pub use subscriber_email::SubscriberEmail;

16
src/domain/comment.rs Normal file
View File

@@ -0,0 +1,16 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
pub struct CommentEntry {
pub comment_id: Uuid,
pub post_id: Uuid,
pub author: Option<String>,
pub content: String,
pub published_at: DateTime<Utc>,
}
impl CommentEntry {
pub fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string()
}
}

View File

@@ -1,4 +1,5 @@
mod admin;
mod comments;
mod health_check;
mod home;
mod login;
@@ -15,6 +16,7 @@ use axum::{
http::{HeaderMap, request::Parts},
response::{Html, IntoResponse, Response},
};
pub use comments::*;
pub use health_check::*;
pub use home::*;
pub use login::*;

152
src/routes/comments.rs Normal file
View File

@@ -0,0 +1,152 @@
use crate::{
domain::CommentEntry,
routes::AppError,
startup::AppState,
templates::{CommentsList, HtmlTemplate, MessageTemplate},
};
use anyhow::Context;
use axum::{
Form,
extract::{Path, Query, State},
response::{IntoResponse, Response},
};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(serde::Deserialize)]
pub struct CommentPathParam {
post_id: Uuid,
}
#[derive(serde::Deserialize)]
pub struct CommentForm {
pub author: Option<String>,
pub content: String,
}
#[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))]
pub async fn post_comment(
Path(CommentPathParam { post_id }): Path<CommentPathParam>,
State(AppState {
connection_pool, ..
}): State<AppState>,
Form(form): Form<CommentForm>,
) -> Result<Response, AppError> {
validate_form(&form)?;
let comment_id = insert_comment(&connection_pool, post_id, form)
.await
.context("Could not insert comment into database.")?;
tracing::info!("new comment with id {} has been inserted", comment_id);
let template = HtmlTemplate(MessageTemplate::success(
"Your comment has been posted.".into(),
));
Ok(template.into_response())
}
fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> {
if form.content.is_empty() {
anyhow::bail!("Comment content cannot be empty.");
}
Ok(())
}
#[tracing::instrument(name = "Inserting new comment in database", skip_all, fields(comment_id = tracing::field::Empty))]
async fn insert_comment(
connection_pool: &PgPool,
post_id: Uuid,
form: CommentForm,
) -> Result<Uuid, sqlx::Error> {
let author = form
.author
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string());
let comment_id = Uuid::new_v4();
tracing::Span::current().record("comment_id", comment_id.to_string());
sqlx::query!(
"
INSERT INTO comments (comment_id, post_id, author, content)
VALUES ($1, $2, $3, $4)
",
comment_id,
post_id,
author,
form.content.trim()
)
.execute(connection_pool)
.await?;
Ok(comment_id)
}
const COMMENTS_PER_PAGE: i64 = 5;
#[derive(serde::Deserialize)]
pub struct GetCommentsQueryParams {
page: i64,
}
#[tracing::instrument(name = "Fetching comments", skip(connection_pool))]
pub async fn get_comments(
Path(CommentPathParam { post_id }): Path<CommentPathParam>,
Query(GetCommentsQueryParams { page }): Query<GetCommentsQueryParams>,
State(AppState {
connection_pool, ..
}): State<AppState>,
) -> Result<Response, AppError> {
let comments = fetch_comments_page(&connection_pool, post_id, page)
.await
.context("Could not fetch comments.")?;
let count = fetch_comments_count(&connection_pool, post_id)
.await
.context("Could not fetch comments count")?;
let max_page = get_comments_page_count(count);
let template = HtmlTemplate(CommentsList {
comments,
current_page: page,
max_page,
});
Ok(template.into_response())
}
pub async fn fetch_comments_page(
connection_pool: &PgPool,
post_id: Uuid,
page: i64,
) -> Result<Vec<CommentEntry>, sqlx::Error> {
let offset = (page - 1) * COMMENTS_PER_PAGE;
let comments = sqlx::query_as!(
CommentEntry,
"
SELECT comment_id, post_id, author, content, published_at
FROM comments
WHERE post_id = $1
ORDER BY published_at DESC
LIMIT $2
OFFSET $3
",
post_id,
COMMENTS_PER_PAGE,
offset
)
.fetch_all(connection_pool)
.await?;
Ok(comments)
}
pub async fn fetch_comments_count(
connection_pool: &PgPool,
post_id: Uuid,
) -> Result<i64, sqlx::Error> {
let count = sqlx::query_scalar!("SELECT count(*) FROM comments WHERE post_id = $1", post_id)
.fetch_one(connection_pool)
.await?
.unwrap_or(0);
Ok(count)
}
pub fn get_comments_page_count(count: i64) -> i64 {
let mut max_page = count.div_euclid(COMMENTS_PER_PAGE);
if count % COMMENTS_PER_PAGE > 0 {
max_page += 1;
}
max_page
}

View File

@@ -1,6 +1,9 @@
use crate::{
domain::PostEntry,
routes::{AppError, Path, Query, not_found_html},
routes::{
AppError, Path, Query, fetch_comments_count, fetch_comments_page, get_comments_page_count,
not_found_html,
},
startup::AppState,
templates::{HtmlTemplate, PostListTemplate, PostTemplate, PostsTemplate},
};
@@ -89,7 +92,21 @@ pub async fn see_post(
let post = post
.to_html()
.context("Could not render markdown with extension.")?;
let template = HtmlTemplate(PostTemplate { post });
let current_page = 1;
let comments_count = fetch_comments_count(&connection_pool, post_id)
.await
.context("Could not fetch comment count")?;
let max_page = get_comments_page_count(comments_count);
let comments = fetch_comments_page(&connection_pool, post_id, 1)
.await
.context("Failed to fetch latest comments")?;
let template = HtmlTemplate(PostTemplate {
post,
comments,
current_page,
max_page,
comments_count,
});
Ok(template.into_response())
} else {
Ok(not_found_html())

View File

@@ -108,6 +108,10 @@ pub fn app(
.route("/posts", get(list_posts))
.route("/posts/load_more", get(load_more))
.route("/posts/{post_id}", get(see_post))
.route(
"/posts/{post_id}/comments",
post(post_comment).get(get_comments),
)
.route("/users/{username}", get(user_profile))
.route("/favicon.ico", get(favicon))
.nest("/admin", auth_routes)

View File

@@ -1,6 +1,6 @@
use crate::{
authentication::AuthenticatedUser,
domain::{PostEntry, SubscriberEntry, UserEntry},
domain::{CommentEntry, PostEntry, SubscriberEntry, UserEntry},
routes::{AppError, DashboardStats},
};
use askama::Template;
@@ -88,6 +88,18 @@ pub struct PostListTemplate {
#[template(path = "posts/page.html")]
pub struct PostTemplate {
pub post: PostEntry,
pub comments: Vec<CommentEntry>,
pub current_page: i64,
pub max_page: i64,
pub comments_count: i64,
}
#[derive(Template)]
#[template(path = "posts/comments/list.html", block = "comments")]
pub struct CommentsList {
pub comments: Vec<CommentEntry>,
pub current_page: i64,
pub max_page: i64,
}
#[derive(Template)]