Skip to main content

max / makenotwork

3.7 KB · 104 lines History Blame Raw
1 //! thread-tracking and read-position reads
2
3 use super::{DateTime, PgPool, Utc, Uuid};
4
5 /// Check if a user is tracking a specific thread.
6 #[tracing::instrument(skip_all)]
7 pub async fn is_thread_tracked(
8 pool: &PgPool,
9 user_id: Uuid,
10 thread_id: Uuid,
11 ) -> Result<bool, sqlx::Error> {
12 sqlx::query_scalar!(
13 r#"SELECT EXISTS(SELECT 1 FROM tracked_threads WHERE user_id = $1 AND thread_id = $2) AS "exists!""#,
14 user_id,
15 thread_id,
16 )
17 .fetch_one(pool)
18 .await
19 }
20
21 #[derive(sqlx::FromRow)]
22 pub struct TrackedThreadRow {
23 pub thread_id: Uuid,
24 pub thread_title: String,
25 pub community_name: String,
26 pub community_slug: String,
27 pub category_slug: String,
28 pub unread_count: i64,
29 pub has_mention: bool,
30 pub tracked_at: DateTime<Utc>,
31 }
32
33 /// List a user's tracked threads with unread post counts, newest activity first.
34 ///
35 /// Paginated (`limit`/`offset`) so a user tracking hundreds of threads doesn't
36 /// render (and recompute) an unbounded page. The last-read cutoff is resolved
37 /// with a single LEFT JOIN rather than a per-row nested subquery, and both the
38 /// unread count and the mention flag count only live posts (`p.is_active`,
39 /// migration 034), so neither badge can be inflated by removed content and both
40 /// can use the `idx_posts_active` index.
41 ///
42 /// The two carry the same filter deliberately. Both point the reader at content,
43 /// so a badge that outlives the post it names sends them to a thread with
44 /// nothing to find.
45 #[tracing::instrument(skip_all)]
46 pub async fn list_tracked_threads(
47 pool: &PgPool,
48 user_id: Uuid,
49 limit: i64,
50 offset: i64,
51 ) -> Result<Vec<TrackedThreadRow>, sqlx::Error> {
52 sqlx::query_as!(
53 TrackedThreadRow,
54 r#"SELECT tt.thread_id,
55 t.title AS thread_title,
56 co.name AS community_name,
57 co.slug AS community_slug,
58 cat.slug AS category_slug,
59 (SELECT COUNT(*) FROM posts p
60 WHERE p.thread_id = tt.thread_id
61 AND p.is_active
62 AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at)
63 ) AS "unread_count!",
64 EXISTS (
65 SELECT 1 FROM post_mentions pm
66 JOIN posts p ON p.id = pm.post_id
67 WHERE pm.mentioned_user_id = tt.user_id
68 AND p.thread_id = tt.thread_id
69 AND p.is_active
70 ) AS "has_mention!",
71 tt.tracked_at AS "tracked_at: chrono::DateTime<chrono::Utc>"
72 FROM tracked_threads tt
73 JOIN threads t ON t.id = tt.thread_id
74 JOIN categories cat ON cat.id = t.category_id
75 JOIN communities co ON co.id = cat.community_id
76 LEFT JOIN posts lrp ON lrp.id = tt.last_read_post_id
77 WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL
78 ORDER BY t.last_activity_at DESC
79 LIMIT $2 OFFSET $3"#,
80 user_id,
81 limit,
82 offset,
83 )
84 .fetch_all(pool)
85 .await
86 }
87
88 /// Count a user's visible tracked threads (for pagination). Mirrors the
89 /// visibility filters of `list_tracked_threads`.
90 #[tracing::instrument(skip_all)]
91 pub async fn count_tracked_threads(pool: &PgPool, user_id: Uuid) -> Result<i64, sqlx::Error> {
92 sqlx::query_scalar!(
93 r#"SELECT COUNT(*) AS "count!"
94 FROM tracked_threads tt
95 JOIN threads t ON t.id = tt.thread_id
96 JOIN categories cat ON cat.id = t.category_id
97 JOIN communities co ON co.id = cat.community_id
98 WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL"#,
99 user_id,
100 )
101 .fetch_one(pool)
102 .await
103 }
104