Files
EventSnap/backend/src/models/comment.rs
fabi ee554e7f38 style(backend): rustfmt the whole tree; gate cargo fmt --check in CI
The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files)
so no future functional diff is buried under formatting churn, then gating `cargo fmt
--check` in checks.yml so it stays clean.

Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat:
cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean.

This is the deferred cleanup noted when CI's Format step was first left out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:52:17 +02:00

115 lines
3.8 KiB
Rust

use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
// Row shape for `comment`: every field is populated by sqlx from `SELECT *` / `RETURNING *`.
// `deleted_at` is not read in Rust today (the soft-delete filter lives in SQL), but it is part of
// the row and stays here so the struct keeps mirroring the table.
#[allow(dead_code)]
#[derive(Debug, sqlx::FromRow)]
pub struct Comment {
pub id: Uuid,
pub upload_id: Uuid,
pub user_id: Uuid,
pub body: String,
pub created_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CommentDto {
pub id: Uuid,
pub upload_id: Uuid,
pub user_id: Uuid,
pub uploader_name: String,
pub body: String,
pub created_at: DateTime<Utc>,
}
impl Comment {
/// Takes any executor so the caller can insert the comment and link its
/// hashtags inside a single transaction.
pub async fn create<'e, E>(
executor: E,
upload_id: Uuid,
user_id: Uuid,
body: &str,
) -> Result<Self, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_as::<_, Self>(
"INSERT INTO comment (upload_id, user_id, body) VALUES ($1, $2, $3) RETURNING *",
)
.bind(upload_id)
.bind(user_id)
.bind(body)
.fetch_one(executor)
.await
}
/// Paginated comment listing — returns up to `limit` rows in chronological
/// order (oldest first). If `before` is set, only comments older than that
/// timestamp are returned, enabling backward cursor pagination ("load
/// earlier"). Without the LIMIT a hot post with thousands of comments could
/// OOM the server on a single GET.
pub async fn list_for_upload(
pool: &PgPool,
upload_id: Uuid,
before: Option<DateTime<Utc>>,
limit: i64,
) -> Result<Vec<CommentDto>, sqlx::Error> {
// Two-step: pick the newest `limit` rows older than `before`, then flip
// them back into ascending order so the caller can render top-to-bottom.
sqlx::query_as::<_, CommentDto>(
"SELECT * FROM (
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
c.body, c.created_at
FROM comment c
JOIN \"user\" u ON u.id = c.user_id
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
AND ($2::timestamptz IS NULL OR c.created_at < $2)
ORDER BY c.created_at DESC
LIMIT $3
) page
ORDER BY created_at ASC",
)
.bind(upload_id)
.bind(before)
.bind(limit)
.fetch_all(pool)
.await
}
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>("SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL")
.bind(id)
.fetch_optional(pool)
.await
}
/// Event-scoped soft delete. Returns `false` if the comment doesn't exist or belongs to a
/// different event.
/// Executor-generic so the delete and the keepsake regeneration can share one transaction
/// (see `Upload::soft_delete_in_event` for why that must be atomic).
pub async fn soft_delete_in_event(
conn: &mut sqlx::PgConnection,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
"UPDATE comment
SET deleted_at = NOW()
WHERE id = $1
AND deleted_at IS NULL
AND upload_id IN (SELECT id FROM upload WHERE event_id = $2)",
)
.bind(id)
.bind(event_id)
.execute(conn)
.await?;
Ok(result.rows_affected() > 0)
}
}