Support for comments
Some checks failed
Rust / Test (push) Successful in 5m31s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Failing after 27s
Rust / Code coverage (push) Successful in 4m28s

This commit is contained in:
Alphonse Paix
2025-10-02 00:26:18 +02:00
parent 2c7282475f
commit 9e5d185aaf
15 changed files with 402 additions and 2751 deletions

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())