Skip to main content

max / makenotwork

Split mt-db queries.rs into per-entity modules Break the 1931-line queries.rs into queries/<entity>.rs (community, category, thread, post, footnote, member, moderation, user, admin, link_preview, mention, endorsement, tracked_thread, tag, search, image) behind a queries/mod.rs facade. mod.rs carries the shared named imports and re-exports every item via pub use <mod>::*, so all mt_db::queries::* paths are unchanged. get_post_for_edit and get_thread_with_breadcrumb also get explicit named re-exports because clippy.toml disallowed-methods paths do not resolve through glob re-exports. No behavior change; fn multiset identical, build and clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 16:10 UTC
Commit: e5ace39a1ae7f57852585c71a3487d99b120cc0e
Parent: 344486c
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 + }