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, pub deleted_at: Option>, } #[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, } 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 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>, limit: i64, ) -> Result, 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, 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 { 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) } }