//! thread reads. `get_thread_with_breadcrumb` returns [`super::Unscoped`], so a //! by-id caller cannot skip the C1 community check use super::{DateTime, PgPool, SortColumn, SortOrder, Utc, Uuid}; #[derive(sqlx::FromRow)] pub struct ThreadWithMeta { pub id: Uuid, pub title: String, pub author_name: String, pub author_username: String, pub reply_count: i64, pub last_activity_at: DateTime, pub pinned: bool, pub locked: bool, } #[derive(sqlx::FromRow)] pub struct ThreadWithBreadcrumb { pub id: Uuid, pub title: String, pub locked: bool, pub pinned: bool, pub author_id: Uuid, pub community_id: Uuid, pub community_name: String, pub community_slug: String, pub category_name: String, pub category_slug: String, } pub struct DeletedThreadRow { pub id: Uuid, pub title: String, pub category_slug: String, pub author_username: String, pub deleted_at: DateTime, /// The thread was deleted by removing its opening post, so restoring it /// brings that post back too. False when a mod deleted the thread directly /// and the opening post was left alone. pub op_removed: bool, } /// List a community's soft-deleted threads, newest deletion first. /// /// The only reader of `threads` that wants `deleted_at IS NOT NULL`: every /// other loader filters deleted threads out, which is exactly why they need a /// surface of their own to be restored from. Capped by `limit` like the other /// moderation reads so a community with a long deletion history cannot make one /// page load materialize an unbounded set. #[tracing::instrument(skip_all)] pub async fn list_deleted_threads( pool: &PgPool, community_id: Uuid, limit: i64, ) -> Result, sqlx::Error> { sqlx::query_as!( DeletedThreadRow, r#"SELECT t.id, t.title, c.slug AS category_slug, u.username AS author_username, t.deleted_at AS "deleted_at!: chrono::DateTime", EXISTS ( SELECT 1 FROM posts op WHERE op.thread_id = t.id AND op.removed_at IS NOT NULL AND op.id = ( SELECT id FROM posts WHERE thread_id = t.id ORDER BY created_at ASC, id ASC LIMIT 1 ) ) AS "op_removed!" FROM threads t JOIN categories c ON c.id = t.category_id JOIN users u ON u.mnw_account_id = t.author_id WHERE c.community_id = $1 AND t.deleted_at IS NOT NULL ORDER BY t.deleted_at DESC LIMIT $2"#, community_id, limit, ) .fetch_all(pool) .await } pub struct DeletedThreadTarget { pub author_id: Uuid, pub category_slug: String, } /// Resolve a soft-deleted thread within a community, for the restore handler. /// /// The scoping is structural, same idea as `get_post_body_markdown_in_community`: /// the community id is part of the lookup, so there is no unscoped by-id variant /// a caller could reach for and skip the check with. `deleted_at IS NOT NULL` is /// deliberate here, this is the one path that only wants deleted rows, and it /// makes restoring an already-live thread a 404 rather than a silent no-op. #[tracing::instrument(skip_all)] pub async fn get_deleted_thread_in_community( pool: &PgPool, thread_id: Uuid, community_id: Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( DeletedThreadTarget, "SELECT t.author_id, c.slug AS category_slug FROM threads t JOIN categories c ON c.id = t.category_id WHERE t.id = $1 AND c.community_id = $2 AND t.deleted_at IS NOT NULL", thread_id, community_id, ) .fetch_optional(pool) .await } /// Look up a thread by its external reference (e.g., "mnw:item:uuid"). #[tracing::instrument(skip_all)] pub async fn get_thread_by_external_ref( pool: &PgPool, external_ref: &str, ) -> Result, sqlx::Error> { sqlx::query!( "SELECT id FROM threads WHERE external_ref = $1", external_ref, ) .fetch_optional(pool) .await .map(|opt| opt.map(|r| (r.id,))) } /// Get thread stats: post count and last activity timestamp. #[tracing::instrument(skip_all)] #[allow(clippy::type_complexity)] pub async fn get_thread_stats( pool: &PgPool, thread_id: Uuid, ) -> Result>)>, sqlx::Error> { // This count surfaces on the MNW server (the internal /stats endpoint), which // can't render mt's tombstones, so it must be the canonical *active* count, // excluding mod-removed and soft-deleted posts (matching the m029 trigger and // the profile tallies). Contrast `count_posts_in_thread`, which deliberately // counts tombstones because the in-app thread list renders them. sqlx::query!( r#"SELECT COUNT(p.id) AS "count!", MAX(p.created_at) AS "max_created: chrono::DateTime" FROM posts p WHERE p.thread_id = $1 AND p.is_active"#, thread_id, ) .fetch_optional(pool) .await .map(|opt| opt.map(|r| (r.count, r.max_created))) } #[tracing::instrument(skip_all)] pub async fn list_threads_in_category_paginated( pool: &PgPool, community_slug: &str, category_slug: &str, limit: i64, offset: i64, ) -> Result, sqlx::Error> { sqlx::query_as!( ThreadWithMeta, r#"SELECT t.id, t.title, COALESCE(u.display_name, u.username) AS "author_name!", u.username AS author_username, GREATEST(t.post_count - 1, 0)::BIGINT AS "reply_count!", t.last_activity_at AS "last_activity_at: chrono::DateTime", t.pinned, t.locked FROM threads t JOIN categories c ON c.id = t.category_id JOIN communities co ON co.id = c.community_id JOIN users u ON u.mnw_account_id = t.author_id WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL ORDER BY t.pinned DESC, t.last_activity_at DESC LIMIT $3 OFFSET $4"#, community_slug, category_slug, limit, offset, ) .fetch_all(pool) .await } /// List threads in a category, sorted, optionally filtered to one tag. /// /// Pinned threads always sort first. `sort`/`order` are typed enums, so the /// `ORDER BY` clause is chosen from an exhaustive match of fixed literals (no /// user string reaches the SQL); when `tag_slug` is `Some`, an extra join /// restricts the results to threads carrying that tag in the same community. #[tracing::instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub async fn list_threads_in_category_sorted( pool: &PgPool, community_slug: &str, category_slug: &str, sort: SortColumn, order: SortOrder, limit: i64, offset: i64, tag_slug: Option<&str>, ) -> Result, sqlx::Error> { let order_clause = match (sort, order) { (SortColumn::Replies, SortOrder::Asc) => { "ORDER BY t.pinned DESC, reply_count ASC, t.last_activity_at DESC" } (SortColumn::Replies, SortOrder::Desc) => { "ORDER BY t.pinned DESC, reply_count DESC, t.last_activity_at DESC" } (SortColumn::Activity, SortOrder::Asc) => "ORDER BY t.pinned DESC, t.last_activity_at ASC", (SortColumn::Activity, SortOrder::Desc) => { "ORDER BY t.pinned DESC, t.last_activity_at DESC" } }; // When a tag filter is present it binds as $3, pushing limit/offset to $4/$5. let (tag_join, limit_ph, offset_ph) = if tag_slug.is_some() { ( "JOIN thread_tags tt ON tt.thread_id = t.id \ JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id", "$4", "$5", ) } else { ("", "$3", "$4") }; let query = format!( "SELECT t.id, t.title, COALESCE(u.display_name, u.username) AS author_name, u.username AS author_username, GREATEST(t.post_count - 1, 0)::BIGINT AS reply_count, t.last_activity_at, t.pinned, t.locked FROM threads t JOIN categories c ON c.id = t.category_id JOIN communities co ON co.id = c.community_id JOIN users u ON u.mnw_account_id = t.author_id {tag_join} WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL {order_clause} LIMIT {limit_ph} OFFSET {offset_ph}" ); // runtime-checked: dynamic SQL (cannot use compile-time macro) let mut q = sqlx::query_as::<_, ThreadWithMeta>(&query) .bind(community_slug) .bind(category_slug); if let Some(tag) = tag_slug { q = q.bind(tag); } q.bind(limit).bind(offset).fetch_all(pool).await } #[tracing::instrument(skip_all)] pub async fn count_threads_in_category( pool: &PgPool, community_slug: &str, category_slug: &str, ) -> Result { sqlx::query_scalar!( r#"SELECT COUNT(*) AS "count!" FROM threads t JOIN categories c ON c.id = t.category_id JOIN communities co ON co.id = c.community_id WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL"#, community_slug, category_slug, ) .fetch_one(pool) .await } #[tracing::instrument(skip_all)] pub async fn get_thread_with_breadcrumb( pool: &PgPool, thread_id: Uuid, ) -> Result>, sqlx::Error> { sqlx::query_as!( ThreadWithBreadcrumb, r#"SELECT t.id, t.title, t.locked, t.pinned, t.author_id, co.id AS community_id, co.name AS community_name, co.slug AS community_slug, c.name AS category_name, c.slug AS category_slug FROM threads t JOIN categories c ON c.id = t.category_id JOIN communities co ON co.id = c.community_id WHERE t.id = $1 AND t.deleted_at IS NULL"#, thread_id, ) .fetch_optional(pool) .await .map(|opt| { opt.map(|row| { let community_id = row.community_id; super::Unscoped::new(row, community_id) }) }) } /// Count threads in a category, optionally filtered by tag slug. #[tracing::instrument(skip_all)] pub async fn count_threads_in_category_filtered( pool: &PgPool, community_slug: &str, category_slug: &str, tag_slug: Option<&str>, ) -> Result { if let Some(tag) = tag_slug { sqlx::query_scalar!( r#"SELECT COUNT(DISTINCT t.id) AS "count!" FROM threads t JOIN categories c ON c.id = t.category_id JOIN communities co ON co.id = c.community_id JOIN thread_tags tt ON tt.thread_id = t.id JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL"#, community_slug, category_slug, tag, ) .fetch_one(pool) .await } else { count_threads_in_category(pool, community_slug, category_slug).await } } /// Check if a thread exists by ID. #[tracing::instrument(skip_all)] pub async fn thread_exists(pool: &PgPool, thread_id: Uuid) -> Result { sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM threads WHERE id = $1) AS "exists!""#, thread_id, ) .fetch_one(pool) .await }