max / makenotwork
18 files changed,
+1920 insertions,
-500 deletions
| @@ -1,1931 +0,0 @@ | |||
| 1 | - | //! Database read queries — projection structs and SQL. | |
| 2 | - | ||
| 3 | - | use chrono::{DateTime, Utc}; | |
| 4 | - | use mt_core::types::{BanType, CommunityRole, CommunityState, ModAction, SortColumn, SortOrder}; | |
| 5 | - | use sqlx::PgPool; | |
| 6 | - | use uuid::Uuid; | |
| 7 | - | ||
| 8 | - | // ============================================================================ | |
| 9 | - | // Projection structs — shaped for templates, not domain models | |
| 10 | - | // ============================================================================ | |
| 11 | - | ||
| 12 | - | #[derive(sqlx::FromRow)] | |
| 13 | - | pub struct CommunityRow { | |
| 14 | - | pub id: Uuid, | |
| 15 | - | pub name: String, | |
| 16 | - | pub slug: String, | |
| 17 | - | pub description: Option<String>, | |
| 18 | - | pub suspended_at: Option<DateTime<Utc>>, | |
| 19 | - | pub auto_hide_threshold: Option<i32>, | |
| 20 | - | pub state: CommunityState, | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | #[derive(sqlx::FromRow)] | |
| 24 | - | pub struct CategoryWithCount { | |
| 25 | - | pub name: String, | |
| 26 | - | pub slug: String, | |
| 27 | - | pub description: Option<String>, | |
| 28 | - | pub thread_count: i64, | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | #[derive(sqlx::FromRow)] | |
| 32 | - | pub struct CategoryRow { | |
| 33 | - | pub name: String, | |
| 34 | - | pub slug: String, | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | #[derive(sqlx::FromRow)] | |
| 38 | - | pub struct ThreadWithMeta { | |
| 39 | - | pub id: Uuid, | |
| 40 | - | pub title: String, | |
| 41 | - | pub author_name: String, | |
| 42 | - | pub author_username: String, | |
| 43 | - | pub reply_count: i64, | |
| 44 | - | pub last_activity_at: DateTime<Utc>, | |
| 45 | - | pub pinned: bool, | |
| 46 | - | pub locked: bool, | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | #[derive(sqlx::FromRow)] | |
| 50 | - | pub struct ThreadWithBreadcrumb { | |
| 51 | - | pub id: Uuid, | |
| 52 | - | pub title: String, | |
| 53 | - | pub locked: bool, | |
| 54 | - | pub pinned: bool, | |
| 55 | - | pub author_id: Uuid, | |
| 56 | - | pub community_id: Uuid, | |
| 57 | - | pub community_name: String, | |
| 58 | - | pub community_slug: String, | |
| 59 | - | pub category_name: String, | |
| 60 | - | pub category_slug: String, | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | #[derive(sqlx::FromRow)] | |
| 64 | - | pub struct PostWithAuthor { | |
| 65 | - | pub id: Uuid, | |
| 66 | - | pub author_id: Uuid, | |
| 67 | - | pub author_name: String, | |
| 68 | - | pub author_username: String, | |
| 69 | - | pub body_html: String, | |
| 70 | - | pub created_at: DateTime<Utc>, | |
| 71 | - | pub edited_at: Option<DateTime<Utc>>, | |
| 72 | - | pub deleted_at: Option<DateTime<Utc>>, | |
| 73 | - | pub removed_at: Option<DateTime<Utc>>, | |
| 74 | - | /// Author's current Fan+ status (denormalised on users; refreshed at | |
| 75 | - | /// OAuth callback / `POST /auth/refresh`). Used for the + badge and | |
| 76 | - | /// for signature visibility — signatures only render for current | |
| 77 | - | /// Fan+ subscribers. | |
| 78 | - | pub author_is_fan_plus: bool, | |
| 79 | - | /// Author's saved signature HTML (rendered at save time). Render only | |
| 80 | - | /// when `author_is_fan_plus` is true. | |
| 81 | - | pub author_signature_html: Option<String>, | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | #[derive(sqlx::FromRow)] | |
| 85 | - | pub struct FootnoteWithAuthor { | |
| 86 | - | pub id: Uuid, | |
| 87 | - | pub post_id: Uuid, | |
| 88 | - | pub author_id: Uuid, | |
| 89 | - | pub author_name: String, | |
| 90 | - | pub author_username: String, | |
| 91 | - | pub body_html: String, | |
| 92 | - | pub created_at: DateTime<Utc>, | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | #[derive(sqlx::FromRow)] | |
| 96 | - | pub struct PostForEdit { | |
| 97 | - | pub id: Uuid, | |
| 98 | - | pub author_id: Uuid, | |
| 99 | - | pub body_markdown: String, | |
| 100 | - | pub created_at: DateTime<Utc>, | |
| 101 | - | pub deleted_at: Option<DateTime<Utc>>, | |
| 102 | - | pub thread_id: Uuid, | |
| 103 | - | pub thread_title: String, | |
| 104 | - | pub community_name: String, | |
| 105 | - | pub community_slug: String, | |
| 106 | - | pub community_id: Uuid, | |
| 107 | - | pub category_name: String, | |
| 108 | - | pub category_slug: String, | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | /// A category row with its ID for internal API lookups. | |
| 112 | - | #[derive(sqlx::FromRow)] | |
| 113 | - | pub struct CategoryIdRow { | |
| 114 | - | pub id: Uuid, | |
| 115 | - | pub name: String, | |
| 116 | - | pub slug: String, | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | /// Look up a category by community_id and category slug. Returns the category ID. | |
| 120 | - | #[tracing::instrument(skip_all)] | |
| 121 | - | pub async fn get_category_by_community_and_slug( | |
| 122 | - | pool: &PgPool, | |
| 123 | - | community_id: Uuid, | |
| 124 | - | category_slug: &str, | |
| 125 | - | ) -> Result<Option<CategoryIdRow>, sqlx::Error> { | |
| 126 | - | sqlx::query_as!( | |
| 127 | - | CategoryIdRow, | |
| 128 | - | "SELECT id, name, slug FROM categories WHERE community_id = $1 AND slug = $2", | |
| 129 | - | community_id, | |
| 130 | - | category_slug, | |
| 131 | - | ) | |
| 132 | - | .fetch_optional(pool) | |
| 133 | - | .await | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | /// Look up a thread by its external reference (e.g., "mnw:item:uuid"). | |
| 137 | - | #[tracing::instrument(skip_all)] | |
| 138 | - | pub async fn get_thread_by_external_ref( | |
| 139 | - | pool: &PgPool, | |
| 140 | - | external_ref: &str, | |
| 141 | - | ) -> Result<Option<(Uuid,)>, sqlx::Error> { | |
| 142 | - | sqlx::query!( | |
| 143 | - | "SELECT id FROM threads WHERE external_ref = $1", | |
| 144 | - | external_ref, | |
| 145 | - | ) | |
| 146 | - | .fetch_optional(pool) | |
| 147 | - | .await | |
| 148 | - | .map(|opt| opt.map(|r| (r.id,))) | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | /// Get thread stats: post count and last activity timestamp. | |
| 152 | - | #[tracing::instrument(skip_all)] | |
| 153 | - | #[allow(clippy::type_complexity)] | |
| 154 | - | pub async fn get_thread_stats( | |
| 155 | - | pool: &PgPool, | |
| 156 | - | thread_id: Uuid, | |
| 157 | - | ) -> Result<Option<(i64, Option<DateTime<Utc>>)>, sqlx::Error> { | |
| 158 | - | // This count surfaces on the MNW server (the internal /stats endpoint), which | |
| 159 | - | // can't render mt's tombstones — so it must be the canonical *active* count, | |
| 160 | - | // excluding mod-removed and soft-deleted posts (matching the m029 trigger and | |
| 161 | - | // the profile tallies). Contrast `count_posts_in_thread`, which deliberately | |
| 162 | - | // counts tombstones because the in-app thread list renders them. | |
| 163 | - | sqlx::query!( | |
| 164 | - | r#"SELECT COUNT(p.id) AS "count!", MAX(p.created_at) AS "max_created: chrono::DateTime<chrono::Utc>" | |
| 165 | - | FROM posts p | |
| 166 | - | WHERE p.thread_id = $1 | |
| 167 | - | AND p.removed_at IS NULL | |
| 168 | - | AND p.deleted_at IS NULL"#, | |
| 169 | - | thread_id, | |
| 170 | - | ) | |
| 171 | - | .fetch_optional(pool) | |
| 172 | - | .await | |
| 173 | - | .map(|opt| opt.map(|r| (r.count, r.max_created))) | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | // ============================================================================ | |
| 177 | - | // Queries | |
| 178 | - | // ============================================================================ | |
| 179 | - | ||
| 180 | - | #[derive(sqlx::FromRow)] | |
| 181 | - | pub struct CommunityListRow { | |
| 182 | - | pub name: String, | |
| 183 | - | pub slug: String, | |
| 184 | - | pub description: Option<String>, | |
| 185 | - | pub category_count: i64, | |
| 186 | - | pub thread_count: i64, | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | /// List non-suspended, non-archived communities with category and thread counts (paginated). | |
| 190 | - | /// | |
| 191 | - | /// Archived communities are hidden from the default listing — see | |
| 192 | - | /// [`list_archived_communities`] for the explicit archived view. | |
| 193 | - | #[tracing::instrument(skip_all)] | |
| 194 | - | pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<CommunityListRow>, sqlx::Error> { | |
| 195 | - | sqlx::query_as!( | |
| 196 | - | CommunityListRow, | |
| 197 | - | // Per-community counts as scalar subqueries rather than a | |
| 198 | - | // categories⋈threads join + GROUP BY COUNT(DISTINCT): the join | |
| 199 | - | // multiplied rows (community × categories × threads) and forced | |
| 200 | - | // aggregating *every* community's threads before LIMIT. As scalar | |
| 201 | - | // subqueries in the target list they're evaluated only for the page's | |
| 202 | - | // output rows (after ORDER BY + LIMIT), bounding the work to one page. | |
| 203 | - | r#"SELECT co.name, co.slug, co.description, | |
| 204 | - | (SELECT COUNT(*) FROM categories c WHERE c.community_id = co.id) AS "category_count!", | |
| 205 | - | (SELECT COUNT(*) FROM threads t | |
| 206 | - | JOIN categories c2 ON c2.id = t.category_id | |
| 207 | - | WHERE c2.community_id = co.id) AS "thread_count!" | |
| 208 | - | FROM communities co | |
| 209 | - | WHERE co.suspended_at IS NULL | |
| 210 | - | AND co.state <> 'archived' | |
| 211 | - | ORDER BY co.name | |
| 212 | - | LIMIT $1 OFFSET $2"#, | |
| 213 | - | limit, | |
| 214 | - | offset, | |
| 215 | - | ) | |
| 216 | - | .fetch_all(pool) | |
| 217 | - | .await | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | /// Count non-suspended, non-archived communities. | |
| 221 | - | #[tracing::instrument(skip_all)] | |
| 222 | - | pub async fn count_communities(pool: &PgPool) -> Result<i64, sqlx::Error> { | |
| 223 | - | sqlx::query_scalar!( | |
| 224 | - | r#"SELECT COUNT(*) AS "count!" FROM communities WHERE suspended_at IS NULL AND state <> 'archived'"#, | |
| 225 | - | ) | |
| 226 | - | .fetch_one(pool) | |
| 227 | - | .await | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | /// List archived communities. Used by the explicit `?filter=archived` view; never | |
| 231 | - | /// merged with the default listing. | |
| 232 | - | #[tracing::instrument(skip_all)] | |
| 233 | - | pub async fn list_archived_communities( | |
| 234 | - | pool: &PgPool, | |
| 235 | - | limit: i64, | |
| 236 | - | offset: i64, | |
| 237 | - | ) -> Result<Vec<CommunityListRow>, sqlx::Error> { | |
| 238 | - | sqlx::query_as!( | |
| 239 | - | CommunityListRow, | |
| 240 | - | // Scalar-subquery counts (see `list_communities`) — bounded to the page. | |
| 241 | - | r#"SELECT co.name, co.slug, co.description, | |
| 242 | - | (SELECT COUNT(*) FROM categories c WHERE c.community_id = co.id) AS "category_count!", | |
| 243 | - | (SELECT COUNT(*) FROM threads t | |
| 244 | - | JOIN categories c2 ON c2.id = t.category_id | |
| 245 | - | WHERE c2.community_id = co.id) AS "thread_count!" | |
| 246 | - | FROM communities co | |
| 247 | - | WHERE co.suspended_at IS NULL | |
| 248 | - | AND co.state = 'archived' | |
| 249 | - | ORDER BY co.name | |
| 250 | - | LIMIT $1 OFFSET $2"#, | |
| 251 | - | limit, | |
| 252 | - | offset, | |
| 253 | - | ) | |
| 254 | - | .fetch_all(pool) | |
| 255 | - | .await | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | #[tracing::instrument(skip_all)] | |
| 259 | - | pub async fn count_archived_communities(pool: &PgPool) -> Result<i64, sqlx::Error> { | |
| 260 | - | sqlx::query_scalar!( | |
| 261 | - | r#"SELECT COUNT(*) AS "count!" FROM communities WHERE suspended_at IS NULL AND state = 'archived'"#, | |
| 262 | - | ) | |
| 263 | - | .fetch_one(pool) | |
| 264 | - | .await | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | #[tracing::instrument(skip_all)] | |
| 268 | - | pub async fn get_community_by_slug( | |
| 269 | - | pool: &PgPool, | |
| 270 | - | slug: &str, | |
| 271 | - | ) -> Result<Option<CommunityRow>, sqlx::Error> { | |
| 272 | - | sqlx::query_as!( | |
| 273 | - | CommunityRow, | |
| 274 | - | r#"SELECT id, name, slug, description, | |
| 275 | - | suspended_at AS "suspended_at: chrono::DateTime<chrono::Utc>", | |
| 276 | - | auto_hide_threshold, | |
| 277 | - | state AS "state: CommunityState" | |
| 278 | - | FROM communities WHERE slug = $1"#, | |
| 279 | - | slug, | |
| 280 | - | ) | |
| 281 | - | .fetch_optional(pool) | |
| 282 | - | .await | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | #[tracing::instrument(skip_all)] | |
| 286 | - | pub async fn get_community_by_id( | |
| 287 | - | pool: &PgPool, | |
| 288 | - | id: Uuid, | |
| 289 | - | ) -> Result<Option<CommunityRow>, sqlx::Error> { | |
| 290 | - | sqlx::query_as!( | |
| 291 | - | CommunityRow, | |
| 292 | - | r#"SELECT id, name, slug, description, | |
| 293 | - | suspended_at AS "suspended_at: chrono::DateTime<chrono::Utc>", | |
| 294 | - | auto_hide_threshold, | |
| 295 | - | state AS "state: CommunityState" | |
| 296 | - | FROM communities WHERE id = $1"#, | |
| 297 | - | id, | |
| 298 | - | ) | |
| 299 | - | .fetch_optional(pool) | |
| 300 | - | .await | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | #[tracing::instrument(skip_all)] | |
| 304 | - | pub async fn list_categories_with_counts( | |
| 305 | - | pool: &PgPool, | |
| 306 | - | community_slug: &str, | |
| 307 | - | ) -> Result<Vec<CategoryWithCount>, sqlx::Error> { | |
| 308 | - | sqlx::query_as!( | |
| 309 | - | CategoryWithCount, | |
| 310 | - | r#"SELECT c.name, c.slug, c.description, | |
| 311 | - | COUNT(t.id) AS "thread_count!" | |
| 312 | - | FROM categories c | |
| 313 | - | JOIN communities co ON co.id = c.community_id | |
| 314 | - | LEFT JOIN threads t ON t.category_id = c.id AND t.deleted_at IS NULL | |
| 315 | - | WHERE co.slug = $1 | |
| 316 | - | GROUP BY c.id, c.name, c.slug, c.description, c.sort_order | |
| 317 | - | ORDER BY c.sort_order"#, | |
| 318 | - | community_slug, | |
| 319 | - | ) | |
| 320 | - | .fetch_all(pool) | |
| 321 | - | .await | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | #[tracing::instrument(skip_all)] | |
| 325 | - | pub async fn get_category_by_slugs( | |
| 326 | - | pool: &PgPool, | |
| 327 | - | community_slug: &str, | |
| 328 | - | category_slug: &str, | |
| 329 | - | ) -> Result<Option<CategoryRow>, sqlx::Error> { | |
| 330 | - | sqlx::query_as!( | |
| 331 | - | CategoryRow, | |
| 332 | - | "SELECT c.name, c.slug | |
| 333 | - | FROM categories c | |
| 334 | - | JOIN communities co ON co.id = c.community_id | |
| 335 | - | WHERE co.slug = $1 AND c.slug = $2", | |
| 336 | - | community_slug, | |
| 337 | - | category_slug, | |
| 338 | - | ) | |
| 339 | - | .fetch_optional(pool) | |
| 340 | - | .await | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | #[tracing::instrument(skip_all)] | |
| 344 | - | pub async fn list_threads_in_category_paginated( | |
| 345 | - | pool: &PgPool, | |
| 346 | - | community_slug: &str, | |
| 347 | - | category_slug: &str, | |
| 348 | - | limit: i64, | |
| 349 | - | offset: i64, | |
| 350 | - | ) -> Result<Vec<ThreadWithMeta>, sqlx::Error> { | |
| 351 | - | sqlx::query_as!( | |
| 352 | - | ThreadWithMeta, | |
| 353 | - | r#"SELECT t.id, t.title, | |
| 354 | - | COALESCE(u.display_name, u.username) AS "author_name!", | |
| 355 | - | u.username AS author_username, | |
| 356 | - | GREATEST(t.post_count - 1, 0)::BIGINT AS "reply_count!", | |
| 357 | - | t.last_activity_at AS "last_activity_at: chrono::DateTime<chrono::Utc>", | |
| 358 | - | t.pinned, t.locked | |
| 359 | - | FROM threads t | |
| 360 | - | JOIN categories c ON c.id = t.category_id | |
| 361 | - | JOIN communities co ON co.id = c.community_id | |
| 362 | - | JOIN users u ON u.mnw_account_id = t.author_id | |
| 363 | - | WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL | |
| 364 | - | ORDER BY t.pinned DESC, t.last_activity_at DESC | |
| 365 | - | LIMIT $3 OFFSET $4"#, | |
| 366 | - | community_slug, | |
| 367 | - | category_slug, | |
| 368 | - | limit, | |
| 369 | - | offset, | |
| 370 | - | ) | |
| 371 | - | .fetch_all(pool) | |
| 372 | - | .await | |
| 373 | - | } | |
| 374 | - | ||
| 375 | - | /// List threads in a category, sorted, optionally filtered to one tag. | |
| 376 | - | /// | |
| 377 | - | /// Pinned threads always sort first. `sort`/`order` are typed enums, so the | |
| 378 | - | /// `ORDER BY` clause is chosen from an exhaustive match of fixed literals (no | |
| 379 | - | /// user string reaches the SQL); when `tag_slug` is `Some`, an extra join | |
| 380 | - | /// restricts the results to threads carrying that tag in the same community. | |
| 381 | - | #[tracing::instrument(skip_all)] | |
| 382 | - | #[allow(clippy::too_many_arguments)] | |
| 383 | - | pub async fn list_threads_in_category_sorted( | |
| 384 | - | pool: &PgPool, | |
| 385 | - | community_slug: &str, | |
| 386 | - | category_slug: &str, | |
| 387 | - | sort: SortColumn, | |
| 388 | - | order: SortOrder, | |
| 389 | - | limit: i64, | |
| 390 | - | offset: i64, | |
| 391 | - | tag_slug: Option<&str>, | |
| 392 | - | ) -> Result<Vec<ThreadWithMeta>, sqlx::Error> { | |
| 393 | - | let order_clause = match (sort, order) { | |
| 394 | - | (SortColumn::Replies, SortOrder::Asc) => { | |
| 395 | - | "ORDER BY t.pinned DESC, reply_count ASC, t.last_activity_at DESC" | |
| 396 | - | } | |
| 397 | - | (SortColumn::Replies, SortOrder::Desc) => { | |
| 398 | - | "ORDER BY t.pinned DESC, reply_count DESC, t.last_activity_at DESC" | |
| 399 | - | } | |
| 400 | - | (SortColumn::Activity, SortOrder::Asc) => { | |
| 401 | - | "ORDER BY t.pinned DESC, t.last_activity_at ASC" | |
| 402 | - | } | |
| 403 | - | (SortColumn::Activity, SortOrder::Desc) => { | |
| 404 | - | "ORDER BY t.pinned DESC, t.last_activity_at DESC" | |
| 405 | - | } | |
| 406 | - | }; | |
| 407 | - | ||
| 408 | - | // When a tag filter is present it binds as $3, pushing limit/offset to $4/$5. | |
| 409 | - | let (tag_join, limit_ph, offset_ph) = if tag_slug.is_some() { | |
| 410 | - | ( | |
| 411 | - | "JOIN thread_tags tt ON tt.thread_id = t.id \ | |
| 412 | - | JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id", | |
| 413 | - | "$4", | |
| 414 | - | "$5", | |
| 415 | - | ) | |
| 416 | - | } else { | |
| 417 | - | ("", "$3", "$4") | |
| 418 | - | }; | |
| 419 | - | ||
| 420 | - | let query = format!( | |
| 421 | - | "SELECT t.id, t.title, | |
| 422 | - | COALESCE(u.display_name, u.username) AS author_name, | |
| 423 | - | u.username AS author_username, | |
| 424 | - | GREATEST(t.post_count - 1, 0)::BIGINT AS reply_count, | |
| 425 | - | t.last_activity_at, | |
| 426 | - | t.pinned, t.locked | |
| 427 | - | FROM threads t | |
| 428 | - | JOIN categories c ON c.id = t.category_id | |
| 429 | - | JOIN communities co ON co.id = c.community_id | |
| 430 | - | JOIN users u ON u.mnw_account_id = t.author_id | |
| 431 | - | {tag_join} | |
| 432 | - | WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL | |
| 433 | - | {order_clause} | |
| 434 | - | LIMIT {limit_ph} OFFSET {offset_ph}" | |
| 435 | - | ); | |
| 436 | - | ||
| 437 | - | // runtime-checked: dynamic SQL (cannot use compile-time macro) | |
| 438 | - | let mut q = sqlx::query_as::<_, ThreadWithMeta>(&query) | |
| 439 | - | .bind(community_slug) | |
| 440 | - | .bind(category_slug); | |
| 441 | - | if let Some(tag) = tag_slug { | |
| 442 | - | q = q.bind(tag); | |
| 443 | - | } | |
| 444 | - | q.bind(limit).bind(offset).fetch_all(pool).await | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | #[tracing::instrument(skip_all)] | |
| 448 | - | pub async fn count_threads_in_category( | |
| 449 | - | pool: &PgPool, | |
| 450 | - | community_slug: &str, | |
| 451 | - | category_slug: &str, | |
| 452 | - | ) -> Result<i64, sqlx::Error> { | |
| 453 | - | sqlx::query_scalar!( | |
| 454 | - | r#"SELECT COUNT(*) AS "count!" | |
| 455 | - | FROM threads t | |
| 456 | - | JOIN categories c ON c.id = t.category_id | |
| 457 | - | JOIN communities co ON co.id = c.community_id | |
| 458 | - | WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL"#, | |
| 459 | - | community_slug, | |
| 460 | - | category_slug, | |
| 461 | - | ) | |
| 462 | - | .fetch_one(pool) | |
| 463 | - | .await | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | #[tracing::instrument(skip_all)] | |
| 467 | - | pub async fn get_thread_with_breadcrumb( | |
| 468 | - | pool: &PgPool, | |
| 469 | - | thread_id: Uuid, | |
| 470 | - | ) -> Result<Option<ThreadWithBreadcrumb>, sqlx::Error> { | |
| 471 | - | sqlx::query_as!( | |
| 472 | - | ThreadWithBreadcrumb, | |
| 473 | - | r#"SELECT t.id, t.title, t.locked, t.pinned, t.author_id, | |
| 474 | - | co.id AS community_id, | |
| 475 | - | co.name AS community_name, co.slug AS community_slug, | |
| 476 | - | c.name AS category_name, c.slug AS category_slug | |
| 477 | - | FROM threads t | |
| 478 | - | JOIN categories c ON c.id = t.category_id | |
| 479 | - | JOIN communities co ON co.id = c.community_id | |
| 480 | - | WHERE t.id = $1 AND t.deleted_at IS NULL"#, | |
| 481 | - | thread_id, | |
| 482 | - | ) | |
| 483 | - | .fetch_optional(pool) | |
| 484 | - | .await | |
| 485 | - | } | |
| 486 | - | ||
| 487 | - | #[tracing::instrument(skip_all)] | |
| 488 | - | pub async fn list_posts_in_thread( | |
| 489 | - | pool: &PgPool, | |
| 490 | - | thread_id: Uuid, | |
| 491 | - | ) -> Result<Vec<PostWithAuthor>, sqlx::Error> { | |
| 492 | - | sqlx::query_as!( | |
| 493 | - | PostWithAuthor, | |
| 494 | - | r#"SELECT p.id, p.author_id, | |
| 495 | - | COALESCE(u.display_name, u.username) AS "author_name!", | |
| 496 | - | u.username AS author_username, | |
| 497 | - | p.body_html, | |
| 498 | - | p.created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 499 | - | p.edited_at AS "edited_at: chrono::DateTime<chrono::Utc>", | |
| 500 | - | p.deleted_at AS "deleted_at: chrono::DateTime<chrono::Utc>", |
Lines truncated
| @@ -0,0 +1,62 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct AdminCommunityRow { | |
| 5 | + | pub id: Uuid, | |
| 6 | + | pub name: String, | |
| 7 | + | pub slug: String, | |
| 8 | + | pub suspended_at: Option<DateTime<Utc>>, | |
| 9 | + | pub suspension_reason: Option<String>, | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | /// List all communities for the admin dashboard (capped at 500). | |
| 13 | + | #[tracing::instrument(skip_all)] | |
| 14 | + | pub async fn list_all_communities( | |
| 15 | + | pool: &PgPool, | |
| 16 | + | limit: i64, | |
| 17 | + | ) -> Result<Vec<AdminCommunityRow>, sqlx::Error> { | |
| 18 | + | sqlx::query_as!( | |
| 19 | + | AdminCommunityRow, | |
| 20 | + | r#"SELECT id, name, slug, | |
| 21 | + | suspended_at AS "suspended_at: chrono::DateTime<chrono::Utc>", | |
| 22 | + | suspension_reason | |
| 23 | + | FROM communities ORDER BY name LIMIT $1"#, | |
| 24 | + | limit, | |
| 25 | + | ) | |
| 26 | + | .fetch_all(pool) | |
| 27 | + | .await | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[derive(sqlx::FromRow)] | |
| 31 | + | pub struct AdminUserRow { | |
| 32 | + | pub id: Uuid, | |
| 33 | + | pub username: String, | |
| 34 | + | pub display_name: Option<String>, | |
| 35 | + | pub suspended_at: Option<DateTime<Utc>>, | |
| 36 | + | pub suspension_reason: Option<String>, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// Search users by username prefix for the admin dashboard. | |
| 40 | + | #[tracing::instrument(skip_all)] | |
| 41 | + | pub async fn search_users( | |
| 42 | + | pool: &PgPool, | |
| 43 | + | query: &str, | |
| 44 | + | ) -> Result<Vec<AdminUserRow>, sqlx::Error> { | |
| 45 | + | let escaped = query | |
| 46 | + | .replace('\\', "\\\\") | |
| 47 | + | .replace('%', "\\%") | |
| 48 | + | .replace('_', "\\_"); | |
| 49 | + | sqlx::query_as!( | |
| 50 | + | AdminUserRow, | |
| 51 | + | r#"SELECT mnw_account_id AS id, username, display_name, | |
| 52 | + | suspended_at AS "suspended_at: chrono::DateTime<chrono::Utc>", | |
| 53 | + | suspension_reason | |
| 54 | + | FROM users | |
| 55 | + | WHERE username ILIKE $1 ESCAPE '\' | |
| 56 | + | ORDER BY username | |
| 57 | + | LIMIT 50"#, | |
| 58 | + | format!("{escaped}%"), | |
| 59 | + | ) | |
| 60 | + | .fetch_all(pool) | |
| 61 | + | .await | |
| 62 | + | } |
| @@ -0,0 +1,145 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct CategoryWithCount { | |
| 5 | + | pub name: String, | |
| 6 | + | pub slug: String, | |
| 7 | + | pub description: Option<String>, | |
| 8 | + | pub thread_count: i64, | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | #[derive(sqlx::FromRow)] | |
| 12 | + | pub struct CategoryRow { | |
| 13 | + | pub name: String, | |
| 14 | + | pub slug: String, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// A category row with its ID for internal API lookups. | |
| 18 | + | #[derive(sqlx::FromRow)] | |
| 19 | + | pub struct CategoryIdRow { | |
| 20 | + | pub id: Uuid, | |
| 21 | + | pub name: String, | |
| 22 | + | pub slug: String, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// Look up a category by community_id and category slug. Returns the category ID. | |
| 26 | + | #[tracing::instrument(skip_all)] | |
| 27 | + | pub async fn get_category_by_community_and_slug( | |
| 28 | + | pool: &PgPool, | |
| 29 | + | community_id: Uuid, | |
| 30 | + | category_slug: &str, | |
| 31 | + | ) -> Result<Option<CategoryIdRow>, sqlx::Error> { | |
| 32 | + | sqlx::query_as!( | |
| 33 | + | CategoryIdRow, | |
| 34 | + | "SELECT id, name, slug FROM categories WHERE community_id = $1 AND slug = $2", | |
| 35 | + | community_id, | |
| 36 | + | category_slug, | |
| 37 | + | ) | |
| 38 | + | .fetch_optional(pool) | |
| 39 | + | .await | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | #[tracing::instrument(skip_all)] | |
| 43 | + | pub async fn list_categories_with_counts( | |
| 44 | + | pool: &PgPool, | |
| 45 | + | community_slug: &str, | |
| 46 | + | ) -> Result<Vec<CategoryWithCount>, sqlx::Error> { | |
| 47 | + | sqlx::query_as!( | |
| 48 | + | CategoryWithCount, | |
| 49 | + | r#"SELECT c.name, c.slug, c.description, | |
| 50 | + | COUNT(t.id) AS "thread_count!" | |
| 51 | + | FROM categories c | |
| 52 | + | JOIN communities co ON co.id = c.community_id | |
| 53 | + | LEFT JOIN threads t ON t.category_id = c.id AND t.deleted_at IS NULL | |
| 54 | + | WHERE co.slug = $1 | |
| 55 | + | GROUP BY c.id, c.name, c.slug, c.description, c.sort_order | |
| 56 | + | ORDER BY c.sort_order"#, | |
| 57 | + | community_slug, | |
| 58 | + | ) | |
| 59 | + | .fetch_all(pool) | |
| 60 | + | .await | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[tracing::instrument(skip_all)] | |
| 64 | + | pub async fn get_category_by_slugs( | |
| 65 | + | pool: &PgPool, | |
| 66 | + | community_slug: &str, | |
| 67 | + | category_slug: &str, | |
| 68 | + | ) -> Result<Option<CategoryRow>, sqlx::Error> { | |
| 69 | + | sqlx::query_as!( | |
| 70 | + | CategoryRow, | |
| 71 | + | "SELECT c.name, c.slug | |
| 72 | + | FROM categories c | |
| 73 | + | JOIN communities co ON co.id = c.community_id | |
| 74 | + | WHERE co.slug = $1 AND c.slug = $2", | |
| 75 | + | community_slug, | |
| 76 | + | category_slug, | |
| 77 | + | ) | |
| 78 | + | .fetch_optional(pool) | |
| 79 | + | .await | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | #[derive(sqlx::FromRow)] | |
| 83 | + | pub struct CategoryForSettings { | |
| 84 | + | pub id: Uuid, | |
| 85 | + | pub name: String, | |
| 86 | + | pub slug: String, | |
| 87 | + | pub description: Option<String>, | |
| 88 | + | pub sort_order: i32, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | #[tracing::instrument(skip_all)] | |
| 92 | + | pub async fn list_categories_for_settings( | |
| 93 | + | pool: &PgPool, | |
| 94 | + | community_id: Uuid, | |
| 95 | + | ) -> Result<Vec<CategoryForSettings>, sqlx::Error> { | |
| 96 | + | sqlx::query_as!( | |
| 97 | + | CategoryForSettings, | |
| 98 | + | "SELECT id, name, slug, description, sort_order | |
| 99 | + | FROM categories | |
| 100 | + | WHERE community_id = $1 | |
| 101 | + | ORDER BY sort_order", | |
| 102 | + | community_id, | |
| 103 | + | ) | |
| 104 | + | .fetch_all(pool) | |
| 105 | + | .await | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// Load a category by id, scoped to the community it must belong to. | |
| 109 | + | /// | |
| 110 | + | /// The `community_id` filter is the C1 invariant for this resource: a category id | |
| 111 | + | /// from a `/p/{slug}/…` URL must belong to the slug's community. Loading by bare | |
| 112 | + | /// id would let an owner of community A render/act on community B's category | |
| 113 | + | /// (see `routes/scope.rs`). Callers pass the community resolved from the slug. | |
| 114 | + | #[tracing::instrument(skip_all)] | |
| 115 | + | pub async fn get_category_in_community( | |
| 116 | + | pool: &PgPool, | |
| 117 | + | category_id: Uuid, | |
| 118 | + | community_id: Uuid, | |
| 119 | + | ) -> Result<Option<CategoryForSettings>, sqlx::Error> { | |
| 120 | + | sqlx::query_as!( | |
| 121 | + | CategoryForSettings, | |
| 122 | + | "SELECT id, name, slug, description, sort_order | |
| 123 | + | FROM categories | |
| 124 | + | WHERE id = $1 AND community_id = $2", | |
| 125 | + | category_id, | |
| 126 | + | community_id, | |
| 127 | + | ) | |
| 128 | + | .fetch_optional(pool) | |
| 129 | + | .await | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Get the maximum sort_order among categories in a community. Returns -1 if no categories exist. | |
| 133 | + | #[tracing::instrument(skip_all)] | |
| 134 | + | pub async fn get_max_category_order( | |
| 135 | + | pool: &PgPool, | |
| 136 | + | community_id: Uuid, | |
| 137 | + | ) -> Result<i32, sqlx::Error> { | |
| 138 | + | let max = sqlx::query_scalar!( | |
| 139 | + | "SELECT MAX(sort_order) FROM categories WHERE community_id = $1", | |
| 140 | + | community_id, | |
| 141 | + | ) | |
| 142 | + | .fetch_one(pool) | |
| 143 | + | .await?; | |
| 144 | + | Ok(max.unwrap_or(-1)) | |
| 145 | + | } |
| @@ -0,0 +1,166 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct CommunityRow { | |
| 5 | + | pub id: Uuid, | |
| 6 | + | pub name: String, | |
| 7 | + | pub slug: String, | |
| 8 | + | pub description: Option<String>, | |
| 9 | + | pub suspended_at: Option<DateTime<Utc>>, | |
| 10 | + | pub auto_hide_threshold: Option<i32>, | |
| 11 | + | pub state: CommunityState, | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | #[derive(sqlx::FromRow)] | |
| 15 | + | pub struct CommunityListRow { | |
| 16 | + | pub name: String, | |
| 17 | + | pub slug: String, | |
| 18 | + | pub description: Option<String>, | |
| 19 | + | pub category_count: i64, | |
| 20 | + | pub thread_count: i64, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// List non-suspended, non-archived communities with category and thread counts (paginated). | |
| 24 | + | /// | |
| 25 | + | /// Archived communities are hidden from the default listing — see | |
| 26 | + | /// [`list_archived_communities`] for the explicit archived view. | |
| 27 | + | #[tracing::instrument(skip_all)] | |
| 28 | + | pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<CommunityListRow>, sqlx::Error> { | |
| 29 | + | sqlx::query_as!( | |
| 30 | + | CommunityListRow, | |
| 31 | + | // Per-community counts as scalar subqueries rather than a | |
| 32 | + | // categories⋈threads join + GROUP BY COUNT(DISTINCT): the join | |
| 33 | + | // multiplied rows (community × categories × threads) and forced | |
| 34 | + | // aggregating *every* community's threads before LIMIT. As scalar | |
| 35 | + | // subqueries in the target list they're evaluated only for the page's | |
| 36 | + | // output rows (after ORDER BY + LIMIT), bounding the work to one page. | |
| 37 | + | r#"SELECT co.name, co.slug, co.description, | |
| 38 | + | (SELECT COUNT(*) FROM categories c WHERE c.community_id = co.id) AS "category_count!", | |
| 39 | + | (SELECT COUNT(*) FROM threads t | |
| 40 | + | JOIN categories c2 ON c2.id = t.category_id | |
| 41 | + | WHERE c2.community_id = co.id) AS "thread_count!" | |
| 42 | + | FROM communities co | |
| 43 | + | WHERE co.suspended_at IS NULL | |
| 44 | + | AND co.state <> 'archived' | |
| 45 | + | ORDER BY co.name | |
| 46 | + | LIMIT $1 OFFSET $2"#, | |
| 47 | + | limit, | |
| 48 | + | offset, | |
| 49 | + | ) | |
| 50 | + | .fetch_all(pool) | |
| 51 | + | .await | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Count non-suspended, non-archived communities. | |
| 55 | + | #[tracing::instrument(skip_all)] | |
| 56 | + | pub async fn count_communities(pool: &PgPool) -> Result<i64, sqlx::Error> { | |
| 57 | + | sqlx::query_scalar!( | |
| 58 | + | r#"SELECT COUNT(*) AS "count!" FROM communities WHERE suspended_at IS NULL AND state <> 'archived'"#, | |
| 59 | + | ) | |
| 60 | + | .fetch_one(pool) | |
| 61 | + | .await | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// List archived communities. Used by the explicit `?filter=archived` view; never | |
| 65 | + | /// merged with the default listing. | |
| 66 | + | #[tracing::instrument(skip_all)] | |
| 67 | + | pub async fn list_archived_communities( | |
| 68 | + | pool: &PgPool, | |
| 69 | + | limit: i64, | |
| 70 | + | offset: i64, | |
| 71 | + | ) -> Result<Vec<CommunityListRow>, sqlx::Error> { | |
| 72 | + | sqlx::query_as!( | |
| 73 | + | CommunityListRow, | |
| 74 | + | // Scalar-subquery counts (see `list_communities`) — bounded to the page. | |
| 75 | + | r#"SELECT co.name, co.slug, co.description, | |
| 76 | + | (SELECT COUNT(*) FROM categories c WHERE c.community_id = co.id) AS "category_count!", | |
| 77 | + | (SELECT COUNT(*) FROM threads t | |
| 78 | + | JOIN categories c2 ON c2.id = t.category_id | |
| 79 | + | WHERE c2.community_id = co.id) AS "thread_count!" | |
| 80 | + | FROM communities co | |
| 81 | + | WHERE co.suspended_at IS NULL | |
| 82 | + | AND co.state = 'archived' | |
| 83 | + | ORDER BY co.name | |
| 84 | + | LIMIT $1 OFFSET $2"#, | |
| 85 | + | limit, | |
| 86 | + | offset, | |
| 87 | + | ) | |
| 88 | + | .fetch_all(pool) | |
| 89 | + | .await | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[tracing::instrument(skip_all)] | |
| 93 | + | pub async fn count_archived_communities(pool: &PgPool) -> Result<i64, sqlx::Error> { | |
| 94 | + | sqlx::query_scalar!( | |
| 95 | + | r#"SELECT COUNT(*) AS "count!" FROM communities WHERE suspended_at IS NULL AND state = 'archived'"#, | |
| 96 | + | ) | |
| 97 | + | .fetch_one(pool) | |
| 98 | + | .await | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | #[tracing::instrument(skip_all)] | |
| 102 | + | pub async fn get_community_by_slug( | |
| 103 | + | pool: &PgPool, | |
| 104 | + | slug: &str, | |
| 105 | + | ) -> Result<Option<CommunityRow>, sqlx::Error> { | |
| 106 | + | sqlx::query_as!( | |
| 107 | + | CommunityRow, | |
| 108 | + | r#"SELECT id, name, slug, description, | |
| 109 | + | suspended_at AS "suspended_at: chrono::DateTime<chrono::Utc>", | |
| 110 | + | auto_hide_threshold, | |
| 111 | + | state AS "state: CommunityState" | |
| 112 | + | FROM communities WHERE slug = $1"#, | |
| 113 | + | slug, | |
| 114 | + | ) | |
| 115 | + | .fetch_optional(pool) | |
| 116 | + | .await | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[tracing::instrument(skip_all)] | |
| 120 | + | pub async fn get_community_by_id( | |
| 121 | + | pool: &PgPool, | |
| 122 | + | id: Uuid, | |
| 123 | + | ) -> Result<Option<CommunityRow>, sqlx::Error> { | |
| 124 | + | sqlx::query_as!( | |
| 125 | + | CommunityRow, | |
| 126 | + | r#"SELECT id, name, slug, description, | |
| 127 | + | suspended_at AS "suspended_at: chrono::DateTime<chrono::Utc>", | |
| 128 | + | auto_hide_threshold, | |
| 129 | + | state AS "state: CommunityState" | |
| 130 | + | FROM communities WHERE id = $1"#, | |
| 131 | + | id, | |
| 132 | + | ) | |
| 133 | + | .fetch_optional(pool) | |
| 134 | + | .await | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// Count all threads in a community (across every category, including | |
| 138 | + | /// soft-deleted — matches the admin dashboard's historical total). | |
| 139 | + | #[tracing::instrument(skip_all)] | |
| 140 | + | pub async fn count_threads_in_community( | |
| 141 | + | pool: &PgPool, | |
| 142 | + | community_id: Uuid, | |
| 143 | + | ) -> Result<i64, sqlx::Error> { | |
| 144 | + | sqlx::query_scalar!( | |
| 145 | + | r#"SELECT COUNT(*) AS "count!" FROM threads t | |
| 146 | + | JOIN categories c ON c.id = t.category_id | |
| 147 | + | WHERE c.community_id = $1"#, | |
| 148 | + | community_id, | |
| 149 | + | ) | |
| 150 | + | .fetch_one(pool) | |
| 151 | + | .await | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// The stored suspension reason for a community, if any. | |
| 155 | + | #[tracing::instrument(skip_all)] | |
| 156 | + | pub async fn get_community_suspension_reason( | |
| 157 | + | pool: &PgPool, | |
| 158 | + | community_id: Uuid, | |
| 159 | + | ) -> Result<Option<String>, sqlx::Error> { | |
| 160 | + | sqlx::query_scalar!( | |
| 161 | + | "SELECT suspension_reason FROM communities WHERE id = $1", | |
| 162 | + | community_id, | |
| 163 | + | ) | |
| 164 | + | .fetch_one(pool) | |
| 165 | + | .await | |
| 166 | + | } |
| @@ -0,0 +1,76 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct EndorsementRow { | |
| 5 | + | pub post_id: Uuid, | |
| 6 | + | pub endorser_id: Uuid, | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | /// One post's total endorsement count (from an aggregated `GROUP BY`). | |
| 10 | + | #[derive(sqlx::FromRow)] | |
| 11 | + | pub struct EndorsementCount { | |
| 12 | + | pub post_id: Uuid, | |
| 13 | + | pub count: i64, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// Batch-fetch endorsements for a set of post IDs. | |
| 17 | + | /// | |
| 18 | + | /// Loads one row per endorsement — fine when the caller needs the endorser ids, | |
| 19 | + | /// but the thread view must NOT use this: a hot post with thousands of | |
| 20 | + | /// endorsements would stream every row on every (unauthenticated, unthrottled) | |
| 21 | + | /// page load. That path uses [`count_endorsements_for_posts`] + | |
| 22 | + | /// [`list_user_endorsed_posts`] instead. | |
| 23 | + | #[tracing::instrument(skip_all)] | |
| 24 | + | pub async fn list_endorsements_for_posts( | |
| 25 | + | pool: &PgPool, | |
| 26 | + | post_ids: &[Uuid], | |
| 27 | + | ) -> Result<Vec<EndorsementRow>, sqlx::Error> { | |
| 28 | + | sqlx::query_as!( | |
| 29 | + | EndorsementRow, | |
| 30 | + | "SELECT post_id, endorser_id FROM post_endorsements WHERE post_id = ANY($1)", | |
| 31 | + | post_ids, | |
| 32 | + | ) | |
| 33 | + | .fetch_all(pool) | |
| 34 | + | .await | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Per-post endorsement counts, aggregated in Postgres. | |
| 38 | + | /// | |
| 39 | + | /// Replaces streaming every endorsement row into the app just to `len()` them: | |
| 40 | + | /// a `GROUP BY` returns at most one row per visible post regardless of how many | |
| 41 | + | /// endorsements a post has (fuzz-2026-07-06 endorsement fan-out). Posts with | |
| 42 | + | /// zero endorsements are absent — the caller defaults them to 0. | |
| 43 | + | #[tracing::instrument(skip_all)] | |
| 44 | + | pub async fn count_endorsements_for_posts( | |
| 45 | + | pool: &PgPool, | |
| 46 | + | post_ids: &[Uuid], | |
| 47 | + | ) -> Result<Vec<EndorsementCount>, sqlx::Error> { | |
| 48 | + | sqlx::query_as!( | |
| 49 | + | EndorsementCount, | |
| 50 | + | r#"SELECT post_id, COUNT(*) AS "count!" | |
| 51 | + | FROM post_endorsements | |
| 52 | + | WHERE post_id = ANY($1) | |
| 53 | + | GROUP BY post_id"#, | |
| 54 | + | post_ids, | |
| 55 | + | ) | |
| 56 | + | .fetch_all(pool) | |
| 57 | + | .await | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// The subset of `post_ids` the given user has endorsed. Bounded by the page's | |
| 61 | + | /// post count (not the endorsement total), so the thread view can mark the | |
| 62 | + | /// viewer's own endorsements without loading every endorser id. | |
| 63 | + | #[tracing::instrument(skip_all)] | |
| 64 | + | pub async fn list_user_endorsed_posts( | |
| 65 | + | pool: &PgPool, | |
| 66 | + | post_ids: &[Uuid], | |
| 67 | + | user_id: Uuid, | |
| 68 | + | ) -> Result<Vec<Uuid>, sqlx::Error> { | |
| 69 | + | sqlx::query_scalar!( | |
| 70 | + | "SELECT post_id FROM post_endorsements WHERE post_id = ANY($1) AND endorser_id = $2", | |
| 71 | + | post_ids, | |
| 72 | + | user_id, | |
| 73 | + | ) | |
| 74 | + | .fetch_all(pool) | |
| 75 | + | .await | |
| 76 | + | } |
| @@ -0,0 +1,49 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct FootnoteWithAuthor { | |
| 5 | + | pub id: Uuid, | |
| 6 | + | pub post_id: Uuid, | |
| 7 | + | pub author_id: Uuid, | |
| 8 | + | pub author_name: String, | |
| 9 | + | pub author_username: String, | |
| 10 | + | pub body_html: String, | |
| 11 | + | pub created_at: DateTime<Utc>, | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | /// Batch-fetch footnotes for a set of post IDs, joined with author info. | |
| 15 | + | #[tracing::instrument(skip_all)] | |
| 16 | + | pub async fn list_footnotes_for_posts( | |
| 17 | + | pool: &PgPool, | |
| 18 | + | post_ids: &[Uuid], | |
| 19 | + | ) -> Result<Vec<FootnoteWithAuthor>, sqlx::Error> { | |
| 20 | + | sqlx::query_as!( | |
| 21 | + | FootnoteWithAuthor, | |
| 22 | + | r#"SELECT f.id, f.post_id, f.author_id, | |
| 23 | + | COALESCE(u.display_name, u.username) AS "author_name!", | |
| 24 | + | u.username AS author_username, | |
| 25 | + | f.body_html, | |
| 26 | + | f.created_at AS "created_at: chrono::DateTime<chrono::Utc>" | |
| 27 | + | FROM post_footnotes f | |
| 28 | + | JOIN users u ON u.mnw_account_id = f.author_id | |
| 29 | + | WHERE f.post_id = ANY($1) | |
| 30 | + | ORDER BY f.created_at"#, | |
| 31 | + | post_ids, | |
| 32 | + | ) | |
| 33 | + | .fetch_all(pool) | |
| 34 | + | .await | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Count footnotes on a specific post. | |
| 38 | + | #[tracing::instrument(skip_all)] | |
| 39 | + | pub async fn count_footnotes_for_post( | |
| 40 | + | pool: &PgPool, | |
| 41 | + | post_id: Uuid, | |
| 42 | + | ) -> Result<i64, sqlx::Error> { | |
| 43 | + | sqlx::query_scalar!( | |
| 44 | + | r#"SELECT COUNT(*) AS "count!" FROM post_footnotes WHERE post_id = $1"#, | |
| 45 | + | post_id, | |
| 46 | + | ) | |
| 47 | + | .fetch_one(pool) | |
| 48 | + | .await | |
| 49 | + | } |
| @@ -0,0 +1,73 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct ImageRow { | |
| 5 | + | pub id: Uuid, | |
| 6 | + | pub community_id: Uuid, | |
| 7 | + | pub s3_key: String, | |
| 8 | + | pub content_type: String, | |
| 9 | + | pub removed_at: Option<DateTime<Utc>>, | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | /// Fetch an image by ID (for serving). Carries `community_id` so the serve | |
| 13 | + | /// handler can enforce the owning community's access policy. | |
| 14 | + | #[tracing::instrument(skip_all)] | |
| 15 | + | pub async fn get_image( | |
| 16 | + | pool: &PgPool, | |
| 17 | + | image_id: Uuid, | |
| 18 | + | ) -> Result<Option<ImageRow>, sqlx::Error> { | |
| 19 | + | sqlx::query_as!( | |
| 20 | + | ImageRow, | |
| 21 | + | r#"SELECT id, community_id, s3_key, content_type, | |
| 22 | + | removed_at AS "removed_at: chrono::DateTime<chrono::Utc>" | |
| 23 | + | FROM images WHERE id = $1"#, | |
| 24 | + | image_id, | |
| 25 | + | ) | |
| 26 | + | .fetch_optional(pool) | |
| 27 | + | .await | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// A removed image whose backing S3 object still needs to be purged. | |
| 31 | + | #[derive(sqlx::FromRow)] | |
| 32 | + | pub struct PendingPurgeImage { | |
| 33 | + | pub id: Uuid, | |
| 34 | + | pub s3_key: String, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// List removed images whose S3 object has not been purged yet, oldest first. | |
| 38 | + | /// Drives the background reconcile sweep; `limit` bounds one batch (S3's | |
| 39 | + | /// batch-delete cap is 1000). | |
| 40 | + | #[tracing::instrument(skip_all)] | |
| 41 | + | pub async fn list_images_pending_s3_purge( | |
| 42 | + | pool: &PgPool, | |
| 43 | + | limit: i64, | |
| 44 | + | ) -> Result<Vec<PendingPurgeImage>, sqlx::Error> { | |
| 45 | + | sqlx::query_as!( | |
| 46 | + | PendingPurgeImage, | |
| 47 | + | "SELECT id, s3_key FROM images | |
| 48 | + | WHERE removed_at IS NOT NULL AND s3_purged_at IS NULL | |
| 49 | + | ORDER BY removed_at ASC | |
| 50 | + | LIMIT $1", | |
| 51 | + | limit, | |
| 52 | + | ) | |
| 53 | + | .fetch_all(pool) | |
| 54 | + | .await | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Count images uploaded by a user in the last N seconds (rate limiting). | |
| 58 | + | #[tracing::instrument(skip_all)] | |
| 59 | + | pub async fn count_recent_uploads_by_user( | |
| 60 | + | pool: &PgPool, | |
| 61 | + | user_id: Uuid, | |
| 62 | + | window_secs: i64, | |
| 63 | + | ) -> Result<i64, sqlx::Error> { | |
| 64 | + | sqlx::query_scalar!( | |
| 65 | + | r#"SELECT COUNT(*) AS "count!" FROM images | |
| 66 | + | WHERE uploader_id = $1 | |
| 67 | + | AND created_at > now() - make_interval(secs => $2::float8)"#, | |
| 68 | + | user_id, | |
| 69 | + | window_secs as f64, | |
| 70 | + | ) | |
| 71 | + | .fetch_one(pool) | |
| 72 | + | .await | |
| 73 | + | } |
| @@ -0,0 +1,27 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct LinkPreviewRow { | |
| 5 | + | pub post_id: Uuid, | |
| 6 | + | pub url: String, | |
| 7 | + | pub title: Option<String>, | |
| 8 | + | pub description: Option<String>, | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | /// Batch-fetch link previews for a set of post IDs. | |
| 12 | + | #[tracing::instrument(skip_all)] | |
| 13 | + | pub async fn list_link_previews_for_posts( | |
| 14 | + | pool: &PgPool, | |
| 15 | + | post_ids: &[Uuid], | |
| 16 | + | ) -> Result<Vec<LinkPreviewRow>, sqlx::Error> { | |
| 17 | + | sqlx::query_as!( | |
| 18 | + | LinkPreviewRow, | |
| 19 | + | "SELECT post_id, url, title, description | |
| 20 | + | FROM link_previews | |
| 21 | + | WHERE post_id = ANY($1) | |
| 22 | + | ORDER BY fetched_at", | |
| 23 | + | post_ids, | |
| 24 | + | ) | |
| 25 | + | .fetch_all(pool) | |
| 26 | + | .await | |
| 27 | + | } |
| @@ -0,0 +1,70 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[tracing::instrument(skip_all)] | |
| 4 | + | pub async fn get_user_role( | |
| 5 | + | pool: &PgPool, | |
| 6 | + | user_id: Uuid, | |
| 7 | + | community_id: Uuid, | |
| 8 | + | ) -> Result<Option<CommunityRole>, sqlx::Error> { | |
| 9 | + | sqlx::query_scalar!( | |
| 10 | + | r#"SELECT role AS "role: CommunityRole" FROM memberships WHERE user_id = $1 AND community_id = $2"#, | |
| 11 | + | user_id, | |
| 12 | + | community_id, | |
| 13 | + | ) | |
| 14 | + | .fetch_optional(pool) | |
| 15 | + | .await | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | #[derive(sqlx::FromRow)] | |
| 19 | + | pub struct MemberRow { | |
| 20 | + | pub username: String, | |
| 21 | + | pub display_name: Option<String>, | |
| 22 | + | pub role: CommunityRole, | |
| 23 | + | pub joined_at: DateTime<Utc>, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | #[tracing::instrument(skip_all)] | |
| 27 | + | pub async fn list_community_members( | |
| 28 | + | pool: &PgPool, | |
| 29 | + | community_id: Uuid, | |
| 30 | + | limit: i64, | |
| 31 | + | offset: i64, | |
| 32 | + | ) -> Result<Vec<MemberRow>, sqlx::Error> { | |
| 33 | + | sqlx::query_as!( | |
| 34 | + | MemberRow, | |
| 35 | + | r#"SELECT u.username, | |
| 36 | + | u.display_name, | |
| 37 | + | m.role AS "role: CommunityRole", | |
| 38 | + | m.joined_at AS "joined_at: chrono::DateTime<chrono::Utc>" | |
| 39 | + | FROM memberships m | |
| 40 | + | JOIN users u ON u.mnw_account_id = m.user_id | |
| 41 | + | WHERE m.community_id = $1 | |
| 42 | + | ORDER BY | |
| 43 | + | CASE m.role | |
| 44 | + | WHEN 'owner' THEN 0 | |
| 45 | + | WHEN 'moderator' THEN 1 | |
| 46 | + | WHEN 'member' THEN 2 | |
| 47 | + | END, | |
| 48 | + | m.joined_at | |
| 49 | + | LIMIT $2 OFFSET $3"#, | |
| 50 | + | community_id, | |
| 51 | + | limit, | |
| 52 | + | offset, | |
| 53 | + | ) | |
| 54 | + | .fetch_all(pool) | |
| 55 | + | .await | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Count members in a community. | |
| 59 | + | #[tracing::instrument(skip_all)] | |
| 60 | + | pub async fn count_community_members( | |
| 61 | + | pool: &PgPool, | |
| 62 | + | community_id: Uuid, | |
| 63 | + | ) -> Result<i64, sqlx::Error> { | |
| 64 | + | sqlx::query_scalar!( | |
| 65 | + | r#"SELECT COUNT(*) AS "count!" FROM memberships WHERE community_id = $1"#, | |
| 66 | + | community_id, | |
| 67 | + | ) | |
| 68 | + | .fetch_one(pool) | |
| 69 | + | .await | |
| 70 | + | } |
| @@ -0,0 +1,47 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | /// Batch-check which threads have at least one mention of the given user. | |
| 4 | + | #[tracing::instrument(skip_all)] | |
| 5 | + | pub async fn get_threads_with_mentions_for_user( | |
| 6 | + | pool: &PgPool, | |
| 7 | + | user_id: Uuid, | |
| 8 | + | thread_ids: &[Uuid], | |
| 9 | + | ) -> Result<Vec<Uuid>, sqlx::Error> { | |
| 10 | + | if thread_ids.is_empty() { | |
| 11 | + | return Ok(Vec::new()); | |
| 12 | + | } | |
| 13 | + | sqlx::query_scalar!( | |
| 14 | + | "SELECT DISTINCT p.thread_id | |
| 15 | + | FROM post_mentions pm | |
| 16 | + | JOIN posts p ON p.id = pm.post_id | |
| 17 | + | WHERE pm.mentioned_user_id = $1 | |
| 18 | + | AND p.thread_id = ANY($2)", | |
| 19 | + | user_id, | |
| 20 | + | thread_ids, | |
| 21 | + | ) | |
| 22 | + | .fetch_all(pool) | |
| 23 | + | .await | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Resolve usernames to user IDs, filtered to community members. | |
| 27 | + | #[tracing::instrument(skip_all)] | |
| 28 | + | pub async fn resolve_usernames_in_community( | |
| 29 | + | pool: &PgPool, | |
| 30 | + | community_id: Uuid, | |
| 31 | + | usernames: &[String], | |
| 32 | + | ) -> Result<std::collections::HashMap<String, Uuid>, sqlx::Error> { | |
| 33 | + | if usernames.is_empty() { | |
| 34 | + | return Ok(std::collections::HashMap::new()); | |
| 35 | + | } | |
| 36 | + | let rows = sqlx::query!( | |
| 37 | + | "SELECT u.username, u.mnw_account_id | |
| 38 | + | FROM users u | |
| 39 | + | WHERE u.username = ANY($1) | |
| 40 | + | AND u.mnw_account_id IN (SELECT user_id FROM memberships WHERE community_id = $2)", | |
| 41 | + | usernames, | |
| 42 | + | community_id, | |
| 43 | + | ) | |
| 44 | + | .fetch_all(pool) | |
| 45 | + | .await?; | |
| 46 | + | Ok(rows.into_iter().map(|r| (r.username, r.mnw_account_id)).collect()) | |
| 47 | + | } |
| @@ -0,0 +1,45 @@ | |||
| 1 | + | //! Database read queries — projection structs and SQL. | |
| 2 | + | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 4 | + | use mt_core::types::{BanType, CommunityRole, CommunityState, ModAction, SortColumn, SortOrder}; | |
| 5 | + | use sqlx::PgPool; | |
| 6 | + | use uuid::Uuid; | |
| 7 | + | ||
| 8 | + | mod community; | |
| 9 | + | mod category; | |
| 10 | + | mod thread; | |
| 11 | + | mod post; | |
| 12 | + | mod footnote; | |
| 13 | + | mod member; | |
| 14 | + | mod moderation; | |
| 15 | + | mod user; | |
| 16 | + | mod admin; | |
| 17 | + | mod link_preview; | |
| 18 | + | mod mention; | |
| 19 | + | mod endorsement; | |
| 20 | + | mod tracked_thread; | |
| 21 | + | mod tag; | |
| 22 | + | mod search; | |
| 23 | + | mod image; | |
| 24 | + | ||
| 25 | + | pub use community::*; | |
| 26 | + | pub use category::*; | |
| 27 | + | pub use thread::*; | |
| 28 | + | pub use post::*; | |
| 29 | + | pub use footnote::*; | |
| 30 | + | pub use member::*; | |
| 31 | + | pub use moderation::*; | |
| 32 | + | pub use user::*; | |
| 33 | + | pub use admin::*; | |
| 34 | + | pub use link_preview::*; | |
| 35 | + | pub use mention::*; | |
| 36 | + | pub use endorsement::*; | |
| 37 | + | pub use tracked_thread::*; | |
| 38 | + | pub use tag::*; | |
| 39 | + | pub use search::*; | |
| 40 | + | pub use image::*; | |
| 41 | + | ||
| 42 | + | // Explicit re-exports so clippy.toml disallowed-methods paths resolve | |
| 43 | + | // (path-based lints do not follow glob re-exports). | |
| 44 | + | pub use post::get_post_for_edit; | |
| 45 | + | pub use thread::get_thread_with_breadcrumb; |
| @@ -0,0 +1,263 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | /// Check if a user is platform-suspended (by admin). | |
| 4 | + | #[tracing::instrument(skip_all)] | |
| 5 | + | pub async fn is_user_suspended( | |
| 6 | + | pool: &PgPool, | |
| 7 | + | user_id: Uuid, | |
| 8 | + | ) -> Result<bool, sqlx::Error> { | |
| 9 | + | sqlx::query_scalar!( | |
| 10 | + | r#"SELECT EXISTS(SELECT 1 FROM users WHERE mnw_account_id = $1 AND suspended_at IS NOT NULL) AS "exists!""#, | |
| 11 | + | user_id, | |
| 12 | + | ) | |
| 13 | + | .fetch_one(pool) | |
| 14 | + | .await | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// Check if user has an active ban in a community. | |
| 18 | + | #[tracing::instrument(skip_all)] | |
| 19 | + | pub async fn is_user_banned( | |
| 20 | + | pool: &PgPool, | |
| 21 | + | community_id: Uuid, | |
| 22 | + | user_id: Uuid, | |
| 23 | + | ) -> Result<bool, sqlx::Error> { | |
| 24 | + | sqlx::query_scalar!( | |
| 25 | + | r#"SELECT EXISTS(SELECT 1 FROM community_bans | |
| 26 | + | WHERE community_id = $1 AND user_id = $2 AND ban_type = 'ban' | |
| 27 | + | AND (expires_at IS NULL OR expires_at > now())) AS "exists!""#, | |
| 28 | + | community_id, | |
| 29 | + | user_id, | |
| 30 | + | ) | |
| 31 | + | .fetch_one(pool) | |
| 32 | + | .await | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Check if user has an active mute in a community. | |
| 36 | + | #[tracing::instrument(skip_all)] | |
| 37 | + | pub async fn is_user_muted( | |
| 38 | + | pool: &PgPool, | |
| 39 | + | community_id: Uuid, | |
| 40 | + | user_id: Uuid, | |
| 41 | + | ) -> Result<bool, sqlx::Error> { | |
| 42 | + | sqlx::query_scalar!( | |
| 43 | + | r#"SELECT EXISTS(SELECT 1 FROM community_bans | |
| 44 | + | WHERE community_id = $1 AND user_id = $2 AND ban_type = 'mute' | |
| 45 | + | AND (expires_at IS NULL OR expires_at > now())) AS "exists!""#, | |
| 46 | + | community_id, | |
| 47 | + | user_id, | |
| 48 | + | ) | |
| 49 | + | .fetch_one(pool) | |
| 50 | + | .await | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | #[derive(sqlx::FromRow)] | |
| 54 | + | pub struct CommunityBanRow { | |
| 55 | + | pub id: Uuid, | |
| 56 | + | pub user_id: Uuid, | |
| 57 | + | pub username: String, | |
| 58 | + | pub display_name: Option<String>, | |
| 59 | + | pub ban_type: BanType, | |
| 60 | + | pub reason: Option<String>, | |
| 61 | + | pub expires_at: Option<DateTime<Utc>>, | |
| 62 | + | pub created_at: DateTime<Utc>, | |
| 63 | + | pub banned_by_username: String, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// List active bans and mutes in a community, newest first, bounded by `limit`. | |
| 67 | + | /// The moderation page caps the read so a community with a large ban backlog | |
| 68 | + | /// can't make a single page load materialize an unbounded result set. | |
| 69 | + | #[tracing::instrument(skip_all)] | |
| 70 | + | pub async fn list_community_bans( | |
| 71 | + | pool: &PgPool, | |
| 72 | + | community_id: Uuid, | |
| 73 | + | limit: i64, | |
| 74 | + | ) -> Result<Vec<CommunityBanRow>, sqlx::Error> { | |
| 75 | + | sqlx::query_as!( | |
| 76 | + | CommunityBanRow, | |
| 77 | + | r#"SELECT cb.id, cb.user_id, | |
| 78 | + | u.username, u.display_name, | |
| 79 | + | cb.ban_type AS "ban_type: BanType", cb.reason, | |
| 80 | + | cb.expires_at AS "expires_at: chrono::DateTime<chrono::Utc>", | |
| 81 | + | cb.created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 82 | + | actor.username AS banned_by_username | |
| 83 | + | FROM community_bans cb | |
| 84 | + | JOIN users u ON u.mnw_account_id = cb.user_id | |
| 85 | + | JOIN users actor ON actor.mnw_account_id = cb.banned_by | |
| 86 | + | WHERE cb.community_id = $1 | |
| 87 | + | AND (cb.expires_at IS NULL OR cb.expires_at > now()) | |
| 88 | + | ORDER BY cb.created_at DESC | |
| 89 | + | LIMIT $2"#, | |
| 90 | + | community_id, | |
| 91 | + | limit, | |
| 92 | + | ) | |
| 93 | + | .fetch_all(pool) | |
| 94 | + | .await | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | #[derive(sqlx::FromRow)] | |
| 98 | + | pub struct ModLogEntry { | |
| 99 | + | pub id: Uuid, | |
| 100 | + | pub actor_username: String, | |
| 101 | + | pub action: ModAction, | |
| 102 | + | pub target_username: Option<String>, | |
| 103 | + | pub reason: Option<String>, | |
| 104 | + | pub created_at: DateTime<Utc>, | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// List mod log entries for a community, paginated, newest first. | |
| 108 | + | #[tracing::instrument(skip_all)] | |
| 109 | + | pub async fn list_mod_log( | |
| 110 | + | pool: &PgPool, | |
| 111 | + | community_id: Uuid, | |
| 112 | + | limit: i64, | |
| 113 | + | offset: i64, | |
| 114 | + | ) -> Result<Vec<ModLogEntry>, sqlx::Error> { | |
| 115 | + | sqlx::query_as!( | |
| 116 | + | ModLogEntry, | |
| 117 | + | // LEFT JOIN + a "System" label for a NULL actor_id: migration 032 made | |
| 118 | + | // actor_id nullable precisely so auto-hide (flag-threshold) removals could | |
| 119 | + | // be recorded with no human actor. An INNER JOIN silently dropped every | |
| 120 | + | // such row — the auto-moderation audit trail the migration exists to keep | |
| 121 | + | // — while count_mod_log counted them, so pagination overcounted. COALESCE | |
| 122 | + | // is non-null, hence the `!` (see fuzz-2026-07-06 top DB fix). | |
| 123 | + | r#"SELECT ml.id, | |
| 124 | + | COALESCE(actor.username, 'System') AS "actor_username!", | |
| 125 | + | ml.action AS "action: ModAction", | |
| 126 | + | target.username AS "target_username?", | |
| 127 | + | ml.reason, | |
| 128 | + | ml.created_at AS "created_at: chrono::DateTime<chrono::Utc>" | |
| 129 | + | FROM mod_log ml | |
| 130 | + | LEFT JOIN users actor ON actor.mnw_account_id = ml.actor_id | |
| 131 | + | LEFT JOIN users target ON target.mnw_account_id = ml.target_user | |
| 132 | + | WHERE ml.community_id = $1 | |
| 133 | + | ORDER BY ml.created_at DESC | |
| 134 | + | LIMIT $2 OFFSET $3"#, | |
| 135 | + | community_id, | |
| 136 | + | limit, | |
| 137 | + | offset, | |
| 138 | + | ) | |
| 139 | + | .fetch_all(pool) | |
| 140 | + | .await | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Count mod log entries for a community. | |
| 144 | + | #[tracing::instrument(skip_all)] | |
| 145 | + | pub async fn count_mod_log( | |
| 146 | + | pool: &PgPool, | |
| 147 | + | community_id: Uuid, | |
| 148 | + | ) -> Result<i64, sqlx::Error> { | |
| 149 | + | sqlx::query_scalar!( | |
| 150 | + | r#"SELECT COUNT(*) AS "count!" FROM mod_log WHERE community_id = $1"#, | |
| 151 | + | community_id, | |
| 152 | + | ) | |
| 153 | + | .fetch_one(pool) | |
| 154 | + | .await | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[derive(sqlx::FromRow)] | |
| 158 | + | pub struct PendingFlagRow { | |
| 159 | + | pub flag_id: Uuid, | |
| 160 | + | pub post_id: Uuid, | |
| 161 | + | pub thread_id: Uuid, | |
| 162 | + | pub thread_title: String, | |
| 163 | + | pub category_slug: String, | |
| 164 | + | pub flagger_username: String, | |
| 165 | + | pub reason: String, | |
| 166 | + | pub detail: Option<String>, | |
| 167 | + | pub created_at: DateTime<Utc>, | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// List unresolved flags in a community, newest first, bounded by `limit`. | |
| 171 | + | /// The read is capped so a coordinated flag-spam backlog can't make the | |
| 172 | + | /// moderation page build an unbounded result set on every load; mods clear the | |
| 173 | + | /// newest flags and the next batch surfaces as they're resolved. | |
| 174 | + | #[tracing::instrument(skip_all)] | |
| 175 | + | pub async fn list_pending_flags( | |
| 176 | + | pool: &PgPool, | |
| 177 | + | community_id: Uuid, | |
| 178 | + | limit: i64, | |
| 179 | + | ) -> Result<Vec<PendingFlagRow>, sqlx::Error> { | |
| 180 | + | sqlx::query_as!( | |
| 181 | + | PendingFlagRow, | |
| 182 | + | r#"SELECT f.id AS flag_id, f.post_id, p.thread_id, | |
| 183 | + | t.title AS thread_title, | |
| 184 | + | cat.slug AS category_slug, | |
| 185 | + | u.username AS flagger_username, | |
| 186 | + | f.reason, f.detail, | |
| 187 | + | f.created_at AS "created_at: chrono::DateTime<chrono::Utc>" | |
| 188 | + | FROM post_flags f | |
| 189 | + | JOIN posts p ON p.id = f.post_id | |
| 190 | + | JOIN threads t ON t.id = p.thread_id | |
| 191 | + | JOIN categories cat ON cat.id = t.category_id | |
| 192 | + | JOIN users u ON u.mnw_account_id = f.flagger_id | |
| 193 | + | WHERE cat.community_id = $1 AND f.resolved_at IS NULL | |
| 194 | + | ORDER BY f.created_at DESC | |
| 195 | + | LIMIT $2"#, | |
| 196 | + | community_id, | |
| 197 | + | limit, | |
| 198 | + | ) | |
| 199 | + | .fetch_all(pool) | |
| 200 | + | .await | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /// Check if a user has already flagged a specific post. | |
| 204 | + | #[tracing::instrument(skip_all)] | |
| 205 | + | pub async fn has_user_flagged_post( | |
| 206 | + | pool: &PgPool, | |
| 207 | + | post_id: Uuid, | |
| 208 | + | flagger_id: Uuid, | |
| 209 | + | ) -> Result<bool, sqlx::Error> { | |
| 210 | + | sqlx::query_scalar!( | |
| 211 | + | r#"SELECT EXISTS(SELECT 1 FROM post_flags WHERE post_id = $1 AND flagger_id = $2) AS "exists!""#, | |
| 212 | + | post_id, | |
| 213 | + | flagger_id, | |
| 214 | + | ) | |
| 215 | + | .fetch_one(pool) | |
| 216 | + | .await | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// Check if a flag belongs to a given community (via post → thread → category chain). | |
| 220 | + | #[tracing::instrument(skip_all)] | |
| 221 | + | pub async fn flag_belongs_to_community( | |
| 222 | + | pool: &PgPool, | |
| 223 | + | flag_id: Uuid, | |
| 224 | + | community_id: Uuid, | |
| 225 | + | ) -> Result<bool, sqlx::Error> { | |
| 226 | + | sqlx::query_scalar!( | |
| 227 | + | r#"SELECT EXISTS( | |
| 228 | + | SELECT 1 FROM post_flags pf | |
| 229 | + | JOIN posts p ON p.id = pf.post_id | |
| 230 | + | JOIN threads t ON t.id = p.thread_id | |
| 231 | + | JOIN categories c ON c.id = t.category_id | |
| 232 | + | WHERE pf.id = $1 AND c.community_id = $2 | |
| 233 | + | ) AS "exists!""#, | |
| 234 | + | flag_id, | |
| 235 | + | community_id, | |
| 236 | + | ) | |
| 237 | + | .fetch_one(pool) | |
| 238 | + | .await | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | /// Post-removal target for a flag, scoped to a community: `(post_id, author_id, | |
| 242 | + | /// thread_id)`. Returns `None` if the flag doesn't exist or belongs to another | |
| 243 | + | /// community — the community scoping is enforced here rather than in the handler. | |
| 244 | + | #[tracing::instrument(skip_all)] | |
| 245 | + | pub async fn get_flag_removal_target( | |
| 246 | + | pool: &PgPool, | |
| 247 | + | flag_id: Uuid, | |
| 248 | + | community_id: Uuid, | |
| 249 | + | ) -> Result<Option<(Uuid, Uuid, Uuid)>, sqlx::Error> { | |
| 250 | + | let row = sqlx::query!( | |
| 251 | + | r#"SELECT pf.post_id AS "post_id!", p.author_id AS "author_id!", t.id AS "thread_id!" | |
| 252 | + | FROM post_flags pf | |
| 253 | + | JOIN posts p ON p.id = pf.post_id | |
| 254 | + | JOIN threads t ON t.id = p.thread_id | |
| 255 | + | JOIN categories c ON c.id = t.category_id | |
| 256 | + | WHERE pf.id = $1 AND c.community_id = $2"#, | |
| 257 | + | flag_id, | |
| 258 | + | community_id, | |
| 259 | + | ) | |
| 260 | + | .fetch_optional(pool) | |
| 261 | + | .await?; | |
| 262 | + | Ok(row.map(|r| (r.post_id, r.author_id, r.thread_id))) | |
| 263 | + | } |
| @@ -0,0 +1,201 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct PostWithAuthor { | |
| 5 | + | pub id: Uuid, | |
| 6 | + | pub author_id: Uuid, | |
| 7 | + | pub author_name: String, | |
| 8 | + | pub author_username: String, | |
| 9 | + | pub body_html: String, | |
| 10 | + | pub created_at: DateTime<Utc>, | |
| 11 | + | pub edited_at: Option<DateTime<Utc>>, | |
| 12 | + | pub deleted_at: Option<DateTime<Utc>>, | |
| 13 | + | pub removed_at: Option<DateTime<Utc>>, | |
| 14 | + | /// Author's current Fan+ status (denormalised on users; refreshed at | |
| 15 | + | /// OAuth callback / `POST /auth/refresh`). Used for the + badge and | |
| 16 | + | /// for signature visibility — signatures only render for current | |
| 17 | + | /// Fan+ subscribers. | |
| 18 | + | pub author_is_fan_plus: bool, | |
| 19 | + | /// Author's saved signature HTML (rendered at save time). Render only | |
| 20 | + | /// when `author_is_fan_plus` is true. | |
| 21 | + | pub author_signature_html: Option<String>, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | #[derive(sqlx::FromRow)] | |
| 25 | + | pub struct PostForEdit { | |
| 26 | + | pub id: Uuid, | |
| 27 | + | pub author_id: Uuid, | |
| 28 | + | pub body_markdown: String, | |
| 29 | + | pub created_at: DateTime<Utc>, | |
| 30 | + | pub deleted_at: Option<DateTime<Utc>>, | |
| 31 | + | pub thread_id: Uuid, | |
| 32 | + | pub thread_title: String, | |
| 33 | + | pub community_name: String, | |
| 34 | + | pub community_slug: String, | |
| 35 | + | pub community_id: Uuid, | |
| 36 | + | pub category_name: String, | |
| 37 | + | pub category_slug: String, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[tracing::instrument(skip_all)] | |
| 41 | + | pub async fn list_posts_in_thread( | |
| 42 | + | pool: &PgPool, | |
| 43 | + | thread_id: Uuid, | |
| 44 | + | ) -> Result<Vec<PostWithAuthor>, sqlx::Error> { | |
| 45 | + | sqlx::query_as!( | |
| 46 | + | PostWithAuthor, | |
| 47 | + | r#"SELECT p.id, p.author_id, | |
| 48 | + | COALESCE(u.display_name, u.username) AS "author_name!", | |
| 49 | + | u.username AS author_username, | |
| 50 | + | p.body_html, | |
| 51 | + | p.created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 52 | + | p.edited_at AS "edited_at: chrono::DateTime<chrono::Utc>", | |
| 53 | + | p.deleted_at AS "deleted_at: chrono::DateTime<chrono::Utc>", | |
| 54 | + | p.removed_at AS "removed_at: chrono::DateTime<chrono::Utc>", | |
| 55 | + | u.is_fan_plus AS author_is_fan_plus, | |
| 56 | + | u.signature_html AS author_signature_html | |
| 57 | + | FROM posts p | |
| 58 | + | JOIN users u ON u.mnw_account_id = p.author_id | |
| 59 | + | WHERE p.thread_id = $1 | |
| 60 | + | ORDER BY p.created_at"#, | |
| 61 | + | thread_id, | |
| 62 | + | ) | |
| 63 | + | .fetch_all(pool) | |
| 64 | + | .await | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | #[tracing::instrument(skip_all)] | |
| 68 | + | pub async fn list_posts_in_thread_paginated( | |
| 69 | + | pool: &PgPool, | |
| 70 | + | thread_id: Uuid, | |
| 71 | + | limit: i64, | |
| 72 | + | offset: i64, | |
| 73 | + | ) -> Result<Vec<PostWithAuthor>, sqlx::Error> { | |
| 74 | + | sqlx::query_as!( | |
| 75 | + | PostWithAuthor, | |
| 76 | + | r#"SELECT p.id, p.author_id, | |
| 77 | + | COALESCE(u.display_name, u.username) AS "author_name!", | |
| 78 | + | u.username AS author_username, | |
| 79 | + | p.body_html, | |
| 80 | + | p.created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 81 | + | p.edited_at AS "edited_at: chrono::DateTime<chrono::Utc>", | |
| 82 | + | p.deleted_at AS "deleted_at: chrono::DateTime<chrono::Utc>", | |
| 83 | + | p.removed_at AS "removed_at: chrono::DateTime<chrono::Utc>", | |
| 84 | + | u.is_fan_plus AS author_is_fan_plus, | |
| 85 | + | u.signature_html AS author_signature_html | |
| 86 | + | FROM posts p | |
| 87 | + | JOIN users u ON u.mnw_account_id = p.author_id | |
| 88 | + | WHERE p.thread_id = $1 | |
| 89 | + | ORDER BY p.created_at | |
| 90 | + | LIMIT $2 OFFSET $3"#, | |
| 91 | + | thread_id, | |
| 92 | + | limit, | |
| 93 | + | offset, | |
| 94 | + | ) | |
| 95 | + | .fetch_all(pool) | |
| 96 | + | .await | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[tracing::instrument(skip_all)] | |
| 100 | + | pub async fn count_posts_in_thread( | |
| 101 | + | pool: &PgPool, | |
| 102 | + | thread_id: Uuid, | |
| 103 | + | ) -> Result<i64, sqlx::Error> { | |
| 104 | + | // Intentionally counts ALL posts including mod-removed/soft-deleted: this | |
| 105 | + | // drives the thread-view pagination, and `list_posts_in_thread_paginated` | |
| 106 | + | // returns those rows so they render as tombstones. The count must match the | |
| 107 | + | // list it paginates, or the last page of tombstones would be cut off. For an | |
| 108 | + | // active-only count (e.g. the server-facing stats), use `get_thread_stats`. | |
| 109 | + | sqlx::query_scalar!( | |
| 110 | + | r#"SELECT COUNT(*) AS "count!" FROM posts WHERE thread_id = $1"#, | |
| 111 | + | thread_id, | |
| 112 | + | ) | |
| 113 | + | .fetch_one(pool) | |
| 114 | + | .await | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Count posts + footnotes by a user in the last N seconds (for per-user rate limiting). | |
| 118 | + | #[tracing::instrument(skip_all)] | |
| 119 | + | pub async fn count_recent_posts_by_user( | |
| 120 | + | pool: &PgPool, | |
| 121 | + | user_id: Uuid, | |
| 122 | + | seconds: i64, | |
| 123 | + | ) -> Result<i64, sqlx::Error> { | |
| 124 | + | sqlx::query_scalar!( | |
| 125 | + | r#"SELECT (SELECT COUNT(*) FROM posts WHERE author_id = $1 AND created_at > NOW() - make_interval(secs => $2)) | |
| 126 | + | + (SELECT COUNT(*) FROM post_footnotes WHERE author_id = $1 AND created_at > NOW() - make_interval(secs => $2)) AS "count!""#, | |
| 127 | + | user_id, | |
| 128 | + | seconds as f64, | |
| 129 | + | ) | |
| 130 | + | .fetch_one(pool) | |
| 131 | + | .await | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[tracing::instrument(skip_all)] | |
| 135 | + | pub async fn get_post_for_edit( | |
| 136 | + | pool: &PgPool, | |
| 137 | + | post_id: Uuid, | |
| 138 | + | ) -> Result<Option<PostForEdit>, sqlx::Error> { | |
| 139 | + | sqlx::query_as!( | |
| 140 | + | PostForEdit, | |
| 141 | + | r#"SELECT p.id, p.author_id, p.body_markdown, | |
| 142 | + | p.created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 143 | + | p.deleted_at AS "deleted_at: chrono::DateTime<chrono::Utc>", | |
| 144 | + | p.thread_id, t.title AS thread_title, | |
| 145 | + | co.name AS community_name, co.slug AS community_slug, | |
| 146 | + | co.id AS community_id, | |
| 147 | + | c.name AS category_name, c.slug AS category_slug | |
| 148 | + | FROM posts p | |
| 149 | + | JOIN threads t ON t.id = p.thread_id | |
| 150 | + | JOIN categories c ON c.id = t.category_id | |
| 151 | + | JOIN communities co ON co.id = c.community_id | |
| 152 | + | WHERE p.id = $1"#, | |
| 153 | + | post_id, | |
| 154 | + | ) | |
| 155 | + | .fetch_optional(pool) | |
| 156 | + | .await | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// Fetch a post's author_id and body_markdown for quote verification, scoped to | |
| 160 | + | /// the quoting post's community. | |
| 161 | + | /// | |
| 162 | + | /// The `community_id` filter is not optional: a `[quote:UUID:HASH]` marker only | |
| 163 | + | /// verifies against a post *in the same community*, so a member of community A | |
| 164 | + | /// can never use quote verification as an oracle against a post in community B | |
| 165 | + | /// (C1 scope class, N3). There is deliberately no unscoped `by-id` variant of | |
| 166 | + | /// this loader — the scoping is structural, which is why it needs no | |
| 167 | + | /// `clippy.toml` disallowed-methods entry (cf. `get_category_in_community`). | |
| 168 | + | #[tracing::instrument(skip_all)] | |
| 169 | + | pub async fn get_post_body_markdown_in_community( | |
| 170 | + | pool: &PgPool, | |
| 171 | + | post_id: Uuid, | |
| 172 | + | community_id: Uuid, | |
| 173 | + | ) -> Result<Option<(Uuid, String)>, sqlx::Error> { | |
| 174 | + | // Only live posts can be quoted: a mod-removed post (or one in a soft-deleted | |
| 175 | + | // thread) must fail quote verification so its text cannot be re-surfaced into | |
| 176 | + | // a live post by quoting it back. | |
| 177 | + | sqlx::query!( | |
| 178 | + | "SELECT p.author_id, p.body_markdown | |
| 179 | + | FROM posts p | |
| 180 | + | JOIN threads t ON t.id = p.thread_id | |
| 181 | + | JOIN categories c ON c.id = t.category_id | |
| 182 | + | WHERE p.id = $1 AND c.community_id = $2 | |
| 183 | + | AND p.removed_at IS NULL AND t.deleted_at IS NULL", | |
| 184 | + | post_id, | |
| 185 | + | community_id, | |
| 186 | + | ) | |
| 187 | + | .fetch_optional(pool) | |
| 188 | + | .await | |
| 189 | + | .map(|opt| opt.map(|r| (r.author_id, r.body_markdown))) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// Whether a post is mod-removed (`removed_at IS NOT NULL`). | |
| 193 | + | #[tracing::instrument(skip_all)] | |
| 194 | + | pub async fn is_post_removed(pool: &PgPool, post_id: Uuid) -> Result<bool, sqlx::Error> { | |
| 195 | + | sqlx::query_scalar!( | |
| 196 | + | r#"SELECT (removed_at IS NOT NULL) AS "removed!" FROM posts WHERE id = $1"#, | |
| 197 | + | post_id, | |
| 198 | + | ) | |
| 199 | + | .fetch_one(pool) | |
| 200 | + | .await | |
| 201 | + | } |
| @@ -0,0 +1,126 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct SearchResultRow { | |
| 5 | + | pub thread_id: Uuid, | |
| 6 | + | pub thread_title: String, | |
| 7 | + | pub author_username: String, | |
| 8 | + | pub community_name: String, | |
| 9 | + | pub community_slug: String, | |
| 10 | + | pub category_name: String, | |
| 11 | + | pub category_slug: String, | |
| 12 | + | pub snippet: String, | |
| 13 | + | pub last_activity_at: DateTime<Utc>, | |
| 14 | + | pub rank: f64, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// Full-text search across threads and posts. Combines tsvector ranking with | |
| 18 | + | /// trigram similarity for typo tolerance. Title matches ranked above body. | |
| 19 | + | /// Optionally scoped to a single community by slug. | |
| 20 | + | #[tracing::instrument(skip_all)] | |
| 21 | + | pub async fn search_threads( | |
| 22 | + | pool: &PgPool, | |
| 23 | + | query: &str, | |
| 24 | + | community_slug: Option<&str>, | |
| 25 | + | limit: i64, | |
| 26 | + | ) -> Result<Vec<SearchResultRow>, sqlx::Error> { | |
| 27 | + | // The trigram fuzzy branch uses the `%` operator (not `similarity(...) > k`) | |
| 28 | + | // so `idx_threads_title_trgm` (gin_trgm_ops) can serve it and the planner can | |
| 29 | + | // BitmapOr it with the tsvector GIN scan instead of seq-scanning every live | |
| 30 | + | // thread. `%` compares against `pg_trgm.similarity_threshold`, so we pin that | |
| 31 | + | // to 0.1 (the old inline constant) with `SET LOCAL` inside a transaction — | |
| 32 | + | // session-scoped, so it can't leak to other pooled connections. `similarity()` | |
| 33 | + | // still appears in the rank expression, but only computed for matched rows. | |
| 34 | + | let mut tx = pool.begin().await?; | |
| 35 | + | sqlx::query("SET LOCAL pg_trgm.similarity_threshold = 0.1") | |
| 36 | + | .execute(&mut *tx) | |
| 37 | + | .await?; | |
| 38 | + | let rows = sqlx::query_as!( | |
| 39 | + | SearchResultRow, | |
| 40 | + | // `q` parses the tsquery once (it was parsed 5× inline); cross-joining | |
| 41 | + | // the single-row CTE feeds it to every rank/match without re-parsing. | |
| 42 | + | // post_matches uses NOT EXISTS rather than NOT IN (better anti-join, | |
| 43 | + | // and NULL-safe). | |
| 44 | + | r#"WITH q AS ( | |
| 45 | + | SELECT websearch_to_tsquery('english', $1) AS tsq | |
| 46 | + | ), | |
| 47 | + | thread_matches AS ( | |
| 48 | + | SELECT t.id AS thread_id, | |
| 49 | + | t.title AS thread_title, | |
| 50 | + | COALESCE(u.display_name, u.username) AS author_username, | |
| 51 | + | co.name AS community_name, | |
| 52 | + | co.slug AS community_slug, | |
| 53 | + | c.name AS category_name, | |
| 54 | + | c.slug AS category_slug, | |
| 55 | + | LEFT(t.title, 200) AS snippet, | |
| 56 | + | t.last_activity_at, | |
| 57 | + | (ts_rank(t.search_tsv, q.tsq) * 2.0 | |
| 58 | + | + similarity(t.title, $2)) AS rank | |
| 59 | + | FROM threads t | |
| 60 | + | JOIN categories c ON c.id = t.category_id | |
| 61 | + | JOIN communities co ON co.id = c.community_id | |
| 62 | + | JOIN users u ON u.mnw_account_id = t.author_id | |
| 63 | + | CROSS JOIN q | |
| 64 | + | WHERE t.deleted_at IS NULL | |
| 65 | + | AND co.suspended_at IS NULL | |
| 66 | + | AND (t.search_tsv @@ q.tsq | |
| 67 | + | OR t.title % $2) | |
| 68 | + | AND ($3::text IS NULL OR co.slug = $3) | |
| 69 | + | ), | |
| 70 | + | post_matches AS ( | |
| 71 | + | SELECT DISTINCT ON (t.id) | |
| 72 | + | t.id AS thread_id, | |
| 73 | + | t.title AS thread_title, | |
| 74 | + | COALESCE(pu.display_name, pu.username) AS author_username, | |
| 75 | + | co.name AS community_name, | |
| 76 | + | co.slug AS community_slug, | |
| 77 | + | c.name AS category_name, | |
| 78 | + | c.slug AS category_slug, | |
| 79 | + | LEFT(p.body_markdown, 200) AS snippet, | |
| 80 | + | t.last_activity_at, | |
| 81 | + | ts_rank(p.search_tsv, q.tsq) AS rank | |
| 82 | + | FROM posts p | |
| 83 | + | JOIN threads t ON t.id = p.thread_id | |
| 84 | + | JOIN categories c ON c.id = t.category_id | |
| 85 | + | JOIN communities co ON co.id = c.community_id | |
| 86 | + | -- Author of the matched *reply* (`p.author_id`), not the thread OP | |
| 87 | + | -- (`t.author_id`): the snippet is the reply's body, so it must be | |
| 88 | + | -- attributed to whoever wrote it. | |
| 89 | + | JOIN users pu ON pu.mnw_account_id = p.author_id | |
| 90 | + | CROSS JOIN q | |
| 91 | + | WHERE t.deleted_at IS NULL | |
| 92 | + | AND co.suspended_at IS NULL | |
| 93 | + | AND p.removed_at IS NULL | |
| 94 | + | AND p.search_tsv @@ q.tsq | |
| 95 | + | AND ($3::text IS NULL OR co.slug = $3) | |
| 96 | + | AND NOT EXISTS (SELECT 1 FROM thread_matches tm WHERE tm.thread_id = t.id) | |
| 97 | + | ORDER BY t.id, ts_rank(p.search_tsv, q.tsq) DESC | |
| 98 | + | ) | |
| 99 | + | SELECT | |
| 100 | + | thread_id AS "thread_id!", | |
| 101 | + | thread_title AS "thread_title!", | |
| 102 | + | author_username AS "author_username!", | |
| 103 | + | community_name AS "community_name!", | |
| 104 | + | community_slug AS "community_slug!", | |
| 105 | + | category_name AS "category_name!", | |
| 106 | + | category_slug AS "category_slug!", | |
| 107 | + | snippet AS "snippet!", | |
| 108 | + | last_activity_at AS "last_activity_at!: chrono::DateTime<chrono::Utc>", | |
| 109 | + | rank AS "rank!" | |
| 110 | + | FROM ( | |
| 111 | + | SELECT * FROM thread_matches | |
| 112 | + | UNION ALL | |
| 113 | + | SELECT * FROM post_matches | |
| 114 | + | ) results | |
| 115 | + | ORDER BY rank DESC, last_activity_at DESC | |
| 116 | + | LIMIT $4"#, | |
| 117 | + | query, | |
| 118 | + | query, | |
| 119 | + | community_slug, | |
| 120 | + | limit, | |
| 121 | + | ) | |
| 122 | + | .fetch_all(&mut *tx) | |
| 123 | + | .await?; | |
| 124 | + | tx.commit().await?; | |
| 125 | + | Ok(rows) | |
| 126 | + | } |
| @@ -0,0 +1,49 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct TagRow { | |
| 5 | + | pub id: Uuid, | |
| 6 | + | pub name: String, | |
| 7 | + | pub slug: String, | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | /// List all tags for a community. | |
| 11 | + | #[tracing::instrument(skip_all)] | |
| 12 | + | pub async fn list_tags_for_community( | |
| 13 | + | pool: &PgPool, | |
| 14 | + | community_id: Uuid, | |
| 15 | + | ) -> Result<Vec<TagRow>, sqlx::Error> { | |
| 16 | + | sqlx::query_as!( | |
| 17 | + | TagRow, | |
| 18 | + | "SELECT id, name, slug FROM tags WHERE community_id = $1 ORDER BY name", | |
| 19 | + | community_id, | |
| 20 | + | ) | |
| 21 | + | .fetch_all(pool) | |
| 22 | + | .await | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | #[derive(sqlx::FromRow)] | |
| 26 | + | pub struct ThreadTagRow { | |
| 27 | + | pub thread_id: Uuid, | |
| 28 | + | pub tag_name: String, | |
| 29 | + | pub tag_slug: String, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Batch-fetch tags for a set of thread IDs. | |
| 33 | + | #[tracing::instrument(skip_all)] | |
| 34 | + | pub async fn list_tags_for_threads( | |
| 35 | + | pool: &PgPool, | |
| 36 | + | thread_ids: &[Uuid], | |
| 37 | + | ) -> Result<Vec<ThreadTagRow>, sqlx::Error> { | |
| 38 | + | sqlx::query_as!( | |
| 39 | + | ThreadTagRow, | |
| 40 | + | r#"SELECT tt.thread_id, t.name AS tag_name, t.slug AS tag_slug | |
| 41 | + | FROM thread_tags tt | |
| 42 | + | JOIN tags t ON t.id = tt.tag_id | |
| 43 | + | WHERE tt.thread_id = ANY($1) | |
| 44 | + | ORDER BY t.name"#, | |
| 45 | + | thread_ids, | |
| 46 | + | ) | |
| 47 | + | .fetch_all(pool) | |
| 48 | + | .await | |
| 49 | + | } |
| @@ -0,0 +1,253 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | #[derive(sqlx::FromRow)] | |
| 4 | + | pub struct ThreadWithMeta { | |
| 5 | + | pub id: Uuid, | |
| 6 | + | pub title: String, | |
| 7 | + | pub author_name: String, | |
| 8 | + | pub author_username: String, | |
| 9 | + | pub reply_count: i64, | |
| 10 | + | pub last_activity_at: DateTime<Utc>, | |
| 11 | + | pub pinned: bool, | |
| 12 | + | pub locked: bool, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | #[derive(sqlx::FromRow)] | |
| 16 | + | pub struct ThreadWithBreadcrumb { | |
| 17 | + | pub id: Uuid, | |
| 18 | + | pub title: String, | |
| 19 | + | pub locked: bool, | |
| 20 | + | pub pinned: bool, | |
| 21 | + | pub author_id: Uuid, | |
| 22 | + | pub community_id: Uuid, | |
| 23 | + | pub community_name: String, | |
| 24 | + | pub community_slug: String, | |
| 25 | + | pub category_name: String, | |
| 26 | + | pub category_slug: String, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Look up a thread by its external reference (e.g., "mnw:item:uuid"). | |
| 30 | + | #[tracing::instrument(skip_all)] | |
| 31 | + | pub async fn get_thread_by_external_ref( | |
| 32 | + | pool: &PgPool, | |
| 33 | + | external_ref: &str, | |
| 34 | + | ) -> Result<Option<(Uuid,)>, sqlx::Error> { | |
| 35 | + | sqlx::query!( | |
| 36 | + | "SELECT id FROM threads WHERE external_ref = $1", | |
| 37 | + | external_ref, | |
| 38 | + | ) | |
| 39 | + | .fetch_optional(pool) | |
| 40 | + | .await | |
| 41 | + | .map(|opt| opt.map(|r| (r.id,))) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Get thread stats: post count and last activity timestamp. | |
| 45 | + | #[tracing::instrument(skip_all)] | |
| 46 | + | #[allow(clippy::type_complexity)] | |
| 47 | + | pub async fn get_thread_stats( | |
| 48 | + | pool: &PgPool, | |
| 49 | + | thread_id: Uuid, | |
| 50 | + | ) -> Result<Option<(i64, Option<DateTime<Utc>>)>, sqlx::Error> { | |
| 51 | + | // This count surfaces on the MNW server (the internal /stats endpoint), which | |
| 52 | + | // can't render mt's tombstones — so it must be the canonical *active* count, | |
| 53 | + | // excluding mod-removed and soft-deleted posts (matching the m029 trigger and | |
| 54 | + | // the profile tallies). Contrast `count_posts_in_thread`, which deliberately | |
| 55 | + | // counts tombstones because the in-app thread list renders them. | |
| 56 | + | sqlx::query!( | |
| 57 | + | r#"SELECT COUNT(p.id) AS "count!", MAX(p.created_at) AS "max_created: chrono::DateTime<chrono::Utc>" | |
| 58 | + | FROM posts p | |
| 59 | + | WHERE p.thread_id = $1 | |
| 60 | + | AND p.removed_at IS NULL | |
| 61 | + | AND p.deleted_at IS NULL"#, | |
| 62 | + | thread_id, | |
| 63 | + | ) | |
| 64 | + | .fetch_optional(pool) | |
| 65 | + | .await | |
| 66 | + | .map(|opt| opt.map(|r| (r.count, r.max_created))) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | #[tracing::instrument(skip_all)] | |
| 70 | + | pub async fn list_threads_in_category_paginated( | |
| 71 | + | pool: &PgPool, | |
| 72 | + | community_slug: &str, | |
| 73 | + | category_slug: &str, | |
| 74 | + | limit: i64, | |
| 75 | + | offset: i64, | |
| 76 | + | ) -> Result<Vec<ThreadWithMeta>, sqlx::Error> { | |
| 77 | + | sqlx::query_as!( | |
| 78 | + | ThreadWithMeta, | |
| 79 | + | r#"SELECT t.id, t.title, | |
| 80 | + | COALESCE(u.display_name, u.username) AS "author_name!", | |
| 81 | + | u.username AS author_username, | |
| 82 | + | GREATEST(t.post_count - 1, 0)::BIGINT AS "reply_count!", | |
| 83 | + | t.last_activity_at AS "last_activity_at: chrono::DateTime<chrono::Utc>", | |
| 84 | + | t.pinned, t.locked | |
| 85 | + | FROM threads t | |
| 86 | + | JOIN categories c ON c.id = t.category_id | |
| 87 | + | JOIN communities co ON co.id = c.community_id | |
| 88 | + | JOIN users u ON u.mnw_account_id = t.author_id | |
| 89 | + | WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL | |
| 90 | + | ORDER BY t.pinned DESC, t.last_activity_at DESC | |
| 91 | + | LIMIT $3 OFFSET $4"#, | |
| 92 | + | community_slug, | |
| 93 | + | category_slug, | |
| 94 | + | limit, | |
| 95 | + | offset, | |
| 96 | + | ) | |
| 97 | + | .fetch_all(pool) | |
| 98 | + | .await | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// List threads in a category, sorted, optionally filtered to one tag. | |
| 102 | + | /// | |
| 103 | + | /// Pinned threads always sort first. `sort`/`order` are typed enums, so the | |
| 104 | + | /// `ORDER BY` clause is chosen from an exhaustive match of fixed literals (no | |
| 105 | + | /// user string reaches the SQL); when `tag_slug` is `Some`, an extra join | |
| 106 | + | /// restricts the results to threads carrying that tag in the same community. | |
| 107 | + | #[tracing::instrument(skip_all)] | |
| 108 | + | #[allow(clippy::too_many_arguments)] | |
| 109 | + | pub async fn list_threads_in_category_sorted( | |
| 110 | + | pool: &PgPool, | |
| 111 | + | community_slug: &str, | |
| 112 | + | category_slug: &str, | |
| 113 | + | sort: SortColumn, | |
| 114 | + | order: SortOrder, | |
| 115 | + | limit: i64, | |
| 116 | + | offset: i64, | |
| 117 | + | tag_slug: Option<&str>, | |
| 118 | + | ) -> Result<Vec<ThreadWithMeta>, sqlx::Error> { | |
| 119 | + | let order_clause = match (sort, order) { | |
| 120 | + | (SortColumn::Replies, SortOrder::Asc) => { | |
| 121 | + | "ORDER BY t.pinned DESC, reply_count ASC, t.last_activity_at DESC" | |
| 122 | + | } | |
| 123 | + | (SortColumn::Replies, SortOrder::Desc) => { | |
| 124 | + | "ORDER BY t.pinned DESC, reply_count DESC, t.last_activity_at DESC" | |
| 125 | + | } | |
| 126 | + | (SortColumn::Activity, SortOrder::Asc) => { | |
| 127 | + | "ORDER BY t.pinned DESC, t.last_activity_at ASC" | |
| 128 | + | } | |
| 129 | + | (SortColumn::Activity, SortOrder::Desc) => { | |
| 130 | + | "ORDER BY t.pinned DESC, t.last_activity_at DESC" | |
| 131 | + | } | |
| 132 | + | }; | |
| 133 | + | ||
| 134 | + | // When a tag filter is present it binds as $3, pushing limit/offset to $4/$5. | |
| 135 | + | let (tag_join, limit_ph, offset_ph) = if tag_slug.is_some() { | |
| 136 | + | ( | |
| 137 | + | "JOIN thread_tags tt ON tt.thread_id = t.id \ | |
| 138 | + | JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id", | |
| 139 | + | "$4", | |
| 140 | + | "$5", | |
| 141 | + | ) | |
| 142 | + | } else { | |
| 143 | + | ("", "$3", "$4") | |
| 144 | + | }; | |
| 145 | + | ||
| 146 | + | let query = format!( | |
| 147 | + | "SELECT t.id, t.title, | |
| 148 | + | COALESCE(u.display_name, u.username) AS author_name, | |
| 149 | + | u.username AS author_username, | |
| 150 | + | GREATEST(t.post_count - 1, 0)::BIGINT AS reply_count, | |
| 151 | + | t.last_activity_at, | |
| 152 | + | t.pinned, t.locked | |
| 153 | + | FROM threads t | |
| 154 | + | JOIN categories c ON c.id = t.category_id | |
| 155 | + | JOIN communities co ON co.id = c.community_id | |
| 156 | + | JOIN users u ON u.mnw_account_id = t.author_id | |
| 157 | + | {tag_join} | |
| 158 | + | WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL | |
| 159 | + | {order_clause} | |
| 160 | + | LIMIT {limit_ph} OFFSET {offset_ph}" | |
| 161 | + | ); | |
| 162 | + | ||
| 163 | + | // runtime-checked: dynamic SQL (cannot use compile-time macro) | |
| 164 | + | let mut q = sqlx::query_as::<_, ThreadWithMeta>(&query) | |
| 165 | + | .bind(community_slug) | |
| 166 | + | .bind(category_slug); | |
| 167 | + | if let Some(tag) = tag_slug { | |
| 168 | + | q = q.bind(tag); | |
| 169 | + | } | |
| 170 | + | q.bind(limit).bind(offset).fetch_all(pool).await | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[tracing::instrument(skip_all)] | |
| 174 | + | pub async fn count_threads_in_category( | |
| 175 | + | pool: &PgPool, | |
| 176 | + | community_slug: &str, | |
| 177 | + | category_slug: &str, | |
| 178 | + | ) -> Result<i64, sqlx::Error> { | |
| 179 | + | sqlx::query_scalar!( | |
| 180 | + | r#"SELECT COUNT(*) AS "count!" | |
| 181 | + | FROM threads t | |
| 182 | + | JOIN categories c ON c.id = t.category_id | |
| 183 | + | JOIN communities co ON co.id = c.community_id | |
| 184 | + | WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL"#, | |
| 185 | + | community_slug, | |
| 186 | + | category_slug, | |
| 187 | + | ) | |
| 188 | + | .fetch_one(pool) | |
| 189 | + | .await | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | #[tracing::instrument(skip_all)] | |
| 193 | + | pub async fn get_thread_with_breadcrumb( | |
| 194 | + | pool: &PgPool, | |
| 195 | + | thread_id: Uuid, | |
| 196 | + | ) -> Result<Option<ThreadWithBreadcrumb>, sqlx::Error> { | |
| 197 | + | sqlx::query_as!( | |
| 198 | + | ThreadWithBreadcrumb, | |
| 199 | + | r#"SELECT t.id, t.title, t.locked, t.pinned, t.author_id, | |
| 200 | + | co.id AS community_id, | |
| 201 | + | co.name AS community_name, co.slug AS community_slug, | |
| 202 | + | c.name AS category_name, c.slug AS category_slug | |
| 203 | + | FROM threads t | |
| 204 | + | JOIN categories c ON c.id = t.category_id | |
| 205 | + | JOIN communities co ON co.id = c.community_id | |
| 206 | + | WHERE t.id = $1 AND t.deleted_at IS NULL"#, | |
| 207 | + | thread_id, | |
| 208 | + | ) | |
| 209 | + | .fetch_optional(pool) | |
| 210 | + | .await | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// Count threads in a category, optionally filtered by tag slug. | |
| 214 | + | #[tracing::instrument(skip_all)] | |
| 215 | + | pub async fn count_threads_in_category_filtered( | |
| 216 | + | pool: &PgPool, | |
| 217 | + | community_slug: &str, | |
| 218 | + | category_slug: &str, | |
| 219 | + | tag_slug: Option<&str>, | |
| 220 | + | ) -> Result<i64, sqlx::Error> { | |
| 221 | + | if let Some(tag) = tag_slug { | |
| 222 | + | sqlx::query_scalar!( | |
| 223 | + | r#"SELECT COUNT(DISTINCT t.id) AS "count!" | |
| 224 | + | FROM threads t | |
| 225 | + | JOIN categories c ON c.id = t.category_id | |
| 226 | + | JOIN communities co ON co.id = c.community_id | |
| 227 | + | JOIN thread_tags tt ON tt.thread_id = t.id | |
| 228 | + | JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id | |
| 229 | + | WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL"#, | |
| 230 | + | community_slug, | |
| 231 | + | category_slug, | |
| 232 | + | tag, | |
| 233 | + | ) | |
| 234 | + | .fetch_one(pool) | |
| 235 | + | .await | |
| 236 | + | } else { | |
| 237 | + | count_threads_in_category(pool, community_slug, category_slug).await | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | /// Check if a thread exists by ID. | |
| 242 | + | #[tracing::instrument(skip_all)] | |
| 243 | + | pub async fn thread_exists( | |
| 244 | + | pool: &PgPool, | |
| 245 | + | thread_id: Uuid, | |
| 246 | + | ) -> Result<bool, sqlx::Error> { | |
| 247 | + | sqlx::query_scalar!( | |
| 248 | + | r#"SELECT EXISTS(SELECT 1 FROM threads WHERE id = $1) AS "exists!""#, | |
| 249 | + | thread_id, | |
| 250 | + | ) | |
| 251 | + | .fetch_one(pool) | |
| 252 | + | .await | |
| 253 | + | } |
| @@ -0,0 +1,95 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | /// Check if a user is tracking a specific thread. | |
| 4 | + | #[tracing::instrument(skip_all)] | |
| 5 | + | pub async fn is_thread_tracked( | |
| 6 | + | pool: &PgPool, | |
| 7 | + | user_id: Uuid, | |
| 8 | + | thread_id: Uuid, | |
| 9 | + | ) -> Result<bool, sqlx::Error> { | |
| 10 | + | sqlx::query_scalar!( | |
| 11 | + | r#"SELECT EXISTS(SELECT 1 FROM tracked_threads WHERE user_id = $1 AND thread_id = $2) AS "exists!""#, | |
| 12 | + | user_id, | |
| 13 | + | thread_id, | |
| 14 | + | ) | |
| 15 | + | .fetch_one(pool) | |
| 16 | + | .await | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | #[derive(sqlx::FromRow)] | |
| 20 | + | pub struct TrackedThreadRow { | |
| 21 | + | pub thread_id: Uuid, | |
| 22 | + | pub thread_title: String, | |
| 23 | + | pub community_name: String, | |
| 24 | + | pub community_slug: String, | |
| 25 | + | pub category_slug: String, | |
| 26 | + | pub unread_count: i64, | |
| 27 | + | pub has_mention: bool, | |
| 28 | + | pub tracked_at: DateTime<Utc>, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// List a user's tracked threads with unread post counts, newest activity first. | |
| 32 | + | /// | |
| 33 | + | /// Paginated (`limit`/`offset`) so a user tracking hundreds of threads doesn't | |
| 34 | + | /// render — and recompute — an unbounded page. The last-read cutoff is resolved | |
| 35 | + | /// with a single LEFT JOIN rather than a per-row nested subquery, and the unread | |
| 36 | + | /// count excludes mod-removed posts (`p.removed_at IS NULL`) so the badge can't | |
| 37 | + | /// be inflated by removed content and can use the `idx_posts_not_removed` index. | |
| 38 | + | #[tracing::instrument(skip_all)] | |
| 39 | + | pub async fn list_tracked_threads( | |
| 40 | + | pool: &PgPool, | |
| 41 | + | user_id: Uuid, | |
| 42 | + | limit: i64, | |
| 43 | + | offset: i64, | |
| 44 | + | ) -> Result<Vec<TrackedThreadRow>, sqlx::Error> { | |
| 45 | + | sqlx::query_as!( | |
| 46 | + | TrackedThreadRow, | |
| 47 | + | r#"SELECT tt.thread_id, | |
| 48 | + | t.title AS thread_title, | |
| 49 | + | co.name AS community_name, | |
| 50 | + | co.slug AS community_slug, | |
| 51 | + | cat.slug AS category_slug, | |
| 52 | + | (SELECT COUNT(*) FROM posts p | |
| 53 | + | WHERE p.thread_id = tt.thread_id | |
| 54 | + | AND p.removed_at IS NULL | |
| 55 | + | AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at) | |
| 56 | + | ) AS "unread_count!", | |
| 57 | + | EXISTS ( | |
| 58 | + | SELECT 1 FROM post_mentions pm | |
| 59 | + | JOIN posts p ON p.id = pm.post_id | |
| 60 | + | WHERE pm.mentioned_user_id = tt.user_id | |
| 61 | + | AND p.thread_id = tt.thread_id | |
| 62 | + | ) AS "has_mention!", | |
| 63 | + | tt.tracked_at AS "tracked_at: chrono::DateTime<chrono::Utc>" | |
| 64 | + | FROM tracked_threads tt | |
| 65 | + | JOIN threads t ON t.id = tt.thread_id | |
| 66 | + | JOIN categories cat ON cat.id = t.category_id | |
| 67 | + | JOIN communities co ON co.id = cat.community_id | |
| 68 | + | LEFT JOIN posts lrp ON lrp.id = tt.last_read_post_id | |
| 69 | + | WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL | |
| 70 | + | ORDER BY t.last_activity_at DESC | |
| 71 | + | LIMIT $2 OFFSET $3"#, | |
| 72 | + | user_id, | |
| 73 | + | limit, | |
| 74 | + | offset, | |
| 75 | + | ) | |
| 76 | + | .fetch_all(pool) | |
| 77 | + | .await | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Count a user's visible tracked threads (for pagination). Mirrors the | |
| 81 | + | /// visibility filters of `list_tracked_threads`. | |
| 82 | + | #[tracing::instrument(skip_all)] | |
| 83 | + | pub async fn count_tracked_threads(pool: &PgPool, user_id: Uuid) -> Result<i64, sqlx::Error> { | |
| 84 | + | sqlx::query_scalar!( | |
| 85 | + | r#"SELECT COUNT(*) AS "count!" | |
| 86 | + | FROM tracked_threads tt | |
| 87 | + | JOIN threads t ON t.id = tt.thread_id | |
| 88 | + | JOIN categories cat ON cat.id = t.category_id | |
| 89 | + | JOIN communities co ON co.id = cat.community_id | |
| 90 | + | WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL"#, | |
| 91 | + | user_id, | |
| 92 | + | ) | |
| 93 | + | .fetch_one(pool) | |
| 94 | + | .await | |
| 95 | + | } |
| @@ -0,0 +1,173 @@ | |||
| 1 | + | use super::*; | |
| 2 | + | ||
| 3 | + | /// A user's saved signature: `(markdown, html)`. Either field may be `NULL`. | |
| 4 | + | pub type UserSignature = (Option<String>, Option<String>); | |
| 5 | + | ||
| 6 | + | /// Look up a user's UUID by username. | |
| 7 | + | #[tracing::instrument(skip_all)] | |
| 8 | + | pub async fn get_user_by_username( | |
| 9 | + | pool: &PgPool, | |
| 10 | + | username: &str, | |
| 11 | + | ) -> Result<Option<Uuid>, sqlx::Error> { | |
| 12 | + | sqlx::query_scalar!( | |
| 13 | + | "SELECT mnw_account_id FROM users WHERE username = $1", | |
| 14 | + | username, | |
| 15 | + | ) | |
| 16 | + | .fetch_optional(pool) | |
| 17 | + | .await | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | #[derive(sqlx::FromRow)] | |
| 21 | + | pub struct UserProfileRow { | |
| 22 | + | pub user_id: Uuid, | |
| 23 | + | pub username: String, | |
| 24 | + | pub display_name: Option<String>, | |
| 25 | + | pub avatar_url: Option<String>, | |
| 26 | + | pub role: CommunityRole, | |
| 27 | + | pub joined_at: DateTime<Utc>, | |
| 28 | + | pub post_count: i64, | |
| 29 | + | pub endorsement_count: i64, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Fetch a user's profile within a specific community. | |
| 33 | + | /// Returns None if the user is not a member of the community. | |
| 34 | + | #[tracing::instrument(skip_all)] | |
| 35 | + | pub async fn get_user_profile_in_community( | |
| 36 | + | pool: &PgPool, | |
| 37 | + | community_slug: &str, | |
| 38 | + | username: &str, | |
| 39 | + | ) -> Result<Option<UserProfileRow>, sqlx::Error> { | |
| 40 | + | sqlx::query_as!( | |
| 41 | + | UserProfileRow, | |
| 42 | + | r#"SELECT u.mnw_account_id AS user_id, | |
| 43 | + | u.username, | |
| 44 | + | u.display_name, | |
| 45 | + | u.avatar_url, | |
| 46 | + | m.role AS "role: CommunityRole", | |
| 47 | + | m.joined_at AS "joined_at: chrono::DateTime<chrono::Utc>", | |
| 48 | + | (SELECT COUNT(*) FROM posts p | |
| 49 | + | JOIN threads t ON t.id = p.thread_id | |
| 50 | + | JOIN categories c ON c.id = t.category_id | |
| 51 | + | WHERE p.author_id = u.mnw_account_id | |
| 52 | + | AND c.community_id = co.id | |
| 53 | + | AND p.removed_at IS NULL | |
| 54 | + | AND p.deleted_at IS NULL | |
| 55 | + | AND t.deleted_at IS NULL) AS "post_count!", | |
| 56 | + | (SELECT COUNT(*) FROM post_endorsements pe | |
| 57 | + | JOIN posts p ON p.id = pe.post_id | |
| 58 | + | JOIN threads t ON t.id = p.thread_id | |
| 59 | + | JOIN categories c ON c.id = t.category_id | |
| 60 | + | WHERE p.author_id = u.mnw_account_id | |
| 61 | + | AND c.community_id = co.id | |
| 62 | + | AND p.removed_at IS NULL | |
| 63 | + | AND p.deleted_at IS NULL | |
| 64 | + | AND t.deleted_at IS NULL) AS "endorsement_count!" | |
| 65 | + | FROM users u | |
| 66 | + | JOIN memberships m ON m.user_id = u.mnw_account_id | |
| 67 | + | JOIN communities co ON co.id = m.community_id | |
| 68 | + | WHERE co.slug = $1 AND u.username = $2"#, | |
| 69 | + | community_slug, | |
| 70 | + | username, | |
| 71 | + | ) | |
| 72 | + | .fetch_optional(pool) | |
| 73 | + | .await | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | #[derive(sqlx::FromRow)] | |
| 77 | + | pub struct UserActivityRow { | |
| 78 | + | pub thread_id: Uuid, | |
| 79 | + | pub thread_title: String, | |
| 80 | + | pub category_name: String, | |
| 81 | + | pub category_slug: String, | |
| 82 | + | pub post_created_at: DateTime<Utc>, | |
| 83 | + | pub is_thread_author: bool, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Fetch a user's recent activity (posts) within a community. | |
| 87 | + | #[tracing::instrument(skip_all)] | |
| 88 | + | pub async fn get_user_activity_in_community( | |
| 89 | + | pool: &PgPool, | |
| 90 | + | community_id: Uuid, | |
| 91 | + | user_id: Uuid, | |
| 92 | + | limit: i64, | |
| 93 | + | ) -> Result<Vec<UserActivityRow>, sqlx::Error> { | |
| 94 | + | sqlx::query_as!( | |
| 95 | + | UserActivityRow, | |
| 96 | + | r#"SELECT t.id AS thread_id, | |
| 97 | + | t.title AS thread_title, | |
| 98 | + | c.name AS category_name, | |
| 99 | + | c.slug AS category_slug, | |
| 100 | + | p.created_at AS "post_created_at: chrono::DateTime<chrono::Utc>", | |
| 101 | + | (t.author_id = $2) AS "is_thread_author!" | |
| 102 | + | FROM posts p | |
| 103 | + | JOIN threads t ON t.id = p.thread_id | |
| 104 | + | JOIN categories c ON c.id = t.category_id | |
| 105 | + | WHERE c.community_id = $1 | |
| 106 | + | AND p.author_id = $2 | |
| 107 | + | AND p.removed_at IS NULL | |
| 108 | + | AND p.deleted_at IS NULL | |
| 109 | + | AND t.deleted_at IS NULL | |
| 110 | + | ORDER BY p.created_at DESC | |
| 111 | + | LIMIT $3"#, | |
| 112 | + | community_id, | |
| 113 | + | user_id, | |
| 114 | + | limit, | |
| 115 | + | ) | |
| 116 | + | .fetch_all(pool) | |
| 117 | + | .await | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | #[derive(sqlx::FromRow, serde::Serialize)] | |
| 121 | + | pub struct UserMembershipSummary { | |
| 122 | + | pub community_name: String, | |
| 123 | + | pub community_slug: String, | |
| 124 | + | pub role: CommunityRole, | |
| 125 | + | pub joined_at: DateTime<Utc>, | |
| 126 | + | pub post_count: i64, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Fetch all community memberships for a user with post counts. | |
| 130 | + | #[tracing::instrument(skip_all)] | |
| 131 | + | pub async fn get_user_membership_summary( | |
| 132 | + | pool: &PgPool, | |
| 133 | + | user_id: Uuid, | |
| 134 | + | ) -> Result<Vec<UserMembershipSummary>, sqlx::Error> { | |
| 135 | + | sqlx::query_as!( | |
| 136 | + | UserMembershipSummary, | |
| 137 | + | r#"SELECT co.name AS community_name, | |
| 138 | + | co.slug AS community_slug, | |
| 139 | + | m.role AS "role: CommunityRole", | |
| 140 | + | m.joined_at AS "joined_at: chrono::DateTime<chrono::Utc>", | |
| 141 | + | (SELECT COUNT(*) FROM posts p | |
| 142 | + | JOIN threads t ON t.id = p.thread_id | |
| 143 | + | JOIN categories c ON c.id = t.category_id | |
| 144 | + | WHERE p.author_id = $1 | |
| 145 | + | AND c.community_id = co.id | |
| 146 | + | AND p.removed_at IS NULL | |
| 147 | + | AND p.deleted_at IS NULL | |
| 148 | + | AND t.deleted_at IS NULL) AS "post_count!" | |
| 149 | + | FROM memberships m | |
| 150 | + | JOIN communities co ON co.id = m.community_id | |
| 151 | + | WHERE m.user_id = $1 | |
| 152 | + | AND co.suspended_at IS NULL | |
| 153 | + | ORDER BY co.name"#, | |
| 154 | + | user_id, | |
| 155 | + | ) | |
| 156 | + | .fetch_all(pool) | |
| 157 | + | .await | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// Fetch a user's saved signature, or `None` if the user row is absent. | |
| 161 | + | #[tracing::instrument(skip_all)] | |
| 162 | + | pub async fn get_user_signature( | |
| 163 | + | pool: &PgPool, | |
| 164 | + | user_id: Uuid, | |
| 165 | + | ) -> Result<Option<UserSignature>, sqlx::Error> { | |
| 166 | + | let row = sqlx::query!( | |
| 167 | + | "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1", | |
| 168 | + | user_id, | |
| 169 | + | ) | |
| 170 | + | .fetch_optional(pool) | |
| 171 | + | .await?; | |
| 172 | + | Ok(row.map(|r| (r.signature_markdown, r.signature_html))) | |
| 173 | + | } |